{ "index": 255, "slug": "editorial-2020-12-practice-incident-review", "title": "Разбор инцидента: как отделить факт от поспешного исправления", "excerpt": "Сервис отвечает ошибкой, команда меняет timeout, а причина остаётся неизвестной. Разбираем учебный случай через наблюдение, гипотезу, ограниченное действие, проверку и профилактику.", "contentHtml": "

Рассмотрим учебный случай: сервис предварительного расчёта заказа начал возвращать blocked вместо цены. Пользователь видит отказ, но инженер пока не знает, где возникла ошибка: поле могло исчезнуть во входе, mapper-е или контракте downstream-сервиса. Цена поспешной правки — не только несколько минут простоя. Новый retry может увеличить нагрузку, timeout может спрятать отказ, а возврат старой версии может оставить причину без защиты.

\n

Тезис: сначала сохраняем наблюдаемый факт, затем формулируем одну проверяемую гипотезу, выбираем ограниченное действие и заранее называем проверку. Временный обход снижает воздействие, но не доказывает причину. Успешный ответ на одном запросе тоже не доказывает готовность системы: нужно проверить отрицательный путь и защиту от повторения.

\n

Сначала зафиксировать симптом

\n

Симптом отвечает на вопрос «что заметил пользователь или мониторинг?». Запишите маршрут, состояние, окно времени и цену повторения. В нашем сценарии POST /preview-order возвращает blocked для заказа с валютой RUB; расчёт не показывается; повторная попытка не помогает. Это исходное наблюдение. Фраза «сломался mapper» уже содержит гипотезу и не должна подменять факт.

\n

После симптома нужны одно или два наблюдения на границах системы. У каждого должны быть источник и способ повторного получения. Например, первое наблюдение — ответ вызова, второе — значение поля во входе адаптера. Пока не проверены промежуточные значения, мы не знаем, где исчезла валюта. Учебная запись E2 может остаться верной, даже если последующая гипотеза окажется неверной.

\n
От наблюдения к проверяемому действию
НаблюдениеРабочая гипотезаПроверкаОграниченное действие
preview-order возвращает blockedОбязательное поле потерялось на одной из границСравнить объект до и после mapper-аНе менять timeout; остановить новый mapper только в учебной ветке
В логах есть «mapper error»Сообщение приняли за доказанную причинуНайти точку записи и исходное значение поляСохранить исходный лог и проверить контракт
После возврата версии ответ стал allowedОбход вернул прежнее поведение, но причину не подтвердилПовторить контролируемый вход и отрицательный путьОставить mitigation и открыть проверку причины
Ошибка исчезла на одном запросеОдин успех приняли за устойчивое исправлениеПовторить сценарий и проверить соседние маршрутыНе объявлять готовность без критерия
\n

Механизм: пять разных записей

\n

Хорошая временная шкала связывает разные типы утверждений, но не смешивает их. Симптом показывает воздействие. Наблюдение фиксирует значение на границе. Гипотеза объясняет наблюдение и может измениться. Действие меняет ограниченный участок. Проверка измеряет эффект именно этого действия. Профилактика меняет будущий путь и получает собственную проверку.

\n

Если написать «mapper сломал заказ, мы откатили его и добавили тест», читателю приходится восстанавливать причинную цепочку. Неясно, видел ли кто-то отсутствие поля, что именно откатили и какой тест должен пройти. Разделение записей делает вывод скромнее, зато его можно проверить.

\n
const events = [\n  { id: 'E1', kind: 'observation', value: 'preview-order -> blocked' },\n  { id: 'E2', kind: 'observation', value: 'pricingInput.currency === undefined' },\n  { id: 'H1', kind: 'hypothesis', basedOn: ['E2'], value: 'mapper drops currency' },\n  { id: 'A1', kind: 'action', basedOn: ['H1'], value: 'use known mapper in demo branch' },\n  { id: 'V1', kind: 'verification', basedOn: ['A1'], expect: 'state === allowed' },\n];\nconst byId = new Map(events.map((event) => [event.id, event]));\nconst position = new Map(events.map((event, index) => [event.id, index]));\nconst referencesExist = events.every((event) =>\n  !event.basedOn || event.basedOn.every((id) => byId.has(id)),\n);\nconst referencesPrecede = events.every((event) =>\n  !event.basedOn || event.basedOn.every((id) => position.get(id) < position.get(event.id)),\n);\nconst validOrder = referencesExist && referencesPrecede;\nconsole.log(validOrder); // true
\n

Этот фрагмент проверяет две вещи: каждая ссылка указывает на существующее событие, а основание стоит раньше события, которое на него ссылается. Он не подключается к HTTP, очереди, базе, логам или production и не доказывает корневую причину. Его задача — не дать записи H1 сослаться на несуществующий факт, а V1 — появиться раньше действия A1.

\n
Учебная временная шкала разбора: симптом, наблюдения, гипотеза, ограниченное действие, проверка и профилактика
Схема показывает зависимость событий: наблюдение предшествует гипотезе, действие — проверке, а профилактика не выдаётся за выполненную работу.
\n

Пример: обязательное поле исчезло на границе

\n

Представим два mapper-а. Старый переносит amount и currency. Новый собирает объект из разрешённого списка полей, но в список попал только amount. Downstream-адаптер принимает объект, проверяет валюту и возвращает blocked. Это модель для воспроизведения, а не утверждение о реальном проекте.

\n
function mapPreview(input) {\n  return { amount: input.amount };\n}\n\nfunction checkPricingInput(mapped) {\n  if (mapped.currency === undefined) {\n    return { state: 'blocked', reason: 'currency-missing' };\n  }\n  return { state: 'allowed' };\n}\n\nconst input = { amount: 100, currency: 'RUB' };\nconst mapped = mapPreview(input);\nconst result = checkPricingInput(mapped);\nconsole.log(mapped, result); // { amount: 100 } { state: 'blocked', ... }
\n

В этом примере ошибка видна на выходе mapper-а: currency исчезла до проверки downstream. Но один такой запуск ещё не доказывает, что именно новый mapper вызвал реальный отказ. В настоящем сервисе нужно снять значения до и после границы из разрешённого журнала, тестового стенда или другого безопасного источника.

\n

Сначала полезно зафиксировать RED-проверку — она должна упасть на дефектном mapper-е:

\n
if (mapped.currency !== input.currency) {\n  throw new Error('currency was lost at mapper boundary');\n}
\n

После исправления mapper переносит оба поля. Проверка должна подтвердить и рабочий, и отрицательный путь:

\n
function mapPreviewFixed(input) {\n  return { amount: input.amount, currency: input.currency };\n}\n\nconst allowed = checkPricingInput(mapPreviewFixed({ amount: 100, currency: 'RUB' }));\nconst missing = checkPricingInput(mapPreviewFixed({ amount: 100 }));\nif (allowed.state !== 'allowed' || missing.state !== 'blocked') {\n  throw new Error('preview contract check failed');\n}\nconsole.log('PASS: positive and negative paths are explicit');
\n

Этот GREEN-пример проверяет только локальный контракт двух функций. Он не заменяет интеграционный тест, проверку реального формата запроса или наблюдение после релиза. Если RED-проверка не падает на дефектной версии, H1 нужно заменить: поле исчезло раньше, вход сформирован иначе или downstream читает другое имя.

\n

Действие не равно устранению причины

\n

Во время сбоя допустимо сначала остановить ухудшение. Такое действие называют mitigation: оно снижает воздействие, но не закрывает причинный вопрос. Граница должна быть записана явно: «для маршрута preview-order отключили новый mapper; другие маршруты не меняли; на контролируемом входе ожидаем allowed». Это описание плана проверки, а не доказанный результат.

\n

Не выбирайте retry или timeout только потому, что они привычны. Повтор помогает при временном отказе, но не возвращает пропущенное поле. Больший timeout меняет длительность ожидания, но не контракт. Если наблюдение указывает на отсутствие значения, проверка должна пройти через это значение. Если новое наблюдение покажет 503 от зависимости, появится другая гипотеза и другая проверка.

\n

Отрицательный путь обязателен. После контрольного вызова проверьте заказ без валюты: он должен получить предсказуемый отказ, а не пройти дальше. Повторите сценарий после возврата версии и убедитесь, что изменение не коснулось маршрутов, не участвовавших в симптоме. Иначе команда докажет только один удачный ответ.

\n

Кто и что держит во время сбоя

\n

Фактологичный разбор не ищет виноватого человека, но требует технической ответственности. Для каждой операции должны быть понятны владелец решения, исполнитель изменения и канал обновлений. Один человек держит общую картину и решает, что делать дальше. Другой выполняет изменение. Третий, если такая роль нужна, фиксирует состояние и проверяет результат. В маленькой команде роли могут совмещаться, но функции нельзя потерять.

\n

Когда несколько инженеров одновременно «просто смотрят» и запускают свои правки, временная шкала перестаёт объяснять результат. Запишите действие до запуска, его область и ожидаемый сигнал. Не смешивайте откат, изменение конфигурации и проверку нового mapper-а в одну неделимую операцию. После успеха должно быть ясно, что именно помогло.

\n

Порядок действий

\n
  1. Зафиксируйте маршрут, состояние, окно времени и цену повторения. Не называйте причину.
  2. Сохраните один или два факта на границах: вход, выход, код ответа, поле или лог с точным местом записи.
  3. Проверьте, что факты можно получить снова без изменения системы. Если нельзя, отметьте пробел.
  4. Сформулируйте одну гипотезу и укажите, на какое наблюдение она опирается.
  5. Выберите ограниченное действие с владельцем, областью, условием отмены и ожидаемым результатом.
  6. Проверьте действие контролируемым сценарием. Повторите успешный и отрицательный путь.
  7. Если проверка не прошла, отмените действие в разрешённых границах или смените гипотезу. Не переписывайте исходное наблюдение.
  8. Разделите mitigation и исправление причины. Для каждого укажите собственный статус и следующий check.
  9. Добавьте профилактику: обязательное поле в контракт, тест на границе, сигнал или runbook с владельцем.
  10. Закройте разбор только после проверки профилактики и явного критерия готовности.
\n

Ограничения

\n

Пять типов записи не заменяют мониторинг, резервирование, коммуникацию или анализ безопасности. Они не определяют допустимое время восстановления и не говорят, какой rollback безопасен для конкретной базы. При внешнем побочном эффекте отдельно проверьте идемпотентность, повторную доставку и состояние данных. Выключение маршрута не отменяет уже отправленный платёж, созданный заказ или опубликованное сообщение.

\n

Учебные значения и имена в коде вымышлены. Здесь нет production-метрик, реального трафика, подтверждённого времени восстановления или доказательства, что конкретная команда применяла этот сценарий. Не переносите allowed, blocked и выбранный mapper в свою систему без проверки её контракта. Если граница неизвестна, безопасное действие — остановить расширение изменения и собрать недостающие данные.

\n

Не всякий сбой требует большого postmortem. Но если затронут пользовательский путь, вовлечена вторая команда, повторяется ошибка или исправление меняет состояние данных, короткая запись должна превратиться в отдельный разбор с владельцем и follow-up. Нельзя объявлять профилактику выполненной только потому, что её записали.

\n

Проверяемый критерий готовности

\n

Разбор готов, когда другой инженер без устного пересказа может назвать исходный симптом, увидеть два наблюдения, проследить ссылку от гипотезы к действию и найти проверку действия. Для отрицательного пути известны ожидаемый отказ и граница его действия. Mitigation отделена от устранения причины. Профилактика имеет владельца, срок или очередь и собственный способ проверки.

\n

Практический критерий простой: запись позволяет повторить контрольный сценарий, получить ожидаемый результат, увидеть предсказуемый отказ на плохом входе и объяснить, что изменится при провале проверки. Если причина ещё неизвестна, это честно указано. Если действие временное, указана граница возврата. Если хотя бы одного элемента нет, разбор не готов к закрытию.

\n

Проверяемые источники

\n" }