Что в итоге?
Очередь — не дефолт. Это тяжёлая зависимость, которая тащит за собой протоколы, идемпотентность, эволюцию схем, эксплуатацию и новые точки отказа.
Нужен буфер? Сделай backpressure и честные 429.
Нужна обработка «не в запросе»? Outbox и воркер.
Нужен событийный продукт между доменами? И тут все еще можно обойтись без брокера, но уже можно задуматься про него, выбрать, закатать рукава и решиться нырнуть в асинхронный персональный ад.
Не тащи очереди «потому что так делают все». Тащи их, когда без них не сходится модель отказов и нагрузок. Во всех остальных случаях это просто ещё один слой боли.
Хочешь поспорить — покажи, где у тебя backpressure, где идемпотентность и как ты репроцессишь хвост. Если ответ «никак» — очередь тебе не помогла, она просто спрятала проблему в тень.
Очередь — не дефолт. Это тяжёлая зависимость, которая тащит за собой протоколы, идемпотентность, эволюцию схем, эксплуатацию и новые точки отказа.
Нужен буфер? Сделай backpressure и честные 429.
Нужна обработка «не в запросе»? Outbox и воркер.
Нужен событийный продукт между доменами? И тут все еще можно обойтись без брокера, но уже можно задуматься про него, выбрать, закатать рукава и решиться нырнуть в асинхронный персональный ад.
Не тащи очереди «потому что так делают все». Тащи их, когда без них не сходится модель отказов и нагрузок. Во всех остальных случаях это просто ещё один слой боли.
Хочешь поспорить — покажи, где у тебя backpressure, где идемпотентность и как ты репроцессишь хвост. Если ответ «никак» — очередь тебе не помогла, она просто спрятала проблему в тень.
👍3🔥2
4. Зачем тебе этот грязный TypeScript?
Продолжим полоскать твой TypeScript. Прошлые посты про него получились довольно мягкими и обтекаемыми. Это непорядок, попробую прямо донести мысль: «TypeScript несёт в твой код шум, грязь, и заставляет тебя гуглить»
Тебе правда нужны вот эти вот всё условные типы, дженерики и танцы с infer? Если начистоту, в 90% случаев ответ: «точно нет»
TypeScript раздувает код и буквально заставляет держать в голове две модели. Предметная логика отдельно, типовые «заклинания» отдельно. В итоге, рано или поздно, ты начнёшь больше времени тратить на дебаг аннотаций, чем бизнес логики. А упадёт всё равно в рантайме.
TS буквально раздувает площадь кода. Любая правка превращается в две: логика и инварианты + синтаксический ритуал вокруг этого. Дифы пухнут, мерж-конфликты из-за
Типы – это твоя статическая гипотеза.
Рантайм – динамический факт.
С ростом проекта, ты становишься заложником типовой сети. Ты просто боишься что-то менять, одно переименование и у тебя 200 ошибок в разных файлах, а если это DTO, то может и в разных проектах. IDE с другим проектом, поможет тебе только после сборки и синхронизации пакета с типами. Это значительно тормозит рефакторинг – главный инструмент борьбы с энтропией.
А если ты встречаешь что-то сложнее чем Record<string, User>, ты полезешь гуглить: “Как эту вашу лабубу выразить через TS”. И случается это чаще, чем ты хотел бы, чаще чем ты читаешь свой код.
Советы от IDE подменяют дисциплину инвариантов и валидации на входе.
Продолжим полоскать твой TypeScript. Прошлые посты про него получились довольно мягкими и обтекаемыми. Это непорядок, попробую прямо донести мысль: «TypeScript несёт в твой код шум, грязь, и заставляет тебя гуглить»
Тебе правда нужны вот эти вот всё условные типы, дженерики и танцы с infer? Если начистоту, в 90% случаев ответ: «точно нет»
TypeScript раздувает код и буквально заставляет держать в голове две модели. Предметная логика отдельно, типовые «заклинания» отдельно. В итоге, рано или поздно, ты начнёшь больше времени тратить на дебаг аннотаций, чем бизнес логики. А упадёт всё равно в рантайме.
TS буквально раздувает площадь кода. Любая правка превращается в две: логика и инварианты + синтаксический ритуал вокруг этого. Дифы пухнут, мерж-конфликты из-за
:Foo|Bar вместо реального поведения. Ошибки типов не ловят всё то, что реально валит прод: регресс в протоколе, гонки, backpressure, дедлоки. Типы – это твоя статическая гипотеза.
Рантайм – динамический факт.
С ростом проекта, ты становишься заложником типовой сети. Ты просто боишься что-то менять, одно переименование и у тебя 200 ошибок в разных файлах, а если это DTO, то может и в разных проектах. IDE с другим проектом, поможет тебе только после сборки и синхронизации пакета с типами. Это значительно тормозит рефакторинг – главный инструмент борьбы с энтропией.
А если ты встречаешь что-то сложнее чем Record<string, User>, ты полезешь гуглить: “Как эту вашу лабубу выразить через TS”. И случается это чаще, чем ты хотел бы, чаще чем ты читаешь свой код.
Советы от IDE подменяют дисциплину инвариантов и валидации на входе.
👍2🔥2
Пройдемся по примерам. (Все примеры вымышленные, любые совпадения случайны)
1) Утилита на бэке filterByTag.
Шум: интерфейсы, readonly, дженерик T, типовые импорты.
JS с JSDoc быстрее читается, без типового мусора, логика не теряется.
2) HTTP JSON-клиент с таймаутом
Шум: обязательные дженерики в вызовах, лишние as-касты.
В JS можно оставить только JSDoc, без кастов в коде и без генерации типов в рантайме.
1) Утилита на бэке filterByTag.
typescript
// user.ts
export interface User {
id: string;
readonly tags?: readonly string[];
email?: string;
}
// filter-by-tag.ts
import { User } from 'user.ts'
export function filterByTag<T extends Readonly<User>>(
users: readonly T[],
tag: string
): T[] {
return users.filter(u => Array.isArray(u.tags) && u.tags.includes(tag))
}
// usage.ts
import { User } from 'user.ts'
import filterByTag from 'filter-by-tag.ts'
const vipUsers = filterByTag<User>(allUsers, 'vip')
Шум: интерфейсы, readonly, дженерик T, типовые импорты.
javascript
// user.mjs
/**
* @typedef {object} User
* @property {string} id
* @property {string[]} [tags]
* @property {string} [email]
*/
// filter-by-tag.mjs
/**
* @param {User[]} users
* @param {string} tag
* @returns {User[]}
*/
export function filterByTag(users, tag) {
return users.filter((u) => Array.isArray(u.tags) && u.tags.includes(tag))
}
// usage.mjs
import { filterByTag } from './filter-by-tag.mjs'
const vipUsers = filterByTag(allUsers, 'vip')
JS с JSDoc быстрее читается, без типового мусора, логика не теряется.
2) HTTP JSON-клиент с таймаутом
// http-client.ts
export async function getJson<T>(url: string, options: { timeoutMs?: number } = {}): Promise<T> {
const abortController = new AbortController()
const timeout = setTimeout(() => abortController.abort(), options.timeoutMs ?? 5000)
try {
const res = await fetch(url, {signal: abortController.signal})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
const json = await res.json() as unknown
if (!json || typeof json !== 'object') {
throw new Error('Bad JSON')
}
return json as T
} finally {
clearTimeout(timeout)
}
}
// usage.ts
type Order = { id: string; price: number }
const order = await getJson<Order>('http://svc/order/42', {timeoutMs: 3000})
Шум: обязательные дженерики в вызовах, лишние as-касты.
// http-client.mjs
/**
* @template T
* @param {string} url
* @param {{timeoutMs?:number}} [options]
* @returns {Promise<T>}
*/
export async function getJson (url, options = {}) {
const abortController = new AbortController()
const timeout = setTimeout(() => abortController.abort(), options.timeoutMs ?? 5000)
try {
const res = await fetch(url, { signal: abortController.signal })
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
const x = await res.json()
if (!x || typeof x !== 'object') {
throw new Error('Bad JSON')
}
return /** @type {T} */ (x)
} finally { clearTimeout(timeout) }
}
// usage.mjs
/**
* @typedef {Object} Order
* @property {string} id
* @property {number} price
*/
import { getJson } from './http-client.mjs'
const order = /** @type {Order} */ (await getJson('http://svc/order/42', { timeoutMs: 3000 }))
В JS можно оставить только JSDoc, без кастов в коде и без генерации типов в рантайме.
❤2👍2
3) Реестр обработчиков событий
Шум: дженерики, сложные типы Partial<Record<...>>, касты вместо проверки данных.
На JS проще — обычные typedef’ы и валидация по факту, без накрутки типов, надежнее.
4) Проекция из репозитория (списки/сводки)
Шум: Pick<User, ...>, обязательный as User[] при парсе, интерфейсы ради пары полей.
JSDoc закрывает типы, код остаётся линейным, без отрыва от логики.
Typescript
// events.ts
type UserCreated = { type: 'user.created'; payload: { id: string } }
type BillingPaid = { type: 'billing.paid'; payload: { invoiceId: string; amount: number } }
type Event = UserCreated | BillingPaid
type Handler<T extends Event = Event> = (event: T) => Promise<void>;
const handlers: Partial<Record<Event['type'], Handler>> = {}
handlers['user.created'] = async (event) => { /* ... */}
handlers['billing.paid'] = async (event) => { /* ... */}
export async function dispatch(event: Event) {
const fn = handlers[event.type]
if (fn) {
await fn(event)
}
}
Шум: дженерики, сложные типы Partial<Record<...>>, касты вместо проверки данных.
// events.mjs
// длинная запись
/** @typedef {Object} UserCreated
* @property {string} type - 'user.created'
* @property {object} payload
* @property {string} payload.id
*/
/** @typedef {Object} BillingPaid
* @property {string} type - 'billing.paid'
* @property {object} payload
* @property {string} payload.invoiceId
* @property {number} payload.amount
*/
/** @typedef {UserCreated | BillingPaid} Event */
// но можно и коротко
/** @typedef {{ type:'user.created', payload:{ id:string } }} UserCreated */
/** @typedef {{ type:'billing.paid', payload:{ invoiceId:string, amount:number } }} BillingPaid */
/** @typedef {UserCreated | BillingPaid} Event */
// я предпочитаю длинный формат
/** @type {Record<string, (event:Event)=>Promise<void>>} */
export const handlers = Object.create(null)
handlers['user.created'] = async (event) => { /* ... */ }
handlers['billing.paid'] = async (event) => { /* ... */ }
function isEvent (event) {
if (!event || typeof event !== 'object') {
return false
}
if (event.type === 'user.created') {
return typeof event.payload?.id === 'string'
}
if (event.type === 'billing.paid') {
return typeof event.payload?.invoiceId === 'string'
&& typeof event.payload?.amount === 'number'
}
return false
}
/**
* @param {Event} event
* @return {Promise<void>}
*/
export async function dispatch (event) {
if (!isEvent(event)) {
throw new Error('Bad event')
}
const fn = handlers[event.type]
if (fn) {
await fn(event)
}
}
На JS проще — обычные typedef’ы и валидация по факту, без накрутки типов, надежнее.
4) Проекция из репозитория (списки/сводки)
export interface User {
id: string;
email: string;
name: string;
status: 'active' | 'blocked';
tags?: string[];
}
export async function listUsers(): Promise<User[]> {
return JSON.parse(await readFile('./users.json', 'utf8')) as User[]
}
export type UserSummary = Pick<User, 'id' | 'email' | 'status'>
export async function listUsersSummary(): Promise<UserSummary[]> {
const users = await listUsers()
return users.map(({id, email, status}) => ({id, email, status}))
}
Шум: Pick<User, ...>, обязательный as User[] при парсе, интерфейсы ради пары полей.
javascript
/** @typedef {Object} User
* @property {string} id
* @property {string} email
* @property {string} name
* @property {string} status - ['active','blocked']
* @property {string[]} [tags]
*/
/** @returns {Promise<User[]>} */
export async function listUsers () {
return JSON.parse(await readFile('./users.json', 'utf8'))
}
/** @returns {Promise<Array<Pick<User,'id'|'email'|'status'>>>} */
export async function listUsersSummary () {
const a = await listUsers()
return a.map(({ id, email, status }) => ({ id, email, status }))
}
JSDoc закрывает типы, код остаётся линейным, без отрыва от логики.
❤2👍2
Что в итоге?
Да не
TS не та экосистема, которую нужно тащить по дефолту, TS – инструмент точечного применения.
Например, когда у тебя публичный SDK – типы оправданы. Их читает не только твоя команда, IDE всем подсказывает сигнатуру, автокомплитит, подсказывает, как не промазать по контракту. В данном случае TS хорошо поможет с саппортом и навигацией по поверхности апи.
А еще, если у тебя сложный формат обмена с третьими командами – тут тоже сможет помочь. Но есть нюанс, сначала нужно завезти формальные схемы (JSON Schema, OpenAPI, Avro), а типы генерировать из них, не самому писать. Основная цель - не забыть, что договорились именно о таком виде полей. Типы тут помогут держать эту информацию на виду. Типы – побочный продукт протокола в данном случае, а не религия размазанная по коду.
Если у тебя много автогенерации, тоже сойдёт. Типы ты получаешь из источника истины, не пишешь их сам. Тут документация и навигация, не новый слой сложности. Генерируешь, используешь, не трогаешь.
Ну вот как бы и всё.
За всё остальное ты будешь платить скоростью и когнитивной нагрузкой, а еще гугл мучить. Но это еще не всё побочки. Статическая гипотеза, которую ты выдвигаешь, внушает тебе ложное чувство безопасности и надёжности, а как я писал в начале, упадёт всё равно в рантайме.
Мы делаем сервисы, а не курсовую по теории типов. Надёжность держится на четырёх столбах: тесты, контракты на границах, валидация на входе и простая архитектура без фетишей. Всё это прекрасно живёт на чистом JS.
Буду рад услышать, где ты со мной не согласен. Особенно про кейсы: "Ну вот же, у меня вот тут TS всех спас!", будет интересно разобраться, тип это был или всё-таки дисциплина.
Другое про TS:
Eric Elliott — The TypeScript Tax
Desphil Boy — Typescript doesn’t make sense
Прошлые посты:
0. Что даёт TypeScript, кроме типов
1. TypeScript не гарантирует надёжность
2. TypeScript ломает zero-deps и замедляет цикл разработки
3. Когда TypeScript оправдан
Да не
нужОн тебе этот TS. Ну ладно, иногда всё-таки нужен. TS не та экосистема, которую нужно тащить по дефолту, TS – инструмент точечного применения.
Например, когда у тебя публичный SDK – типы оправданы. Их читает не только твоя команда, IDE всем подсказывает сигнатуру, автокомплитит, подсказывает, как не промазать по контракту. В данном случае TS хорошо поможет с саппортом и навигацией по поверхности апи.
А еще, если у тебя сложный формат обмена с третьими командами – тут тоже сможет помочь. Но есть нюанс, сначала нужно завезти формальные схемы (JSON Schema, OpenAPI, Avro), а типы генерировать из них, не самому писать. Основная цель - не забыть, что договорились именно о таком виде полей. Типы тут помогут держать эту информацию на виду. Типы – побочный продукт протокола в данном случае, а не религия размазанная по коду.
Если у тебя много автогенерации, тоже сойдёт. Типы ты получаешь из источника истины, не пишешь их сам. Тут документация и навигация, не новый слой сложности. Генерируешь, используешь, не трогаешь.
Ну вот как бы и всё.
За всё остальное ты будешь платить скоростью и когнитивной нагрузкой, а еще гугл мучить. Но это еще не всё побочки. Статическая гипотеза, которую ты выдвигаешь, внушает тебе ложное чувство безопасности и надёжности, а как я писал в начале, упадёт всё равно в рантайме.
Мы делаем сервисы, а не курсовую по теории типов. Надёжность держится на четырёх столбах: тесты, контракты на границах, валидация на входе и простая архитектура без фетишей. Всё это прекрасно живёт на чистом JS.
Буду рад услышать, где ты со мной не согласен. Особенно про кейсы: "Ну вот же, у меня вот тут TS всех спас!", будет интересно разобраться, тип это был или всё-таки дисциплина.
Другое про TS:
Eric Elliott — The TypeScript Tax
Desphil Boy — Typescript doesn’t make sense
Прошлые посты:
0. Что даёт TypeScript, кроме типов
1. TypeScript не гарантирует надёжность
2. TypeScript ломает zero-deps и замедляет цикл разработки
3. Когда TypeScript оправдан
👍2🔥2
Date.now может убить твой RPSОтвлечемся на более прикладную тему – микрооптимизацию.
Уточню сразу, для 99% систем, это не нужно, не те нагрузки, не тот RPS, влияние микроскопическое.
Но если у тебя на hot-path уже набегает до 100k RPS и каждый тик на счету, об этом уже можно подумать.
Вот возьмем совершенно с виду безобидный
Date.now(), статический метод возвращает миллисекунды с 1970-01-01T00:00:00Z. Используют его повсеместно для метрик, вычислений, логов. «Ну читает таймер и читает, что тут такого?» - для обычной системы в целом ничего. Для нагруженной — это лишний системный вызов (или почти системный), прямо внутри горячего цикла.
Как это работает?
Date.now не просто читает регистр или "переменную", он читает системное время. Под капотом:
1. JS → V8 intrinsic → C++
JSDate::CurrentTimeValue(isolate).2. Системный вызов:
- Linux:
clock_gettime(CLOCK_REALTIME)- Windows:
GetSystemTimeAsFileTime()3. Перевод в миллисекунды и возврат в JS.
Вызов идёт синхронно. Eventloop не задействуется.
Date.now() не кеширует значения и каждый раз ходит в системный API. Монотонность не гарантируется, системное время может корректироваться (например, через NTP).Цена вопроса (примерный порядок):
- vDSO (`clock_gettime` без перехода в ядро): 30–80 нс.
- С переходом в ядро (syscall): 300–500 нс.
- На 100k RPS × 3 вызова на запрос = 300k вызовов/с.
- При 0.3 мкс на вызов — ~90 мс CPU/с (около 10% одного ядра) только на чтение времени.
А как проверял?
Стенд предельно прямолинейный, без зависимостей.
server.mjs — два ендпоинта, разница только в том, где вызывается
Date.now():
// /bad — дергаем Date.now() внутри цикла
function workloadBad(n) {
let s = 0
for (let i = 0; i < n; i++) {
s += (Date.now() & 1)
}
return s
}
// /good — один вызов Date.now() и работа со значением
function workloadGood(n) {
const t = Date.now()
let s = 0
for (let i = 0; i < n; i++) {
s += (t & 1)
}
return s
}
bench.mjs — шлёт запросы параллельно, считает RPS и перцентили p50/p95/p99 по длительности ответа последовательно для каждого ендпоинта.
Параметры запуска:
DURATION=10s, CONCURRENCY=32, LOOPS=200_000
Цифры:
Path | RPS | p50 | p95 | p99 | req
-----|-------|------|------|------|-------
/bad | 218.9 | 143 | 149 | 290 | 2189
/good| 8023.2| 3.86 | 4.01 | 7.74 | 80232
Это не «нанопроценты». Это порядок разницы. Просто перестали дёргать
Date.now() миллионы раз в горячем цикле.А какая альтернатива? И когда заменять?
Оговорюсь, не стоит бросаться и выжигать
Date.now повсеместно. Стоит учитывать контекст.— Хочешь измерять интервалы — используй монотонные таймеры:
performance.now() (мс с долями, монотонный) или process.hrtime.bigint() (нс). Они не завязаны на системные поправки времени и обычно дешевле. — Нужен штамп времени (для логов/бизнес-логики) — ок,
Date.now(). Но один раз на запрос/батч, а не в каждой итерации.— В горячем коде — вынеси чтение времени из цикла, работай с локальной переменной. Это дёшево и надёжно.
Что в итоге?
Date.now() — нормальный инструмент. Но в hot‑path многократные вызовы — это может быть overhead, который влечет за собой жирные p95/p99 и просадку RPS.Оптимизация тривиальна: читаешь один раз или переключаешься на монотонный таймер там, где нужно мерить интервалы. Никаких фреймворков, никакой магии.
👍4🔥2
Монолит vs распределёнка. Выбираешь не топологию, а тип боли.
Ранее я уже трогал тему архитектуры: где она начинается, зачем нужна её чистота и как не скатиться в фетишизм. Настало время копнуть глубже — поговорим про топологию системы. Монолит против распределённой архитектуры.
Об эту тему сломано уже немало копий. Но если немного погрузиться в тему, то почти все архитектурные споры рано или поздно скатываются в лозунги:
«Монолиты — это прошлый век»,
«Микросервисы — это оверинжиниринг».
Я не согласен с обоими. Уже слышу из чата: «ну кто бы сомневался, автор опять набрасывает на вентилятор». Но надеюсь, что после прочтения ты хотя бы задумаешься: «может, автор не совсем ещё поехал?».
Для начала давай определимся с терминами и тем, что они из себя представляют.
Ранее я уже трогал тему архитектуры: где она начинается, зачем нужна её чистота и как не скатиться в фетишизм. Настало время копнуть глубже — поговорим про топологию системы. Монолит против распределённой архитектуры.
Об эту тему сломано уже немало копий. Но если немного погрузиться в тему, то почти все архитектурные споры рано или поздно скатываются в лозунги:
«Монолиты — это прошлый век»,
«Микросервисы — это оверинжиниринг».
Я не согласен с обоими. Уже слышу из чата: «ну кто бы сомневался, автор опять набрасывает на вентилятор». Но надеюсь, что после прочтения ты хотя бы задумаешься: «может, автор не совсем ещё поехал?».
Для начала давай определимся с терминами и тем, что они из себя представляют.
👍2🔥2
Монолит
Итак, его величество Монолит. Это не обязательно спагетти-код. У него может быть хорошая модульная структура, хорошо прописанный DTO сквозь всю систему и хорошо собранный CI/CD-пайплайн.
Некоторые умельцы даже реализуют hot-reload модуля без рестарта всего процесса, но это уже экзотика.
В любом случае весь основной функционал всё ещё собран в одном исполняемом блоке: один деплой, единая база, общие модули.
В монолите границы между частями системы — логические, внутри одного процесса.
Достоинства:
- нет сетевых задержек, всё работает в памяти;
- проще трассировка и отладка: один процесс, один стек;
- деплой — один пакет, одна точка обновления;
- минимум инфраструктуры и зависимостей;
- быстрый старт и короткий цикл изменений;
- согласованность данных и транзакции из коробки;
- единый контекст исполнения без сериализации;
- локальная разработка без зоопарка сервисов;
- меньше DevOps-нагрузки.
Недостатки:
- масштабируется только целиком;
- сильная связность: падение одного модуля валит всё;
- релиз любого изменения тянет за собой весь проект;
- сложно изолировать сбои и деградировать частично;
- со временем риск вырасти в неподъёмного монстра;
- тяжело делить работу между командами в одной кодовой базе;
- долгие интеграционные тесты;
- миграции схемы и контрактов только одним большим релизом.
Монолит — это про скорость старта, скорость разработки, простоту и предсказуемость. Можно быстро собрать рабочий прототип без оверхеда на межсервисное взаимодействие и протоколы. Подходит для старта почти любого проекта, особенно когда бизнес хочет протестировать идею. Хорошо там, где важна согласованность, минимальная инфраструктура или бюджет на неё ограничен. А ещё это про «олдскул», «про хардкор» и уложить весь прод одной запятой.
Монолит очень любят PHP-шники — потому что там особого выбора и нет, вся экосистема росла на монолитах. Когда у тебя Laravel на shared-хостинге, никакой RabbitMQ с сервис-мешем просто не пролезет.
Итак, его величество Монолит. Это не обязательно спагетти-код. У него может быть хорошая модульная структура, хорошо прописанный DTO сквозь всю систему и хорошо собранный CI/CD-пайплайн.
Некоторые умельцы даже реализуют hot-reload модуля без рестарта всего процесса, но это уже экзотика.
В любом случае весь основной функционал всё ещё собран в одном исполняемом блоке: один деплой, единая база, общие модули.
В монолите границы между частями системы — логические, внутри одного процесса.
Достоинства:
- нет сетевых задержек, всё работает в памяти;
- проще трассировка и отладка: один процесс, один стек;
- деплой — один пакет, одна точка обновления;
- минимум инфраструктуры и зависимостей;
- быстрый старт и короткий цикл изменений;
- согласованность данных и транзакции из коробки;
- единый контекст исполнения без сериализации;
- локальная разработка без зоопарка сервисов;
- меньше DevOps-нагрузки.
Недостатки:
- масштабируется только целиком;
- сильная связность: падение одного модуля валит всё;
- релиз любого изменения тянет за собой весь проект;
- сложно изолировать сбои и деградировать частично;
- со временем риск вырасти в неподъёмного монстра;
- тяжело делить работу между командами в одной кодовой базе;
- долгие интеграционные тесты;
- миграции схемы и контрактов только одним большим релизом.
Монолит — это про скорость старта, скорость разработки, простоту и предсказуемость. Можно быстро собрать рабочий прототип без оверхеда на межсервисное взаимодействие и протоколы. Подходит для старта почти любого проекта, особенно когда бизнес хочет протестировать идею. Хорошо там, где важна согласованность, минимальная инфраструктура или бюджет на неё ограничен. А ещё это про «олдскул», «про хардкор» и уложить весь прод одной запятой.
Монолит очень любят PHP-шники — потому что там особого выбора и нет, вся экосистема росла на монолитах. Когда у тебя Laravel на shared-хостинге, никакой RabbitMQ с сервис-мешем просто не пролезет.
🔥3👍2
Распределённая архитектура
А теперь — королева хаоса, распределённая архитектура. Это когда части системы разнесены по разным узлам и общаются по сети. Нет, это ещё не микросервисы — но уже ближе к ним. Микросервисы — это частный случай распределённой топологии.
Под это понятие попадает всё: от двух жирненьких серверов, связанных gRPC-протоколом, до сотен микросервисов на паре десятков машин, связанных охапкой брокеров, сокетами и прямым православным HTTP.
Границы здесь уже физические: сетевое взаимодействие, форматы данных, протоколы, ретраи, таймауты.
Достоинства:
- изоляция отказов (если контракты соблюдены);
- независимые деплой и релизы компонентов;
- масштабирование только узких мест;
- гибкость в выборе технологий под задачу;
- можно делить работу между командами без войны за один репозиторий;
- проще внедрять или переписывать отдельные части;
- можно разворачивать разные части системы в разных средах.
Недостатки:
- сеть становится частью контракта: задержки, таймауты, ретраи;
- сложнее трассировка и отладка: один запрос проходит через десяток сервисов;
- нужна синхронизация данных, иногда с жертвой консистентности;
- инфраструктурные накладные расходы: брокеры, балансировщики, API-шлюзы, сервис-меши;
- выше требования к мониторингу и логированию;
- рост операционной сложности: деплой, тесты и отладка требуют координации.
Распределёнка — это про изоляцию, гибкость, масштабирование и управляемую деградацию. Позволяет выбрать отдельный технологический стек для отдельной задачи. Подходит, когда система уже выросла за рамки одного процесса, команды работают параллельно и бизнес требует высокой доступности.
Но вместе с этим ты подписываешь контракт с сетью, мониторингом и DevOps-шапито. А ещё получаешь шанс провести несколько бессонных ночей, разбираясь, почему этот грёбаный сервис на 200 строк в проде отвечает 499 — и только по пятницам.
Тут уже любителей больше — джависты, гошники, писатели на Node.js — просто потому, что их стеки уже живут в мире брокеров, Kubernetes и сервис-мешей. Когда у тебя Spring Boot, Go-kit или NestJS в обнимку с Docker, всегда проще накатить новый сервис, чем ковырять старый.
«А где питухонисты?» — спросишь ты. Там сложнее. У них картинка где-то между PHP и Node.js. Много монолитных Django/Flask-приложений, особенно в вебе. Но они тоже не будут против распределёнки, особенно если её можно склеить из Flask, FastAPI и пары очередей, а деплоить через kubectl apply и скрипт на 50 строк. Python часто в компаниях соседствует с Go, Java или Node.js, и тогда питонячие куски становятся сервисами внутри уже готовой распределённой платформы.
А теперь — королева хаоса, распределённая архитектура. Это когда части системы разнесены по разным узлам и общаются по сети. Нет, это ещё не микросервисы — но уже ближе к ним. Микросервисы — это частный случай распределённой топологии.
Под это понятие попадает всё: от двух жирненьких серверов, связанных gRPC-протоколом, до сотен микросервисов на паре десятков машин, связанных охапкой брокеров, сокетами и прямым православным HTTP.
Границы здесь уже физические: сетевое взаимодействие, форматы данных, протоколы, ретраи, таймауты.
Достоинства:
- изоляция отказов (если контракты соблюдены);
- независимые деплой и релизы компонентов;
- масштабирование только узких мест;
- гибкость в выборе технологий под задачу;
- можно делить работу между командами без войны за один репозиторий;
- проще внедрять или переписывать отдельные части;
- можно разворачивать разные части системы в разных средах.
Недостатки:
- сеть становится частью контракта: задержки, таймауты, ретраи;
- сложнее трассировка и отладка: один запрос проходит через десяток сервисов;
- нужна синхронизация данных, иногда с жертвой консистентности;
- инфраструктурные накладные расходы: брокеры, балансировщики, API-шлюзы, сервис-меши;
- выше требования к мониторингу и логированию;
- рост операционной сложности: деплой, тесты и отладка требуют координации.
Распределёнка — это про изоляцию, гибкость, масштабирование и управляемую деградацию. Позволяет выбрать отдельный технологический стек для отдельной задачи. Подходит, когда система уже выросла за рамки одного процесса, команды работают параллельно и бизнес требует высокой доступности.
Но вместе с этим ты подписываешь контракт с сетью, мониторингом и DevOps-шапито. А ещё получаешь шанс провести несколько бессонных ночей, разбираясь, почему этот грёбаный сервис на 200 строк в проде отвечает 499 — и только по пятницам.
Тут уже любителей больше — джависты, гошники, писатели на Node.js — просто потому, что их стеки уже живут в мире брокеров, Kubernetes и сервис-мешей. Когда у тебя Spring Boot, Go-kit или NestJS в обнимку с Docker, всегда проще накатить новый сервис, чем ковырять старый.
«А где питухонисты?» — спросишь ты. Там сложнее. У них картинка где-то между PHP и Node.js. Много монолитных Django/Flask-приложений, особенно в вебе. Но они тоже не будут против распределёнки, особенно если её можно склеить из Flask, FastAPI и пары очередей, а деплоить через kubectl apply и скрипт на 50 строк. Python часто в компаниях соседствует с Go, Java или Node.js, и тогда питонячие куски становятся сервисами внутри уже готовой распределённой платформы.
🔥3👍2
Что в итоге?
Выбор топологии — это первый серьёзный архитектурный компромисс. Он определяет, какими проблемами ты расплатишься за достоинства и какие инструменты будешь тянуть за собой.
Монолит даст быстрый старт и контроль, но упрётся в масштабирование по частям.
Распределёнка даст гибкость и изоляцию, но привяжет к вечной возне с сетью и «дебагу призраков»
Нет «лучшей» топологии. Есть та, что подходит под твои задачи и ограничения.
И да, стартовое решение не высечено в камне — монолит можно распилить на сервисы, а микросервисное болото превратить в один аккуратный монолит. Но миграция почти всегда дороже, чем тебе будут обещать разработчики.
Если коротко: монолит — «быстро запустили, потом разгребаем», распределёнка — «долго строили, потом разгребаем». Разница только в том, чем и где ты будешь грести.
А что еще почитать?
- Мартин Фаулер — Monolith First - почем для начала лучше монолит
- Roadmap for Managing Chaos — Planing Migration from a Monolith to Microservices - этапы перехода от монолита к микросервисам
- GOTO 2019 • Monoliths vs Microservices • Martin Fowler - когда и почему каждая топология работает или ломается
Выбор топологии — это первый серьёзный архитектурный компромисс. Он определяет, какими проблемами ты расплатишься за достоинства и какие инструменты будешь тянуть за собой.
Монолит даст быстрый старт и контроль, но упрётся в масштабирование по частям.
Распределёнка даст гибкость и изоляцию, но привяжет к вечной возне с сетью и «дебагу призраков»
Нет «лучшей» топологии. Есть та, что подходит под твои задачи и ограничения.
И да, стартовое решение не высечено в камне — монолит можно распилить на сервисы, а микросервисное болото превратить в один аккуратный монолит. Но миграция почти всегда дороже, чем тебе будут обещать разработчики.
Если коротко: монолит — «быстро запустили, потом разгребаем», распределёнка — «долго строили, потом разгребаем». Разница только в том, чем и где ты будешь грести.
А что еще почитать?
- Мартин Фаулер — Monolith First - почем для начала лучше монолит
- Roadmap for Managing Chaos — Planing Migration from a Monolith to Microservices - этапы перехода от монолита к микросервисам
- GOTO 2019 • Monoliths vs Microservices • Martin Fowler - когда и почему каждая топология работает или ломается
🔥2
Очереди все еще не нужны.
Каждый разработчик думает об архитектуре нового проекта, и часто приходит к такому заключению: «Завезу микросервисы, и чтоб красиво кафку туда, или реббит, чтоб общались. Редис для кешей, и все это в кубере. Заживу!».
Обоснование тому такое: «Задел на будущее, кучу систем так построили, нормально работает, даже не больно, практически. И распределенка, и отказы с пиками держим. Красота!». Я мог бы согласится, но не буду. Если начать копать глубже, всплывает много нюансов. Большую часть того, что всплывает, я описал в прошлый раз.
Тогда же, в комментариях, чтобы не быть голословным и ради академического интереса, решил собрать стенд и уже на цифрах тебе показать, что очереди в классическом понимании все еще не нужны.
Расскажу что собирал, как и что измерял и какие результаты получил.
Каждый разработчик думает об архитектуре нового проекта, и часто приходит к такому заключению: «Завезу микросервисы, и чтоб красиво кафку туда, или реббит, чтоб общались. Редис для кешей, и все это в кубере. Заживу!».
Обоснование тому такое: «Задел на будущее, кучу систем так построили, нормально работает, даже не больно, практически. И распределенка, и отказы с пиками держим. Красота!». Я мог бы согласится, но не буду. Если начать копать глубже, всплывает много нюансов. Большую часть того, что всплывает, я описал в прошлый раз.
Тогда же, в комментариях, чтобы не быть голословным и ради академического интереса, решил собрать стенд и уже на цифрах тебе показать, что очереди в классическом понимании все еще не нужны.
Расскажу что собирал, как и что измерял и какие результаты получил.
👍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