Очереди все еще не нужны.
Каждый разработчик думает об архитектуре нового проекта, и часто приходит к такому заключению: «Завезу микросервисы, и чтоб красиво кафку туда, или реббит, чтоб общались. Редис для кешей, и все это в кубере. Заживу!».
Обоснование тому такое: «Задел на будущее, кучу систем так построили, нормально работает, даже не больно, практически. И распределенка, и отказы с пиками держим. Красота!». Я мог бы согласится, но не буду. Если начать копать глубже, всплывает много нюансов. Большую часть того, что всплывает, я описал в прошлый раз.
Тогда же, в комментариях, чтобы не быть голословным и ради академического интереса, решил собрать стенд и уже на цифрах тебе показать, что очереди в классическом понимании все еще не нужны.
Расскажу что собирал, как и что измерял и какие результаты получил.
Каждый разработчик думает об архитектуре нового проекта, и часто приходит к такому заключению: «Завезу микросервисы, и чтоб красиво кафку туда, или реббит, чтоб общались. Редис для кешей, и все это в кубере. Заживу!».
Обоснование тому такое: «Задел на будущее, кучу систем так построили, нормально работает, даже не больно, практически. И распределенка, и отказы с пиками держим. Красота!». Я мог бы согласится, но не буду. Если начать копать глубже, всплывает много нюансов. Большую часть того, что всплывает, я описал в прошлый раз.
Тогда же, в комментариях, чтобы не быть голословным и ради академического интереса, решил собрать стенд и уже на цифрах тебе показать, что очереди в классическом понимании все еще не нужны.
Расскажу что собирал, как и что измерял и какие результаты получил.
👍4🔥3
Часть первая: Преамбула
Начну издалека, с теоретического обоснования, как учили еще в университетах.
Есть у тебя сервис А. Базу свою этот сервис и в хвост и в гриву, поэтому запись там медленная — допустим, 60 мс. Сервис А принимает на эндпоинт
«Ждать ажно 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, наливаешь кофу и начинаешь думать, какие варианты у тебя есть и чего эти варианты тебе будут стоить.
Начну издалека, с теоретического обоснования, как учили еще в университетах.
Есть у тебя сервис А. Базу свою этот сервис и в хвост и в гриву, поэтому запись там медленная — допустим, 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.
Возьмем сферический проект в вакууме, представим, что у тебя нет ничего кроме как 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. Давай теперь расскажу как и на чем тесты проводились.
Ну вот и добрались до Очереди. Да, именно с большой буквы. Настоящая, взрослая, жирненькая очередь, как у больших дядек в фаанге. "Для очередей придумана!" - это про нее. Все архитекторы дружно кивают головами: 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 на запись каждой строки.
Нагрузка идёт отдельным сервисом
Сценарий нагрузки:
RPS: 500.
Каждый прогон: 30 с прогрев (1/2 RPS) → 5 мин стабильно RPS → 30 спад (1/2 RPS).
Сбои (на 3-й минуте): Kill воркера на 30с, затем рестарт.
Можно придумать другие сценарии, но не стал, одного считаю достаточным для сравнения в рамках данного изыскания.
По метриками тоже не стал изобретать. Telegraf для os, там все из коробки. По бизнес метрикам json логи в файл. Просто читать и анализировать.
Что собирал:
-
- WAL: до записи в журнал.
- Ingest: до коммита транзакции в ingest-БД.
- Rabbit: до получения publisher confirm.
- Valkey: задержка
-
-
-
-
- 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, суммируем и получаем «индекс когнитивки»; чем ниже — тем проще.
В целом никакого рокет сайенс, можно самому при желании потрогать. Перейдем к конкретике.
Собрал значит стенд, один репозиторий, 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) — минимум кода у тебя, максимум сложности в инфраструктуре.
Оценка субъективна, попытка переложить на цифры виденье сложности каждого решения. Перейдем к тому, ради чего мы тут все собрались.
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 мс. Ответы приходят практически мгновенно, так как запрос завершается сразу после записи в локальный журнал.
- RabbitMQ — 23 мс. Здесь есть сетевой hop и подтверждение от брокера, но запас прочности остаётся высоким.
- Ingest — 44 мс, вплотную к верхней границе SLA. Для сценария «50 мс» этого хватает, но зазора на будущее почти нет.
- Valkey — 46 мс, прямо на краю допустимого. Любые пики нагрузки могут выбить его за SLA.
Все четыре решения «держат SLA», но WAL и RabbitMQ имеют запас, Ingest и Valkey балансируют на границе.
Durable latency
Это время до того момента, когда запись гарантированно не потеряется (fsync или confirm).
- WAL — 14 мс, фактически совпадает с API latency. Минимум прослоек = минимум задержки.
- RabbitMQ — 22 мс. Брокер добавляет задержку из-за своей внутренней журнализации и подтверждений.
- Ingest — 44 мс. Всё упирается в транзакцию Postgres, на каждое подтверждение уходит десятки миллисекунд.
- Valkey — 45 мс, почти то же самое: 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 в этом плане экономичнее.
Все собранные цифры можно посмотреть тут или самому собрать.
SLA (p99 API ≤ 50 мс)
Все четыре кейса выдержали заявленный SLA по входным запросам. Разница в том, насколько уверенно они это делают:
- WAL — минимальные задержки, p99 = 14 мс. Ответы приходят практически мгновенно, так как запрос завершается сразу после записи в локальный журнал.
- RabbitMQ — 23 мс. Здесь есть сетевой hop и подтверждение от брокера, но запас прочности остаётся высоким.
- Ingest — 44 мс, вплотную к верхней границе SLA. Для сценария «50 мс» этого хватает, но зазора на будущее почти нет.
- Valkey — 46 мс, прямо на краю допустимого. Любые пики нагрузки могут выбить его за SLA.
Все четыре решения «держат SLA», но WAL и RabbitMQ имеют запас, Ingest и Valkey балансируют на границе.
Durable latency
Это время до того момента, когда запись гарантированно не потеряется (fsync или confirm).
- WAL — 14 мс, фактически совпадает с API latency. Минимум прослоек = минимум задержки.
- RabbitMQ — 22 мс. Брокер добавляет задержку из-за своей внутренней журнализации и подтверждений.
- Ingest — 44 мс. Всё упирается в транзакцию Postgres, на каждое подтверждение уходит десятки миллисекунд.
- Valkey — 45 мс, почти то же самое: 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
По цифрам картинка складывается однозначная:
- 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() позже или не вызвать вовсе
– повлиять на следующие обработчики
– устроить гонку с асинхронным кодом
Логика зависит от порядка.
Проблема не в названии, а в архитектурной инверсии. Фреймворк заставляет писать не так, как нужно тебе и бизнесу, а так как удобно ему.
Express все еще остаётся популярным – его тянут «для удобства». Но удобство быстро превращается в зависимость от порядка регистрации обработчиков и растущую стоимость по latency и памяти. Навязанный паттерн middleware — это та самая «магия», которая ломается в проде и чинится с болью по мегабайтам логов.
Middleware делает бизнес-логику зависимой от глобального контекста и скрытых эффектов. Итог — неконтролируемая цепочка обработки, сложная трассировка, задержки, хрупкие абстракции и рост когнитивной нагрузки на разработчика. Особенно опасно в системах с жёсткими SLA: SLA — это про предсказуемость, а middleware делает поведение менее предсказуемым.
Middleware выглядит как список функций, обрабатывающих запрос по цепочке. На деле это неявный поток исполнения, где каждая функция может:
– мутировать общий контекст
– прервать выполнение
– вызвать next() позже или не вызвать вовсе
– повлиять на следующие обработчики
– устроить гонку с асинхронным кодом
Логика зависит от порядка.
req превращается в сервис-локатор, который мутирует каждый обработчик. Локальность и тестируемость страдают. Невозможно предсказать поведение, не зная всей цепочки. Добавляешь один middleware — и есть шанс сломать весь процесс.🔥3❤1
Пример
Express-цепочка:
Каждый middleware что-то делает с req/res: requestId для логов, metrics для времени ответа, rateLimiter через Redis, auth для JWT, require2FA, express.json(), validate, checkAccess и, наконец, бизнес-логика в handler.
На старте выглядит удобно: добавил шаг — и всё работает. Но именно здесь проявляется инверсия. Логика зависит от порядка и глобального контекста. Баги типовые: next() после res.end(), падение из-за req.user === undefined, пропавшие метрики, гонки. Предсказать поведение без знания всей цепочки невозможно.
Альтернатива — один обработчик:
Здесь шаги изолированы и идут в строгом порядке. Контракты заданы явно: вход – проверка – выход. Ошибки обрабатываются локально, нет скрытой зависимости от req, нет риска продолжить выполнение после ответа. Каждый шаг легко тестируется отдельно.
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
CPU / RAM / Event Loop
Micro даёт +4.5% RPS, короче хвосты и меньший p99, потребляет меньше памяти. Цена — чуть больший CPU.
Express выигрывает только по CPU-нагрузке на запрос, но проигрывает по latency и RAM.
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
«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 такого «страховочного рендера» нет: задушил цикл синхронщиной — он так и повиснет в фазе, пока ты не перезапустишь.
Мажорный релиз, деплой на прод. Сидишь, смотришь плотно в графану. Красота: 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 такого «страховочного рендера» нет: задушил цикл синхронщиной — он так и повиснет в фазе, пока ты не перезапустишь.
🔥3❤2👍1
Соответственно, чтобы измерить, насколько твой event loop загружен, нужно посчитать задержку. По факту это будет дреф между постановкой таймера и вызовом колбэка этого таймера.
Для этого в Node уже есть встроенный инструмент:
Но также можно и старым дедовским методом:
ELD смотри по p95/p99, среднее бесполезно. Если у тебя воркеры — снимай метрику с каждого процесса, иначе «плохой» утонет в усреднении. Держи ELD рядом с HTTP p95/p99 и RPS: ходят синхронно — проблема в GC/коде, а не в сети/БД. Алерты: eldP99 > 100ms (2+ мин), eldP95 > 50ms (5+ мин), плюс «сдвиг базы» — p95 вырос ×2 против недельной медианы.
Такой джентльменский набор поможет поймать плохой релиз и отреагировать в случае начала деградации.
Для этого в 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 против недельной медианы.
Такой джентльменский набор поможет поймать плохой релиз и отреагировать в случае начала деградации.
🔥2✍1👍1🙈1
Как задушить event-loop и как с этим бороться?
Тяжелый cpu-bound sync в hot path.
-
- zlib.*Sync, crypto.*Sync, bcrypt в sync-режиме.
-
-
Симптомы: ступеньки на 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.
- Злоупотребление
Симптомы: высокая ELD при «нормальном» CPU, «пульсирующие» лаги на миллисекунды–десятки мс, таймеры и setImmediate поздно исполняются.
Что делать
- Если нужно «уступить», не nextTick, а setImmediate/await new Promise(setImmediate) — это даёт воздуха poll-фазе.
- Для циклов — yield каждые N итераций:
GC-паузы (stop the world V8)
- Большой heap, бурст аллокаций, массивы/буферы, которые живут чуть дольше нужного.
- «Незаметные» замыкания и держатели ссылок, мешающие сборке.
Симптомы: редкие, но жирные пики ELD (100–500 мс), --trace-gc показывает Mark-Compact/Scavenge с большими паузами; корреляция с ростом heap и количеством аллокаций.
Что делать
- Снижать текучку объектов: переиспользовать буферы (пулы), не плодить временные объекты в критическом пути.
- Дробить большие структуры/ответы; не держать «один гигантский объект» в памяти.
- Убедиться, что нет «липких» ссылок (кеши без TTL, замыкания на большие данные).
- Не лечить всё «бОльшим heap»: чаще увеличение old-space увеличивает длительность паузы. Стоит начать с профиля.
Тяжелый 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🔥2✍1👍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.
- Всплеск сетевых событий, бурст таймеров, «вентилятор» ретраев.
- Цикл тратит всё время на вычерпывание очереди, не успевает «вздохнуть».
Симптомы: 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.
🔥2✍1
Что в итоге?
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
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 тормозят твой бэкенд
Ранее я писал, как
Казалось бы, простая задача: превратить строку в число. По старой памяти ты пишешь parseInt, как в jQuery эпоху — можно даже строку с мусором скормить, всё равно что-то вернётся, плюс явный radix, удобно.
Но это удобство обходится дорого. В браузере такие мелочи не критичны, а вот на бэкенде
Как это работает?
Допустим, у тебя строка "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 тут почти не помогает. Для
Порядок цен:
- простой кейс (короткая ASCII, без экспоненты): 50–120 нс;
- обычная дробь/точка через double-conversion: 200–600 нс;
- длинные строки/экспоненты: 0.8–2.0 мкс;
- плюс аллокация HeapNumber: 30–80 нс.
На уровне бэкенда это превращается в ощутимый налог: 100k RPS × 3 вызова parseFloat = до 300 млн нс/с (300 мс CPU/с). Это уже ~30% ядра, которое ты тратишь на разбор строк.
Ранее я писал, как
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, но без сложного парсинга).
Что по цифрам?
Ну и куда же без "померять". Также как и в прошлый раз, измерял два пути:
И вот результаты нескольких прогонов с разными параметрами нагрузки:
И что же получается. Эндпоинт с 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
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 не про стрелочки и квадратики, зачем тебе он нужен и как он может облегчить твои страдания.
В начале карьеры все архитектурные вопросы сводятся к проектированию малых частей системы. Все в пределах одного сервиса. Немного думаешь про базу, немного про, прости господи, очереди, чуть-чуть про оптимизацию.
Обо всем остальном думают умные дядьки. Они рассказывают, зачем ты пишешь этот сервис и какие требования к нему. Скажут, какую бд взять, какой язык использовать и как сделать так, чтобы держало нагрузку. Остальное дело техники: пара паттернов, охапка костылей – сервис готов. Можно перекладывать 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 всегда живёт на четырёх слоях: технологическом, организационном, фундаментальном и изобретательском. Игнорируешь хоть один и система падает не там, где ждёшь, а там, где больнее всего.
Чем System design точно не является: рисованием и инструментом для прохождения интервью. Так его блоггеры на ютубе продают. Так проще. Но реальность гораздо сложнее.
System design - это спор с реальностью, борьба с ограничениями, поиск компромиссов. Это мир, в котором у каждой идеи есть цена, у каждого ограничения есть причина. Это мир, в котором "правильная архитектура" сейчас становится "неправильной" через пару месяцев.
Любая система живет в изменчивой среде, и твоя задача – сделать так, чтобы она не была хрупкой, выгибалась и деформировалась, но не ломалась с треском. Это свойство системы: гибкость. Это способность пережить пик нагрузки, переварить новый источник данных, выдержать новый SLA, предоставить новый контракт. При этом не сломать команду, которая разрабатывает систему.
С осознанием этого, ты скорее всего придешь к первой взрослой мысли: «**нужно собрать требования**». Это на первый взгляд просто. Спросил, чего хотят, сделал что-то похожее. Но нет.
Собирать нужно с умом. Формулировка: «хотим ленту как у Threads” не подойдет. Нужно выбить из бизнеса и аналитиков конкретику: сколько пользователей планируется на старте, сколько через год, сколько времени у тебя на p99, какой сбой считается нормой, чем можно пожертвовать при пиках, важнее истина или скорость, чем бизнес готов жертвовать взамен на экономию.
Если всего этого не узнать в начале, ты рисуешь картинку — сферический сервис в вакууме, который красиво выглядит на схеме, но не имеет ничего общего с реальностью. Подробнее поговорим отдельно. Сбор требований важная большая тема.
После сбора требований логически появится следующая мысль: "**нужно определить технологический стек**". Это не про знать все языки и инструменты, это не про держать в голове энциклопедию, это не про культ «возьмем модный брокер». Это простой расчет.
Требования дают представление: «такой профиль данных, здесь читаем, тут пишем, такие хвосты распределения, такие пики». Отсюда вытекают формат данных, модель консистентности, понимание, что журнала хватит и очередь не нужна, что вот тут может быть узко, а тут проблем быть не должно. Ты уже прикидываешь, нужен ли hot path в памяти, и чем придётся платить за восстановление.
Стек складывается естественно: какие составные части, на чём они будут написаны, какие инструменты лягут в основу. Эти мысли уже можно переносить в ТЗ и схемы.
Что-то уже есть на бумаге и технологический стек первично определен. Начинаешь погружаться глубже. Теперь нужно работать со связями. Мысль три: «**нужно определить поток данных и обратную связь системы**».
На этом этапе зачастую красивые презентации заканчивается. Поток данных это не просто стрелочки от сервиса к сервису. Нужно описать ритм, как система будет жить. Где данные зарождаются, как они движутся по системе, где данные накапливаются, где могут упереться в ботлнек, где задерживаются.
И главное - как система реагирует, когда на нее давят. Не существует «пассивной» системы: если разнообразие воздействий мира на систему больше спектра ее реакций, система уходит в отказ.
Если по-простому: поток данных должен быть сопряжён с обратной связью. Когда вход растёт быстрее, чем ты можешь переварить, ты либо сигнализируешь «стоп, хватит» (backpressure), либо начинаешь управляемо деградировать. Не «всё для всех, но “сдохли”», а «меньше, но вовремя».
И вот здесь начинает появляться настоящая архитектура: система, которая умеет не геройствовать до смерти, а вовремя сказать «нет» и остаться живой.
Если обобщить: System design всегда живёт на четырёх слоях: технологическом, организационном, фундаментальном и изобретательском. Игнорируешь хоть один и система падает не там, где ждёшь, а там, где больнее всего.
❤5