Постмортемы инцидентов: три продакшен-отказа React под микроскопом
Три разобранных инцидента React: асинхронный цикл рендера, выжигающий CPU без исключений, шторм расхождений гидрации из-за рендера, зависящего от таймзоны, и утечка подписки, роняющая 8-часовые сессии. Blameless-формат: обнаружение, ущерб, причина, смягчение, предотвращение.
Пейджер молчал. Поддержка — нет. Тикет #4 411: «Приложение тормозит после обеда». Тикет #4 418: те же слова, другой клиент. Трекер ошибок не показывал ничего — ни исключений, ни упавших запросов, ровные 200-е. Тем временем в другой компании в том же квартале трекер ошибок упёрся в rate limit через восемь минут после деплоя — ошибки гидрации лились по 40 000 в минуту; а в третьей счётчики длинных задач в RUM выросли в 12 раз за ночь, пока все health-чеки оставались зелёными. Три инцидента, три разных сигнала обнаружения — паттерн в тикетах поддержки, поток ошибок, тихий всплеск RUM — и ни один не начался с исключения в коде приложения. Это определяющее свойство продакшен-инцидентов React: фреймворк редко падает; он деградирует, зацикливается, расходится и течёт, и либо ваш мониторинг называет механизм, либо вы неделю открываете его заново. Этот урок разбирает все три в формате постмортема — обнаружение, ущерб, корневая причина, смягчение, предотвращение, — потому что формат и есть инструмент, превращающий плохую неделю в класс багов, который ваша организация больше никогда не выкатит.
Инцидент 1: цикл рендера, который так и не бросил исключение
Обнаружение. Счётчики длинных задач в RUM выросли в 12 раз после релиза 142; p75 INP роута портфеля ушёл со 180 мс за 2 с; трекер ошибок молчит. Пользователи жаловались на вентиляторы и разряд батареи — каждая открытая вкладка роута выжигала 100% ядра. Ущерб. Шесть часов на полную мощность в ~40 000 открытых вкладок дашборда; INP в полу; порчи данных нет. Корневая причина. Рефакторинг вынес опции отображения в const options = { locale, tz } в теле рендера. Эффект указал её в deps и ставил состояние результатом асинхронной нормализации:
const options = { locale, tz }; // новая идентичность на каждом рендере
useEffect(() => {
normalize(data, options) // async — резолвится в следующей задаче
.then((rows) => setRows(rows)); // setState -> рендер -> новый options -> эффект...
}, [data, options]);Каждый цикл: рендер собирает свежий options, эффект срабатывает, промис резолвится в следующей задаче, setState планирует рендер — и так вечно. Принципиально: защита React «Maximum update depth exceeded» не сработала: этот счётчик ловит синхронные каскады обновлений — setState прямо в теле эффекта уронил бы всё за 50 итераций, громко и ещё в dev. .then уносил каждый setState в свежую задачу, обнуляя учёт каскада; цикл крутился со скоростью планировщика микрозадач, тихий и вечный. Смягчение. Откат релиза 142; INP восстановился за минуты. Предотвращение. Стабильность deps как правило ревью — deps обязаны быть примитивами или доказуемо стабильными ссылками ([data, locale, tz], а не [data, options]); exhaustive-deps остаётся включённым (он и был включён — он честно перечислил нестабильную dep; не хватало дисциплины стабильности, которую линт проверить не может); и RUM-алерт на число длинных задач по кольцу релиза — сигнал, который инцидент в итоге и поймал.
В инциденте 1 эффект зациклился навечно на 100% CPU, но React так и не бросил Maximum update depth exceeded. Почему?
Инцидент 2: шторм расхождений гидрации
Обнаружение. Трекер ошибок упёрся в rate limit через восемь минут после деплоя: десятки тысяч ошибок гидрации через onRecoverableError. Вторичный сигнал: регрессии CLS и LCP — страницы видимо мигали. Ущерб. Каждый SSR-просмотр в течение 3,5 часов перерисовывался на клиенте с нуля — серверный HTML выброшен, вспышка контента на каждой загрузке, двойная стоимость рендера ровно на тех устройствах, которые меньше всего могли её себе позволить; SEO-снапшоты зафиксировали состояние вспышки. Корневая причина. Деплой добавил в шелл страницы строку «Последнее обновление»: new Date(ts).toLocaleString() — на сервере отрендерено в UTC с серверной локалью, на клиенте пересчитано в таймзоне и локали пользователя. Серверный HTML и первый клиентский рендер расходились в тексте у каждого пользователя вне UTC; React 18 считает расхождение невосстановимым для поддерева и падает в полный клиентский рендер, отчитываясь через onRecoverableError. Паттерн обобщается: любой вход рендера, различающийся через границу — Date.now(), Math.random(), таймзона, локаль, чтения window, фиче-флаги, вычисленные в разное время, — даёт тот же шторм. Смягчение. Реверт; частота расхождений вернулась к базовой. Предложенное не-лечение: suppressHydrationWarning. Он глушит отчёт о диффе для текста одного элемента и велит React оставить серверное значение — он для намеренно расходящегося контента (таймстамп, который вы поправите после маунта), действует на один уровень вглубь и ничего не делает со структурными расхождениями; заклеивать им шторм — значит оставить в UI устаревшие серверные значения, а корневую причину — в строю. Лечение. Рендерить значения как чистую функцию входов, общих для обеих сторон: форматировать даты после маунта в эффекте (приняв осознанный контролируемый плейсхолдер) или явно передавать таймзону/локаль пользователя из запроса, чтобы сервер и клиент вычисляли идентичные строки. Предотвращение. Метрика частоты расхождений гидрации по кольцу релиза, заведённая на onRecoverableError и гейтящая раскат — штормы расхождений имеют форму деплоя, и канарейка ловит их за минуты; плюс e2e-проверка SSR-страниц с таймзоной не-UTC и неанглийской локалью — две строки CI-конфига, навсегда закрывающие этот класс.
Инцидент 3: утечка, ронявшая послеобеденные сессии
Обнаружение. Ни одного алерта. Тикеты поддержки, сходящиеся на «тормозит после обеда» от бэк-офисных пользователей; корреляционный анализ показал, что деградация следует за возрастом сессии, а не временем суток. Краши вкладок («Опаньки…») у пользователей с 8-часовыми сессиями. Ущерб. Ежедневная потеря продуктивности операционной команды примерно три недели до диагноза; куча на момент краша ~1,5 ГБ. Корневая причина. Виджет рыночного фида подписывался в эффекте без cleanup:
useEffect(() => {
feed.subscribe(handleTick); // замыкание захватывает rows, filters, props
// нет return — обработчик никогда не снимается
}, [route]);Каждое повторное посещение роута добавляло ещё один обработчик в массив слушателей фида; каждый обработчик замыкался на props того рендера, включая массив на 5 000 строк. Эмиттер держал каждое замыкание достижимым, поэтому целые размонтированные деревья компонентов оставались удержанными — GC корректно работал по мусору, который мусором не был. Триаж по снапшотам кучи сделал это читаемым за двадцать минут: три снапшота за час, сортировка по дельте retained-размера — массив слушателей на 14 000 записей, каждая удерживает старый рендер. Смягчение. Хотфикс с cleanup (return () => feed.unsubscribe(handleTick)); рекомендация перезагрузить затронутые вкладки. Предотвращение. Пункт чек-листа ревью про cleanup эффектов — каждый подписывающийся эффект возвращает отписку (двойной прогон StrictMode делает нарушение видимым в dev — ровно для этого он и существует); и длинносессионный синтетический тест: безголовый браузер, ежечасно проигрывающий 4-часовую сессию и ассертящий ограниченный рост кучи — тест, представляющий пользователя, которого ни один 90-секундный лабораторный прогон не моделирует.
▸Почему это работает
Почему настаивать на blameless-формате? Потому что все три корневые причины — одно честное предложение: нестабильная dep, рендер, зависящий от таймзоны, отсутствующий cleanup, — а в культуре поиска виноватых это предложение никогда не будет написано. Инженер, знающий, что случилось, обкладывает постмортем туманом («сложное взаимодействие факторов»), следующая команда выкатывает тот же баг, и организация не узнаёт ничего по цене полного инцидента. Blameless не значит без последствий; это значит, что единица анализа — система, допустившая баг: отсутствующий линт, несуществующая метрика расхождений, неотревьюенный эффект, — потому что люди повторяют ошибки, а системы не обязаны. Тест хорошего постмортема: его пункты предотвращения — механизмы (метрика, гейт, тест), а не намерения («быть внимательнее с deps»).
Три инцидента как один урок
Выстройте инциденты в ряд — и паттерном окажется колонка обнаружения. Ни один не начался с исключения в приложении: цикл был всплеском длинных задач в RUM, шторм — потоком ошибок из собственного пути восстановления React, утечка — корреляцией тикетов поддержки. Мониторинг, называющий отказные режимы React по именам — длинные задачи по кольцу, частота расхождений гидрации по деплою, рост кучи на час сессии, — и есть то, что отделяет двадцатиминутный диагноз от трёхнедельного. И каждый пункт предотвращения, переживший ревью, был механизмом, а не резолюцией: линт, который остаётся включённым; метрика, гейтящая раскаты; синтетика, проживающая длинную сессию. Пишите постмортем так, чтобы следующая команда могла найти его грепом. Когда пишете собственный — проверьте по правилу: может ли новый инженер прочитать его, найти системный пробел и закрыть пункт предотвращения без единого вопроса коллегам?
После шторма расхождений коллега предлагает добавить suppressHydrationWarning на элемент таймстампа и закрыть инцидент. В чём точное возражение?
- 01Восстановите инцидент 1: механизм цикла, почему не вылетело исключение и пакет предотвращения.
- 02Для шторма расхождений и утечки: назовите сигнал обнаружения, корневую причину, лечение-ловушку и предотвращение уровня механизма.
Три продакшен-инцидента, один структурный вывод: React отказывает деградацией, а не падением, поэтому колонка обнаружения в постмортеме значит не меньше корневой причины. Цикл рендера — нестабильная объектная dep, питающая эффект, чей setState жил внутри промиса, — выжигал CPU в каждой открытой вкладке при зелёном трекере ошибок, потому что защита Maximum update depth считает синхронные каскады, а асинхронная граница обнуляла её на каждой итерации; сработавшей растяжкой стал RUM-алерт на длинные задачи по кольцу релиза, а долговечным предотвращением — дисциплина стабильности deps поверх exhaustive-deps, который перечисляет deps, но не ручается за их идентичность. Шторм расхождений гидрации — toLocaleString в шелле страницы, дающий разный текст на сервере и клиенте, — заливал onRecoverableError на 40 000 ошибок в минуту и заставлял React выбрасывать серверный HTML на каждом просмотре; suppressHydrationWarning — лечение-ловушка, санкционирующее расхождение на глубину одного элемента, оставляя устаревшие значения и причину в строю, а настоящее лечение — чистота рендера над общими входами: форматировать после маунта или передавать таймзону и локаль явно; предотвращение — метрика частоты расхождений, гейтящая раскат, плюс e2e под не-UTC таймзоной. Утечка подписки — subscribe без cleanup в долгоживущем дашборде — удерживала 14 000 замыканий-слушателей, каждое с мёртвым рендером на 5 000 строк, пока восьмичасовые сессии не умирали на 1,5 ГБ; обнаружение пришло из корреляции тикетов с возрастом сессии, триаж — из дельт снапшотов кучи, предотвращение — из чек-листа cleanup, который StrictMode принуждает в dev, и длинносессионной синтетики, моделирующей пользователя, которого лабораторные прогоны не видят. Blameless-формат делает знание передаваемым: называйте дыру в системе, а не инженера, и выкатывайте предотвращение механизмами — линтами, метриками, гейтами, синтетикой, — потому что намерения выветриваются, а машинерия нет. Теперь, когда встретите продакшен-инцидент React, откройте шаблон постмортема первым делом: назовите сигнал обнаружения раньше корневой причины — именно сигнал поймает следующий вариант этого бага до того, как он дойдёт до пользователей.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.