{ "index": 37, "slug": "editorial-2026-12-field-portfolio-case", "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" }