9 lines
26 KiB
JSON
9 lines
26 KiB
JSON
{
|
||
"index": 39,
|
||
"slug": "editorial-2026-12-practice-portfolio-case",
|
||
"title": "Инженерный кейс: как связать симптом, решение и доказательство",
|
||
"excerpt": "Практический способ разобрать задержку API: проверить гипотезу о дублирующих вызовах, ограничить кэш одной операцией и не приписать изменению эффект без сопоставимых измерений.",
|
||
"contentHtml": "<p>Запрос к API иногда занимает две секунды вместо двухсот миллисекунд. Пользователь видит пустой экран, оператор получает всплеск таймаутов, а команда увеличивает лимит ожидания. Для разбора этого симптома мало сказать «нужно добавить кэш»: такой совет меняет поведение, но не отвечает, где возникла лишняя работа и как доказать, что она исчезла.</p>\n<p>Разберём условный маршрут <code>GET /portfolio</code>. Он получает список позиций, а затем для каждой позиции запрашивает один и тот же профиль владельца. Цифры ниже — измеряемый пример для воспроизведения метода, а не отчёт о реальном проекте. Главная цель — связать симптом, гипотезу, минимальное изменение, наблюдение и ограничение так, чтобы другой инженер мог проверить каждый переход.</p>\n<h2>Сначала зафиксируйте наблюдение</h2>\n<p>Начинайте не с решения, а с записи, которую можно открыть. Вместо «страница стала медленной» укажите маршрут, окно и показатель: например, P95 для <code>GET /portfolio</code> вырос с 240 до 1900 мс при двадцати параллельных запросах. P95 — это значение, ниже которого попадает 95 процентов наблюдений; оно показывает хвост задержек, но не объясняет его причину.</p>\n<p>Следом зафиксируйте состав операции. Если ответ содержит десять позиций, сколько внешних вызовов сделано? Сколько из них относятся к портфелю, а сколько — к профилям? Есть ли повторяющиеся идентификаторы? Один trace или связанная группа логов должна позволить ответить на эти вопросы. Без идентификатора операции легко сравнить разные запросы и принять разницу нагрузки за эффект изменения.</p>\n<p>Здесь полезно разделять три утверждения. «Профиль запрашивается десять раз» — наблюдение, если это видно в трассе или журнале. «Повторы образуются в сборщике ответа» — гипотеза, которую ещё надо проверить. «Локальное кэширование уменьшило задержку» — причинный вывод, для которого понадобится сопоставимое измерение до и после. Чем сильнее фраза, тем больше независимых условий она должна выдержать.</p>\n<h2>Постройте несколько гипотез</h2>\n<p>Один симптом может иметь несколько причин. Повторные вызовы профиля — лишь первая гипотеза. Альтернативами могут быть медленный запрос списка, очередь соединений, ограничение внешнего API или изменение состава трафика. Увеличение таймаута способно уменьшить число ранних ошибок, но не уменьшает количество операций. Если заранее не записать этот вариант, команда легко примет более поздний ответ за ускорение.</p>\n<div class=\"table-scroll\"><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>Профиль запрашивается повторно</td><td>Число обращений к профилю растёт вместе с числом позиций, идентификаторы повторяются</td><td>Посчитать вызовы по одному trace и сгруппировать их по profileId</td><td>Одно измерение общей задержки</td></tr><tr><td>Медленно отвечает список позиций</td><td>Основная доля времени лежит в <code>/portfolios</code></td><td>Сравнить длительность span списка и дочерних вызовов</td><td>Снижение числа запросов к профилю</td></tr><tr><td>Заканчивается пул соединений</td><td>Ожидание ресурса растёт при параллельной нагрузке, внешние ответы сами не медленнее</td><td>Посмотреть время ожидания пула отдельно от времени upstream</td><td>Рост P95 без разбивки по этапам</td></tr><tr><td>Изменился трафик</td><td>После релиза отличаются размер ответа, доля клиентов или профиль маршрутов</td><td>Сопоставить одинаковые срезы по версии и типу запроса</td><td>Факт, что изменение было выкачено раньше замера</td></tr><tr><td>Увеличили таймаут</td><td>Ошибок меньше, но работа и длительность успешных запросов не сократились</td><td>Сравнить error rate, P95 и число вызовов</td><td>Только доля 5xx</td></tr></tbody></table></div>\n<p>Таблица задаёт не диагноз, а способ его опровергнуть. Если trace показывает один вызов профиля на позицию, локальный кэш не является первым изменением: дублирования нет. Если основная задержка приходится на получение списка, надо исследовать базу или upstream. Сопоставление вариантов защищает от подгонки объяснения под уже выбранный инструмент.</p>\n<h2>Определите границу состояния</h2>\n<p>Кэш — это не только правило хранения, но и правило владения. Для этого маршрута безопасная граница может проходить по одному вызову <code>loadPortfolio</code>: одинаковый <code>profileId</code> внутри одной операции использует один результат, а следующая операция получает новое хранилище. Такая область жизни убирает дублирование, но не делает профиль общим для пользователей и не решает согласованность между запросами.</p>\n<p>У ключа должна быть та же точность, что и у данных. Если профиль зависит от <code>profileId</code>, ключом должен быть именно идентификатор, а не имя или индекс позиции. Если ответ зависит ещё от языка, версии прав или набора полей, эти признаки входят в ключ либо кэширование запрещается. Ошибка с ключом не обязательно проявится в latency: она может вернуть правдоподобные, но чужие или устаревшие данные.</p>\n<p>Нужно заранее решить, что происходит с отказом. Сохранённый успешно выполненный ответ и сохранённое отклонённое обещание — разные политики. В примере ниже отклонённый promise удаляется из карты. Это позволяет следующему вызову повторить операцию в пределах той же обработки, если такой retry разрешён контрактом. Если повторять запрос нельзя, следует пробросить ошибку наружу и не добавлять неявную попытку.</p>\n<h2>Воспроизводимый пример: кэш на время одной операции</h2>\n<p>Код ниже не использует глобальное состояние. Карта создаётся при входе в функцию, а в неё кладётся promise сразу после запуска запроса. Поэтому два параллельных обращения с одним ключом видят один объект ожидания, даже если ответ ещё не готов. При ошибке запись удаляется; при новом вызове <code>loadPortfolio</code> карта создаётся заново.</p>\n<pre><code>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}</code></pre>\n<p>Важная деталь находится между проверкой и записью. В JavaScript синхронный участок функции выполняется до следующей точки ожидания, поэтому после <code>inFlightProfiles.get</code> здесь нет <code>await</code>: первый вызов успевает положить promise в карту до того, как второй вызов проверит её. <code>Promise.all</code> затем ждёт результаты всех позиций и возвращает массив в порядке входного массива, хотя сами обращения могут завершаться в разное время.</p>\n<p>Это свойство не следует расширять за пределы примера. Если <code>api.get</code> вызывает внешний сервис с побочным эффектом, повторное обращение и его безопасность определяются контрактом API. Если профиль изменился между двумя независимыми операциями, request-scoped кэш его не синхронизирует. Если <code>api.get</code> может бросить ошибку до возврата promise, обработчик должен дополнительно учитывать такой контракт клиента.</p>\n<h2>Проверьте положительный и отрицательный путь</h2>\n<p>Положительная проверка отвечает на вопрос «дублирование действительно исчезло?». Создайте ответ портфеля с двумя позициями одного владельца, задержите ответ профиля и запустите обработку. В журнале вызовов должен появиться один URL профиля, а обе позиции должны получить один и тот же результат. Отдельно проверьте два разных владельца: тогда ожидаются два вызова и два ключа, иначе тест не проверяет разделение данных.</p>\n<p>Отрицательные проверки важнее счастливого примера. Запустите две функции <code>loadPortfolio</code> для разных пользователей одновременно и убедитесь, что их карты не пересекаются. Затем отклоните запрос профиля и проверьте, что ошибка доходит до вызывающего кода, а повторная попытка не получает старое отклонённое promise, если retry предусмотрен. Наконец, передайте пустой список позиций: внешний вызов профиля не должен появиться.</p>\n<ol><li>Соберите fixture с двумя позициями одного владельца и отдельный fixture с двумя владельцами.</li><li>Подмените <code>api.get</code> функцией, которая записывает URL и возвращает управляемые promises.</li><li>До разрешения promise запустите обработку и проверьте число вызовов по каждому profileId.</li><li>Повторите операцию для двух userId и убедитесь, что вызовы не используют общую карту.</li><li>Отклоните один запрос профиля, проверьте ошибку и отдельно определите допустимость повтора.</li><li>Запустите пустой портфель и зафиксируйте отсутствие обращений к профилям.</li><li>Сравните результат с исходной реализацией: проверяйте не только latency, но и состав ответа.</li></ol>\n<p>Проверка счётчика вызовов показывает дедупликацию, но не доказывает полезность для пользователя. Для этого нужен следующий слой — одинаковая нагрузка и одинаковая метрика. Тесты должны оставить диагностическое сообщение при нарушении: «ожидался один профильный вызов для profileId=p-1, получено два». Без такого сообщения падение будет трудно связать с границей состояния.</p>\n<h2>Сравните изменение с базовой линией</h2>\n<p>До изменения сохраните базовую линию: версия кода, число позиций, доля ошибок, P50 и P95, длительность каждого внешнего этапа и объём параллельной нагрузки. После изменения повторите тот же сценарий. Сравнивать среднее до и P95 после нельзя: это разные характеристики. Сравнивать два окна с разным числом позиций тоже нельзя без нормализации или раздельных срезов.</p>\n<p>Наблюдаемость должна быть связной. Trace показывает путь одного запроса и отношения между операциями. Metrics позволяют увидеть распределение и динамику по группе запросов. Logs полезны для конкретного события и контекста ошибки. OpenTelemetry описывает эти сигналы как взаимодополняющие, но сама телеметрия не превращает корреляцию в причинное доказательство. Она только даёт материал для проверки.</p>\n<p>Причинный вывод становится крепче, если меняется один существенный фактор, а альтернативы получают отдельную проверку. Сравните контрольный и изменённый вариант на одной версии клиента, одинаковом наборе данных и сопоставимой нагрузке. Если одновременно изменились SQL-запрос, лимит таймаута и кэш, результат нельзя честно приписать только карте promises. Запишите также отрицательный результат: отсутствие снижения числа вызовов — аргумент против выбранной гипотезы.</p>\n<figure><img src=\"/assets/editorial/2026/portfolio-case-2026-causal-timeline.svg\" alt=\"Схема проверки инженерного кейса: наблюдаемый симптом проходит через гипотезу и варианты к изменению, измерению и остаточному риску\" loading=\"lazy\" /><figcaption>Схема показывает порядок перехода от наблюдения к ограниченному выводу. Она помогает не перепутать временную последовательность событий с доказанной причиной и не изображает результат конкретной системы.</figcaption></figure>\n<h2>Когда локальный кэш неприменим</h2>\n<p>Request-scoped кэш подходит, когда один ответ повторно запрашивает неизменяемый или допустимо согласованный ресурс, а повторная работа действительно возникает внутри одной операции. Он не заменяет кэш между запросами, CDN или хранилище с явной политикой инвалидации. Не применяйте его автоматически к данным, чья свежесть критична для решения пользователя.</p>\n<p>Ограничение по памяти зависит от числа уникальных ключей в одном ответе. Портфель с миллионом разных владельцев создаст миллион записей и может сделать локальную оптимизацию источником давления на память. Нужны верхняя граница размера, отказ от кэширования при превышении лимита или другой контракт загрузки. Такое решение следует измерять на худшем допустимом входе, а не только на маленьком fixture.</p>\n<p>Не переносите карту на уровень модуля или singleton без доказанной области владения. В долгоживущем серверном процессе это может связать запросы разных пользователей, удерживать данные дольше разрешённого срока и создать утечку. Для общего кэша потребуются TTL, инвалидация, политика ошибок, ограничение памяти, защита ключа и проверка согласованности. Это уже другой механизм и другая статья решений.</p>\n<p>Сетевой upstream может иметь rate limit, собственную кэш-политику и побочные эффекты. Один вызов вместо десяти снижает нагрузку только на этот маршрут и при условии, что запросы действительно эквивалентны. Он не гарантирует сокращения итоговой задержки: время может находиться в другом span. Не объявляйте исправление успешным по одному локальному запуску.</p>\n<h2>Сформулируйте вывод уже, чем обещание</h2>\n<p>Хороший кейс заканчивается не фразой «система ускорилась», а условным выводом. В нашем примере можно утверждать: «при двух позициях одного владельца функция создаёт один профильный promise внутри одной операции; отдельные вызовы функции не делят карту; отклонённая запись удаляется по выбранной политике». Это проверяемые свойства кода.</p>\n<p>Нельзя утверждать без измерения, что P95 всего продукта уменьшился, стоимость инфраструктуры снизилась или проблема больше не повторится. Для таких выводов нужны данные из сопоставимых окон, описание нагрузки и проверка альтернативных причин. Если измерения ещё нет, следующий шаг — собрать его, а не дописывать красивый результат.</p>\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>",
|
||
"readingMinutes": 12
|
||
}
|