zero deps
57 subscribers
1 photo
27 links
Node.js, V8, highload, архитектура.
Где производительность, а где оверинжиниринг?

Код, профилирование и воспроизводимые бенчмарки.

Всё, что ставим — потом и поддерживаем.
Фиксим то, что сами и сломали.

zerodeps.tech
Download Telegram
Очереди все еще не нужны.

Каждый  разработчик думает об архитектуре нового проекта,  и часто приходит к такому заключению: «Завезу микросервисы, и чтоб красиво кафку туда, или реббит, чтоб общались. Редис для кешей, и все это в кубере. Заживу!». 

Обоснование тому такое: «Задел на будущее, кучу систем так построили, нормально работает, даже не больно, практически. И распределенка, и отказы с пиками держим. Красота!». Я мог бы согласится, но не буду. Если начать копать глубже, всплывает много нюансов. Большую часть того, что всплывает, я описал в прошлый раз

Тогда же, в комментариях, чтобы не быть голословным и ради академического интереса, решил собрать стенд и уже на цифрах тебе показать, что очереди в классическом понимании все еще не нужны. 

Расскажу что собирал, как и что измерял и какие результаты получил.
👍4🔥3
Часть первая: Преамбула 

Начну издалека, с теоретического обоснования, как учили еще в университетах. 

Есть у тебя сервис А. Базу свою этот сервис и в хвост и в гриву, поэтому запись там медленная — допустим, 60 мс. Сервис А принимает на эндпоинт /task задачу, а бизнес вместе с SRE говорит тебе как архитектору:
«Ждать ажно 60мс никак не можем, ответить нужно за 50 и точка. Zero loss, идемпотентность, at-least-once. Ещё давай, чтобы graceful degradation было, throughput не падал, latency стабильно в SLA. Ordering guarantees можно не жёсткие, но durability кровь из носа. И чтоб backpressure обязательно. Завтра релиз».

Отдельное спасибо бизнесу, что порядок записи не так уж и важен, и этим ты можем пренебречь, то есть писать можно параллельно пачками и быстрее разбирать хвост.

Формализуем требования: 
- SLA входа: p99(t_api) ≤ 50 мс при 500 RPS (без аварий).
- At-least-once доставка без дублей в целевой БД (идемпотентность по `id`).
- Durability на приёме: после ответа клиенту запись потеряться не должна при падении сервиса/воркера/базы.

Остальное как бы не услышал, оно тебе не важно.

Уяснив требования бизнеса и хотелки SRE, наливаешь кофу и начинаешь думать, какие варианты у тебя есть и чего эти варианты тебе будут стоить.
🔥6
Часть вторая: Кейсы

Возьмем сферический проект в вакууме, представим, что у тебя нет ничего кроме как api и базы. Api напрямую пишет задачу в бд и беспощадно нарушает SLA. 

"Чтож делать? Чтож делать?"

Кейс 1. WAL (Write-Ahead Logging)

Старый “советский” метод. Api пишет не в базу, а в локальный журнал. Формат тупой как гвоздь: длина + payload. Один fsync на батч или по таймауту. Ротация — по размеру и времени. Старый сегмент закрыли, новый открыли. Воркеры читают завершённые сегменты и докатывают в базу пачками, хоть по 100, хоть по 1000 штук.

Плюсы:
– Latency +-1 мс на входе (fsync по батчу/таймеру).
– Durability честная после fsync.
– At‑least‑once через идемпотентность в БД.
– Failover тривиальный (перечитываем сегмент).
– Естественный backpressure по росту сегментов.

Минусы:
– Дисковая нагрузка (особенно fsync=always).
– Своя реализация и поддержка (скорее всего реализации готовой не будет или будет написана индусом).
– Нужен мониторинг лага/ротации, контроль места на диске.

WAL – это "скучный" вариант, но и предсказуемый, сам сломал –сам чинишь. Нет брокеров, нет «кластеров», нет магии. Файл и логика: как писать, как читать, как ротировать. Стоит упомянуть, что все последующие кейсы в той или иной степени под капотом имеют схожий механизм обеспечения сохранности данных – fsync.

Кейс 2. Ingest DB

Тут уже будет нужен DevOps. Вместо того, чтобы писать в журнал, пишем сразу в базу — но не в боевую, а в отдельный инстанс (ingest), который не тормозит, заточен на быструю запись и физически крутится на другом сервере.  Уже из него воркер параллельно пачками переливает в нужную базу.

Плюсы:
– Durability на стороне СУБД.
– Дубликаты гасит ON CONFLICT.
– Масштабирование воркерами, размер пачки.
– Лаг прозрачен (ingest → main).

Минусы:
– Индексы на ingest могут тормозить вставку.
– Вакуум/блоат и размер пачки нужно тюнить.
– UNLOGGED нельзя при zero‑loss.
– Возможна гонка за ресурс на горячих партициях.
– Медленная/общая БД = боттлнек.

Получается, что IngestDB — это в теории решение между чистым WAL и брокером. Простая вставка на входе, батчи на выходе, минимум инфраструктуры. Теоретически хорошее решение, завез отдельную базу, затюнил и решил проблему. 

Кейс 3: Valkey (Streams с AOF)

Вот ты уже практически прям рядом с очередью. Еще не жирный брокер, но уже очередь на минималках. Берём Valkey (форк Redis), включаем streams и appendfsync=always. Тут как и с базой нужОн DevOps, лучше два, поднимаем новый отдельный сервис.

А работает кейс так: 
– api пишет событие в XADD stream * payload. Операция быстрая, но не бесплатная, каждый fsync блокирует запись.
– Клиент получает ACK сразу после попадания в AOF, значит durability честная.
– Воркер читает через XREADGROUP, обрабатывает пачку и помечает offset.

Плюсы:
– Низкая теоретическая базовая задержка на XADD.
– AOF даёт честную durability.
– At‑least‑once встроен (PEL/ACK) (с оговоркой, только на доставку, дубли не контролируются).
– Привычный стек (в теории).

Минусы:
– p99 скачет из‑за fsync на нагрузке.
– Backpressure кривой: растёт лаг, сервис ест память.
– Failover: потеря хвоста без WAIT/min‑replicas‑to‑write.
– Доп. сервис и зоопарк, рост RAM до краша.

Valkey хорош для UX-шных событий, коротких очередей, быстрых стримов, где пропускная способность важнее стабильной задержки. Но в задаче «zero loss + SLA по latency» valkey начинает подтекать: да, не потеряет, но предсказуемость страдает. В оправдание запишем тот факт, что скорее всего, valkey или redis уже будет на проекте как cache.
🔥5
Кейс 4: Rabbit

Ну вот и добрались до Очереди. Да, именно с большой буквы. Настоящая, взрослая, жирненькая очередь, как у больших дядек в фаанге. "Для очередей придумана!" - это про нее. Все архитекторы дружно кивают головами: durable queue, ack, retry, всё как завещали тебе Фаулер, Хоппе (Хохпе?) и Клишин. 

Как работает:
– api пишет задачу в durable queue через AMQP. Это не запись в файл напрямую, а запись через брокер, его буферы, его собственный журнал, еще и по сети.
– ACK клиенту прилетает сразу после попадания в очередь (и тут уже вопрос: в память или на диск).
– Воркер подписан на очередь, получает сообщения, подтверждает через ack, или говорит, что не смог через nack.

Плюсы:
– Ack/Nack/retry/маршрутизация из коробки.
– At‑least‑once встроен (с оговоркой: гарантируется сохранность сообщения, но не отсутствие дубликатов)
– Fanout/headers/топики для сложной топологии.

Минусы:
– Latency выше и пила по p99.
– Durability условная без durable+persistent+publisher confirm.
– Failover через Raft: пауза на выбор лидера, redelivery, без confirm — потеря.
– Сложный зоопарк (кластер, HA‑политики, мониторинг), непредсказуемый throughput при пиках.

Несмотря на то, что RabbitMQ выглядит «серьёзным», "большим", "взрослым" и очень нужным,  по факту добавляет: дополнительный hop в pipeline по сети, непредсказуемые задержки и вероятность потерять данные при кривой конфигурации.

Для задач «принять таску и сохранить без потерь» — rabbit не лучше, чем WAL или Ingest. Для задач «маршрутизировать в десять разных консьюмеров с fanout/headers-routing» — норм. Но если тебе это не надо, RabbitMQ — это, уже на теоретической части, оверинжиниринг.

Примерно такой поток мыслей будет в голове у архитектора, который с кофой сидит и думает как будет закрывать SLA. Давай теперь расскажу как и на чем тесты проводились.
🔥5
Часть третья: Стенд. Что? Где? Когда?

Собрал значит стенд, один репозиторий, 4 сценария. Поднимаем через docker-compose, немного bash скриптов, make. 

Окружение у стенда получилось следующее: 
 - Node.js ≥ 22:  api, воркеры, утилиты, клиенты для бд и очередей, самописный WAL
 - PostgreSQL 16:  основная медленная СУБД и ingest в отдельном контейнере для второго кейса
 - RabbitMQ 4.1: для четвертого кейса
 - Генератор нагрузки: autocannon.
 - Метрики: логи в json для таймингов.
 - ОС-метрики: telegraf 1.35

Для каждого кейса отдельный api и worker (см. apps/*).
– WAL: запись в файл, ротация и воркер, который докатывает сегменты.
– Ingest: отдельная БД для приема и воркер, который батчами с ограниченной параллельностью переливает из ingest в main.
– Valkey: XADD в stream, чтение через consumer group.
– Rabbit: durable очередь по AMQP.

Для Ingest, Valkey, Rabbit использовал готовые клиенты из npm. WAL пришлось написать самому, в npm не оказалось нормальной реализации, есть пара пакетов, один древний, второй сомнительный. Ну и zerodeps как никак. 700 строк и +- рабочий WAL готов, с ротациями, с таймерами, локами и блекджеком.  

Долгую запись в основную бд эмулирую через триггер со sleep на запись каждой строки. 
Нагрузка идёт отдельным сервисом loadgen (autocannon под капотом).

Сценарий нагрузки: 
RPS: 500.
Каждый прогон: 30 с прогрев (1/2 RPS) → 5 мин стабильно RPS → 30 спад (1/2 RPS).
Сбои (на 3-й минуте):  Kill воркера на 30с, затем рестарт.

Можно придумать другие сценарии, но не стал, одного считаю достаточным для сравнения в рамках данного изыскания.  

По метриками тоже не стал изобретать. Telegraf для os, там все из коробки. По бизнес метрикам json логи в файл. Просто читать и анализировать.

Что собирал: 
- durable — время надёжной фиксации в соответствующем стораже:
    - WAL: до записи в журнал.
    - Ingest: до коммита транзакции в ingest-БД.
    - Rabbit: до получения publisher confirm.
    - Valkey: задержка XADD; только при appendfsync=always это «сразу durable».
- committed — кол-во строк и время коммита в основную бд
- db_count – кол-во записей в основной бд в момент времени
- backlog – размер хвоста в момент времени
- api_latency – время, за которое ответил api и каким кодом
- OS метрики cpu, mem, diskio, net для контейнеров, участвующих в прогоне (api/worker/сервис).

Для сравнения по каждому кейсу будем считать набор стабильных метрик: 
- latency (api, durable, committed) — чтобы видеть, где именно в цепочке появляется задержка; 
- backlog — динамику роста и слива очереди, пик и площадь под кривой, то есть сколько «долга» система накапливает; 
- пропускную способность (committed rps), стабильность и хвостовые индексы (tail index) — насколько тяжёлые редкие задержки по сравнению с медианой; 
- итоговое количество записей (rows_total) — для нормализации этих значений между прогонами разного масштаба. 

В результате получаем сопоставимые профили: видно, как быстро система набирает очередь, с какой скоростью от неё избавляется, какие задержки видны пользователю на API-уровне и насколько стабильны внутренние транзакции. 

Пример отчета можно посмотреть тут: comare.md

Так же оценим когнитивную нагрузку для каждого кейса.

Критерии оценки: 
- Код: строки собственного кода (API+воркер+реплей/ретраи/метрики).
- Конфиг: число обязательных настроек.
- Failure modes: список типов отказов, о которых нужно помнить.
- Наблюдаемость: минимально достаточный набор метрик/алертов.
- Ранбуки: шаги запуска/восстановления для «упал X».

Каждый пункт от 0 до 5, суммируем и получаем «индекс когнитивки»; чем ниже — тем проще.

В целом никакого рокет сайенс, можно самому при желании потрогать. Перейдем к конкретике.
🔥5
Часть четвертая: Когнитивная нагрузка

WAL
• Код: 5/5. (+- 50 api и worker + 600 реализация). 
• Конфиг: 1/5 (Путь до рабочей директории, когда и как fsync, когда и как rorate, размер батча). 0 devops
• Failure: 3/5 (диск/ротация/fsync).
• Наблюдаемость: 3/5 (лаг сегментов, место на диске, скорость докатки).
• Ранбуки: 2/5 (перезапуск воркера, зачистка сегментов, квоты на диск).

Сумма: 14/25 - реализация подводит, код, который нужно будет поддерживать, не выкинуть.
DevOps: 0 - не нужен.

Ingest
• Код:  2/5, ( +-100 api + worker).
• Конфиг: 2/5 (Конфиги БД, размер батча, lease_ms) . 1 devops
• Failure: 4/5 (вакуум/блоат, гонки за ресурс, ретраи транзакций, тайминги lease).
• Наблюдаемость: 3/5 (лаг ingest→main, deadlocks/retries, длительность батчей).
• Ранбуки: 3/5 (восстановить сервис, чистки/автовакуум, реплей).

Сумма: 14/25 — средняя  нагрузка, «можно держать в голове».
DevOps: 1 - вторая бд на другом сервере

Valkey (Streams + AOF)
• Код: 2/5 ( +- 100 api + worker).
• Конфиг: 3/5. (Конфигурация Valkey, stream, group, blockMs  claimIdleMs) 2 devops
• Failure modes: 5/5 (RAM-утечка на lag, AOF/репликация, PEL/claim, trim-политики).
• Наблюдаемость: 4/5 (lag/PEL/maxlen/latency скачет от fsync).
• Ранбуки: 4/5 (перевыбор лидера, WAIT/min-replicas-to-write, ручные trim).

Сумма: 18 — высокий индекс, «держать в голове труднее». 
DevOps: 2  - отдельный "новый" сервис, возможны трудности. 

RabbitMQ (Quorum)
• Код: 1/5 (+-80 строк api + worker).
• Конфиг: 5/5 (users, permissions, exchanges, queues, bindings, policies и тд) 4 DevOps
• Failure modes: 5/5 (quorum/confirm, flow control, DLX, poison msg, переизбрания).
• Наблюдаемость: 5/5 (queues/channels/consumers/lag/DLQ/flow/memory/disk alarms).
• Ранбуки: 5/5 (кластер, политики, шардирование, восстановление, дрейф конфигов).

Сумма: 21 — высокая когнитивка, зоопарк, труднее удержать в голове
DevOps: 4  - кластер, конфиги и риски потерь высокие. 

В итоге по когнитивке: 
- WAL (14/25) — простая эксплуатация, но платишь поддержкой собственного кода.
- Ingest (14/25) — баланс: чуть DevOps, остальное — дисциплина СУБД.
- Valkey (18/25) — «быстро, но дорого в голове»: много тюнинга и аварийных сценариев.
- Rabbit (21/25) — минимум кода у тебя, максимум сложности в инфраструктуре.

Оценка субъективна, попытка переложить на цифры виденье сложности каждого решения. Перейдем к тому, ради чего мы тут все собрались.
🔥5
Часть пятая: Цифры

SLA (p99 API ≤ 50 мс)

Все четыре кейса выдержали заявленный SLA по входным запросам. Разница в том, насколько уверенно они это делают:
- WAL — минимальные задержки, p99 = 14 мс. Ответы приходят практически мгновенно, так как запрос завершается сразу после записи в локальный журнал.
- RabbitMQ23 мс. Здесь есть сетевой hop и подтверждение от брокера, но запас прочности остаётся высоким.
- Ingest44 мс, вплотную к верхней границе SLA. Для сценария «50 мс» этого хватает, но зазора на будущее почти нет.
- Valkey46 мс, прямо на краю допустимого. Любые пики нагрузки могут выбить его за SLA.

Все четыре решения «держат SLA», но WAL и RabbitMQ имеют запас, Ingest и Valkey балансируют на границе.

Durable latency

Это время до того момента, когда запись гарантированно не потеряется (fsync или confirm).

- WAL14 мс, фактически совпадает с API latency. Минимум прослоек = минимум задержки.
- RabbitMQ22 мс. Брокер добавляет задержку из-за своей внутренней журнализации и подтверждений.
- Ingest44 мс. Всё упирается в транзакцию Postgres, на каждое подтверждение уходит десятки миллисекунд.
- Valkey45 мс, почти то же самое: fsync на AOF дороже, чем кажется, и скачет вместе с нагрузкой.

Видно, что «каждая новая прослойка» добавляет десятки миллисекунд к фиксации. WAL выигрывает именно простотой: запись в файл и всё.

Коммиты в основную БД

Тут все варианты упираются в одно «бутылочную горлышко»: медленная основная база, ожидаемо. p99 коммита ~ 12.3 секунд, средний throughput около 16 rps.

- WAL: среднее время ~ 9.8 с. Профиль ровный, вариативность низкая — хорошая предсказуемость.
- Ingest: ~ 11.7 с. Немного хуже WAL, но тоже стабильно.
- Valkey: ~ 11.3 с. Схож с Ingest, чуть быстрее.
- RabbitMQ: выбивается — среднее всего 5.5 с, но при этом хвост тяжёлый, доходит до 12.4 с. Коэффициент вариации самый высокий (0.035), то есть профиль «рваный»: часть задач пролетает быстро, часть задерживается надолго.

Никакая очередь сама по себе не лечит узкое место в базе: throughput одинаковый, разница только в том, насколько предсказуемо сообщение добирается до базы.

Backlog и дренаж

Здесь видно, насколько быстро система накапливает очередь и как справляется с хвостом после сбоя:
- WAL: пик ~ 47k задач, скорость дренажа 353/с, хвост уходит за ~ 133 с. Рабочий, предсказуемый профиль.
- Ingest: чуть больше очередь — 50k, дренаж 362/с, очистка за ~ 138 с. По сути те же цифры, что у WAL.
- Valkey: также 50k в пике, но дренирует медленнее — 327/с, до нуля ~ 152 с. Отставание небольшое, но заметное.
- RabbitMQ: резко выбивается. Очередь раздувается до 88k, скорость дренажа всего 250/с, на полное очищение уходит ~ 353 с.

RabbitMQ тратит больше ресурсов на собственные механизмы и за счёт этого копит самый большой хвост и дренирует его хуже всех. WAL и Ingest — наоборот, разгребают быстрее всего.

Стабильность RPS

Коэффициент вариации показывает, насколько «ровно» система держит нагрузку.
- WAL: CV = 0.004 — почти идеально прямая линия.
- Ingest: CV = 0.003 — ещё ровнее.
- Valkey: CV = 0.026, заметные колебания.
- RabbitMQ: CV = 0.035, самая «пилообразная» динамика.

Чем выше CV, тем чаще будут провалы и скачки нагрузки. Для бизнеса важнее ровный поток — тут выигрывают WAL и Ingest.

Нагрузка на железо

Смотрим на ресурсы контейнеров:
- WAL: worker грузит CPU до 68%, в среднем ~**16%**. Для single-process нагрузки это нормально.
- Ingest: Postgres-ingest забирает ~**9% CPU**, пиками до ~**32%**. Очень умеренно.
- Valkey: CPU потребление невысокое, память в пределах пары гигабайт.
- RabbitMQ: сам брокер в среднем ~**19% CPU**, но с пиками до 100%. При этом база тоже нагружена.

RabbitMQ на ровном месте создаёт дополнительную нагрузку на CPU, WAL и Ingest в этом плане экономичнее.

Все собранные цифры можно посмотреть тут или самому собрать.
🔥5
Что в итоге?

По цифрам картинка складывается однозначная:

- WAL и Ingest закрывают SLA и zero-loss с минимальными накладными расходами. Первый вариант даёт мгновенный ответ и предсказуемое поведение ценой поддержки собственного кода. Второй требует отдельной базы и DevOps-дисциплины, но зато использует "привычные" инструменты (бд). В обоих случаях задержка минимальна, профиль ровный, дренаж очереди быстрый.

- Valkey и RabbitMQ тоже справляются с задачей, но делают это хуже: добавляют десятки миллисекунд на ровном месте, сильнее накапливают хвост и дают нестабильный профиль. RabbitMQ особенно выделяется большим backlog и скачущим RPS, а вместе с этим тащит в проект новые точки отказа, дполонительные сложности эксплуатации и накладные расходы.

Из этого следует простой вывод: очередь — не фундамент архитектуры, а специализированный инструмент.

Нужен фан-аут на десятки консьюмеров, маршрутизация по ключам или хитрые сценарии доставки? Тогда Rabbit/Kafka оправданы: они решают задачи иснтументами, которых нет у простого WAL или Ingest.

Нужно просто принять событие и гарантированно не потерять? Файловый журнал или ingest-база справляются проще, быстрее и надёжнее.

Тащить очередь «по умолчанию» — это всё равно что начинать проект сразу с микросервисов в Kubernetes: звучит солидно, но на деле означает больше кода, DevOps шапито, лишняя инфраструктура, больше мониторинга и больше точек отказа.

Очереди нужны не «по умолчанию», а когда без них никак. В остальных случаях — это фетиш, технический долг и лишний костыль, который сам себе суёшь в ...


Что еще почитать?
- Прошлый пост про очереди
- Репо со стендом
- RabbitMQ vs Reddis
- The advantages of queues on logs
🔥6🤯3
Middleware — антипаттерн

Проблема не в названии, а в архитектурной инверсии. Фреймворк заставляет писать не так, как нужно тебе и бизнесу, а так как удобно ему.

Express все еще остаётся популярным – его тянут «для удобства». Но удобство быстро превращается в зависимость от порядка регистрации обработчиков и растущую стоимость по latency и памяти. Навязанный паттерн middleware — это та самая «магия», которая ломается в проде и чинится с болью по мегабайтам логов.

Middleware делает бизнес-логику зависимой от глобального контекста и скрытых эффектов. Итог — неконтролируемая цепочка обработки, сложная трассировка, задержки, хрупкие абстракции и рост когнитивной нагрузки на разработчика. Особенно опасно в системах с жёсткими SLA: SLA — это про предсказуемость, а middleware делает поведение менее предсказуемым.

Middleware выглядит как список функций, обрабатывающих запрос по цепочке. На деле это неявный поток исполнения, где каждая функция может:
– мутировать общий контекст
– прервать выполнение
– вызвать next() позже или не вызвать вовсе
– повлиять на следующие обработчики
– устроить гонку с асинхронным кодом

Логика зависит от порядка. req превращается в сервис-локатор, который мутирует каждый обработчик. Локальность и тестируемость страдают. Невозможно предсказать поведение, не зная всей цепочки. Добавляешь один middleware — и есть шанс сломать весь процесс.
🔥31
Пример

Express-цепочка:

app.use(requestId)
app.use(metrics)
app.use(rateLimiter)
app.use(auth) 
app.use(require2FA) 
app.use(express.json())
app.use(validate)
app.use(checkAccess)
app.use(handler)


Каждый middleware что-то делает с req/res: requestId для логов, metrics для времени ответа, rateLimiter через Redis, auth для JWT, require2FA, express.json(), validate, checkAccess и, наконец, бизнес-логика в handler.

На старте выглядит удобно: добавил шаг — и всё работает. Но именно здесь проявляется инверсия. Логика зависит от порядка и глобального контекста. Баги типовые: next() после res.end(), падение из-за req.user === undefined, пропавшие метрики, гонки. Предсказать поведение без знания всей цепочки невозможно.

Альтернатива — один обработчик:

const requestId = generateRequestId()
const metrics = startTimer()
logRequestStart(requestId, req)

function reply(status, body) {
if (!res.writableEnded) {
send(res, status, body)
}
}

try {
const apiKey = req.headers['apikey']
const auth = req.headers['authorization'] || ''
const token = auth.startsWith('Bearer ') ? auth.slice(7) : auth

if (!token || !apiKey) {
return reply(401, { error: 'unauthorized' })
}

const isAllowed = await checkRateLimit(req.ip, apiKey)

if (!isAllowed) {
return reply(429, { error: 'too many requests' })
}

const session = await verifyToken(token)

if (!session) {
return reply(401, { error: 'unauthorized' })
}

if (session.requires2FA) {
return reply(403, { error: '2FA required' })
}

const rawBody = await getRawBody(req)
const parsed = safeJsonParse(rawBody)

if (!parsed.ok) {
return reply(400, { error: 'invalid JSON' })
}

const validDto = validate(parsed.data)

if (!validDto) {
return reply(400, { error: 'bad request' })
}

const checkResult = await checkAccess(sessiom, validDto)

if (!checkResult) {
return reply(403, { error: 'forbidden' })
}

const result = await handleBusinessLogic(parsed.data, session)
reply(200, result)
} finally {
logRequestEnd(requestId, metrics.end(), res.statusCode ?? 0)
}


Здесь шаги изолированы и идут в строгом порядке. Контракты заданы явно: вход – проверка – выход. Ошибки обрабатываются локально, нет скрытой зависимости от req, нет риска продолжить выполнение после ответа. Каждый шаг легко тестируется отдельно.
🔥4👍1
Нагрузочный тест  

Cравнил express (8 middleware + handler) и micro (8 функций + handler). В обоих случаях работа имитировалась CPU и задержкой.

RPS/Latency

Path RPS p50 p95 p99 OK
/express 2332 85.6 89.1 91.7 46641
/micro 2440 82.1 84.0 85.9 48800


CPU / RAM / Event Loop

Path CPUms ELDp99 RSSΔMB HeapΔMB
/express 3645 11.7 53.2 15.4
/micro 4873 11.9 7.2 1.0


Micro даёт +4.5% RPS, короче хвосты и меньший p99, потребляет меньше памяти. Цена — чуть больший CPU.
Express выигрывает только по CPU-нагрузке на запрос, но проигрывает по latency и RAM.
🔥3
Что в итоге?

«Cтоимость фреймворка» есть всегда, и далеко не всегда эта стоимость оправдана.

Middleware — это магия которую тебе навязали.

Хорошо выглядит на старте, плохо живёт в системах с нагрузкой, SLA и трейсингом.
Явная обработка может выглядеть примитивно, скучно, зато баги сразу на поверхности.
В middleware цепочке баг — это квест: «угадай, кто не вызвал next()».

Middleware допустимы только как транспортные фильтры без побочных эффектов и зависимости от порядка: CORS, compression, статика. Всё, что связано с бизнес-логикой и observability — выносить в явный пайплайн.

Один обработчик вместо middleware-зоопарка. Прозрачность вместо инверсии. Контроль вместо фреймворка. Проще – лучше.


Что почитать?

- Micro
- fastify
- Understanding the Impact of Middleware on Node.js Performance
2🔥2
Намного об event loop

Мажорный релиз, деплой на прод. Сидишь, смотришь плотно в графану. Красота: CPU ровный, память без пиков, p99 по hot path зелёный. Ещё парочка «нужных» графиков для самоуспокоения. 

И вдруг — никогда такого не было, и вот опять. Задержка растёт. Число отказов ползёт вверх. 429-ответы сыпятся, сыпятся алерты по SLA.

Что делаешь? Логи. Всегда логи. Смотришь сервис, который увёл SLA в кино. Логи чистые, ошибок нет. А задержка продолжает расти. 

Окей, «много бед — один ресет». Перезапускаешь сервис — всё нормализовалось. Ушёл налить кофе. Возвращаешься — та же херня. CPU норм, память норм, логи чистые, а бизнес уже пишет: «почему не грузится ничего?». 

Магия? Нет. В 99% случаев виновата не база, не сеть и не «плохой деплой». Виноват event loop. Тот самый, про который все слышали на первых лекциях по node, и даже на собеседованиях спрашивают про его стейджи, но забывают включить в дашборд. 

Сейчас разберём, что это, как он уронил твой прод, как и зачем его мониторить.

Надеюсь, ты помнишь, что Node однопоточный. Да там можно пойти в worker threads. Но это костыль для спец-кейсов. Нужна настоящая многопоточность – бери c++, rust, go. Но для 99% процентов задач, хватает и «всего» одного потока Node. 

А как это? На сцену с ноги залетает event loop. Именно он позволяет поверить в магию однопоточной многозадачности. Как будто всё параллельно: и запросы летят, и таймеры тикают, и промисы резолвятся. На самом деле – нет. Всё идёт строго по плану, в одном потоке и последовательно. 

Документацию цитировать не буду — об неё уже немало цифровых перьев сломано. Дальше кратко по делу.

Event loop — это механизм планирования. В Node он крутится в libuv: есть очереди для разных задач — timers, I/O, poll, check, close. Каждая итерация проходит по очередям и вытаскивает колбэки. Между фазами Node гоняет микротаски (Promise), а process.nextTick сидит в отдельной стопке с самым высоким приоритетом. Всё последовательно.

В браузере похожая схема, но приоритет другой: tasks, microtasks, плюс обязательная отрисовка и обработка ввода. Кадр всё равно будет перерисован. В Node такого «страховочного рендера» нет: задушил цикл синхронщиной — он так и повиснет в фазе, пока ты не перезапустишь.
🔥32👍1
Соответственно, чтобы измерить, насколько твой event loop загружен, нужно посчитать задержку. По факту это будет дреф между постановкой таймера и вызовом колбэка этого таймера. 

Для этого в Node уже есть встроенный инструмент:


import { monitorEventLoopDelay } from 'node:perf_hooks'

const h = monitorEventLoopDelay({ resolution: 10 })
h.enable()

setInterval(() => {
const p95 = h.percentile(95)
const p99 = h.percentile(99)
const max = h.max

console.log({
eldP95: +p95.toFixed(2),
eldP99: +p99.toFixed(2),
eldMax: +max.toFixed(2)
})

h.reset()
}, 1000)


Но также можно и старым дедовским методом:


import { performance } from 'node:perf_hooks'

const interval = 100
let t = performance.now()

setInterval(() => {
const diff = performance.now() - t - interval
t = performance.now()
}, interval)


ELD смотри по p95/p99, среднее бесполезно. Если у тебя воркеры — снимай метрику с каждого процесса, иначе «плохой» утонет в усреднении. Держи ELD рядом с HTTP p95/p99 и RPS: ходят синхронно — проблема в GC/коде, а не в сети/БД. Алерты: eldP99 > 100ms (2+ мин), eldP95 > 50ms (5+ мин), плюс «сдвиг базы» — p95 вырос ×2 против недельной медианы.

Такой джентльменский набор поможет поймать плохой релиз и отреагировать в случае начала деградации.
🔥21👍1🙈1
Как задушить event-loop и как с этим бороться?

Тяжелый cpu-bound sync в hot path.
- JSON.parse()`/`JSON.stringify() на мегабайты.
- zlib.*Sync, crypto.*Sync, bcrypt в sync-режиме.
- fs.readFileSync`/`writeFileSync (да, диск тоже блокирует цикл).
- child_process.execSync, Atomics.wait, любые «подвесы» CPU.

Симптомы: ступеньки на ELD p95/p99, совпадающие с графиком конкретного эндпоинта. HTTP p95 ходит синхронно с ELD, CPU «вроде норм», но один воркер красный.

Что делать
- Любой *Sync из hot path — выносить: использовать async API (zlib.gzip, crypto.pbkdf2, fs.promises/стримы).
- На тяжёлое — threadpool libuv (это уже делает async API). Если узко — подними UV_THREADPOOL_SIZE до разумного (8–32).
- Избегать мегабаитов JSON «целиком». Или NDJSON, или стримить чанками и парсить.
- execSync переписать на spawn + стримы.

Микротаски/nextTick
- Длинные цепочки await/Promise.then в tight-loop.
- Неконтролируемые queueMicrotask.
- Злоупотребление process.nextTick() (приоритет выше промисов, можно навсегда «запереть» следующие фазы).

Симптомы: высокая ELD при «нормальном» CPU, «пульсирующие» лаги на миллисекунды–десятки мс, таймеры и setImmediate поздно исполняются.

Что делать
- Если нужно «уступить», не nextTick, а setImmediate/await new Promise(setImmediate) — это даёт воздуха poll-фазе.
- Для циклов — yield каждые N итераций:


async function badLoop(N) {
console.time('badLoop')
for (let i = 0; i < N; i++) {
await Promise.resolve() // остаёмся в пуле микротасок

if (i % 1000 === 0) {
console.log('progress', i)
}
}

console.timeEnd('badLoop')
}

setTimeout(() => console.log('timer fired'), 0)

badLoop(1e6).then(() => console.log('done'))

/**
progress 0
...
progress 900000
badLoop: 50ms
done
timer fired
**/



import { setImmediate as yieldImmediate } from "node:timers/promises"

async function goodLoop(N) {
  console.time("goodLoop")

  for (let i = 0; i < N; i++) {
    await Promise.resolve()
   
    if (i % 1000 === 0) {
      // каждые 1000 итераций уступаем управление event loop
      await yieldImmediate()
      console.log("progress", i)
    }
  }
 
  console.timeEnd("goodLoop")
}

setTimeout(() => console.log("timer fired"), 0)
goodLoop(1e6).then(() => console.log("done"))

/**
progress 0
timer fired
progress 1000
...
progress 999000
goodLoop: 50ms
done
**/


GC-паузы (stop the world V8)
- Большой heap, бурст аллокаций, массивы/буферы, которые живут чуть дольше нужного.
- «Незаметные» замыкания и держатели ссылок, мешающие сборке.

Симптомы: редкие, но жирные пики ELD (100–500 мс), --trace-gc показывает Mark-Compact/Scavenge с большими паузами; корреляция с ростом heap и количеством аллокаций.

Что делать
- Снижать текучку объектов: переиспользовать буферы (пулы), не плодить временные объекты в критическом пути.
- Дробить большие структуры/ответы; не держать «один гигантский объект» в памяти.
- Убедиться, что нет «липких» ссылок (кеши без TTL, замыкания на большие данные).
- Не лечить всё «бОльшим heap»: чаще увеличение old-space увеличивает длительность паузы. Стоит начать с профиля.
2🔥21👍1
Забитая фаза poll/шторм колбэков
- Всплеск сетевых событий, бурст таймеров, «вентилятор» ретраев.
- Цикл тратит всё время на вычерпывание очереди, не успевает «вздохнуть».

Симптомы: ELD растёт линейно с RPS, p95 задержка гуляет вместе. CPU занят явно, но профайлер показывает «колбэковое трясучее месиво».

Что делать
- Ретраи — только с джиттером и потолком; убрать «вентилятор».
- Лимиты на конкурентность обработки, мутексы, семафоры.
- Таймеры группировать, добавить дебаунс, не запускать 10k setTimeout в один тик.
- Контролировать backpressure: не читать и не писать быстрее, чем можешь обработать.

Контейнерные квоты/ throttling
- CGroup режет квоту, «сосед» шумит на ноде.

Симптомы: Высокий ELD при «не высоком» CPU — смотри cgroups: nr_throttled, throttled_usec. В прометеусе: container_cpu_cfs_throttled_seconds_total. Тебя могут душить квотой, даже если график CPU зелёный.

Что делать
- Поднять квоту/лимиты или распределить воркеры по нодам без буйных соседей.
- Следить за threadpool saturation: async crypto, zlip, gzip упрётся в 4 потока — поднимать UV_THREADPOOL_SIZE.
🔥21
Что в итоге?

Event loop — это сердце Node. И умирает прод именно там, а не «в базе». CPU зелёный, память гладкая, графики красивые — а цикл задушен. И всё, сервис лежит, SLA курит в сторонке, пользователи грустят, бизнес угрожает.

Нет мониторинга ELD — значит, ты слеп. Латаешь симптомы и перезапускаешь процессы, вместо того чтобы видеть причину. Любая sync-операция, лишний nextTick, утечка в замыкании или сосед в контейнере — и твой «highload» превращается в highlag.

Так что забудь сказки про «Node сама справится». Справишься ты — если держишь ELD в дашборде и умеешь читать его вместе с RPS и GC. Всё остальное — магия и надежда. А магия, как ты знаешь, в проде всегда кончается одинаково.


Что почитать?

- How we tamed Node.js event loop lag: a deepdive
- Event Loop Starvation in NodeJS
- What the heck is the event loop anyway? | Philip Roberts | JSConf EU
3👍3🔥3
ParseInt и parseFloat тормозят твой бэкенд

Ранее я писал, как Date.now() может просаживать RPS на hot path. Продолжу тему микрооптимизации и расскажу про parseInt и parseFloat.

Казалось бы, простая задача: превратить строку в число. По старой памяти ты пишешь parseInt, как в jQuery эпоху — можно даже строку с мусором скормить, всё равно что-то вернётся, плюс явный radix, удобно.

Но это удобство обходится дорого. В браузере такие мелочи не критичны, а вот на бэкенде parseInt и parseFloat — тяжёлые операции, которые легко просаживают производительность.

Как это работает?

parseFloat - это не просто приведение типов, это полноценный парсер. Он поддерживает дроби, научную нотацию, Infinity, NaN и весь синтаксис IEEE-754. Алгоритм описан в ECMAScript Spec, В V8 он реализован через С++ функцию StringToDouble.

Допустим, у тебя строка "322.01kek". Чтобы получить число 322.01, ты вызываешь функцию parseFloat("322.01kek") и происходит следующее:

1. JS - Intrinsic - C++ - StringToDouble.
Твой вызов parseFloat(str) попадает во встроенную функцию V8. Проверяется тип аргумента, выполняются быстрые ветки для простых случаев.

2. Парсер числа
Стейт-машина идёт по строке: пробелы, знак, цифры, точка, дробная часть, экспонента. На первом мусорном символе (kek) парсинг останавливается.

3. Конвертация в double
Мусор обрезается.
Накопленное число конвертируется в double с учётом диапазонов и округления IEEE-754. Используется библиотека «double-conversion» от Google.

4. Возврат полученного числа или NaN в случае ошибки.
Если число помещается в Smi, оно возвращается без аллокации. Иначе создаётся HeapNumber.

Упрощенный callstak следующий (вызов синхронный):
- JS parseFloat - V8 builtin (TurboFan/CodeStubAssembler)
- быстрые проверки (тип/длина/ASCII)
- C++ рантайм (общий парсер)
- double_conversion::StringToDouble(...)
- возврат числа в JS (Smi/HeapNumber).

JIT тут почти не помогает. Для parseFloat приходится звать универсальный парсер, который всегда идёт по “медленному пути”.

Порядок цен:
- простой кейс (короткая ASCII, без экспоненты): 50–120 нс;
- обычная дробь/точка через double-conversion: 200–600 нс;
- длинные строки/экспоненты: 0.8–2.0 мкс;
- плюс аллокация HeapNumber: 30–80 нс.

На уровне бэкенда это превращается в ощутимый налог: 100k RPS × 3 вызова parseFloat = до 300 млн нс/с (300 мс CPU/с). Это уже ~30% ядра, которое ты тратишь на разбор строк.
🔥3👍2
Какая альтернатива?

Number(str) — это не парсер, а прямое приведение типов. Оно не гоняет строку через стейт машину, а сразу использует быстрый путь в V8:
1. Проверка типа.
2. Попытка инлайновой конверсии (короткая ASCII → fast path).
3. Fallback на общий StringToDouble только в экзотике (научная нотация). Если есть мусор, вернет NaN.

Callstack проще: JS Number - V8 builtin - быстрые ветки - либо сразу Smi/HeapNumber, либо C++ StringToDouble. В обычных кейсах JIT умеет заинлайнить эти проверки и кэшировать паттерн.

Порядок цен на вызов:
– Number("123"): 20–40 нс (инлайн + Smi, без аллокаций).
– Number("123.45"): 60–150 нс (HeapNumber, но без сложного парсинга).

Что по цифрам?

Ну и куда же без "померять". Также как и в прошлый раз, измерял два пути:


function workloadBad (n) {
for (let i = 0; i < n; i++) {
parseFloat('0.12')
}
}

function workloadGood (n) {
for (let i = 0; i < n; i++) {
Number('0.12')
}
}


И вот результаты нескольких прогонов с разными параметрами нагрузки:


duration=20s, conc=32, loops=1000
> /bad ...
ok=426248 rps=21312.4 p50=1.33 p95=2.60 p99=3.52 ms
> /good ...
ok=646309 rps=32315.5 p50=0.81 p95=2.05 p99=3.05 ms

duration=20s, conc=32, loops=10000
> /bad ...
ok=99127 rps=4956.4 p50=6.10 p95=7.70 p99=12.93 ms
> /good ...
ok=798381 rps=39919.1 p50=0.75 p95=1.43 p99=1.57 ms

duration=20s, conc=32, loops=100000
> /bad ...
ok=11902 rps=595.1 p50=51.63 p95=54.76 p99=104.17 ms
> /good ...
ok=304598 rps=15229.9 p50=2.06 p95=2.17 p99=4.13 ms

duration=20s, conc=32, loops=1000000
> /bad ...
ok=1221 rps=61.0 p50=510.21 p95=541.45 p99=3122.19 ms
> /good ...
ok=50729 rps=2536.4 p50=12.16 p95=12.70 p99=24.41 ms


И что же получается. Эндпоинт с Number во всех тестах дает лучший показатель по перцентилям и rps. Помимо этого, ты можешь заметить, что деградация производительности происходит намного плавнее, чем у эндпоинта с parseFloat.

Что в итоге?
Если у тебя контролируемый ввод (JSON, DTO, API) — используй Number().

parseInt/parseFloat оставь только там, где сознательно нужен парсинг «грязных» строк.

На hot path разница ощутимая: Number() в 5–10 раз быстрее, меньше аллокаций, JIT умеет его заинлайнить. parseFloat и parseInt — это всегда вызов тяжёлого парсера с кучей проверок и fallback в C++.

Оптимизация простая: если данные у тебя валидные, используй самый дешёвый путь. Number() или унарный + делают ровно то, что нужно.

parse* — это костыль из браузерной молодости, а не инструмент для высоконагруженного бэкенда

Что еще почитать?
- Date.now() может убить твой RPS
- Engine Analysis: String to Number Conversion in JS
🔥4👍2
0. System design – это тебе не квадратики рисовать

В начале карьеры все архитектурные вопросы сводятся к проектированию малых частей системы. Все в пределах одного сервиса. Немного думаешь про базу, немного про, прости господи, очереди, чуть-чуть про оптимизацию.

Обо всем остальном думают умные дядьки. Они рассказывают, зачем ты пишешь этот сервис и какие требования к нему. Скажут, какую бд взять, какой язык использовать и как сделать так, чтобы держало нагрузку. Остальное дело техники: пара паттернов, охапка костылей – сервис готов. Можно перекладывать json’ы.

Проходит несколько лет, и твой умный дядька исчезает. PM приходит уже к тебе. И вдруг выясняется: теперь именно ты отвечаешь на неудобные вопросы и должен объяснить, как строить сервис.

Последний год ты много слышал про system design, но не обращал внимания. Думал, это для больших и умных. Слышал, что он про архитектуру. Его хотят на собесах. Там рисуют квадратики и стрелочки: квадратики становятся сервисами, базами, кешами и (прости господи) очередями , стрелочки - каналами связи.

Посмотрел ютуб, посмотрел прошлые сервисы, подумал и нарисовал. Рассказал команде. Они постарались и сделали. Сервис выкатили на прод.

Сервис крутится, бизнес мутится, растет нагрузка. Растут задержки. Сервис начинает задыхаться и падать, downtime растет. Всё чаще тебе приходится отвечать на вопросы, на которые раньше отвечали другие.

Ты идешь советоваться к умными дядьками из других команд и компаний. Они почему-то в разрез с ютубом мало говорят про квадратики и стрелочки. Они говорят про какого-то Клепмана, про Эшби и кибернетику, про Альтшуллера и ТРИЗ.

Тебе говорят, что system design это про компромиссы: надежность, масштабируемость и сопровождаемость. Говорят, что мало один раз нарисовать — нужно возвращаться, пересматривать, менять архитектуру под новые боли и требования. Что нужно собирать эти требования. Что схем должно быть много: для бизнеса — своя, для девопсов — своя, для разработки — своя. Разные уровни абстракции, разные нюансы.

И все это – теперь хотят от тебя. Мрак. Ужас.

Поэтому, пока не поздно, я предлагаю тебе погрузиться со мной в знакомство с system design. Я не буду рассказывать «как пройти собес». Я расскажу тебе: почему system design не про стрелочки и квадратики, зачем тебе он нужен и как он может облегчить твои страдания.
5
Что же такое «System design»?

Чем System design точно не является: рисованием и инструментом для прохождения интервью. Так его блоггеры на ютубе продают. Так проще. Но реальность гораздо сложнее.

System design - это спор с реальностью, борьба с ограничениями, поиск компромиссов. Это мир, в котором у каждой идеи есть цена, у каждого ограничения есть причина. Это мир, в котором "правильная архитектура" сейчас становится "неправильной" через пару месяцев.

Любая система живет в изменчивой среде, и твоя задача – сделать так, чтобы она не была хрупкой, выгибалась и деформировалась, но не ломалась с треском. Это свойство системы: гибкость. Это способность пережить пик нагрузки, переварить новый источник данных, выдержать новый SLA, предоставить новый контракт. При этом не сломать команду, которая разрабатывает систему.

С осознанием этого, ты скорее всего придешь к первой взрослой мысли: «**нужно собрать требования**». Это на первый взгляд просто. Спросил, чего хотят, сделал что-то похожее. Но нет.

Собирать нужно с умом. Формулировка: «хотим ленту как у Threads” не подойдет. Нужно выбить из бизнеса и аналитиков конкретику: сколько пользователей планируется на старте, сколько через год, сколько времени у тебя на p99, какой сбой считается нормой, чем можно пожертвовать при пиках, важнее истина или скорость, чем бизнес готов жертвовать взамен на экономию.

Если всего этого не узнать в начале, ты рисуешь картинку — сферический сервис в вакууме, который красиво выглядит на схеме, но не имеет ничего общего с реальностью. Подробнее поговорим отдельно. Сбор требований важная большая тема.

После сбора требований логически появится следующая мысль: "**нужно определить технологический стек**". Это не про знать все языки и инструменты, это не про держать в голове энциклопедию, это не про культ «возьмем модный брокер». Это простой расчет.

Требования дают представление: «такой профиль данных, здесь читаем, тут пишем, такие хвосты распределения, такие пики». Отсюда вытекают формат данных, модель консистентности, понимание, что журнала хватит и очередь не нужна, что вот тут может быть узко, а тут проблем быть не должно. Ты уже прикидываешь, нужен ли hot path в памяти, и чем придётся платить за восстановление.

Стек складывается естественно: какие составные части, на чём они будут написаны, какие инструменты лягут в основу. Эти мысли уже можно переносить в ТЗ и схемы.

Что-то уже есть на бумаге и технологический стек первично определен. Начинаешь погружаться глубже. Теперь нужно работать со связями. Мысль три: «**нужно определить поток данных и обратную связь системы**».

На этом этапе зачастую красивые презентации заканчивается. Поток данных это не просто стрелочки от сервиса к сервису. Нужно описать ритм, как система будет жить. Где данные зарождаются, как они движутся по системе, где данные накапливаются, где могут упереться в ботлнек, где задерживаются.

И главное - как система реагирует, когда на нее давят. Не существует «пассивной» системы: если разнообразие воздействий мира на систему больше спектра ее реакций, система уходит в отказ.

Если по-простому: поток данных должен быть сопряжён с обратной связью. Когда вход растёт быстрее, чем ты можешь переварить, ты либо сигнализируешь «стоп, хватит» (backpressure), либо начинаешь управляемо деградировать. Не «всё для всех, но “сдохли”», а «меньше, но вовремя».

И вот здесь начинает появляться настоящая архитектура: система, которая умеет не геройствовать до смерти, а вовремя сказать «нет» и остаться живой.

Если обобщить: System design всегда живёт на четырёх слоях: технологическом, организационном, фундаментальном и изобретательском. Игнорируешь хоть один и система падает не там, где ждёшь, а там, где больнее всего.
5