diff --git a/editorial/agent-rewrites/035.json b/editorial/agent-rewrites/035.json index cf4fee2..30a4a9a 100644 --- a/editorial/agent-rewrites/035.json +++ b/editorial/agent-rewrites/035.json @@ -3,5 +3,5 @@ "slug": "editorial-2027-01-mechanism-debugging-decade", "title": "Trace ID связывает события, но не доказывает причину", "excerpt": "Как читать trace, log и metric вместе, проверять разрывы контекста и не принимать совпадение идентификатора за доказательство причины.", - "contentHtml": "

В двух журналах найден один trace ID. Временные метки почти совпадают. Один span длится дольше остальных. Команда объявляет его причиной задержки и меняет таймаут в этом сервисе. Через день задержка возвращается: запросы ждали соединение в шлюзе, а длинный span лишь включал это ожидание. Цена ошибки — потерянное время, лишний rollback и новый побочный эффект.

\n

Trace ID отвечает на вопрос «к каким данным относится эта запись?». Он не отвечает на вопрос «какая операция вызвала отказ или задержку?». Причинный вывод требует проверить структуру трассы, интервалы, статус, локальные журналы и путь, по которому запрос действительно прошёл.

\n

Механизм: три сигнала и три разных вопроса

\n

Log фиксирует событие в одном процессе: сообщение, локальное состояние, уровень и время. Span описывает операцию: начало, конец, родителя, сервис и атрибуты. Metric агрегирует много запросов и показывает частоту, распределение или долю ошибок. Один сигнал не заменяет другой.

\n

W3C Trace Context задаёт формат передачи traceparent и tracestate между HTTP-границами. Так разные сервисы могут продолжить общий контекст. Инструмент может только передать контекст, не создав подробный span. Очередь, фоновая задача, retry или библиотека без интеграции могут остаться за пределами записи.

\n

Поэтому trace ID создаёт область поиска, а parent/child-связи задают наблюдаемую структуру. Если у span нет родителя, это не доказывает, что операция независима. Возможны потеря записи, неверное поле, sampling или отдельная работа, ошибочно попавшая в trace. Отсутствие события в одном источнике означает только, что его там не нашли.

\n
\"Сравнение
Корреляционный ключ связывает записи. Причину подтверждает только согласованный набор независимых признаков.
\n

Пример: найти разрыв, а не назначить виновника

\n

Следующий код — учебный пример. Он работает с заранее заданным массивом и ничего не знает о production-трафике. Его задача — показать отрицательный путь: система должна явно отметить отсутствующего родителя, а не дорисовать целую цепочку.

\n
const spans = [\n  { traceId: 't-7', spanId: 'gateway', parentSpanId: '', service: 'gateway', durationMs: 22 },\n  { traceId: 't-7', spanId: 'api', parentSpanId: 'gateway', service: 'api', durationMs: 81 },\n  { traceId: 't-7', spanId: 'db', parentSpanId: 'missing', service: 'db', durationMs: 4 }\n];\n\nconst byId = new Map(spans.map((span) => [span.spanId, span]));\nconst links = spans.map((span) => ({\n  service: span.service,\n  parent: span.parentSpanId\n    ? (byId.has(span.parentSpanId) ? 'present' : 'missing')\n    : 'root'\n}));\n\nconsole.log(links);\n// gateway: root; api: present; db: missing
\n

Результат даёт один проверяемый факт: у db нет родителя в принятом наборе. Он не говорит, что db вызвал задержку. Следующая проверка зависит от вопроса. Нужно узнать, потерялся ли span, не перепуталось ли поле parentSpanId, не создалась ли операция вне контекста и не отфильтровал ли сборщик запись.

\n

Длительность тоже требует контекста. Если gateway ждёт upstream 800 мс, эти 800 мс могут включать DNS, установку соединения, очередь, retry и чтение ответа. Долгий span показывает время, проведённое внутри его границ. Он не раскладывает это время по причинам без дочерних span-ов или дополнительных журналов.

\n

Симптомы и проверяемые действия

\n
Симптом → причина → проверка → действие
СимптомВозможная причинаПроверкаДействие
Один trace ID есть в gateway и API, но ответа нетСервис не записал span или запрос прервался до негоСверить access log, статус соединения, sampling и окно времениОтметить разрыв; не называть API причиной без записи операции
У span есть parentSpanId, но родителя нетПотеря span, ошибка экспорта или неверная связьПроверить полный экспорт, формат ID и дубликаты span-idИсправить передачу или сбор; сохранить missing parent как сигнал
Самый длинный span совпал с пиком latencySpan включает ожидание upstream, retry или очередьСопоставить дочерние интервалы, status, retry count и метрику populationРазделить время по операциям; не оптимизировать сервис по одному trace
В log нет записи с нужным trace IDПоле не попало в журнал, запись отбросил collector или выбран другой IDПроверить схему, доставку, источник и request-id на границеСчитать источник неполным и продолжить по access/metric, не делать вывод об отсутствии события
\n

Действия по порядку

\n
  1. Зафиксировать конверт симптома: метод, маршрут, статус, размер ответа, timestamp, длительность, trace ID и границу, на которой получена запись.
  2. Проверить формат trace-id и span-id. Убедиться, что сервисы не меняют trace ID без явной новой границы и не смешивают его с request-id.
  3. Построить граф parent/child. Отдельно отметить root, missing parent, duplicate span-id, пустой service.name и операции с разными trace ID.
  4. Сверить start/end span-ов с локальными временными метками. Учесть clock skew, асинхронную передачу, retry, очередь и время ожидания соединения.
  5. Сопоставить span с application log по span-id или request-id. Metric использовать для проверки масштаба: единичный trace должен быть сопоставим с общей картиной запросов.
  6. Сформулировать узкий вывод. Например: «gateway наблюдал задержку чтения ответа» или «контекст потерян между API и worker». Не писать «API был причиной» без различающего доказательства.
  7. Только после этого менять код, конфигурацию или лимит. Повторить тот же сценарий и проверить, исчез ли исходный симптом, не ухудшились ли соседние метрики и сохранился ли контекст.
\n

Ограничения

\n

Sampling может исключить нужный span. Tail-based filtering может оставить только часть цепочки. Collector может получить события не по порядку или отбросить запись при перегрузке. Разные часы на узлах искажают сравнение timestamps. Асинхронный consumer может законно продолжить работу после завершения parent span. Для него нужны отдельные связи producer, сообщения и consumer.

\n

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

\n

Критерий готовности

\n

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

\n

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

\n" + "contentHtml": "

В двух журналах найден один trace ID. Временные метки почти совпадают, а один span заметно длиннее соседних. Команда объявляет его причиной задержки и увеличивает таймаут сервиса. На следующем пике задержка возвращается: запросы ждали соединение в шлюзе, а длинный span только включал это ожидание. Ошибка стоила времени, rollback и нового побочного эффекта.

\n

Trace ID отвечает на вопрос «к каким данным относится запись?». Он не отвечает на вопрос «какая операция вызвала отказ или задержку?». Чтобы перейти от корреляции к рабочей гипотезе, нужно сверить граф span-ов, интервалы, статусы, локальные события, метрики и реальный путь запроса.

\n

Сначала разделите четыре сущности

\n

Trace — вся наблюдаемая цепочка, которая может проходить через несколько компонентов. Span — отдельная операция с началом, концом, атрибутами и связью с другой операцией. Log — запись события, произошедшего в процессе. Metric — агрегированное измерение за окно времени: счётчик, распределение, доля или значение. Эти сущности дополняют друг друга, но не имеют одинаковой доказательной силы.

\n

В стандарте W3C Trace Context поле traceparent переносит версию, trace-id, parent-id и флаги. trace-id идентифицирует весь trace, а parent-id показывает, какой идентификатор операции передал вызывающий компонент. Это контракт передачи контекста, а не протокол доказательства причинности.

\n

Для HTTP такой контекст передаётся заголовками traceparent и необязательным tracestate. Компонент может только переслать полученный контекст, не создав подробный span. Поэтому наличие одинакового trace-id в двух записях ещё не говорит, что между ними корректно записана связь parent/child или что одна операция вызвала другую.

\n

OpenTelemetry прямо разделяет traces, metrics и logs: trace показывает путь запроса, metric — измерение во время работы, log — запись события. На практике полезный вывод появляется на пересечении сигналов. Trace локализует участок, log объясняет локальное состояние, metric проверяет, является ли наблюдение единичным или массовым.

\n
\"Сравнение
Trace ID сужает поиск. Причину задержки подтверждает согласованный набор наблюдений, а не самое заметное значение в одном сигнале.
\n

Почему одинаковый ID не равен причине

\n

У trace есть две разные структуры. Идентификатор собирает записи в одну область поиска, а parent/child-связи описывают наблюдаемое отношение операций. Даже корректное отношение не означает, что родитель «виноват»: родитель может включать ожидание очереди, DNS, установку соединения, retry или чтение ответа. Для причины нужно найти различающий признак внутри интервала.

\n

Рассмотрим запрос GET /profile, который проходит через gateway, API и worker. Gateway записал 802 миллисекунды, API — 91 миллисекунду, worker — 14 миллисекунд. Если внутри gateway нет span ожидания пула соединений, число 802 показывает длительность границы gateway, но не объясняет все 802 миллисекунды. Утверждение «медленный API» противоречит этим данным: его дочерний интервал покрывает только часть времени.

\n

С sampling нужно быть особенно осторожным. Флаг sampled в W3C Trace Context сообщает о решении записи, но не гарантирует, что каждая система сохранила все события. Отдельный span может не попасть в экспорт, collector может быть перегружен, а часть пути может проходить через библиотеку без инструментирования. Отсутствующий span — это разрыв наблюдения, а не доказательство отсутствия операции.

\n

Воспроизводимый отрицательный путь

\n

Ниже учебная проверка заранее записанного набора. Она не подключается к production и не определяет виновный сервис. Её задача — не дорисовать связь, если заявленный родитель отсутствует, и вернуть данные для следующей проверки.

\n
const spans = [\n  { traceId: 't-7', spanId: 'gateway', parentSpanId: null, service: 'gateway', durationMs: 802 },\n  { traceId: 't-7', spanId: 'api', parentSpanId: 'gateway', service: 'api', durationMs: 91 },\n  { traceId: 't-7', spanId: 'worker', parentSpanId: 'missing', service: 'worker', durationMs: 14 }\n];\n\nfunction inspectParents(items) {\n  const byId = new Map(items.map((span) => [span.spanId, span]));\n\n  return items.map((span) => ({\n    service: span.service,\n    durationMs: span.durationMs,\n    parent: span.parentSpanId === null\n      ? 'root'\n      : (byId.has(span.parentSpanId) ? 'present' : 'missing')\n  }));\n}\n\nconsole.log(inspectParents(spans));\n// gateway: root; api: present; worker: missing
\n

Результат подтверждает только три свойства набора: gateway — корень, api ссылается на существующий span, worker ссылается на отсутствующий. Он не подтверждает, что worker вызвал задержку или что запись worker действительно не существовала.

\n

Для разрыва нужно проверить четыре альтернативы: span потерялся при экспорте, поле связи записано неверно, операция действительно создана на новой границе или в набор попал другой trace. Если разрыв воспроизводится на нескольких запросах, это уже основание проверить propagation на конкретной границе. Один случай остаётся сигналом для поиска.

\n

Как читать время внутри trace

\n

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

\n

Сначала сравните четыре числа: длительность пользовательского запроса, корневого span, критичного дочернего span и внешнего ответа. Затем отметьте пропуски. Если gateway ждёт upstream 700 миллисекунд, проверьте pool wait, connect, DNS, retry и read. Если есть только общий span на 700 миллисекунд, честный вывод звучит так: «задержка наблюдалась на границе gateway; причина внутри интервала не разделена».

\n

Wall-clock полезен, чтобы сопоставить записи разных узлов, но рассинхрон часов меняет порядок близких событий. Для измерения длительности одного процесса используйте его монотонные часы. Если collector доставил записи не по порядку, сортируйте их по времени начала с оговоркой и восстанавливайте связь по ID, а не по строкам в интерфейсе.

\n

Симптом → причина → проверка → действие

\n
Диагностическая матрица для разборов trace
СимптомРабочая гипотезаРазличающая проверкаБезопасное действие
Trace ID есть в gateway и API, но ответа API нетВызов прерван до span или span не экспортированСверить access log, статус соединения, sampling и окно времениПометить неполный путь; не назначать API причиной
У span есть parentSpanId, но родителя нетПотеря записи, ошибка propagation или другой traceПроверить полный экспорт, формат ID, trace-id и дубликаты span-idСохранить missing parent как сигнал и проверить границу передачи
Самый длинный span совпал с p95 latencySpan включает ожидание upstream, пула или retryСопоставить дочерние интервалы, status, retry и population метрикиРазделить время; не менять таймаут по одному trace
В log нет нужного trace IDПоле не записалось, запись потерялась или журнал усечёнПроверить схему, доставку, лимит сообщения и альтернативный request-idСчитать log неполным; подтвердить событие другим сигналом
В trace есть ошибка, а общий error rate не изменилсяЕдиничный запрос не отражает populationСверить окно, labels, маршрут и число запросовОтделить локальный разбор от массового регресса
\n

Протокол расследования по шагам

\n
  1. Зафиксируйте симптом. Запишите маршрут, метод, статус, размер ответа, временной интервал, длительность, trace ID и границу, на которой получена запись.
  2. Проверьте идентификаторы. Убедитесь, что trace-id не смешан с request-id, span-id имеет ожидаемый формат, а повторная попытка получает новую операцию, если это предусмотрено инструментом.
  3. Постройте граф. Отметьте root, parent, missing parent, duplicate span-id, разные trace-id и пустые service.name. Не восстанавливайте невидимые связи догадкой.
  4. Разложите интервал. Сопоставьте start/end, дочерние операции, ожидание очереди, соединение, retry и ответ зависимости. Зафиксируйте clock skew и асинхронные границы.
  5. Сверьте log. Ищите span-id или request-id, статус и локальное состояние. Не принимайте свободный текст сообщения за структурированное доказательство: в syslog сообщение может быть усечено или отброшено при ограничениях доставки.
  6. Проверьте масштаб. Сопоставьте один trace с метриками p50/p95, error rate, объёмом запросов и теми же labels. Trace отвечает за пример, metric — за распространённость.
  7. Сформулируйте узкий вывод. Например: «задержка наблюдалась на gateway между началом запроса и чтением ответа; внутренний источник не разделён». Такой вывод направляет следующую проверку и не выдаёт корреляцию за причину.
  8. Измените ровно одну границу. Добавьте instrumentation, исправьте propagation или измените timeout только после различающей проверки. Повторите тот же сценарий и сравните исходный сигнал с соседними метриками.
\n

Что должно попасть в следующий incident note

\n

Хорошая запись расследования позволяет другому инженеру повторить путь без устного объяснения. Укажите исходный симптом, ссылку на trace, полноту данных, граф связей, проверенные интервалы, альтернативные гипотезы и то, какой факт каждую из них различает.

\n

Полезна форма «наблюдение → интерпретация → граница». Например: «root span gateway длится 802 мс; внутри есть 91 мс API и нет span пула; причина оставшихся 711 мс не установлена; следующий шаг — включить измерение pool wait и повторить нагрузочный сценарий». В такой записи ясно, где заканчиваются данные.

\n

Если после изменения gateway длительность упала с 802 до 120 миллисекунд на повторяемом сценарии, это усиливает гипотезу о выбранной границе. Но для утверждения о причине нужны контрольные прогоны, одинаковая нагрузка, стабильный sampling и отсутствие параллельного изменения зависимости. Одного удачного trace недостаточно.

\n

Ограничения применимости

\n

Метод рассчитан на систему, где команда может получить хотя бы часть trace, локальных событий и агрегированных метрик. Он слабее при head- или tail-sampling, потере collector, коротком хранении, рассинхроне часов, динамическом fan-out и асинхронных очередях. Для worker понадобятся связи producer, сообщения и consumer; parent HTTP span сам по себе не описывает всю фоновую работу.

\n

Одинаковый trace-id не доказывает бизнес-причину, безопасность данных или контрфактическое утверждение «без этого вызова всё было бы быстро». Он также не доказывает, что downstream применил изменение. Для платежа, заказа или другого побочного эффекта нужен отдельный read-state или идемпотентный контракт.

\n

Не помещайте токены, cookie, тело запроса и персональные параметры в trace или log только ради удобного поиска. Trace Context предупреждает об информационных и privacy-рисках, а идентификатор должен сужать поиск, не раскрывать содержимое запроса. Настройка retention, доступа и маскирования зависит от вашей системы и политики данных.

\n

Критерий готовности

\n

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

\n

Изменение готово, когда повторяемый сценарий улучшает исходный сигнал, не ухудшает error rate и latency соседних маршрутов, а propagation и sampling проверены на всех нужных границах. Если причинный вывод всё ещё зависит от невидимого события, его нужно так и записать: данных недостаточно для уверенного назначения виновника.

\n

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

\n" } diff --git a/editorial/agent-rewrites/036.json b/editorial/agent-rewrites/036.json index 81b0809..e6d0476 100644 --- a/editorial/agent-rewrites/036.json +++ b/editorial/agent-rewrites/036.json @@ -1,7 +1,7 @@ { "index": 36, "slug": "editorial-2027-01-practice-debugging-decade", - "title": "502, пустой экран, отказ: как проверить причину по сигналам", - "excerpt": "Практический маршрут от наблюдаемого web-симптома к проверяемой гипотезе: что сохранить, где искать разрыв и когда исправление действительно готово.", - "contentHtml": "

Пользователь открывает страницу, а получает 502. Или видит пустой экран после ответа 200. Или форма отвечает 403, хотя доступ должен быть разрешён. В каждом случае команда быстро называет причину: «упал сервис», «сломался фронтенд», «протух токен». Если первая версия неверна, инженер меняет не тот слой, стирает исходный сигнал и тратит часы на новый симптом. Для пользователя это недоступная операция. Для команды — лишний релиз, повторный инцидент и решение, которое трудно откатить.

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

Что именно наблюдает клиент

HTTP-статус описывает ответ на одной границе. Статус 502 означает, что шлюз или прокси получил недействительный ответ от вышестоящего сервера. Он не сообщает, разорвалось ли соединение, истёк ли таймаут, не совпал ли маршрут или приложение вернуло неожиданные данные. Статус 504 говорит о таймауте шлюза, но тоже не указывает, на каком участке закончился бюджет времени. Поэтому запись «получили 502» — факт, а не диагноз.

Пустой экран требует такой же дисциплины. Браузер мог получить пустой HTML. JavaScript мог не выполнить рендер. API мог вернуть пустой массив по корректному условию. Компонент мог скрыть ошибку и вывести контейнер без содержимого. Все четыре случая выглядят похоже на скриншоте. Различают их тело ответа, ошибки консоли, сетевые события и фактически построенный DOM.

Сохраните исходный сигнал до повтора и до исправления. Минимальный конверт содержит метод, путь, статус, время, длительность, размер ответа, content-type, идентификатор запроса и границу наблюдения. Для браузерного симптома добавьте URL документа, статус API, ошибку консоли и результат проверки DOM. Эти поля не доказывают причину. Они ограничивают поиск и показывают, какого сигнала пока не хватает.

\"Маршрут
Каждый переход от симптома к действию должен добавлять наблюдаемый признак. Иллюстрация показывает маршрут, а не готовый диагноз.

Механизм: гипотеза должна различать причины

Формулируйте гипотезу в условной форме: «если причина X, то при проверке Y увидим Z». Такая запись заранее допускает отрицательный результат. Например: если gateway не получил ответ приложения, в access log будет 502, а в application log не будет события с тем же идентификатором. Если приложение вернуло ошибку, обе записи появятся в одном временном окне, а статусы и длительности будут различаться.

Идентификатор запроса связывает записи, но не доказывает причинность. Он может потеряться на границе, повториться из-за ошибки интеграции или не попасть в sampled trace. Совпадение времени тоже не является связью: параллельные запросы имеют похожие отметки, а часы узлов могут расходиться. Если ключа нет, это результат проверки — «цепочка не связана», — а не разрешение взять ближайшую запись.

Для распределённого маршрута полезно различать три вида данных. Log показывает сообщение и локальное состояние процесса. Span показывает границу операции, родителя и длительность. Metric показывает агрегат по множеству запросов. Trace ID помогает найти общий контекст. Ни один из этих сигналов в одиночку не отвечает на все вопросы. Длинный span не обязательно является причиной задержки. Ошибка в метрике не доказывает ошибку конкретного запроса.

Учебный пример: классифицировать вход, не объявляя root cause

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

function classifyWebSymptom(input) {
  const status = Number(input?.status);
  const body = String(input?.body ?? '');
  const headers = Object.fromEntries(Object.entries(input?.headers ?? {}).map(([key, value]) => [key.toLowerCase(), String(value)]));
  if (status === 502 || status === 504) return headers.traceparent || headers['x-request-id'] ? 'связать gateway и upstream по идентификатору' : 'включить идентификатор на границе';
  if (status === 401 || status === 403) return 'сверить аутентификацию, авторизацию и политику доступа';
  if (status === 200 && /empty|blank|undefined/i.test(body)) return 'сравнить тело ответа API с фактическим DOM';
  return 'сохранить метод, путь, статус, размер и время';
}
console.log(classifyWebSymptom({ status: 502, headers: { traceparent: '00-abc-123-01' }, body: 'Bad Gateway' }));
// связать gateway и upstream по идентификатору

Вход с 502 и traceparent ведёт к сопоставлению записей на двух границах. Вход с 502 без идентификатора ведёт к исправлению наблюдаемости, а не к перезапуску приложения. Вход с 200 и пустым содержимым ведёт к сравнению ответа, ошибок рендера и DOM. Вход 403 ведёт к проверке схемы аутентификации и решения авторизации. Эти маршруты не заменяют расследование. Они не дают функции права выбрать базу данных, прокси или браузер виновником.

Симптом → причина → проверка → действие

Минимальные развилки для первого прохода
СимптомВозможная причинаПроверкаДействие
502 на границеgateway не получил корректный ответ upstreamСопоставить request-id или traceparent; проверить запись приложения в том же окнеРазделить отказ до приложения и ошибку приложения; затем исправлять найденный слой
504 после фиксированного интервалаБюджет времени закончился на gateway, клиенте или зависимостиСравнить таймауты границ и длительности span-овНайти участок, который исчерпал бюджет; не добавлять повтор вслепую
200 и пустой экранпустые данные, ошибка рендера или пустой HTMLСопоставить response body, console error и DOMИсправить контракт данных или рендер; отдельно проверить fallback
403 для ожидаемого пользователярешение политики не совпало с контекстом доступаПроверить токен, claims, scope, ресурс и версию политикиИсправить конкретное условие; не ослаблять всю политику
Запись есть только в одном слоепотеря корреляции, sampling или отказ до следующей границыПроверить формат полей, перенос заголовка и временное окноОтметить разрыв как результат; добавить сигнал перед повтором

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

  1. Запишите симптом без объяснения: метод, путь, статус, время, длительность, размер ответа, content-type и границу, на которой увидели ответ.
  2. Сформулируйте минимум две причины. Для каждой запишите наблюдение, которое должно появиться, и наблюдение, которое её опровергнет.
  3. Проверьте внешний access log и журнал следующего слоя в одном временном окне. Сопоставляйте записи по идентификатору, а не только по пути и секунде.
  4. Разделите синхронный и асинхронный путь. Для очереди или фоновой задачи найдите отдельную связь между producer, сообщением и consumer.
  5. Проверьте отрицательный путь: запрос без токена, просроченный токен, отсутствующий parent span, пустой ответ и превышение таймаута.
  6. Внесите одно изменение в найденном слое. Повторите тот же сценарий с теми же входами и сравните исходный конверт с новым.
  7. Сохраните проверку рядом с исправлением: тест, запрос для воспроизведения, структурированный лог или короткую операционную инструкцию.

Ограничения

Этот маршрут не восстанавливает данные, которых система не записала. Если gateway не переносит идентификатор, связь нельзя честно реконструировать по одному времени. Если trace sampling отбросил span, отсутствие span не означает отсутствие работы. Если прокси переписал статус или тело, нужно искать его access log и правила маршрутизации. Если несколько запросов выполняются параллельно, порядок строк в журнале не равен порядку причин.

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

Статус 502 или 504 также не доказывает, что downstream был недоступен. Причина может быть в несовместимом формате ответа, неверном DNS, закрытом соединении или ограничении шлюза. Статус 403 не доказывает, что пользователь «не имеет доступа» в бизнес-смысле: решение могло использовать устаревшие claims или другую версию политики. Проверяйте именно тот контекст, который использовал компонент.

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

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

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

" + "title": "502, пустой экран и 403: диагностика по различающим сигналам", + "excerpt": "Практический маршрут от web-симптома к проверяемой причине: как сохранить конверт запроса, связать границы и отличить исправление от удачного повтора.", + "contentHtml": "

Пользователь открывает страницу и получает 502. Или видит пустой экран после ответа 200. Или форма возвращает 403, хотя доступ должен быть разрешён. Команда быстро называет причину: «упал сервис», «сломался фронтенд», «протух токен». Если первая версия неверна, инженер меняет не тот слой, стирает исходный сигнал и получает новый симптом. Для пользователя это недоступная операция. Для команды — лишний релиз и повторный отказ.

Диагностика должна идти от наблюдаемого эффекта к различающему сигналу. Сначала сохраните конверт запроса. Затем назовите две причины, совместимые с фактами. Для каждой запишите проверку, которая может её опровергнуть. Только после этого меняйте конфигурацию или код. Такой порядок превращает «похоже на» в последовательность, которую может повторить другой инженер.

Сначала отделите статус от причины

HTTP-статус описывает ответ на одной границе. RFC 9110 определяет 502 как ответ gateway или proxy, который получил недействительный ответ от входного сервера, и 504 как ситуацию, в которой gateway не получил своевременный ответ от upstream. Это полезные факты о границе, но не диагноз: за ними могут стоять несовместимый формат, закрытое соединение, неверный маршрут или исчерпанный таймаут.

403 тоже не равен фразе «у пользователя нет доступа». По RFC 9110 сервер понял запрос, но отказался его выполнить. Причина может находиться в claims, scope, ресурсе или версии политики. Если отправитель получил валидные credentials, но они недостаточны для доступа, 403 соответствует этому результату; выяснить, почему credentials оказались недостаточны, можно только по контексту решения.

Пустой экран требует отдельного разбиения. Браузер мог получить пустой HTML. JavaScript мог завершиться с ошибкой до рендера. API мог вернуть пустой массив по корректному условию. Компонент мог скрыть ошибку и оставить контейнер без содержимого. Все случаи похожи на скриншоте, но различаются телом ответа, сетевыми событиями, ошибками консоли и фактически построенным DOM.

Маршрут диагностики web-запроса от симптома через идентификатор к проверяемому действию
На каждой границе нужен свой наблюдаемый сигнал. Если ключа связи нет, цепочку нельзя честно восстановить по одному времени.

Конверт запроса: что сохранить до повтора

Сохраните один пример сбоя и один успешный пример с тем же маршрутом до изменения системы. Минимальный конверт содержит метод, путь, статус, время с часовым поясом, длительность, размер ответа, content-type, request-id или traceparent и границу наблюдения. Для браузерного симптома добавьте URL документа, статус API, ошибку консоли и результат проверки DOM.

Не записывайте в такой конверт cookies, токены, полный пользовательский ввод и другие секреты. Для корреляции достаточно технического идентификатора и безопасной части контекста. Поле boundary должно отвечать на вопрос «где это наблюдали»: browser, gateway или application. Без этой пометки одинаковый статус легко принять за одно и то же событие.

{
  'request_id': 'req-7f31',
  'traceparent': '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01',
  'method': 'GET',
  'route': '/reports',
  'status': 502,
  'duration_ms': 184,
  'response_bytes': 11,
  'content_type': 'text/plain',
  'boundary': 'gateway'
}

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

Гипотеза должна разделять причины

Полезная гипотеза имеет форму «если X, то при проверке Y увидим Z». В ней есть ожидаемый признак и результат, который заставит отказаться от объяснения. Например: если gateway не получил ответ приложения, в его access log будет 502, а в журнале приложения не появится событие с тем же идентификатором. Если приложение сформировало ошибочный ответ, записи будут на обеих границах, а статусы, размеры или длительности могут различаться.

Идентификатор запроса связывает записи, но не доказывает причинность. Заголовок мог потеряться на прокси, трасса могла быть отобрана sampling-механизмом, а часы узлов могут быть настроены с разным смещением. При отсутствии ключа вывод звучит так: «граница не наблюдаема», а не «виновата ближайшая строка журнала». Это отрицательное знание подсказывает, какой сигнал добавить перед следующим повтором.

Разделяйте три типа телеметрии. Лог — запись отдельного события и его контекста. Span — отрезок операции внутри трассы, с родителем и длительностью. Метрика — измерение, собранное во времени для множества операций. Трасса показывает путь конкретного запроса, но не гарантирует, что каждый участок был записан. Метрика показывает масштаб проблемы, но не выбирает виновный запрос. Лог даёт детали, но без общего ключа плохо связывается с соседними слоями.

Воспроизводимый локальный разбор

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

function nextCheck(event) { const status = Number(event.status); const hasTrace = typeof event.traceparent === 'string' && event.traceparent.length > 0; const appSeen = event.application_seen === true; const domReady = event.dom_ready === true; if ((status === 502 || status === 504) && !hasTrace) return 'добавить корреляцию на границе gateway'; if ((status === 502 || status === 504) && hasTrace && !appSeen) return 'проверить маршрут gateway, DNS и соединение до upstream'; if ((status === 502 || status === 504) && hasTrace && appSeen) return 'сравнить ответ приложения, длительности и таймауты границ'; if (status === 200 && !domReady) return 'сравнить HTML, ошибки консоли, API-response и DOM'; if (status === 403) return 'сверить контекст авторизации и решение политики доступа'; return 'зафиксировать следующий сигнал для этой границы'; } const cases = [{ status: 502, application_seen: false }, { status: 502, traceparent: '00-abc-123-01', application_seen: true }, { status: 200, dom_ready: false }, { status: 403, application_seen: true }]; console.log(cases.map(nextCheck));

Первый объект показывает, почему отсутствие идентификатора — самостоятельный дефект наблюдаемости. Второй переводит проверку к сравнению ответа и таймаутов, но не доказывает неисправность приложения. Третий разделяет «HTTP 200» и «страница готова». Четвёртый отправляет расследование к контексту доступа, а не к случайному перезапуску. Функция полезна как тест маршрута диагностики; её нельзя использовать как готовое правило прокси или авторизации.

Матрица симптомов и проверок

Первый проход по четырём web-симптомам
НаблюдениеДве рабочие гипотезыРазличающий сигналБезопасное действие
502 на gatewayНекорректный ответ upstream или отказ до приложенияtraceparent/request-id на gateway и запись приложения в том же контекстеСначала найти границу разрыва; затем менять маршрут или код
504 через близкий интервалИстёк таймаут gateway или зависла зависимостьДлительности span-ов и значения таймаутов на каждой границеСопоставить бюджет времени; не увеличивать таймаут без измерения
200 и пустой экранПустые данные или ошибка рендераТело HTML/API, console error и фактический DOMПовторить с теми же входами и проверить отрицательный путь
403 для ожидаемого пользователяНеверный контекст или отказ политикиТокен, claims, scope, ресурс и версия правилаИзменить конкретное условие; не ослаблять всю политику
Событие есть только на одной границеПотерян заголовок или отсутствует записьФормат переноса идентификатора и временное окноСчитать цепочку несвязанной и добавить сигнал

Порядок расследования

  1. Опишите эффект без объяснения: кто его увидел, какой метод и маршрут использовал, какой статус и размер ответа получил.
  2. Сохраните время с часовым поясом, длительность, content-type, request-id или traceparent и границу наблюдения.
  3. Назовите две причины, совместимые с фактами. Для каждой запишите ожидаемый признак и результат, который её опровергнет.
  4. Проверьте gateway и следующий слой в одном временном окне. Приоритет у общего идентификатора; совпадение пути и секунды недостаточно.
  5. Для пустой страницы отдельно проверьте HTML документа, сетевой ответ API, ошибки JavaScript и DOM после выполнения кода.
  6. Для 401/403 повторите безопасный сценарий с валидным, просроченным и отсутствующим контекстом доступа, не публикуя секреты в логе.
  7. Проверьте отрицательный путь: таймаут, потерянный parent span, пустой ответ, отсутствие права и повторную попытку после отказа.
  8. Внесите одно изменение в найденном слое, повторите исходный сценарий и сравните старый конверт с новым. Если одновременно изменились прокси, приложение и клиент, результат нельзя приписать одному изменению.
  9. Оставьте проверку рядом с исправлением: тест, запрос для воспроизведения, структурированный лог или короткую операционную инструкцию.

Как отличить исправление от удачного повтора

Один успешный запрос не закрывает расследование. Сравните минимум четыре поля: статус, длительность, размер ответа и наличие связанной записи на следующей границе. Для пользовательской страницы добавьте DOM и console error. Успешный результат должен повторяться на том же входе, а отрицательный сценарий должен по-прежнему завершаться ожидаемым отказом.

Если исправили маршрут, отдельно проверьте старый и новый upstream. Если изменили таймаут, измерьте время до отказа и нагрузку на зависимость. Если добавили retry, проверьте число попыток и суммарную стоимость запроса: повтор может увеличить нагрузку и скрыть первичный сбой. Если изменили политику доступа, проверьте соседние роли, чтобы разрешение одному контексту не стало разрешением всем.

Ограничения применимости

Этот метод работает только с доступными наблюдениями. Он не восстанавливает событие, которое система не записала, и не превращает близкое время в доказательство связи. Если gateway удаляет traceparent, нужно исправлять перенос или добавлять собственный безопасный request-id. Если sampling отбросил span, отсутствие span не означает отсутствие операции.

Семантика статуса зависит от границы. Согласно RFC 9110, 502 относится к недействительному ответу от входного сервера, к которому обращался gateway, а 504 — к отсутствию своевременного ответа. Конкретная реализация может вернуть 502 из-за формата ответа, соединения или настройки маршрута. Поэтому статус задаёт направление поиска, но не выбирает единственную причину.

Учебная функция намеренно грубая: она не учитывает CDN, кеш, редиректы, несколько upstream, фоновые очереди, разные протоколы и локальные правила безопасности. В рабочем окружении нельзя копировать её ветки в firewall, балансировщик или middleware без отдельной проверки контракта. Не помещайте в корреляционные поля токены, персональные данные и тело запроса.

Критерий готовности

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

Если после изменения «стало зелёным», но нет объясняющего сигнала, честный итог — «симптом исчез, причина не доказана». В таком случае следующий шаг — улучшить наблюдаемость, повторить сценарий и только потом закреплять решение.

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

" } diff --git a/editorial/agent-rewrites/037.json b/editorial/agent-rewrites/037.json index e3acab5..d268217 100644 --- a/editorial/agent-rewrites/037.json +++ b/editorial/agent-rewrites/037.json @@ -1,7 +1,7 @@ { "index": 37, "slug": "editorial-2026-12-field-portfolio-case", - "title": "Как передать инженерный кейс, не превратив сценарий в доказательство", - "excerpt": "Практический разбор synthetic hand-off: как отделить входные данные от вывода, остановить усиленный claim и передать следующему владельцу проверяемую границу риска.", - "contentHtml": "

Получатель открывает карточку и видит знакомые слова: evidence, decision, residual risk, next action. Через час он ищет ADR, метрику, тест и запись rollout, которых никогда не было. Ошибка началась не в коде. В документе сценарий выглядел как отчёт о выполненной работе. Цена — потерянное время, неверная операционная память и решение, принятое на основании отсутствующих данных.

\n

Есть и более тихий симптом. Автор называет synthetic hand-off «field report», добавляет правдоподобную дату, имя команды или номер изменения. Читатель уже не различает учебный literal и наблюдение из production. В следующем пересказе оговорка исчезает, а выдуманный результат остаётся. Поэтому такой текст должен начинаться не с красивого итога, а с границы: какие входы существуют, чего в них нет и какой вывод разрешён.

\n

Тезис простой: безопасная передача не доказывает результат. Она передаёт фиксированный сценарий, его происхождение, допустимую силу утверждения и явный путь остановки. Если вход имеет статус scenario-only, claim не может стать benchmark-confirmed. Если результат не наблюдался, его нельзя назвать выполненным. Это правило одинаково полезно для редакционного примера, архитектурной записки и будущего hand-off между командами.

\n

Сначала отделите наблюдение от модели

\n

Наблюдение отвечает на вопрос «что произошло и откуда это известно». Модель отвечает на вопрос «как можно организовать будущую проверку». Эти вопросы нельзя закрыть одной карточкой. В synthetic case есть фиксированный объект в памяти: его имя, дата плана, дата отсечения источников, варианты, отклонённый вариант, состояние evidence и residual risk. У объекта нет системы, пользователя, change или telemetry.

\n

В этом различии важен не английский словарь, а сила claim. no-observation говорит, что наблюдение не собрано. scenario-only говорит, что вход — учебная конструкция. external-effect-none говорит, что внешний эффект не запускался. Вместе эти поля не делают сценарий слабым. Они не дают ему притвориться сильнее, чем он есть.

\n
const handoff = { caseName: 'named-fixed-synthetic-portfolio-case', plan-date: '2026-12', source-cutoff: '2026-07-31', evidence: { inputStrength: 'scenario-only', claimStrength: 'scenario-only', state: 'no-observation' }, requestedResult: 'bounded-hand-off', externalEffect: 'external-effect-none' };
\n

Это учебный объект. Он не взят из production и не описывает выполненный проект. Его смысл — удержать границу между данными и выводом. Поля plan-date и source-cutoff задают время сценария, но не утверждают, что в декабре шла работа. Поле requestedResult описывает допустимый ответ функции, а не полезность решения для пользователя.

\n

Варианты защищают от удобной легенды

\n

Одновариантный рассказ почти всегда выглядит убедительно. Автор показывает выбранную структуру и не говорит, что могло быть иначе. В результате читатель принимает отсутствие альтернативы за качество решения. В фиксированном сценарии есть два имени: narrow-evidence-note и expanded-evidence-note. Первый сохраняет только границу входа и следующий вопрос. Второй добавил бы детали, похожие на реальные доказательства. В literal явно указан rejectedOption.

\n

Отклонённый вариант не означает, что состоялся design review. Он нужен как контроль потери контекста. Если поле пустое, evaluator возвращает stop-missing-rejected-option. Он не выбирает вариант сам и не дописывает причину отказа. Такой отрицательный путь полезнее автоматического значения по умолчанию: он возвращает проблему туда, где исчезло решение.

\n

То же правило действует для дат и источников. Если сценарий теряет plan-date или меняет cutoff, evaluator возвращает stop-undated-scenario-or-cutoff. Дата не превращает модель в исторический факт. Она лишь не даёт пересказать сценарий как нечто вне времени.

\n
\"Схема
Иллюстрация показывает маршрут статусов synthetic hand-off. Она не показывает реальный workflow, запуск, изменение системы или production-результат.
\n

Симптом → причина → проверка → действие

\n
Проверки границы для безопасной передачи
СимптомПричинаПроверкаДействие
В карточке есть положительный результат, но вход только scenario-onlyClaim сильнее исходных данныхСравнить inputStrength и claimStrengthВернуть stop и снизить claim до scenario-only
Указан один вариантОтсутствует rejected optionПроверить минимум два options и имя отвергнутого вариантаОстановить передачу и восстановить границу выбора
Нет даты плана или cutoffСценарий потерял временную рамкуСверить plan-date 2026-12 и source-cutoff 2026-07-31Вернуть stop-undated-scenario-or-cutoff
В тексте появились ADR, metric или rolloutМодель получила неразрешённые production-поверхностиНайти утверждения о сделанном и источник каждогоУдалить claim или заменить его на допустимый вопрос
Положительный status запускает внешнее действиеHand-off перепутан с очередью работПроверить externalEffect и наличие вызовов наружуОставить только in-memory результат с external-effect-none
\n

Как работает fail-closed проверка

\n

Проверка должна принимать только известный fixed literal. Произвольный объект с похожими полями недостаточен: он может содержать незаметно усиленный claim. Поэтому evaluator сначала сравнивает вход с одним из именованных сценариев. Затем он проверяет дату, варианты, состояние evidence и запрошенный результат. Ошибка на любом шаге возвращает статус stop, причину и следующий безопасный шаг.

\n

Порядок важен. Сначала проверяется provenance объекта, потом сила его утверждения. Нельзя обсуждать качество решения, если неизвестно, откуда взялся вход. Нельзя обсуждать rollout, если результат уже запрещён самим контрактом. Короткий ответ с причиной лучше длинного текста, который компенсирует пропущенное поле правдоподобной историей.

\n
const incompleteStory = prepareHandoff('missing-rejected-option-v1'); const reply = gateHandoff(incompleteStory); console.log({ status: reply.status, action: reply.nextAction, production: reply.externalEffect }); // status: stop-missing-rejected-option; action: name-the-option-not-carried-forward; production: external-effect-none
\n

Фрагмент учебный. Он вызывает функции из локального модуля, не читает файлы и не делает сетевых запросов. Его вывод показывает классификацию неполного literal. Он не подтверждает качество архитектуры, не создаёт ADR и не доказывает, что подобный сценарий случался в рабочей системе.

\n

Ветвь с более сильным evidence должна завершаться так же жёстко. Если inputStrength равен scenario-only, а claimStrength равен benchmark-confirmed, результат — stop-evidence-stronger-than-input. Если запрошен case-complete, evaluator возвращает stop-disallowed-positive-result. В обоих случаях следующий шаг описывает исправление boundary, а не назначает владельца и не запускает работу.

\n

Provenance — это не выдуманный audit trail

\n

Для synthetic hand-off достаточно короткого происхождения: имя fixed case, дата плана, cutoff, набор вариантов и состояние no-observation. JSON clone отделяет выданный экземпляр от исходной константы. Deep freeze не даёт учебному вызову изменить вложенные поля в памяти. Эти свойства делают модель читаемой. Они не создают историю событий.

\n

Не добавляйте номер инцидента, ссылку на dashboard, имя реального владельца, timestamp якобы запуска или процент улучшения. Без источника такие детали не увеличивают воспроизводимость. Они только создают поверхность для ложной ссылки. Если цифра важна, сначала нужен разрешённый источник и метод измерения. До этого корректнее записать «не собрано» или «нельзя утверждать».

\n

Риск тоже надо формулировать точно. future-owner-may-need-a-separate-evidence-contract — это открытое условие. Оно не означает, что владелец уже назначен, контракт согласован или данные будут доступны. Следующий читатель может остановиться, запросить полномочия или отказаться от отдельного исследования. Материал должен позволять эти решения, а не подталкивать к ним скрытым обещанием.

\n

Hand-off не равен очереди работ

\n

Фраза «владелец подготовит ADR» уже утверждает владельца и будущий артефакт. Фраза «после change проверим метрику» утверждает change и набор метрик. В fixed input этого нет. Поэтому nextAction должен называть класс будущего вопроса: «уточнить, нужен ли отдельный evidence contract». Он не должен содержать назначение, дедлайн, уведомление или запуск.

\n

Техническая возможность также не равна разрешению. Модуль мог бы получить file reader, API client или доступ к telemetry. Это не даёт права читать production данные. Аналогично, команда могла бы написать тест, но модель не может заявить, что тест нужен, согласован или уже запущен. Граница hand-off — возвращаемый объект в памяти. Он не меняет систему и не отправляет сообщение наружу.

\n

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

\n
  1. Назвать материал synthetic-сценарием и указать plan-date 2026-12 и source-cutoff 2026-07-31.
  2. Выбрать только именованный fixed literal. Не принимать объект с похожей формой из неизвестного источника.
  3. Проверить контекст и записать минимум два варианта. Явно назвать rejected option.
  4. Оставить evidence как scenario-only, claim как scenario-only, а state как no-observation.
  5. Проверить, что в тексте нет утверждений о реальном ADR, benchmark, metric, test, incident, change, rollout или результате.
  6. Отклонить любой requested result, кроме bounded-hand-off с externalEffect: external-effect-none.
  7. Вернуть status, reason, boundary, residual risk и nextAction без внешних вызовов.
  8. Передать карточку следующему читателю как ограниченный вопрос, а не как поручение и не как подтверждение.
\n

Ограничения и отрицательный путь

\n

Такая модель не заменяет реальный evidence hand-off. Она не проверяет качество будущего решения, совместимость вариантов, безопасность изменения или пользу для пользователя. В ней нет production inputs, контрольной группы, периода наблюдения, измерительного плана и разрешения на внешнее действие. Нельзя использовать её как аргумент для release decision, security review или архитектурного утверждения.

\n

Отрицательный путь не означает, что система сломалась. Он означает, что вход не позволяет сделать следующий вывод. Отсутствующий rejected option возвращает вопрос о выборе. Усиленный claim возвращает вопрос о доказательстве. Недатированный сценарий возвращает вопрос о границе времени. Запрещённый positive result возвращает материал к hand-off. Это полезная остановка: она сохраняет неопределённость видимой и не заполняет её выдуманными фактами.

\n

Есть и практическое ограничение формата. Короткая карточка может потерять детали при пересказе. Поэтому рядом с полями нужно хранить boundary и reason, а не только status. Статус без объяснения быстро превращается в зелёную галочку. Причина удерживает связь между конкретным нарушением и действием, которое допустимо дальше.

\n

Критерий готовности

\n

Передача готова, если читатель за один проход может назвать источник входа, временную рамку, два варианта, отвергнутый вариант, силу evidence, состояние наблюдения и residual risk. Для каждого усиленного claim существует явный stop. Успешный status не обещает production effect и возвращает только bounded-hand-off. Учебный код помечен как учебный, иллюстрация имеет существующий asset path, а ссылки отделены от собственных данных модели.

\n

Готовность не равна фразе «кейс доказан». Здесь проверяемый итог скромнее: граница не потерялась при hand-off, отрицательный путь различим, а следующий читатель понимает, что ещё нужно получить до любого реального решения. Если хотя бы одно из этих условий нарушено, документ следует остановить и исправить, а не украшать дополнительными деталями.

\n

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

\n" + "title": "Инженерный кейс от проблемы до результата: как проверить причинность", + "excerpt": "Разбираем инженерный кейс на воспроизводимом примере: как связать симптом с причиной, выбрать проверку, измерить эффект и честно описать ограничения решения.", + "contentHtml": "

Архитектурная схема сама по себе не является результатом. Если в кейсе написано «мы добавили слой кэширования и ускорили API», читатель всё ещё не знает, что было медленным, какая гипотеза проверялась и не изменился ли вместе с задержкой состав ответа. Такой текст красиво рассказывает о решении, но не позволяет отделить причину от совпадения.

\\n

Цена ошибки появляется на следующем изменении. Команда повторяет подход в другом endpoint, а там узкое место находится в базе, сериализации или внешнем сервисе. Возникает лишняя сложность, а исходная проблема остаётся. Хороший инженерный кейс поэтому начинается с наблюдаемого симптома и заканчивается не лозунгом, а границей применимости: что проверено, каким измерением и при каких условиях вывод перестаёт быть верным.

\\n

Ниже — контрольный пример для списка проектов, который возвращает имя владельца. Числа и имена таблиц придуманы для воспроизведения, а не выданы за замер конкретной компании. Зато причинную цепочку можно повторить на своей схеме: посчитать запросы, увидеть план PostgreSQL, измерить одинаковый HTTP-контракт до и после изменения и проверить, что данные не потерялись.

\\n

Начните с наблюдаемого симптома

\\n

Первый абзац кейса должен позволять другому инженеру повторить наблюдение. Вместо «страница стала медленной» запишите маршрут, размер ответа, диапазон нагрузки и сигнал, на котором заметно отклонение. Например: «GET /api/projects возвращает 100 записей; после добавления displayName владельца p95 вырос в контрольном прогоне». Если p95 ещё не измерен, так и напишите: есть жалоба или единичный замер, но нет распределения.

\\n

Симптом и причина — разные утверждения. Большой ответ может увеличить время передачи, но не объясняет рост времени SQL. Большое число SQL-запросов может объяснить задержку базы, но не доказывает, что именно база определяет время всего HTTP-запроса. Такие переходы нужно проверять, а не склеивать в один вывод.

\\n
Как превратить впечатление в проверяемое утверждение
Слабая формулировкаПроверяемое утверждениеМинимальный сигнал
Список открывается медленноПри 100 элементах GET /api/projects выполняет 101 запрос к базе в текущей реализацииСчётчик запросов в логах или трассировке
Новая архитектура ускорила сервисПри одинаковом наборе данных и нагрузке p95 полного HTTP-запроса снизился после измененияПовторяемый прогон до и после
Кэш решил проблемуПовторный запрос читает ответ из кэша, а промах обращается к тому же источнику данныхМетрики hit/miss и проверка свежести
\\n

У каждой строки есть владелец состояния. Клиент формирует запрос и получает ответ. Обработчик выбирает данные. База выполняет SQL. Система наблюдения фиксирует время и ошибки. Кейс становится полезным, когда не приписывает один слой работе другого: trace показывает путь запроса, metric — числовое измерение во времени, log — отдельное событие с контекстом. Это разные сигналы, даже если их выводят на одну панель.

\\n

Постройте цепочку причин

\\n

Между симптомом и изменением запишите гипотезу в форме, которую можно опровергнуть: «время растёт из-за отдельного запроса за владельцем для каждой строки списка». Для списка из N проектов такая гипотеза предсказывает 1 + N запросов, если загрузка проекта и владельца выполняется последовательно и повторные владельцы не объединяются.

\\n

Следующий шаг — перечислить альтернативы. Время может уходить на сетевой hop, блокировку, сортировку, кодирование JSON или холодный пул соединений. Если проверить только счётчик SQL, вы докажете наличие дополнительной работы, но не докажете её долю в полном времени ответа. Поэтому цепочка должна иметь несколько звеньев: запросы к базе, время SQL, время обработчика, размер ответа и итоговый HTTP latency.

\\n
\"Схема
Граница хорошего кейса проходит между наблюдением и выводом: каждый следующий шаг должен иметь собственный сигнал и условие остановки.
\\n

Полезная запись выглядит так: «Симптом — p95 маршрута выше целевого значения. Гипотеза — N+1 запросов к владельцам. Проверка — посчитать SQL и сопоставить его с trace. Решение — получить проект и владельца одним запросом при сохранении формы ответа. Риск — JOIN может ухудшить план на другой селективности». В такой записи уже видны проверка и цена решения; читателю не приходится угадывать их по названию технологии.

\\n

Воспроизводимый пример: запросы растут вместе со списком

\\n

Пусть есть две таблицы: project хранит проект и внешний ключ owner_id, а app_user — имя владельца. Первая версия обработчика сначала получает страницу проектов, а затем обращается к владельцу внутри цикла. При 100 строках это один запрос за списком и до 100 запросов за владельцами. Если пул, сеть и база добавляют задержку на каждый round trip, стоимость растёт вместе с размером страницы.

\\n
const projects = await db.query(\\n  'SELECT id, name, owner_id FROM project ORDER BY id LIMIT $1',\\n  [100],\\n);\\n\\nconst result = [];\\nfor (const project of projects.rows) {\\n  const owner = await db.query(\\n    'SELECT id, display_name FROM app_user WHERE id = $1',\\n    [project.owner_id],\\n  );\\n  result.push({\\n    id: project.id,\\n    name: project.name,\\n    owner: owner.rows[0] ?? null,\\n  });\\n}
\\n

Этот фрагмент не обещает конкретную задержку. Он даёт проверяемое следствие: при 100 возвращённых проектах цикл может выполнить 100 отдельных запросов. Точное количество зависит от реализации репозитория, дедупликации и обработки отсутствующего владельца. Именно поэтому в реальном кейсе рядом с кодом нужен фактический счётчик, а не только рассуждение о сложности.

\\n

Один из вариантов исправления — перенести связь в SQL. Запрос возвращает тот же набор полей, но база видит операцию целиком и сама выбирает план соединения. Для владельца, который может отсутствовать, используйте LEFT JOIN, иначе inner join изменит контракт и удалит проекты без найденной записи.

\\n
SELECT\\n  p.id,\\n  p.name,\\n  u.id AS owner_id,\\n  u.display_name AS owner_name\\nFROM project AS p\\nLEFT JOIN app_user AS u ON u.id = p.owner_id\\nORDER BY p.id\\nLIMIT 100;
\\n

Вариант с JOIN не является автоматически лучшим. Он может вернуть дубликаты, если связь не один-к-одному, увеличить ширину строки или выбрать дорогой план. Перед применением проверьте уникальность ключа, индексы, порядок сортировки, NULL-поведение и права на обе таблицы. Семантика результата важнее красивого уменьшения числа запросов.

\\n

Проверяйте гипотезу несколькими сигналами

\\n

Начните с наблюдаемого количества запросов. В тесте обработчика зафиксируйте число обращений к репозиторию и сравните его с размером страницы. В интеграционном прогоне включите логирование SQL или счётчик на соединении. Так вы проверите структуру работы, но ещё не ответите, где тратится время.

\\n

Затем посмотрите план запроса. PostgreSQL строит план для каждого полученного запроса; EXPLAIN показывает дерево узлов и оценки стоимости, строк и ширины. Оценка не равна времени HTTP: планировщик не учитывает, например, передачу результата клиенту. Поэтому план помогает объяснить работу базы, но не заменяет замер полного маршрута.

\\n
EXPLAIN (ANALYZE, BUFFERS)\\nSELECT p.id, p.name, u.id AS owner_id, u.display_name AS owner_name\\nFROM project AS p\\nLEFT JOIN app_user AS u ON u.id = p.owner_id\\nORDER BY p.id\\nLIMIT 100;
\\n

Опция ANALYZE действительно выполняет запрос и показывает фактические строки и время узлов. Для SELECT это обычно безопаснее, чем для изменения данных, но запуск всё равно планируйте на среде и наборе данных, где нагрузка допустима. Сопоставьте estimated rows с actual rows, найдите лишний Seq Scan или большое расхождение оценок. Если статистика устарела, сначала исправьте качество входных данных для планировщика.

\\n

Третья проверка — трасса и метрики маршрута. Trace должен показать длительность обработчика и SQL-операций, metric — распределение latency и ошибок, log — параметры конкретного прогона без секретов и персональных данных. Не смешивайте корреляцию с причинностью: совпадение снижения SQL-времени и HTTP latency поддерживает гипотезу, но побочный параллельный релиз или изменение нагрузки может дать тот же рисунок.

\\n

Выбирайте решение по ограничению

\\n

После проверки сравните не только «до» и «после», но и цену каждого варианта. JOIN уменьшает число round trip, но связывает запрос с конкретной схемой. Batch-загрузка владельцев сохраняет два этапа и требует корректного сопоставления по ключу. Кэш может снять повторное чтение, но добавляет вопрос свежести и инвалидирования. Решение должно соответствовать контракту данных и допустимому риску.

\\n
Три решения для связи проекта с владельцем
ВариантЧто проверяет кейсЦенаКогда остановиться
LEFT JOINОдин SQL-план возвращает проект и владельца; число строк не меняетсяСвязь с таблицами, ширина результата, риск дубликатовПлан стал дороже или нарушилась семантика отсутствующего владельца
Batch по owner_idВладельцы загружаются одним запросом по набору ключейДва этапа, map по ключу, лимит размера INСписок ключей слишком велик или требуется строгая атомарность
КэшПовторный запрос получает допустимо свежие данныеИнвалидация, память, промахи и наблюдаемая рассинхронизацияНет ясного срока свежести или hit rate не покрывает стоимость
\\n

В кейсе должен быть виден отвергнутый вариант и причина отказа. Если кэш не выбран из-за требования показывать смену владельца сразу, это ограничение сильнее лозунга «кэш быстрее». Если JOIN не выбран из-за нескольких владельцев на проект, такой факт направляет следующего инженера к batch или отдельной модели данных.

\\n

Сравните утверждение с доказательством

\\n

Сила вывода не должна превышать силу проверки. Фраза «мы доказали, что база была причиной» допустима только при контроле альтернатив: одинаковый набор данных, одинаковая нагрузка, сопоставимая среда и измерения нескольких слоёв. Если есть лишь план и счётчик запросов, честнее сказать: «подтверждён лишний SQL-цикл; вклад в полное время требует замера».

\\n
Матрица допустимых выводов
Есть в данныхМожно утверждатьНельзя утверждать
Код цикла и тест на 100 элементовКоличество обращений растёт с размером списка в этой реализацииИменно это даёт весь рост latency в рабочей среде
EXPLAIN без ANALYZEКакой план и оценки выбрал планировщикФактическое время и улучшение для всех данных
Повторный прогон с p95 до и послеИзменился latency при заданных условияхИзменение безопасно для всех нагрузок и размеров ответа
Trace, метрики и проверка результатаКакие этапы изменились и сохранился ли контракт ответаЧто не измерялось: стоимость сопровождения, редкие данные, отказ внешнего сервиса
\\n

Такой контроль защищает и от слишком слабого, и от слишком сильного текста. Результат можно описать конкретно: «число SQL-вызовов на страницу уменьшилось с 101 до 1 в контрольном наборе; p95 обработчика измерен отдельным прогоном; форма ответа и проекты без владельца сохранены». Не добавляйте процент, пока его нельзя пересчитать из приложенного метода и сырых измерений.

\\n

Замерьте одинаковый контракт до и после

\\n

Сравнение имеет смысл только при неизменном контракте. Зафиксируйте URL, параметры, размер страницы, состав данных, процент попаданий в кэш, число конкурентных запросов и прогрев соединений. Снимите несколько прогонов, а не один удачный ответ. Для хвостовой задержки храните p50 и p95; для ошибок — количество и класс ответа. Среднее может скрыть редкие, но дорогие зависания.

\\n
# пример локального прогона; URL и нагрузку замените на свои\\nfor run in 1 2 3 4 5; do\\n  curl --silent --show-error --output /dev/null \\\\\\n    --write-out \"run=$run status=%{http_code} time=%{time_total}\\\\n\" \\\\\\n    'http://localhost:3000/api/projects?limit=100'\\ndone
\\n

Этот цикл фиксирует время клиента для пяти одиночных запросов, но не строит полноценное распределение под конкурентной нагрузкой. Для нагрузочного вывода нужен согласованный генератор, размер выборки и способ агрегации. Если после изменения время уменьшилось только на localhost, а trace показывает задержку внешнего сервиса, кейс ещё не закончен.

\\n

Проверьте корректность ответа отдельно от скорости: количество элементов, порядок, владельца с NULL, права доступа, пагинацию и сериализацию. Быстрый запрос, который возвращает лишние данные или пропускает проект, не является успешным результатом. Тест на границу страницы и тест на отсутствующую связанную запись часто ловят ошибку раньше, чем benchmark.

\\n

Ограничения применимости

\\n

Разбор N+1 применим, когда один внешний запрос порождает повторяющуюся работу для элементов коллекции. Он не доказывает, что любое большое число SQL-вызовов нужно заменить JOIN. Иногда отдельные вызовы идут параллельно, кэшируются драйвером или защищают независимые права доступа. Иногда JOIN создаёт взрыв строк и расход памяти. Проверяйте фактическую модель данных и план.

\\n

Контрольный пример не заменяет наблюдение реального сервиса. Он не учитывает репликацию, блокировки, очереди, холодный старт, лимиты базы, размер индексов и изменения трафика. Результат «101 запрос против 1» относится к структуре данного обработчика и странице из 100 элементов. Вывод о p95, стоимости инфраструктуры или пользовательском эффекте требует отдельного измерения.

\\n

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

\\n

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

\\n
  1. Опишите маршрут, входные параметры, размер ответа и наблюдаемый симптом без объяснения причины.
  2. Сформулируйте одну опровержимую гипотезу и минимум две альтернативы.
  3. Зафиксируйте контракт результата: поля, порядок, NULL-поведение, права и пагинацию.
  4. Посчитайте повторяющуюся работу на маленьком и увеличенном наборе данных.
  5. Сопоставьте счётчик работы с планом базы, trace, метриками и логом одного прогона.
  6. Выберите решение по ограничению, а не по названию технологии; запишите отвергнутый вариант.
  7. Повторите одинаковый прогон до и после, сохранив p50, p95, ошибки и размер ответа.
  8. Проверьте корректность данных на пустой связи, границе страницы и отказе зависимого слоя.
  9. Опишите результат только в пределах измеренных условий и отдельно перечислите остаточный риск.
\\n

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

\\n

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

\\n" } diff --git a/editorial/agent-rewrites/038.json b/editorial/agent-rewrites/038.json index b946c64..0823f95 100644 --- a/editorial/agent-rewrites/038.json +++ b/editorial/agent-rewrites/038.json @@ -1,6 +1,7 @@ -{"index": 38, +{ + "index": 38, "slug": "editorial-2026-12-mechanism-portfolio-case", "title": "Инженерный кейс: как проверить причинность, а не приписать результат изменению", - "excerpt": "Если после изменения стало лучше, это ещё не доказывает причинность. Разбираем, как отделить событие от наблюдения, проверить контрфакты и остановить кейс, когда данных недостаточно.", - "contentHtml": "

После релиза команда видит знакомый симптом: задержка ответа снизилась, ошибок стало меньше, а в отчёте появляется фраза «изменение дало результат». Через неделю показатель снова меняется. Уже непонятно, помог релиз, закончилась нагрузка, изменился состав трафика или перестал отвечать другой компонент. Цена ошибки — неверное решение на следующем шаге. Команда может закрепить бесполезный код, отменить полезный откат или объявить временный эффект доказанным.

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

Сначала разделите событие, наблюдение и вывод

Событие — действие, которое действительно произошло: например, сервис начал отдавать ответ из локального кеша. Наблюдение — запись с измерением: время ответа, число ошибок, трасса запроса или лог. Вывод — утверждение о связи между ними. Эти три слоя нельзя заменять друг другом.

Фраза «после включения кеша p95 снизился» описывает последовательность. Фраза «кеш снизил p95» уже утверждает причинность. Для второй фразы нужно знать входы, окно измерения, контрольное сравнение и альтернативные причины. OpenTelemetry разделяет traces, metrics и logs именно как разные сигналы: путь запроса, измерение во время работы и запись события. Один сигнал не заменяет остальные.

Не начинайте с красивого итога. Запишите симптом и цену ошибки. Затем зафиксируйте, какое изменение проверяется, какой механизм должен сработать и какое наблюдение его подтвердит. Если механизм нельзя описать одним-двумя предложениями, причинную связь пока рано считать рабочей гипотезой.

\"Схема
Схема помогает удержать границу между данными и выводом. Это иллюстрация метода, а не отчёт о production-системе.

Механизм причинности держится на сравнении

Рассмотрим учебный пример. Страница каталога долго ждёт ответ от API. Команда добавляет кеш на пять минут. Ожидаемый механизм таков: повторный запрос с тем же ключом читает локальное значение, не обращается к API и завершает работу быстрее. Это проверяемая гипотеза. Она не равна обещанию, что p95 улучшится во всей системе.

Минимальная модель должна назвать ключ кеша, срок жизни, ветку промаха и измеряемое событие. Нужны также условия, при которых сравнение честно. Если до изменения запросы шли в час пик, а после — ночью, число «до/после» ничего не доказывает. Если одновременно изменился размер ответа, источник трафика или лимит API, у результата появились конкурирующие объяснения.

function assessCase(input) {\n  const sameWindow = input.before.window === input.after.window;\n  const sameTraffic = input.before.trafficClass === input.after.trafficClass;\n  const mechanismObserved = input.after.cacheHits > 0 && input.after.apiCalls < input.before.apiCalls;\n  const effectObserved = input.after.p95Ms < input.before.p95Ms;\n\n  if (!sameWindow || !sameTraffic) {\n    return { status: 'stop', reason: 'comparison-is-not-comparable' };\n  }\n  if (!mechanismObserved || !effectObserved) {\n    return { status: 'stop', reason: 'mechanism-or-effect-is-not-observed' };\n  }\n  return { status: 'hypothesis-supported', confidence: 'bounded' };\n}

Код — учебный пример. Он не измеряет реальный сервис и не даёт production-результата. Его задача — показать отрицательный путь. При несовпоставимых окнах функция возвращает stop, а не достраивает причинность. При отсутствии признака механизма она тоже останавливается. Даже положительный статус остаётся ограниченным: он говорит, что гипотеза согласуется с входом, но не исключает все внешние причины.

Контрфакт отсекает удобное объяснение

Контрфактический вопрос звучит так: «Что должно было бы наблюдаться, если изменение не вызвало эффект?» Для кеша это может быть снижение p95 без роста cache hits, если одновременно упала нагрузка на API. Тогда результат совместим с другим объяснением. Второй вопрос: «Что должно измениться, если механизм работает?» Должны появиться cache hits, уменьшиться обращения к API и сохраниться сравнимый класс трафика.

Контрфакт не требует идеального эксперимента. Он требует назвать альтернативу до интерпретации результата. В зависимости от системы это может быть контрольная группа, поэтапное включение, повторное измерение в том же окне или сравнение запросов с одинаковыми ключами. Если ни один вариант недоступен, вывод становится слабее: «наблюдение совпало с гипотезой», а не «изменение вызвало эффект».

Диагностика причинности в инженерном кейсе
СимптомПричинаПроверкаДействие
После релиза p95 нижеИзменилось окно или распределение трафикаСравнить время, регион, endpoint и класс нагрузкиПонизить вывод и собрать сопоставимое окно
Ошибок меньше, cache hits нетСработал внешний fallback или изменился upstreamПроверить traces, логи ошибок и вызовы APIНе приписывать эффект кешу
Cache hits есть, API calls не снизилисьКлючи расходятся или кеш не участвует в ответеСопоставить ключ, TTL и ветку чтенияИсправить механизм или остановить кейс
До/после различаются сильноОдновременно изменились несколько факторовСоставить список изменений и найти контрольРазделить изменения либо назвать результат неоднозначным
Наблюдения неполныеСигнал не собирался в нужном местеПроверить покрытие метрик, логов и трассОписать пробел, не заполнять его предположением

Как писать кейс без ретроспективного proof

Сильный материал не скрывает отвергнутую ветку. Для кеша это может быть увеличение TTL, предварительная загрузка или изменение самого API. Назовите вариант и причину отказа только там, где есть запись. Если решения не было, пишите «рассматривался как учебная альтернатива», а не «команда отвергла его на проверке». Правдоподобная деталь без источника превращает пример в ложное свидетельство.

Разделяйте факты и условия применимости. Факт — в наблюдаемом окне было 120 cache hits. Условие — вывод относится только к запросам с тем же ключом и TTL. Ограничение — холодный кеш, ошибки сериализации и инвалидация не проверены. Такая запись переносима: другой инженер видит, какую часть можно повторить, а какую нельзя переносить без новых данных.

Для риска полезно использовать не одно число, а пару «воздействие × вероятность» и явно отмечать неопределённость. NIST SP 800-30 описывает оценку риска как работу с потенциальным событием, его последствиями и вероятностью, а также рекомендует фиксировать допущения и ограничения. Это не готовая формула для любого продукта. Это дисциплина, которая не даёт спрятать неизвестное за словом «результат».

Порядок проверки

  1. Запишите наблюдаемый симптом и цену ошибки одним абзацем.
  2. Назовите одно изменение и механизм, который связывает его с эффектом.
  3. Определите сигнал для механизма и сигнал для результата: например, cache hits и p95.
  4. Сделайте окна, трафик и границы сравнения сопоставимыми.
  5. Назовите хотя бы одну альтернативную причину и сформулируйте контрфакт.
  6. Проверьте отрицательный путь: что делает кейс при пропущенном сигнале или несовместимом сравнении.
  7. Снизьте силу вывода до уровня входных данных и явно запишите остаточный риск.
  8. Назовите критерий готовности и источник каждого внешнего факта.

Ограничения

Наблюдаемая корреляция не доказывает причинность, если система менялась сразу в нескольких местах. Даже контрольная группа может быть нерепрезентативной. Sampling может скрыть редкую ошибку. Метрика может быть правильно собрана, но измерять не тот пользовательский путь. Traces показывают маршрут запроса, но не объясняют бизнес-причину сами по себе. Logs фиксируют события, но без контекста их трудно сопоставить с запросом.

Учебный код также не заменяет нагрузочный тест, проверку инвалидации, анализ стоимости хранения и оценку отказа API. Не называйте его production-проверкой. Если данных нет, корректный результат — остановка с конкретным next action: собрать сигнал, выровнять окно, добавить контроль или отказаться от сильного claim. Отрицательный путь — часть механизма, а не признак незавершённости текста.

Критерий готовности

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

Если хотя бы на один вопрос отвечает только предположение, кейс не должен переходить в формулировку «изменение улучшило систему». Оставьте более узкий вывод: «наблюдение совместимо с гипотезой при таких условиях». Это не ослабляет инженерную работу. Это сохраняет возможность проверить её снова и не превращает временную удачу в правило.

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

" + "excerpt": "Если после изменения стало лучше, это ещё не доказывает причинность. Разбираем, как отделить событие от наблюдения, проверить механизм и остановить вывод, когда данных недостаточно.", + "contentHtml": "

После релиза команда видит знакомый симптом: задержка ответа снизилась, ошибок стало меньше, а в отчёте появляется фраза «изменение дало результат». Через неделю показатель снова меняется. Уже непонятно, помог релиз, закончилась нагрузка, изменился состав трафика или перестал отвечать другой компонент. Цена ошибки — неверное решение на следующем шаге: команда закрепит бесполезный код, отменит полезный откат или объявит временный эффект доказанным.

Разберём учебный кейс с кешем страницы каталога. Его цель — не показать красивый процент улучшения, а дать маршрут, который можно повторить на своём сервисе. Причинное утверждение требует цепочки «изменение → механизм → наблюдение → сопоставимое сравнение → ограниченный вывод». Если одно звено отсутствует, корректный результат — ослабить формулировку или остановить проверку.

\n

Почему «после» не значит «из-за»

\n

Событие — действие, которое действительно произошло: сервис начал читать ответ из локального кеша. Наблюдение — запись измерения: время ответа, число ошибок, трасса запроса или лог промаха кеша. Вывод — утверждение о связи между событием и наблюдением. Эти слои нельзя подменять друг другом.

\n

Фраза «после включения кеша p95 снизился» описывает последовательность. Фраза «кеш снизил p95» утверждает причинность. Для неё нужно знать маршрут, окно, класс нагрузки, число запросов и альтернативные изменения. Один график latency показывает разницу, но не объясняет, почему она появилась.

\n

OpenTelemetry называет traces, metrics и logs сигналами системы и описывает их как разные углы наблюдения за одной активностью. Поэтому в кейсе полезно разделить два вопроса: какой сигнал подтверждает работу механизма и какой сигнал показывает эффект для пользователя. Метрика p95 без признаков чтения из кеша оставляет несколько равно правдоподобных объяснений.

\n
\"График
Иллюстрация фиксирует границу сравнения. Разные нагрузка и входы могут изменить latency без причинного эффекта кеша.
\n

Сформулируйте механизм до просмотра результата

\n

Начните с одного изменения. В примере это кеширование ответа каталога на пять минут для повторных запросов с одинаковым ключом. Механизм должен быть коротким и проверяемым: запрос с тем же ключом читает локальное значение, не вызывает upstream и возвращает ответ без ожидания сети. Ветка промаха продолжает обращаться к upstream.

\n

Из механизма следуют два наблюдения. Во-первых, после изменения должны появиться cache hits для подходящих ключей. Во-вторых, число upstream-вызовов на сопоставимом наборе запросов должно уменьшиться. Только затем смотрим на p95, ошибки и стоимость памяти. Если p95 улучшился, но hits не появились, кеш не является подтверждённым объяснением.

\n

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

\n

Соберите сопоставимое сравнение

\n

Сравнение «час до релиза с ночью после релиза» не проверяет эффект кеша. Минимум нужно зафиксировать одинаковый маршрут, длительность окна, класс трафика и близкое число запросов. Если одновременно менялись размер ответа, регион, лимит upstream или формат сериализации, их нужно записать как возможные причины.

\n

Ниже — маленький fixture для проверки правил сравнения. Значения вымышлены и нужны только для воспроизведения логики: до изменения было 10 000 запросов, после — 9 800. Такой пример не заявляет реальный результат и не заменяет измерение.

\n
function compareWindows(before, after) {\n  const requestDelta = Math.abs(after.requestCount - before.requestCount);\n  const requestRatio = requestDelta / before.requestCount;\n  const comparable = before.windowMinutes === after.windowMinutes\n    && before.route === after.route\n    && before.trafficClass === after.trafficClass\n    && requestRatio <= 0.05;\n  const mechanismObserved = after.cacheHits > before.cacheHits\n    && after.upstreamCalls < before.upstreamCalls;\n  const effectObserved = after.p95Ms < before.p95Ms;\n\n  if (!comparable) {\n    return { status: 'stop', reason: 'windows-not-comparable' };\n  }\n  if (!mechanismObserved) {\n    return { status: 'stop', reason: 'cache-mechanism-not-observed' };\n  }\n  if (!effectObserved) {\n    return { status: 'stop', reason: 'user-facing-effect-not-observed' };\n  }\n  return { status: 'hypothesis-supported', confidence: 'bounded' };\n}\n\nconst before = {\n  windowMinutes: 60, route: '/catalog', trafficClass: 'catalog',\n  requestCount: 10000, cacheHits: 0, upstreamCalls: 10000, p95Ms: 780,\n};\nconst after = {\n  windowMinutes: 60, route: '/catalog', trafficClass: 'catalog',\n  requestCount: 9800, cacheHits: 6200, upstreamCalls: 3800, p95Ms: 510,\n};\nconsole.log(compareWindows(before, after));
\n

Ключевая проверка здесь — не число 510, а условие сопоставимости. Для окон до и после не требуется одинаковая отметка времени: требуется одинаковая длительность и близкая популяция запросов. Функция останавливается при несовпоставимом входе, при отсутствии признака кеша или при отсутствии улучшения. Положительный статус означает только «данные согласуются с гипотезой».

\n

Проверьте механизм отдельным сигналом

\n

Разложите измерения по уровням. Счётчик cache hits отвечает на вопрос о ветке чтения. Счётчик upstream calls показывает, уменьшилась ли работа внешней зависимости. p95 отвечает на вопрос о распределении времени ответа, но не сообщает, какой путь его сформировал. Ошибки и просроченные записи показывают цену побочных эффектов.

\n

Сопоставьте один и тот же request или trace там, где это возможно: вход в обработчик, решение hit или miss, вызов upstream и итоговый ответ. Для метрик без связи с конкретным запросом оставьте агрегаты и явно запишите их границы. Нельзя выдавать отсутствие события в непокрытой телеметрии за доказательство, что события не было.

\n

Проверьте отрицательные ветки отдельно. Холодный кеш должен приводить к miss и вызову upstream. Истёкший TTL не должен отдавать устаревшую запись. Ошибка десериализации не должна превращаться в тихий ответ из повреждённого значения. Если эти ветки не проверены, вывод ограничивается тёплым кешем и успешным ответом.

\n
Как отделить эффект кеша от конкурирующих объяснений
НаблюдениеВозможное объяснениеПроверкаРешение
p95 ниже, cache hits не вырослиИзменился трафик или upstreamСопоставить маршрут, класс нагрузки и tracesНе приписывать эффект кешу
hits выросли, upstream calls прежниеКеш не участвует в итоговом путиПроверить ключ, ветку чтения и вызов APIИсправить механизм или остановиться
upstream calls снизились, ошибки вырослиПромахи или повреждённые записи скрываютсяСравнить коды ошибок, TTL и размер выборкиОткатить изменение до разбирательства
Окна различаются по трафикуСостав запросов изменилсяРазбить данные по региону, маршруту и типу клиентаСобрать новое сопоставимое окно
Данные неполныСигнал не собирался на нужной веткеПроверить покрытие логов и трассНазвать пробел, не достраивать факт
\n

Отделите альтернативы контрфактом

\n

Контрфактический вопрос звучит так: «Что увидели бы мы, если кеш не вызвал улучшение?» Например, p95 мог снизиться из-за падения нагрузки на upstream. Тогда похожее снижение должно наблюдаться и на маршруте без кеша или вместе с уменьшением общей очереди. Если этого контроля нет, формулировка остаётся слабой: «изменение совпало с улучшением в выбранном окне».

\n

Второй вопрос: «Что должно измениться, если механизм работает?» В примере должны вырасти hits, снизиться upstream calls и сохраниться корректность ответа. Ищите альтернативы до того, как увидели итог: другой релиз, изменение конфигурации, сдвиг регионов, сезонный пик, прогрев инфраструктуры, изменение лимита или сбой зависимости.

\n

Идеальный эксперимент доступен не всегда. Подойдут поэтапное включение, контрольный маршрут, чередование вариантов, повторные окна с одинаковыми фильтрами или сравнение запросов одного класса. Но каждый вариант имеет цену: контрольный маршрут может быть меньше по объёму, повторные окна — зависеть от времени, а чередование — давать нагрузочный след. Укажите этот компромисс рядом с результатом.

\n

Не подменяйте данные историей

\n

Технический текст часто становится убедительным за счёт деталей, которых никто не измерял: «команда увидела проблему в 14:30», «после переключения график сразу стабилизировался», «пользователи перестали жаловаться». Если таких записей нет, это не факты кейса. В учебном материале называйте их условиями примера; в рабочем — прикладывайте запрос к метрике, trace, лог или ссылку на изменение.

\n

Разделяйте факт, интерпретацию и решение. Факт: в окне 60 минут зафиксировано 6 200 cache hits. Интерпретация: механизм кеша согласуется с уменьшением upstream calls. Решение: оставить изменение только для маршрута и TTL, которые прошли проверку. Такая разметка даёт читателю воспроизводимый следующий шаг и не обещает переносимость результата в другую систему.

\n

Не используйте число как замену неопределённости. NIST SP 800-30 связывает риск с возможным неблагоприятным воздействием и вероятностью, а также отдельно описывает неполное знание, нераспознанные зависимости и ограниченную применимость оценки во времени. Для инженерного кейса достаточно указать остаточный риск словами: устаревшие данные, стоимость памяти, рост miss после рестарта и неизвестное поведение при пике.

\n

Порядок проверки

\n
  1. Запишите симптом, маршрут и цену ошибки до изменения.
  2. Сформулируйте одно изменение и механизм, который должен его связать с эффектом.
  3. Выберите сигнал механизма и сигнал результата: например, cache hits и p95.
  4. Сделайте окна сопоставимыми по длительности, маршруту, трафику и близкому числу запросов.
  5. Назовите хотя бы одну альтернативную причину и сформулируйте контрфакт.
  6. Проверьте hit, miss, TTL, ошибку чтения и корректность ответа.
  7. Сверьте агрегаты с traces или логами там, где это технически возможно.
  8. Снизьте силу вывода до уровня данных и запишите остаточный риск.
  9. Назовите критерий остановки и действие после него: собрать сигнал, выровнять окно или откатить изменение.
\n

Ограничения применимости

\n

Сравнение двух окон не доказывает причинность, если одновременно изменялись несколько факторов. Контрольный маршрут может не представлять всех пользователей. Sampling может скрыть редкую ошибку. p95 может улучшиться для одного endpoint и ухудшиться для критичного сценария. Агрегатная метрика не заменяет проверку содержимого ответа, прав доступа и свежести данных.

\n

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

\n

Если нет сопоставимого окна или сигнала механизма, остановка — это результат. Следующий шаг должен быть конкретным: добавить счётчик на ветку hit/miss, связать trace с upstream-вызовом, повторить измерение на одном классе трафика или отменить сильное утверждение. Нельзя заполнять пробел правдоподобной цифрой.

\n

Критерий готовности кейса

\n

Кейс готов, когда читатель без доверия к автору может ответить на четыре вопроса: что наблюдали; какое изменение должно было сработать; какое сравнение отделяет его от альтернативы; что произойдёт при нехватке данных. У каждого вывода должны быть окно, маршрут, сигналы, условия применимости и остаточный риск.

\n

Поэтому финальная фраза редко должна звучать как «кеш ускорил систему». Точнее написать: «В сопоставимых окнах для маршрута /catalog наблюдение совместимо с гипотезой кеша: hits появились, upstream calls снизились, p95 уменьшился; влияние других изменений и поведение холодного кеша требуют отдельной проверки». Такая формулировка оставляет место для следующего измерения и не превращает локальную корреляцию в универсальное правило.

\n

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

\n" } diff --git a/editorial/agent-rewrites/039.json b/editorial/agent-rewrites/039.json index 4d33fc5..6ab3353 100644 --- a/editorial/agent-rewrites/039.json +++ b/editorial/agent-rewrites/039.json @@ -2,6 +2,7 @@ "index": 39, "slug": "editorial-2026-12-practice-portfolio-case", "title": "Инженерный кейс: как связать симптом, решение и доказательство", - "excerpt": "Практический способ разобрать инженерную проблему: отделить наблюдаемый симптом от причины, сравнить варианты, проверить отрицательный путь и не приписать решению эффект без данных.", - "contentHtml": "

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

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

Тезис: кейс должен показывать причинную цепочку

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

Сила вывода не может быть выше силы наблюдения. Лог подтверждает запись. Трасса подтверждает путь запроса и его длительность. Сравнение двух групп подтверждает различие между группами. Ни один источник сам по себе не доказывает, что изменение улучшило всю систему.

Механизм причинной цепочки

Начните с наблюдаемого симптома. Укажите маршрут, условие, временной диапазон и единицу измерения. «Медленно» недостаточно. «P95 запроса GET /portfolio вырос с 240 до 1900 мс при 20 параллельных запросах» уже задаёт объект проверки.

Отделите симптом от гипотезы. Симптом виден в логе или трассе. Гипотеза объясняет его и может оказаться неверной. Здесь гипотеза такая: задержка возникает на повторном получении одного профиля для каждой позиции портфеля, а не в базе данных. Проверка должна различить эти варианты.

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

Решение должно описывать механизм. «Добавили кэш» — слабая запись. «В пределах одного запроса сохраняем профиль по ключу пользователя; повторный вызов читает это значение; кэш живёт только во время обработки запроса» — проверяемый контракт. Из него следуют тесты, ограничения и способ наблюдения.

Пример: кэш только в пределах одного запроса

Ниже учебный пример на JavaScript. Он показывает форму локального кэша. Он не сообщает, что конкретная система получила ускорение. Данные, нагрузку и результат нужно измерять отдельно.

export async function loadPortfolio(userId, api) { const profile = new Map(); async function getProfile(id) { if (!profile.has(id)) profile.set(id, api.get('/profiles/' + id)); return profile.get(id); } const positions = await api.get('/portfolios/' + userId); return Promise.all(positions.map(async (position) => ({ ...position, owner: await getProfile(position.ownerId) }))); }

Карта создаётся внутри loadPortfolio. Поэтому два параллельных вызова не делят состояние. Значение сохраняется как promise, а не как готовый ответ. Два одновременных обращения к одному владельцу получают один запрос, даже если первый ещё не завершился.

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

Симптом → причина → проверка → действие

Разбор инженерного кейса по наблюдаемым признакам
СимптомПричинаПроверкаДействие
P95 растёт вместе с числом позицийПовторный запрос профиляСравнить число позиций и запросов в одной трассеУстранить дубликаты внутри запроса
Таймаутов меньше, длительность та жеИзменён лимит, не механизмСопоставить длительность и число запросовВернуться к гипотезе о лишней работе
Появляются чужие данныеСостояние живёт дольше запросаПроверить ключ и два разных userIdПеренести хранилище внутрь обработчика
Скачки только при параллельной нагрузкеНе сохраняется promiseЗапустить два одинаковых вызова до завершения первогоСохранять общий promise и обработать ошибку

Иллюстрация причинной границы

\"Схема
Схема показывает порядок связи между наблюдением и выводом. Она иллюстрирует структуру разбора, а не измеренный результат конкретной системы.

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

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

  1. Запишите один симптом с маршрутом, условием, периодом и измерением.
  2. Отделите наблюдение от гипотезы и назовите альтернативную причину.
  3. Назовите варианты, включая путь, который меняет симптом, но не механизм.
  4. Опишите область жизни состояния, ключи, ошибки и параллельные вызовы.
  5. Сделайте минимальное изменение и сохраните исходное поведение для сравнения.
  6. Проверьте положительный путь: повторное чтение использует тот же promise или значение.
  7. Проверьте отрицательный путь: разные пользователи не делят данные, ошибка не оставляет битое значение, пустой список не вызывает лишних обращений.
  8. Сравните одинаковые показатели до и после в сопоставимых условиях.
  9. Запишите остаточный риск и сформулируйте вывод не шире найденных данных.

Что считать доказательством

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

Сравнение требует базовой линии. Если до изменения измеряли среднее, а после — P95, вывода о сравнении нет. Если объём запросов различался на порядки, различие может отражать нагрузку. Если менялись код, база и лимит одновременно, эффект нельзя надёжно приписать одному фактору.

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

Ограничения

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

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

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

Кейс готов, если независимый читатель может назвать симптом, проверить гипотезу, воспроизвести положительный и отрицательный путь, увидеть источник измерения и понять, какой вывод запрещено делать. Для loadPortfolio минимальный критерий таков: запрос с двумя позициями одного владельца вызывает один запрос профиля; два разных вызова не делят карту; ошибка профиля не оставляет пригодное значение; в выводе нет обещания эффекта, которого не измеряли.

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

" + "excerpt": "Практический способ разобрать задержку API: проверить гипотезу о дублирующих вызовах, ограничить кэш одной операцией и не приписать изменению эффект без сопоставимых измерений.", + "contentHtml": "

Запрос к API иногда занимает две секунды вместо двухсот миллисекунд. Пользователь видит пустой экран, оператор получает всплеск таймаутов, а команда увеличивает лимит ожидания. Для разбора этого симптома мало сказать «нужно добавить кэш»: такой совет меняет поведение, но не отвечает, где возникла лишняя работа и как доказать, что она исчезла.

\n

Разберём условный маршрут GET /portfolio. Он получает список позиций, а затем для каждой позиции запрашивает один и тот же профиль владельца. Цифры ниже — измеряемый пример для воспроизведения метода, а не отчёт о реальном проекте. Главная цель — связать симптом, гипотезу, минимальное изменение, наблюдение и ограничение так, чтобы другой инженер мог проверить каждый переход.

\n

Сначала зафиксируйте наблюдение

\n

Начинайте не с решения, а с записи, которую можно открыть. Вместо «страница стала медленной» укажите маршрут, окно и показатель: например, P95 для GET /portfolio вырос с 240 до 1900 мс при двадцати параллельных запросах. P95 — это значение, ниже которого попадает 95 процентов наблюдений; оно показывает хвост задержек, но не объясняет его причину.

\n

Следом зафиксируйте состав операции. Если ответ содержит десять позиций, сколько внешних вызовов сделано? Сколько из них относятся к портфелю, а сколько — к профилям? Есть ли повторяющиеся идентификаторы? Один trace или связанная группа логов должна позволить ответить на эти вопросы. Без идентификатора операции легко сравнить разные запросы и принять разницу нагрузки за эффект изменения.

\n

Здесь полезно разделять три утверждения. «Профиль запрашивается десять раз» — наблюдение, если это видно в трассе или журнале. «Повторы образуются в сборщике ответа» — гипотеза, которую ещё надо проверить. «Локальное кэширование уменьшило задержку» — причинный вывод, для которого понадобится сопоставимое измерение до и после. Чем сильнее фраза, тем больше независимых условий она должна выдержать.

\n

Постройте несколько гипотез

\n

Один симптом может иметь несколько причин. Повторные вызовы профиля — лишь первая гипотеза. Альтернативами могут быть медленный запрос списка, очередь соединений, ограничение внешнего API или изменение состава трафика. Увеличение таймаута способно уменьшить число ранних ошибок, но не уменьшает количество операций. Если заранее не записать этот вариант, команда легко примет более поздний ответ за ускорение.

\n
Как отличить гипотезы по наблюдаемому признаку
ГипотезаЧто должно быть видноМинимальная проверкаЧто не доказывает гипотезу
Профиль запрашивается повторноЧисло обращений к профилю растёт вместе с числом позиций, идентификаторы повторяютсяПосчитать вызовы по одному trace и сгруппировать их по profileIdОдно измерение общей задержки
Медленно отвечает список позицийОсновная доля времени лежит в /portfoliosСравнить длительность span списка и дочерних вызововСнижение числа запросов к профилю
Заканчивается пул соединенийОжидание ресурса растёт при параллельной нагрузке, внешние ответы сами не медленнееПосмотреть время ожидания пула отдельно от времени upstreamРост P95 без разбивки по этапам
Изменился трафикПосле релиза отличаются размер ответа, доля клиентов или профиль маршрутовСопоставить одинаковые срезы по версии и типу запросаФакт, что изменение было выкачено раньше замера
Увеличили таймаутОшибок меньше, но работа и длительность успешных запросов не сократилисьСравнить error rate, P95 и число вызововТолько доля 5xx
\n

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

\n

Определите границу состояния

\n

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

\n

У ключа должна быть та же точность, что и у данных. Если профиль зависит от profileId, ключом должен быть именно идентификатор, а не имя или индекс позиции. Если ответ зависит ещё от языка, версии прав или набора полей, эти признаки входят в ключ либо кэширование запрещается. Ошибка с ключом не обязательно проявится в latency: она может вернуть правдоподобные, но чужие или устаревшие данные.

\n

Нужно заранее решить, что происходит с отказом. Сохранённый успешно выполненный ответ и сохранённое отклонённое обещание — разные политики. В примере ниже отклонённый promise удаляется из карты. Это позволяет следующему вызову повторить операцию в пределах той же обработки, если такой retry разрешён контрактом. Если повторять запрос нельзя, следует пробросить ошибку наружу и не добавлять неявную попытку.

\n

Воспроизводимый пример: кэш на время одной операции

\n

Код ниже не использует глобальное состояние. Карта создаётся при входе в функцию, а в неё кладётся promise сразу после запуска запроса. Поэтому два параллельных обращения с одним ключом видят один объект ожидания, даже если ответ ещё не готов. При ошибке запись удаляется; при новом вызове loadPortfolio карта создаётся заново.

\n
export async function loadPortfolio(userId, api) {\n  const inFlightProfiles = new Map();\n\n  async function getProfile(profileId) {\n    const saved = inFlightProfiles.get(profileId);\n    if (saved) return saved;\n\n    const request = api\n      .get('/profiles/' + encodeURIComponent(profileId))\n      .catch((error) => {\n        inFlightProfiles.delete(profileId);\n        throw error;\n      });\n\n    inFlightProfiles.set(profileId, request);\n    return request;\n  }\n\n  const positions = await api.get(\n    '/portfolios/' + encodeURIComponent(userId),\n  );\n\n  return Promise.all(\n    positions.map(async (position) => ({\n      ...position,\n      owner: await getProfile(position.ownerId),\n    })),\n  );\n}
\n

Важная деталь находится между проверкой и записью. В JavaScript синхронный участок функции выполняется до следующей точки ожидания, поэтому после inFlightProfiles.get здесь нет await: первый вызов успевает положить promise в карту до того, как второй вызов проверит её. Promise.all затем ждёт результаты всех позиций и возвращает массив в порядке входного массива, хотя сами обращения могут завершаться в разное время.

\n

Это свойство не следует расширять за пределы примера. Если api.get вызывает внешний сервис с побочным эффектом, повторное обращение и его безопасность определяются контрактом API. Если профиль изменился между двумя независимыми операциями, request-scoped кэш его не синхронизирует. Если api.get может бросить ошибку до возврата promise, обработчик должен дополнительно учитывать такой контракт клиента.

\n

Проверьте положительный и отрицательный путь

\n

Положительная проверка отвечает на вопрос «дублирование действительно исчезло?». Создайте ответ портфеля с двумя позициями одного владельца, задержите ответ профиля и запустите обработку. В журнале вызовов должен появиться один URL профиля, а обе позиции должны получить один и тот же результат. Отдельно проверьте два разных владельца: тогда ожидаются два вызова и два ключа, иначе тест не проверяет разделение данных.

\n

Отрицательные проверки важнее счастливого примера. Запустите две функции loadPortfolio для разных пользователей одновременно и убедитесь, что их карты не пересекаются. Затем отклоните запрос профиля и проверьте, что ошибка доходит до вызывающего кода, а повторная попытка не получает старое отклонённое promise, если retry предусмотрен. Наконец, передайте пустой список позиций: внешний вызов профиля не должен появиться.

\n
  1. Соберите fixture с двумя позициями одного владельца и отдельный fixture с двумя владельцами.
  2. Подмените api.get функцией, которая записывает URL и возвращает управляемые promises.
  3. До разрешения promise запустите обработку и проверьте число вызовов по каждому profileId.
  4. Повторите операцию для двух userId и убедитесь, что вызовы не используют общую карту.
  5. Отклоните один запрос профиля, проверьте ошибку и отдельно определите допустимость повтора.
  6. Запустите пустой портфель и зафиксируйте отсутствие обращений к профилям.
  7. Сравните результат с исходной реализацией: проверяйте не только latency, но и состав ответа.
\n

Проверка счётчика вызовов показывает дедупликацию, но не доказывает полезность для пользователя. Для этого нужен следующий слой — одинаковая нагрузка и одинаковая метрика. Тесты должны оставить диагностическое сообщение при нарушении: «ожидался один профильный вызов для profileId=p-1, получено два». Без такого сообщения падение будет трудно связать с границей состояния.

\n

Сравните изменение с базовой линией

\n

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

\n

Наблюдаемость должна быть связной. Trace показывает путь одного запроса и отношения между операциями. Metrics позволяют увидеть распределение и динамику по группе запросов. Logs полезны для конкретного события и контекста ошибки. OpenTelemetry описывает эти сигналы как взаимодополняющие, но сама телеметрия не превращает корреляцию в причинное доказательство. Она только даёт материал для проверки.

\n

Причинный вывод становится крепче, если меняется один существенный фактор, а альтернативы получают отдельную проверку. Сравните контрольный и изменённый вариант на одной версии клиента, одинаковом наборе данных и сопоставимой нагрузке. Если одновременно изменились SQL-запрос, лимит таймаута и кэш, результат нельзя честно приписать только карте promises. Запишите также отрицательный результат: отсутствие снижения числа вызовов — аргумент против выбранной гипотезы.

\n
\"Схема
Схема показывает порядок перехода от наблюдения к ограниченному выводу. Она помогает не перепутать временную последовательность событий с доказанной причиной и не изображает результат конкретной системы.
\n

Когда локальный кэш неприменим

\n

Request-scoped кэш подходит, когда один ответ повторно запрашивает неизменяемый или допустимо согласованный ресурс, а повторная работа действительно возникает внутри одной операции. Он не заменяет кэш между запросами, CDN или хранилище с явной политикой инвалидации. Не применяйте его автоматически к данным, чья свежесть критична для решения пользователя.

\n

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

\n

Не переносите карту на уровень модуля или singleton без доказанной области владения. В долгоживущем серверном процессе это может связать запросы разных пользователей, удерживать данные дольше разрешённого срока и создать утечку. Для общего кэша потребуются TTL, инвалидация, политика ошибок, ограничение памяти, защита ключа и проверка согласованности. Это уже другой механизм и другая статья решений.

\n

Сетевой upstream может иметь rate limit, собственную кэш-политику и побочные эффекты. Один вызов вместо десяти снижает нагрузку только на этот маршрут и при условии, что запросы действительно эквивалентны. Он не гарантирует сокращения итоговой задержки: время может находиться в другом span. Не объявляйте исправление успешным по одному локальному запуску.

\n

Сформулируйте вывод уже, чем обещание

\n

Хороший кейс заканчивается не фразой «система ускорилась», а условным выводом. В нашем примере можно утверждать: «при двух позициях одного владельца функция создаёт один профильный promise внутри одной операции; отдельные вызовы функции не делят карту; отклонённая запись удаляется по выбранной политике». Это проверяемые свойства кода.

\n

Нельзя утверждать без измерения, что P95 всего продукта уменьшился, стоимость инфраструктуры снизилась или проблема больше не повторится. Для таких выводов нужны данные из сопоставимых окон, описание нагрузки и проверка альтернативных причин. Если измерения ещё нет, следующий шаг — собрать его, а не дописывать красивый результат.

\n

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

\n

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

\n", + "readingMinutes": 12 } diff --git a/editorial/agent-rewrites/040.json b/editorial/agent-rewrites/040.json index fb58295..5119301 100644 --- a/editorial/agent-rewrites/040.json +++ b/editorial/agent-rewrites/040.json @@ -3,5 +3,5 @@ "slug": "editorial-2026-11-field-technology-evaluation", "title": "Как сравнивать технологии, когда цена ошибки выше цены эксперимента", "excerpt": "Сравнение технологий начинается не с рейтинга. Сначала нужно определить симптом, стоимость ошибки, критерии, измерение и границы вывода.", - "contentHtml": "

На встрече появляется таблица из двух технологий и итоговых баллов: 86 против 74. Через неделю никто не может ответить, откуда взялись числа. Не указаны версии, входные данные, число повторов и правило обработки разброса. Симптом простой: вывод выглядит точным, но его нельзя воспроизвести.

\n

Цена ошибки зависит от решения. Если команда выбирает библиотеку для короткого скрипта, потеря обычно ограничивается временем разработчика. Если выбор затрагивает данные, задержки, безопасность и обслуживание, ошибка переезжает в архитектуру. Она увеличивает стоимость миграции, усложняет диагностику и заставляет защищать решение, основанное на неизвестных предпосылках.

\n

Тезис: сравнивать нужно не названия, а условия

\n

Технология не бывает лучшей сама по себе. Сравнение имеет смысл только внутри задачи: с известными входами, версией, окружением и ограничением по стоимости ошибки. Рейтинг без этих условий смешивает измеряемый сигнал с предпочтением.

\n

Сначала отделите четыре вещи. Симптом показывает, что в текущем решении болит. Критерий описывает, что важно для новой альтернативы. Измерение даёт наблюдаемый сигнал. Риск показывает, чем обернётся неверный вывод. Вес критерия выражает приоритет. Он не доказывает свойство инструмента.

\n

Механизм: четыре слоя решения

\n

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

\n

Поэтому вопрос формулируют уже: «Как меняется время обработки сообщения заданного размера при одинаковом числе потребителей и одинаковой политике подтверждения?» Вопрос задаёт границы. Он не обещает ответа до измерения.

\n

Критерии должны быть наблюдаемыми или проверяемыми отдельно. Например: p95 времени обработки, доля повторной доставки, сложность миграции, требования к операционному сопровождению. В эти критерии нельзя незаметно включить симпатию к знакомому API. Если удобство важнее задержки, его нужно назвать и объяснить.

\n

Измерение требует протокола. Зафиксируйте версии, размер и форму входа, число потребителей, длительность прогрева, число повторов и способ вычисления итогового значения. Низкое среднее не компенсирует длинный хвост, если именно хвост ломает пользовательский сценарий.

\n

Риск связывает наблюдение с последствием. Разница в 2 миллисекунды может быть важной для одного маршрута и бесполезной для другого. Ошибка в оценке миграции может стоить больше, чем разница в скорости. Взвешенная матрица помогает сделать этот обмен видимым, но не делает его объективным автоматически.

\n
\"Схема
Существующая схема показывает границы сравнения: критерии и условия измерения должны быть видимыми до вывода.
\n

Учебный пример: матрица для двух библиотек

\n

Ниже приведён учебный пример с условными именами queue-a и queue-b. Он не описывает конкретный продукт и не содержит данных реальной системы. Числа весов нужны только для демонстрации расчёта. Их нельзя выдавать за измеренный результат.

\n
Границы сравнения до получения чисел
КритерийВесЧто наблюдаемЧто пока неизвестно
p95 времени обработки35Миллисекунды на одинаковом входеЗначение для реальной нагрузки
Повторная доставка25Доля сообщений с повторомПричины отказов за пределами теста
Стоимость миграции25Список изменений и трудоёмкость шаговФактическое время команды
Сопровождение15Количество обязательных компонентовДолгосрочная нагрузка на операторов
\n

Сумма весов равна 100. Это проверка полноты, а не доказательство правильности шкалы. Для каждой строки задайте шкалу от 0 до 3 и опишите смысл каждого балла. Нельзя ставить ноль только потому, что данных ещё нет. Отсутствие наблюдения — это unknown, а не плохое значение.

\n

Пример кода с явной границей

\n
const criteria = [\n  { id: 'p95-latency', weight: 35, scoreA: 0, scoreB: 0 },\n  { id: 'redelivery', weight: 25, scoreA: 0, scoreB: 0 },\n  { id: 'migration-cost', weight: 25, scoreA: 0, scoreB: 0 },\n  { id: 'operations', weight: 15, scoreA: 0, scoreB: 0 }\n];\n\nfunction weightedScore(items, side) {\n  const weightTotal = items.reduce((sum, item) => sum + item.weight, 0);\n  if (weightTotal !== 100) return { status: 'stop-invalid-weights' };\n\n  const hasUnknown = items.some((item) => item[side] === 'unknown');\n  if (hasUnknown) return { status: 'stop-missing-observation' };\n\n  const score = items.reduce(\n    (sum, item) => sum + item.weight * item[side] / 3,\n    0\n  );\n  return { status: 'score-available', score };\n}\n\nconsole.log(weightedScore(criteria, 'scoreA'));\n// { status: 'score-available', score: 0 }
\n

Этот код показывает арифметику матрицы. Нулевые баллы здесь означают начальное состояние примера, а не качество queue-a. До подстановки наблюдений функция не выбирает библиотеку. Если один критерий получает unknown, она останавливается. Это важнее красивого итогового числа: неизвестность не должна маскироваться под результат.

\n

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

\n

Симптом → причина → проверка → действие

\n
Диагностика неубедительного сравнения
СимптомПричинаПроверкаДействие
Есть победитель, но нет исходных чиселПредпочтение выдали за наблюдениеНайти входы, версии, протокол и результаты повторовУбрать вывод и вернуть статус unknown
Среднее улучшилось, а ошибки вырослиСмотрели один показательСравнить p95, p99, ошибки и повторы на одном наборе входовДобавить критерий надёжности и пересчитать решение
Числа меняются после каждого запускаНе зафиксированы прогрев и окружениеСверить версии, ресурсы, размер входа и число повторовУточнить протокол или признать результат несопоставимым
Неизвестное значение заменили нулёмПропуск смешали с плохим результатомПроверить источник каждого баллаИспользовать unknown и остановить итоговую оценку
Миграция выглядит дешёвой по одной строкеНе учли данные, откат и обучениеСоставить карту изменений, зависимостей и обратного путиСчитать стоимость диапазоном с явными допущениями
\n

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

\n
  1. Опишите наблюдаемый симптом: что именно изменилось, где это видно и кому мешает.
  2. Назовите цену ошибки: задержка, потеря данных, трудоёмкость отката или дополнительное сопровождение.
  3. Сформулируйте один вопрос с измеримыми входами и двумя сравниваемыми альтернативами.
  4. Выберите критерии, задайте шкалы и веса; проверьте, что каждый вес имеет объяснение, а сумма равна 100.
  5. Зафиксируйте версии, окружение, входные данные, прогрев, повторы и формулу расчёта.
  6. Проведите одинаковые измерения для каждой альтернативы и сохраните исходные значения, а не только средний балл.
  7. Отдельно проверьте хвост распределения, ошибки, повторную доставку и стоимость изменений.
  8. Отметьте неизвестные поля как unknown. Не делайте вывод, пока критичный критерий не получил наблюдение.
  9. Сравните результат с порогом решения и укажите, какие условия ограничивают перенос вывода.
\n

Ограничения и отрицательный путь

\n

Матрица не заменяет нагрузочное испытание, анализ безопасности и разговор с владельцем данных. Она не предсказывает поведение другой версии, другого размера входа или другого окружения. Если библиотека показывает лучший p95 на маленьком сообщении, это не доказывает преимущество на больших сообщениях.

\n

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

\n

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

\n

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

\n

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

\n

Быстрая проверка состоит из пяти вопросов: что измеряли; на каких входах; сколько было повторов; какие показатели могли ухудшиться; что произойдёт при ошибочном выборе. Если хотя бы на один вопрос нет ответа, таблица ещё не поддерживает решение. Это не недостаток оформления. Это граница достоверности.

\n

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

" + "contentHtml": "

На встрече появляется таблица из двух технологий и итоговых баллов: 86 против 74. Через неделю никто не может ответить, откуда взялись числа. Не указаны версии, входные данные, число повторов и правило обработки разброса. Симптом понятен: вывод выглядит точным, но его нельзя воспроизвести.

\n

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

\n

Сначала зафиксируйте границу выбора

\n

Сравнивайте не названия технологий, а два способа решить одну задачу в одинаковых условиях. Формула вопроса проста: «Какой вариант лучше подходит для конкретного сценария при заданных входах, нагрузке и ограничениях?» В таком вопросе есть объект, условия и критерий. Вопрос «что быстрее и современнее?» не задаёт ни одного из них.

\n

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

\n

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

\n

Разделите симптом, риск и измерение

\n

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

\n

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

\n

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

\n
Четыре слоя сравнения и их проверяемый след
СлойВопросКакой след сохранитьКогда остановиться
СимптомЧто именно болит сейчас?Запрос, метрика, лог или воспроизводимое наблюдениеЕсли симптом описан только словами
КритерийЧто должно измениться?Метрика, шкала и порог приемкиЕсли критерий нельзя проверить отдельно
ИзмерениеВ каких условиях сравниваем?Версии, входы, ресурсы, повторы и сырые значенияЕсли условия для вариантов различаются
РешениеЧто делаем с результатом?Правило выбора, стоп-условие и план откатаЕсли неизвестное выдали за нулевой балл
\n

Таблица нужна не для декоративного рейтинга. Она показывает, какой пробел мешает перейти от жалобы к действию. Если команда не может заполнить одну строку доказательством, это повод сузить вопрос.

\n

Соберите воспроизводимый протокол

\n

Сравнение становится воспроизводимым, когда другой инженер может повторить его без устного объяснения. Зафиксируйте версию приложения и каждой альтернативы, операционную систему, лимиты CPU и памяти, размер и форму входных данных, число потребителей, сетевой режим, длительность прогрева и число повторов. Сохраните команду запуска и исходный вывод, а не только красивое среднее.

\n

Меняйте за один раз только то, что вы сравниваете. Если для одного варианта включён кэш, а для другого нет, результат отвечает на вопрос о двух конфигурациях, а не о технологиях. Если один тест прогрет, а другой стартует с холодного процесса, сначала измеряйте этот эффект отдельно.

\n

Для времени обработки смотрите распределение, а не только среднее. p95 означает границу, ниже которой оказалось 95 процентов наблюдений; он показывает хвост задержек лучше среднего, когда редкие длинные операции портят пользовательский сценарий. Но p95 не объясняет причину задержки. Его нужно сопоставить с ошибками, повторами, размером входа и ресурсами.

\n
Схема сравнения технологий: от симптома через критерий и измерение к решению, которое останавливается при неизвестном условии
Сравнение движется к решению только после проверки условий; неизвестный вход возвращает задачу на уточнение протокола.
\n

Схема полезна как контрольная точка перед объявлением победителя. В ней нет отдельного шага «поверить презентации». Заявление поставщика, демонстрация на конференции и локальный тест — разные виды свидетельств. Они могут сформулировать гипотезу, но не заменяют измерение вашего сценария.

\n

Учебный пример: две очереди в одном сценарии

\n

Представим сервис, который принимает одинаковые сообщения, обрабатывает их и подтверждает результат. Команда сравнивает queue-a и queue-b. Симптом: после роста размера сообщения увеличился хвост задержек. Гипотеза: одна из очередей хуже ведёт себя при заданном размере сообщения. Альтернативная гипотеза: задержку создаёт запись результата, а очередь лишь получает вину вместе с ней.

\n

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

\n
Рабочая матрица до подстановки измерений
КритерийВесШкала 0–3НаблюдениеОграничение
p95 обработки353 — ниже порога, 0 — выше критическогоСырые повторы и p95Порог действует только для этой нагрузки
Повторная доставка253 — в пределах лимита, 0 — за лимитомДоля повторов и причиныТест не описывает все виды отказа
Миграция253 — обратимый малый объём, 0 — сложный откатКарта изменений и пробный откатОценка зависит от команды и данных
Сопровождение153 — минимум обязательных операцийПрава, метрики и аварийные процедурыНужно подтвердить владельца каждого сигнала
\n

Весы 35, 25, 25 и 15 складываются в 100. Это проверяет арифметическую полноту, но не делает приоритеты объективными. Команда должна объяснить, почему повторная доставка важнее или менее важна, чем время. Если нарушение лимита по повторам недопустимо, его нельзя компенсировать высокой скоростью. Такой критерий становится жёстким стоп-условием.

\n

Пока измерений нет, в колонке «Наблюдение» должно быть unknown. Ноль означает худшее значение по согласованной шкале. Неизвестность означает, что шкала ещё не применена. Подмена одного другим создаёт ложную точность и может выбрать вариант, который никто не проверял.

\n

Код, который не рисует победителя

\n
const criteria = [\n  { id: 'latency-p95', weight: 35, scoreA: 3, scoreB: 'unknown' },\n  { id: 'redelivery', weight: 25, scoreA: 2, scoreB: 1 },\n  { id: 'migration', weight: 25, scoreA: 'unknown', scoreB: 2 },\n  { id: 'operations', weight: 15, scoreA: 2, scoreB: 3 }\n];\n\nfunction decide(items, side) {\n  const weightTotal = items.reduce((sum, item) => sum + item.weight, 0);\n  if (weightTotal !== 100) return { status: 'stop-invalid-weights' };\n  if (items.some((item) => item[side] === 'unknown')) {\n    return { status: 'stop-missing-observation' };\n  }\n\n  const score = items.reduce(\n    (sum, item) => sum + item.weight * item[side] / 3,\n    0\n  );\n  return { status: 'score-available', score };\n}\n\nconsole.log(decide(criteria, 'scoreA'));\n// { status: 'stop-missing-observation' }
\n

Пример намеренно останавливает расчёт для scoreB и для scoreA: у каждой стороны есть неизвестное значение. Это не проверка качества очереди и не benchmark. Это маленький предохранитель против незаполненной таблицы. В рабочем коде к нему добавляют проверку, что балл лежит в диапазоне от 0 до 3, что идентификаторы критериев уникальны и что решение не публикуется при стоп-статусе.

\n

Пример также показывает границу ответственности. Функция проверяет структуру оценки, но не знает, как измерялись миллисекунды и что означал повтор. Эти факты должны храниться рядом с результатом: в отчёте запуска, артефакте CI или журнале эксперимента. Арифметика не исправляет плохой протокол.

\n

Как читать результаты без самообмана

\n

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

\n

Проверяйте устойчивость вывода. Повторите измерение на нескольких размерах сообщения, при разных уровнях конкуренции и после восстановления процесса. Спросите, меняется ли порядок вариантов при разумном изменении веса. Если небольшое изменение веса переворачивает выбор, решение чувствительно к предпочтениям и требует явного согласования, а не уверенного заголовка.

\n

Стоимость миграции тоже должна иметь доказательство. Разложите её на изменение схемы, перенос данных, переключение трафика, наблюдаемость, обучение и откат. Запись «дёшево» не сравнима с «дорого». Даже диапазон с допущениями полезнее одного числа без происхождения: например, «два–четыре инженерных дня при готовом адаптере; неизвестно, если потребуется перенос исторических данных».

\n

Симптом → причина → проверка → действие

\n
Что делать, если сравнение не поддерживает решение
СимптомВероятная причинаПроверкаДействие
Есть итоговый победитель, но нет сырых чиселПредпочтение выдали за наблюдениеНайти входы, версии, повторы и команду запускаСнять вывод и вернуть unknown
Среднее улучшилось, а ошибки вырослиИзмеряли только один показательСопоставить p95, ошибки, повторы и пропускную способностьДобавить критерий надёжности или применить стоп-условие
Результат меняется каждый запускНе зафиксированы прогрев, ресурсы или фонСверить окружение и повторить серии в одинаковом порядкеНазвать разброс и не объявлять устойчивый вывод
Неизвестное заменили нулёмПропуск смешали с худшей оценкойПроверить происхождение каждого баллаРазделить unknown и 0 в модели данных
Миграция выглядит дешёвой в одной строкеНе учли данные, откат и обучениеСоставить карту зависимостей и обратного путиСчитать диапазон с явными допущениями
\n

Порядок проверки

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

Ограничения применимости

\n

Матрица не заменяет нагрузочное, отказоустойчивое и security-тестирование. Она не доказывает поведение другой версии, другого размера входа, другого региона или другой политики подтверждения. Хороший результат на стенде не переносится на продакшен автоматически. Сначала докажите совпадение условий, затем расширяйте вывод.

\n

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

\n

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

\n

Критерий готовности решения

\n

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

\n

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

\n

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

" }