{ "index": 39, "slug": "editorial-2026-12-practice-portfolio-case", "title": "Инженерный кейс: как связать симптом, решение и доказательство", "excerpt": "Практический способ разобрать задержку API: проверить гипотезу о дублирующих вызовах, ограничить кэш одной операцией и не приписать изменению эффект без сопоставимых измерений.", "contentHtml": "
Запрос к API иногда занимает две секунды вместо двухсот миллисекунд. Пользователь видит пустой экран, оператор получает всплеск таймаутов, а команда увеличивает лимит ожидания. Для разбора этого симптома мало сказать «нужно добавить кэш»: такой совет меняет поведение, но не отвечает, где возникла лишняя работа и как доказать, что она исчезла.
\nРазберём условный маршрут GET /portfolio. Он получает список позиций, а затем для каждой позиции запрашивает один и тот же профиль владельца. Цифры ниже — измеряемый пример для воспроизведения метода, а не отчёт о реальном проекте. Главная цель — связать симптом, гипотезу, минимальное изменение, наблюдение и ограничение так, чтобы другой инженер мог проверить каждый переход.
Начинайте не с решения, а с записи, которую можно открыть. Вместо «страница стала медленной» укажите маршрут, окно и показатель: например, P95 для GET /portfolio вырос с 240 до 1900 мс при двадцати параллельных запросах. P95 — это значение, ниже которого попадает 95 процентов наблюдений; оно показывает хвост задержек, но не объясняет его причину.
Следом зафиксируйте состав операции. Если ответ содержит десять позиций, сколько внешних вызовов сделано? Сколько из них относятся к портфелю, а сколько — к профилям? Есть ли повторяющиеся идентификаторы? Один trace или связанная группа логов должна позволить ответить на эти вопросы. Без идентификатора операции легко сравнить разные запросы и принять разницу нагрузки за эффект изменения.
\nЗдесь полезно разделять три утверждения. «Профиль запрашивается десять раз» — наблюдение, если это видно в трассе или журнале. «Повторы образуются в сборщике ответа» — гипотеза, которую ещё надо проверить. «Локальное кэширование уменьшило задержку» — причинный вывод, для которого понадобится сопоставимое измерение до и после. Чем сильнее фраза, тем больше независимых условий она должна выдержать.
\nОдин симптом может иметь несколько причин. Повторные вызовы профиля — лишь первая гипотеза. Альтернативами могут быть медленный запрос списка, очередь соединений, ограничение внешнего API или изменение состава трафика. Увеличение таймаута способно уменьшить число ранних ошибок, но не уменьшает количество операций. Если заранее не записать этот вариант, команда легко примет более поздний ответ за ускорение.
\n| Гипотеза | Что должно быть видно | Минимальная проверка | Что не доказывает гипотезу |
|---|---|---|---|
| Профиль запрашивается повторно | Число обращений к профилю растёт вместе с числом позиций, идентификаторы повторяются | Посчитать вызовы по одному trace и сгруппировать их по profileId | Одно измерение общей задержки |
| Медленно отвечает список позиций | Основная доля времени лежит в /portfolios | Сравнить длительность span списка и дочерних вызовов | Снижение числа запросов к профилю |
| Заканчивается пул соединений | Ожидание ресурса растёт при параллельной нагрузке, внешние ответы сами не медленнее | Посмотреть время ожидания пула отдельно от времени upstream | Рост P95 без разбивки по этапам |
| Изменился трафик | После релиза отличаются размер ответа, доля клиентов или профиль маршрутов | Сопоставить одинаковые срезы по версии и типу запроса | Факт, что изменение было выкачено раньше замера |
| Увеличили таймаут | Ошибок меньше, но работа и длительность успешных запросов не сократились | Сравнить error rate, P95 и число вызовов | Только доля 5xx |
Таблица задаёт не диагноз, а способ его опровергнуть. Если trace показывает один вызов профиля на позицию, локальный кэш не является первым изменением: дублирования нет. Если основная задержка приходится на получение списка, надо исследовать базу или upstream. Сопоставление вариантов защищает от подгонки объяснения под уже выбранный инструмент.
\nКэш — это не только правило хранения, но и правило владения. Для этого маршрута безопасная граница может проходить по одному вызову loadPortfolio: одинаковый profileId внутри одной операции использует один результат, а следующая операция получает новое хранилище. Такая область жизни убирает дублирование, но не делает профиль общим для пользователей и не решает согласованность между запросами.
У ключа должна быть та же точность, что и у данных. Если профиль зависит от profileId, ключом должен быть именно идентификатор, а не имя или индекс позиции. Если ответ зависит ещё от языка, версии прав или набора полей, эти признаки входят в ключ либо кэширование запрещается. Ошибка с ключом не обязательно проявится в latency: она может вернуть правдоподобные, но чужие или устаревшие данные.
Нужно заранее решить, что происходит с отказом. Сохранённый успешно выполненный ответ и сохранённое отклонённое обещание — разные политики. В примере ниже отклонённый promise удаляется из карты. Это позволяет следующему вызову повторить операцию в пределах той же обработки, если такой retry разрешён контрактом. Если повторять запрос нельзя, следует пробросить ошибку наружу и не добавлять неявную попытку.
\nКод ниже не использует глобальное состояние. Карта создаётся при входе в функцию, а в неё кладётся promise сразу после запуска запроса. Поэтому два параллельных обращения с одним ключом видят один объект ожидания, даже если ответ ещё не готов. При ошибке запись удаляется; при новом вызове loadPortfolio карта создаётся заново.
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 затем ждёт результаты всех позиций и возвращает массив в порядке входного массива, хотя сами обращения могут завершаться в разное время.
Это свойство не следует расширять за пределы примера. Если api.get вызывает внешний сервис с побочным эффектом, повторное обращение и его безопасность определяются контрактом API. Если профиль изменился между двумя независимыми операциями, request-scoped кэш его не синхронизирует. Если api.get может бросить ошибку до возврата promise, обработчик должен дополнительно учитывать такой контракт клиента.
Положительная проверка отвечает на вопрос «дублирование действительно исчезло?». Создайте ответ портфеля с двумя позициями одного владельца, задержите ответ профиля и запустите обработку. В журнале вызовов должен появиться один URL профиля, а обе позиции должны получить один и тот же результат. Отдельно проверьте два разных владельца: тогда ожидаются два вызова и два ключа, иначе тест не проверяет разделение данных.
\nОтрицательные проверки важнее счастливого примера. Запустите две функции loadPortfolio для разных пользователей одновременно и убедитесь, что их карты не пересекаются. Затем отклоните запрос профиля и проверьте, что ошибка доходит до вызывающего кода, а повторная попытка не получает старое отклонённое promise, если retry предусмотрен. Наконец, передайте пустой список позиций: внешний вызов профиля не должен появиться.
api.get функцией, которая записывает URL и возвращает управляемые promises.Проверка счётчика вызовов показывает дедупликацию, но не доказывает полезность для пользователя. Для этого нужен следующий слой — одинаковая нагрузка и одинаковая метрика. Тесты должны оставить диагностическое сообщение при нарушении: «ожидался один профильный вызов для profileId=p-1, получено два». Без такого сообщения падение будет трудно связать с границей состояния.
\nДо изменения сохраните базовую линию: версия кода, число позиций, доля ошибок, P50 и P95, длительность каждого внешнего этапа и объём параллельной нагрузки. После изменения повторите тот же сценарий. Сравнивать среднее до и P95 после нельзя: это разные характеристики. Сравнивать два окна с разным числом позиций тоже нельзя без нормализации или раздельных срезов.
\nНаблюдаемость должна быть связной. Trace показывает путь одного запроса и отношения между операциями. Metrics позволяют увидеть распределение и динамику по группе запросов. Logs полезны для конкретного события и контекста ошибки. OpenTelemetry описывает эти сигналы как взаимодополняющие, но сама телеметрия не превращает корреляцию в причинное доказательство. Она только даёт материал для проверки.
\nПричинный вывод становится крепче, если меняется один существенный фактор, а альтернативы получают отдельную проверку. Сравните контрольный и изменённый вариант на одной версии клиента, одинаковом наборе данных и сопоставимой нагрузке. Если одновременно изменились SQL-запрос, лимит таймаута и кэш, результат нельзя честно приписать только карте promises. Запишите также отрицательный результат: отсутствие снижения числа вызовов — аргумент против выбранной гипотезы.
\nRequest-scoped кэш подходит, когда один ответ повторно запрашивает неизменяемый или допустимо согласованный ресурс, а повторная работа действительно возникает внутри одной операции. Он не заменяет кэш между запросами, CDN или хранилище с явной политикой инвалидации. Не применяйте его автоматически к данным, чья свежесть критична для решения пользователя.
\nОграничение по памяти зависит от числа уникальных ключей в одном ответе. Портфель с миллионом разных владельцев создаст миллион записей и может сделать локальную оптимизацию источником давления на память. Нужны верхняя граница размера, отказ от кэширования при превышении лимита или другой контракт загрузки. Такое решение следует измерять на худшем допустимом входе, а не только на маленьком fixture.
\nНе переносите карту на уровень модуля или singleton без доказанной области владения. В долгоживущем серверном процессе это может связать запросы разных пользователей, удерживать данные дольше разрешённого срока и создать утечку. Для общего кэша потребуются TTL, инвалидация, политика ошибок, ограничение памяти, защита ключа и проверка согласованности. Это уже другой механизм и другая статья решений.
\nСетевой upstream может иметь rate limit, собственную кэш-политику и побочные эффекты. Один вызов вместо десяти снижает нагрузку только на этот маршрут и при условии, что запросы действительно эквивалентны. Он не гарантирует сокращения итоговой задержки: время может находиться в другом span. Не объявляйте исправление успешным по одному локальному запуску.
\nХороший кейс заканчивается не фразой «система ускорилась», а условным выводом. В нашем примере можно утверждать: «при двух позициях одного владельца функция создаёт один профильный promise внутри одной операции; отдельные вызовы функции не делят карту; отклонённая запись удаляется по выбранной политике». Это проверяемые свойства кода.
\nНельзя утверждать без измерения, что P95 всего продукта уменьшился, стоимость инфраструктуры снизилась или проблема больше не повторится. Для таких выводов нужны данные из сопоставимых окон, описание нагрузки и проверка альтернативных причин. Если измерения ещё нет, следующий шаг — собрать его, а не дописывать красивый результат.
\nПеред публикацией или передачей решения проверьте четыре границы: где начинается и заканчивается состояние; какие признаки входят в ключ; что происходит при ошибке; какой именно сигнал подтверждает эффект. Затем назовите остаточный риск. В этой схеме он состоит в том, что локальная дедупликация может быть правильной, но недостаточной: настоящая задержка может находиться в другом участке пути, а стоимость больших входов — проявиться только под нагрузкой.
\nEXPLAIN ANALYZE выполняет запрос и показывает фактические значения. Оценки зависят от статистики и платформы, поэтому источник не заменяет замер в конкретной базе.