Files

8 lines
28 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 37,
"slug": "editorial-2026-12-field-portfolio-case",
"title": "Инженерный кейс от проблемы до результата: как проверить причинность",
"excerpt": "Разбираем инженерный кейс на воспроизводимом примере: как связать симптом с причиной, выбрать проверку, измерить эффект и честно описать ограничения решения.",
"contentHtml": "<p>Архитектурная схема сама по себе не является результатом. Если в кейсе написано «мы добавили слой кэширования и ускорили API», читатель всё ещё не знает, что было медленным, какая гипотеза проверялась и не изменился ли вместе с задержкой состав ответа. Такой текст красиво рассказывает о решении, но не позволяет отделить причину от совпадения.</p>\\n<p>Цена ошибки появляется на следующем изменении. Команда повторяет подход в другом endpoint, а там узкое место находится в базе, сериализации или внешнем сервисе. Возникает лишняя сложность, а исходная проблема остаётся. Хороший инженерный кейс поэтому начинается с наблюдаемого симптома и заканчивается не лозунгом, а границей применимости: что проверено, каким измерением и при каких условиях вывод перестаёт быть верным.</p>\\n<p>Ниже — контрольный пример для списка проектов, который возвращает имя владельца. Числа и имена таблиц придуманы для воспроизведения, а не выданы за замер конкретной компании. Зато причинную цепочку можно повторить на своей схеме: посчитать запросы, увидеть план PostgreSQL, измерить одинаковый HTTP-контракт до и после изменения и проверить, что данные не потерялись.</p>\\n<h2>Начните с наблюдаемого симптома</h2>\\n<p>Первый абзац кейса должен позволять другому инженеру повторить наблюдение. Вместо «страница стала медленной» запишите маршрут, размер ответа, диапазон нагрузки и сигнал, на котором заметно отклонение. Например: «GET /api/projects возвращает 100 записей; после добавления displayName владельца p95 вырос в контрольном прогоне». Если p95 ещё не измерен, так и напишите: есть жалоба или единичный замер, но нет распределения.</p>\\n<p>Симптом и причина — разные утверждения. Большой ответ может увеличить время передачи, но не объясняет рост времени SQL. Большое число SQL-запросов может объяснить задержку базы, но не доказывает, что именно база определяет время всего HTTP-запроса. Такие переходы нужно проверять, а не склеивать в один вывод.</p>\\n<table><caption>Как превратить впечатление в проверяемое утверждение</caption><thead><tr><th scope=\"col\">Слабая формулировка</th><th scope=\"col\">Проверяемое утверждение</th><th scope=\"col\">Минимальный сигнал</th></tr></thead><tbody><tr><td>Список открывается медленно</td><td>При 100 элементах GET /api/projects выполняет 101 запрос к базе в текущей реализации</td><td>Счётчик запросов в логах или трассировке</td></tr><tr><td>Новая архитектура ускорила сервис</td><td>При одинаковом наборе данных и нагрузке p95 полного HTTP-запроса снизился после изменения</td><td>Повторяемый прогон до и после</td></tr><tr><td>Кэш решил проблему</td><td>Повторный запрос читает ответ из кэша, а промах обращается к тому же источнику данных</td><td>Метрики hit/miss и проверка свежести</td></tr></tbody></table>\\n<p>У каждой строки есть владелец состояния. Клиент формирует запрос и получает ответ. Обработчик выбирает данные. База выполняет SQL. Система наблюдения фиксирует время и ошибки. Кейс становится полезным, когда не приписывает один слой работе другого: trace показывает путь запроса, metric — числовое измерение во времени, log — отдельное событие с контекстом. Это разные сигналы, даже если их выводят на одну панель.</p>\\n<h2>Постройте цепочку причин</h2>\\n<p>Между симптомом и изменением запишите гипотезу в форме, которую можно опровергнуть: «время растёт из-за отдельного запроса за владельцем для каждой строки списка». Для списка из N проектов такая гипотеза предсказывает 1 + N запросов, если загрузка проекта и владельца выполняется последовательно и повторные владельцы не объединяются.</p>\\n<p>Следующий шаг — перечислить альтернативы. Время может уходить на сетевой hop, блокировку, сортировку, кодирование JSON или холодный пул соединений. Если проверить только счётчик SQL, вы докажете наличие дополнительной работы, но не докажете её долю в полном времени ответа. Поэтому цепочка должна иметь несколько звеньев: запросы к базе, время SQL, время обработчика, размер ответа и итоговый HTTP latency.</p>\\n<figure><img src=\"/assets/editorial/2026/portfolio-case-2026-evidence-boundary-loop.svg\" alt=\"Схема проверки инженерного кейса: симптом ведёт к гипотезе, измерению и решению, а неподтверждённый вывод возвращается на дополнительную проверку\" loading=\"lazy\" /><figcaption>Граница хорошего кейса проходит между наблюдением и выводом: каждый следующий шаг должен иметь собственный сигнал и условие остановки.</figcaption></figure>\\n<p>Полезная запись выглядит так: «Симптом — p95 маршрута выше целевого значения. Гипотеза — N+1 запросов к владельцам. Проверка — посчитать SQL и сопоставить его с trace. Решение — получить проект и владельца одним запросом при сохранении формы ответа. Риск — JOIN может ухудшить план на другой селективности». В такой записи уже видны проверка и цена решения; читателю не приходится угадывать их по названию технологии.</p>\\n<h2>Воспроизводимый пример: запросы растут вместе со списком</h2>\\n<p>Пусть есть две таблицы: <code>project</code> хранит проект и внешний ключ <code>owner_id</code>, а <code>app_user</code> — имя владельца. Первая версия обработчика сначала получает страницу проектов, а затем обращается к владельцу внутри цикла. При 100 строках это один запрос за списком и до 100 запросов за владельцами. Если пул, сеть и база добавляют задержку на каждый round trip, стоимость растёт вместе с размером страницы.</p>\\n<pre><code>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}</code></pre>\\n<p>Этот фрагмент не обещает конкретную задержку. Он даёт проверяемое следствие: при 100 возвращённых проектах цикл может выполнить 100 отдельных запросов. Точное количество зависит от реализации репозитория, дедупликации и обработки отсутствующего владельца. Именно поэтому в реальном кейсе рядом с кодом нужен фактический счётчик, а не только рассуждение о сложности.</p>\\n<p>Один из вариантов исправления — перенести связь в SQL. Запрос возвращает тот же набор полей, но база видит операцию целиком и сама выбирает план соединения. Для владельца, который может отсутствовать, используйте <code>LEFT JOIN</code>, иначе inner join изменит контракт и удалит проекты без найденной записи.</p>\\n<pre><code>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;</code></pre>\\n<p>Вариант с JOIN не является автоматически лучшим. Он может вернуть дубликаты, если связь не один-к-одному, увеличить ширину строки или выбрать дорогой план. Перед применением проверьте уникальность ключа, индексы, порядок сортировки, NULL-поведение и права на обе таблицы. Семантика результата важнее красивого уменьшения числа запросов.</p>\\n<h2>Проверяйте гипотезу несколькими сигналами</h2>\\n<p>Начните с наблюдаемого количества запросов. В тесте обработчика зафиксируйте число обращений к репозиторию и сравните его с размером страницы. В интеграционном прогоне включите логирование SQL или счётчик на соединении. Так вы проверите структуру работы, но ещё не ответите, где тратится время.</p>\\n<p>Затем посмотрите план запроса. PostgreSQL строит план для каждого полученного запроса; <code>EXPLAIN</code> показывает дерево узлов и оценки стоимости, строк и ширины. Оценка не равна времени HTTP: планировщик не учитывает, например, передачу результата клиенту. Поэтому план помогает объяснить работу базы, но не заменяет замер полного маршрута.</p>\\n<pre><code>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;</code></pre>\\n<p>Опция <code>ANALYZE</code> действительно выполняет запрос и показывает фактические строки и время узлов. Для SELECT это обычно безопаснее, чем для изменения данных, но запуск всё равно планируйте на среде и наборе данных, где нагрузка допустима. Сопоставьте <code>estimated rows</code> с <code>actual rows</code>, найдите лишний Seq Scan или большое расхождение оценок. Если статистика устарела, сначала исправьте качество входных данных для планировщика.</p>\\n<p>Третья проверка — трасса и метрики маршрута. Trace должен показать длительность обработчика и SQL-операций, metric — распределение latency и ошибок, log — параметры конкретного прогона без секретов и персональных данных. Не смешивайте корреляцию с причинностью: совпадение снижения SQL-времени и HTTP latency поддерживает гипотезу, но побочный параллельный релиз или изменение нагрузки может дать тот же рисунок.</p>\\n<h2>Выбирайте решение по ограничению</h2>\\n<p>После проверки сравните не только «до» и «после», но и цену каждого варианта. JOIN уменьшает число round trip, но связывает запрос с конкретной схемой. Batch-загрузка владельцев сохраняет два этапа и требует корректного сопоставления по ключу. Кэш может снять повторное чтение, но добавляет вопрос свежести и инвалидирования. Решение должно соответствовать контракту данных и допустимому риску.</p>\\n<table><caption>Три решения для связи проекта с владельцем</caption><thead><tr><th scope=\"col\">Вариант</th><th scope=\"col\">Что проверяет кейс</th><th scope=\"col\">Цена</th><th scope=\"col\">Когда остановиться</th></tr></thead><tbody><tr><td>LEFT JOIN</td><td>Один SQL-план возвращает проект и владельца; число строк не меняется</td><td>Связь с таблицами, ширина результата, риск дубликатов</td><td>План стал дороже или нарушилась семантика отсутствующего владельца</td></tr><tr><td>Batch по owner_id</td><td>Владельцы загружаются одним запросом по набору ключей</td><td>Два этапа, map по ключу, лимит размера IN</td><td>Список ключей слишком велик или требуется строгая атомарность</td></tr><tr><td>Кэш</td><td>Повторный запрос получает допустимо свежие данные</td><td>Инвалидация, память, промахи и наблюдаемая рассинхронизация</td><td>Нет ясного срока свежести или hit rate не покрывает стоимость</td></tr></tbody></table>\\n<p>В кейсе должен быть виден отвергнутый вариант и причина отказа. Если кэш не выбран из-за требования показывать смену владельца сразу, это ограничение сильнее лозунга «кэш быстрее». Если JOIN не выбран из-за нескольких владельцев на проект, такой факт направляет следующего инженера к batch или отдельной модели данных.</p>\\n<h2>Сравните утверждение с доказательством</h2>\\n<p>Сила вывода не должна превышать силу проверки. Фраза «мы доказали, что база была причиной» допустима только при контроле альтернатив: одинаковый набор данных, одинаковая нагрузка, сопоставимая среда и измерения нескольких слоёв. Если есть лишь план и счётчик запросов, честнее сказать: «подтверждён лишний SQL-цикл; вклад в полное время требует замера».</p>\\n<table><caption>Матрица допустимых выводов</caption><thead><tr><th scope=\"col\">Есть в данных</th><th scope=\"col\">Можно утверждать</th><th scope=\"col\">Нельзя утверждать</th></tr></thead><tbody><tr><td>Код цикла и тест на 100 элементов</td><td>Количество обращений растёт с размером списка в этой реализации</td><td>Именно это даёт весь рост latency в рабочей среде</td></tr><tr><td>EXPLAIN без ANALYZE</td><td>Какой план и оценки выбрал планировщик</td><td>Фактическое время и улучшение для всех данных</td></tr><tr><td>Повторный прогон с p95 до и после</td><td>Изменился latency при заданных условиях</td><td>Изменение безопасно для всех нагрузок и размеров ответа</td></tr><tr><td>Trace, метрики и проверка результата</td><td>Какие этапы изменились и сохранился ли контракт ответа</td><td>Что не измерялось: стоимость сопровождения, редкие данные, отказ внешнего сервиса</td></tr></tbody></table>\\n<p>Такой контроль защищает и от слишком слабого, и от слишком сильного текста. Результат можно описать конкретно: «число SQL-вызовов на страницу уменьшилось с 101 до 1 в контрольном наборе; p95 обработчика измерен отдельным прогоном; форма ответа и проекты без владельца сохранены». Не добавляйте процент, пока его нельзя пересчитать из приложенного метода и сырых измерений.</p>\\n<h2>Замерьте одинаковый контракт до и после</h2>\\n<p>Сравнение имеет смысл только при неизменном контракте. Зафиксируйте URL, параметры, размер страницы, состав данных, процент попаданий в кэш, число конкурентных запросов и прогрев соединений. Снимите несколько прогонов, а не один удачный ответ. Для хвостовой задержки храните p50 и p95; для ошибок — количество и класс ответа. Среднее может скрыть редкие, но дорогие зависания.</p>\\n<pre><code># пример локального прогона; 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</code></pre>\\n<p>Этот цикл фиксирует время клиента для пяти одиночных запросов, но не строит полноценное распределение под конкурентной нагрузкой. Для нагрузочного вывода нужен согласованный генератор, размер выборки и способ агрегации. Если после изменения время уменьшилось только на localhost, а trace показывает задержку внешнего сервиса, кейс ещё не закончен.</p>\\n<p>Проверьте корректность ответа отдельно от скорости: количество элементов, порядок, владельца с NULL, права доступа, пагинацию и сериализацию. Быстрый запрос, который возвращает лишние данные или пропускает проект, не является успешным результатом. Тест на границу страницы и тест на отсутствующую связанную запись часто ловят ошибку раньше, чем benchmark.</p>\\n<h2>Ограничения применимости</h2>\\n<p>Разбор N+1 применим, когда один внешний запрос порождает повторяющуюся работу для элементов коллекции. Он не доказывает, что любое большое число SQL-вызовов нужно заменить JOIN. Иногда отдельные вызовы идут параллельно, кэшируются драйвером или защищают независимые права доступа. Иногда JOIN создаёт взрыв строк и расход памяти. Проверяйте фактическую модель данных и план.</p>\\n<p>Контрольный пример не заменяет наблюдение реального сервиса. Он не учитывает репликацию, блокировки, очереди, холодный старт, лимиты базы, размер индексов и изменения трафика. Результат «101 запрос против 1» относится к структуре данного обработчика и странице из 100 элементов. Вывод о p95, стоимости инфраструктуры или пользовательском эффекте требует отдельного измерения.</p>\\n<p>Остаточный риск тоже должен остаться в тексте. После JOIN может измениться план при росте таблиц. После batch может превыситься лимит параметров. После кэша может появиться устаревшее имя. Запишите, какой сигнал обнаружит каждое отклонение и какое действие допустимо: откатить изменение, уменьшить размер страницы, обновить статистику или пересмотреть контракт свежести.</p>\\n<h2>Порядок действий</h2>\\n<ol><li>Опишите маршрут, входные параметры, размер ответа и наблюдаемый симптом без объяснения причины.</li><li>Сформулируйте одну опровержимую гипотезу и минимум две альтернативы.</li><li>Зафиксируйте контракт результата: поля, порядок, NULL-поведение, права и пагинацию.</li><li>Посчитайте повторяющуюся работу на маленьком и увеличенном наборе данных.</li><li>Сопоставьте счётчик работы с планом базы, trace, метриками и логом одного прогона.</li><li>Выберите решение по ограничению, а не по названию технологии; запишите отвергнутый вариант.</li><li>Повторите одинаковый прогон до и после, сохранив p50, p95, ошибки и размер ответа.</li><li>Проверьте корректность данных на пустой связи, границе страницы и отказе зависимого слоя.</li><li>Опишите результат только в пределах измеренных условий и отдельно перечислите остаточный риск.</li></ol>\\n<p>Такой порядок превращает кейс в рабочий инструмент. Другой инженер видит не только выбранную конструкцию, но и условия, при которых её стоит повторить, сигнал, который подтвердит эффект, и остановку, которая не даст расширить вывод без новых данных.</p>\\n<h2>Проверяемые источники</h2>\\n<ul><li><a href=\"https://www.postgresql.org/docs/current/using-explain.html\" target=\"_blank\" rel=\"noopener noreferrer\">PostgreSQL Documentation: Using EXPLAIN</a> — официальная документация о дереве плана, оценках стоимости и строк, вариантах соединения и том, что <code>EXPLAIN ANALYZE</code> выполняет запрос и показывает фактические значения. Оценки зависят от статистики и платформы, поэтому источник не заменяет замер в конкретной базе.</li><li><a href=\"https://opentelemetry.io/docs/concepts/signals/\" target=\"_blank\" rel=\"noopener noreferrer\">OpenTelemetry Documentation: Signals</a> — официальные определения traces как пути запроса, metrics как измерения во время работы и logs как записи события. Документ помогает разделить сигналы, но не доказывает причинность конкретной задержки.</li><li><a href=\"https://csrc.nist.gov/pubs/sp/800/30/r1/final\" target=\"_blank\" rel=\"noopener noreferrer\">NIST SP 800-30 Rev. 1: Guide for Conducting Risk Assessments</a> — официальное руководство по оценке риска и выбору действий на основании выявленного риска. Это общий документ по risk assessment, а не методика нагрузочного тестирования или оптимизации SQL.</li></ul>"
}