Суть Чтение реальных backend-сниппетов — заблокированный event loop, безграничный acquire из pool, неатомарная проверка идемпотентности и breaker без таймаута — и выбор фикса, который senior сделал бы первым.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на senior-высоте — в орбите
◷ 14 min
Составные сбои диагностируют в коде и логах, а не в абстракции. Прочитай каждый сниппет, предскажи его поведение под нагрузкой и выбери фикс с наибольшим рычагом — тот, что закрывает взаимодействие, а не только локальный симптом.
Цель
Отработай цикл, который ты крутишь в каждом инциденте: прочитать горячий путь, заметить, чьё плохое поведение питает следующий механизм, и потянуться к фиксу, останавливающему каскад у истока.
Сниппет 1 — обработчик, блокирующий loop
// Node.js, один event loop, тысячи конкурентных запросовapp.post("/report", (req, res) => { const body = req.body; // уже распарсено const hash = crypto.pbkdf2Sync( // СИНХРОННО, ~80мс body.token, body.salt, 200000, 64, "sha512" ); const pdf = renderPdfSync(body.rows); // СИНХРОННО, ~120мс CPU res.send({ ok: true, digest: hash.toString("hex"), pdf });});
Викторина
Completed
Под конкурентной нагрузкой этот обработчик роняет p99 для каждого эндпоинта, не только /report. Почему и каков фикс с наибольшим рычагом?
Heads-up В этом пути нет ни I/O, ни pool — это чистый CPU на event loop. Бо́льший pool ничего не меняет; фикс — перестать блокировать loop синхронной CPU-работой.
Heads-up Retry на блокирующем loop обработчике добавляет ещё CPU-работы на уже застопоренный loop — чистое усиление. Фикс — унести синхронную работу с loop, а не повторять её.
Heads-up Цена не в 200мс одного запроса — в том, что синхронная работа одного запроса замораживает loop и добавляет задержку тысячам несвязанных запросов, делящих его.
Сниппет 2 — acquire из pool без границы
// Go: фиксированный pool из 50 соединений, без acquisition timeoutfunc (h *Handler) Charge(ctx context.Context, req ChargeReq) error { conn := h.pool.Acquire() // блокируется бесконечно, если pool пуст defer h.pool.Release(conn) // у вызова провайдера тоже нет своего таймаута resp, err := h.provider.Call(conn, req) if err != nil { return err } return h.repo.Save(conn, resp)}
Викторина
Completed
Провайдер замедлился до 4 с на вызов. Что этот код делает со всем сервисом и каков первый фикс?
Heads-up Ограниченность лимитирует конкурентную работу, но без acquire timeout 51-й вызывающий ждёт бесконечно. Bounded плюс безграничное ожидание — так медленный downstream стопорит всех; нужен ещё fail-fast на acquire.
Heads-up Бо́льший pool лишь оттягивает исчерпание и сажает больше соединений на медленного провайдера, расширяя blast radius. Фикс — fail-fast acquire и таймаут вызова, а не больше соединений.
Heads-up Таймаута, чтобы вызов упал, нет, поэтому retry не срабатывают — а если бы и сработали, схватили бы больше соединений и усилили исчерпание. Сначала ограничь acquire и вызов.
Сниппет 3 — неатомарная проверка идемпотентности
-- Приложение делает: check-then-insert, два отдельных оператораSELECT result FROM charges WHERE idempotency_key = $1; -- шаг A: не найдено-- ... обработчик идёт списывать с карты ...INSERT INTO charges (idempotency_key, result) VALUES ($1, $2); -- шаг B
Викторина
Completed
Клиент повторяет тот же POST, и два запроса идут конкурентно. С этой check-then-insert идемпотентностью что произойдёт и как это починить?
Heads-up Отдельные SELECT, затем INSERT не атомарны; два конкурентных запроса оба проходят SELECT раньше, чем любой сделает INSERT, поэтому оба списывают. Идемпотентность надо обеспечивать атомарно на слое хранилища, а не предварительным чтением.
Heads-up Повтор INSERT не устраняет гонку, уже пропустившую два списания. Нужно ограничение уникальности, чтобы дублирующая запись отклонялась атомарно, а не retry.
Heads-up Простая транзакция на уровне изоляции по умолчанию всё равно позволяет обоим прочитать «не найдено» и обоим вставить. Нужен UNIQUE (или SELECT ... FOR UPDATE / serializable), а не просто граница транзакции.
Сниппет 4 — breaker, который не срабатывает
const breaker = new CircuitBreaker(callProvider, { errorThresholdPercentage: 50, // открыть при 50% ошибок resetTimeout: 30000, // half-open через 30с // опция `timeout` не задана — вызовы могут висеть бесконечно});async function charge(req) { return breaker.fire(req); // await callProvider без дедлайна}
Викторина
Completed
Провайдер перестал отвечать — вызовы висят минутами, а не падают с ошибкой. Почему этот breaker не защищает сервис и каков фикс?
Heads-up Низкий порог не помогает, когда завершённых ошибок почти нет — вызовы висят, а не падают. Breaker нужен таймаут, чтобы зависания стали учитываемыми сбоями.
Heads-up resetTimeout управляет тем, как быстро открытый breaker пробует восстановление; он неважен, пока breaker не открывается. Недостающее — таймаут вызова, превращающий зависания в сбои, достойные срабатывания.
Heads-up Вызов, висящий минутами, — худший вид сбоя: он держит ресурсы и не разрешается. Breaker без таймаута слеп именно к тем медленным сбоям, которые ему важнее всего сбрасывать.
Итог
Каждый сниппет — это один механизм, чей изъян становится катастрофой следующего: синхронный CPU на event loop стопорит всю конкурентность; безграничный acquire из pool превращает медленный downstream в полный стопор; неатомарная проверка идемпотентности списывает дважды под гонкой retry; и breaker без таймаута вызова слеп к зависшим вызовам. Фикс всегда тот, что останавливает взаимодействие у истока — унести работу с loop, fail-fast на acquire, атомарный dedup и таймаут для breaker — а не ручка, откладывающая или усиливающая проблему.