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

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

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

zerodeps.tech
Download Telegram
Визуализация архитектуры через C4 Model

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

Вместо одной перегруженной диаграммы C4 предлагает четыре отдельных представления:

1. Context
Показывает систему в окружении: кто её использует, какие внешние сервисы есть, как идёт взаимодействие. Это уровень, на котором важны роли пользователей, интеграции и точки входа.

2. Container
Разделение системы на крупные исполняемые блоки: сервисы, базы данных, мобильные приложения, очереди, файлохранилища. Здесь фиксируются протоколы, точки развёртывания, границы отказоустойчивости.

3. Component
Внутреннее устройство контейнера: ключевые модули, их обязанности и связи. Это взгляд разработчика на систему, когда важно понять, в каком модуле живёт логика и как она связана с другими частями.

4. Code
Детализация до уровня классов и функций. В отличие от UML, в C4 этот уровень опционален — его используют только там, где реально полезно держать карту реализации.

Чем полезна модель:
- Делит архитектурное описание на понятные и управляемые слои.
- Даёт общий язык для обсуждения — от бизнеса до инженеров.
- Упрощает актуализацию документации: можно обновить один уровень, не переписывая всё.
- Работает одинаково для монолитов, распределённых систем и микросервисов.

Что важно понимать:
- C4 — это формат описания, а не метод проектирования. Он не подскажет, как правильно разделить сервисы или где ставить границы.
- Модель фиксирует уже принятые решения. Если решение плохое, на диаграмме оно просто будет аккуратно нарисовано.
- Она хорошо работает только при регулярном обновлении. Устаревшая диаграмма хуже, чем её отсутствие.


Пример применения:
Для новой системы можно сделать Context-диаграмму для стейкхолдеров, Container-уровень для DevOps и команды разработки, а Component-уровень — для бэкендеров, которые будут поддерживать сервис.
При этом все уровни связаны: элемент на Context-уровне соответствует контейнеру на следующем, а контейнер — набору компонентов внутри.


Полезное:
https://c4model.com/
👍53
C4 Model и "уровни" архитектуры

В прошлом посте я рассказал про C4 модель Саймона Брауна. Модель помогает визуализировать архитектуру так, чтобы и бизнес, и инженеры, и эксплуатация видели одну и ту же картину, но с нужной детализацией.

А так же я писал про то Где начинает архитектура - от переменной до экосистемы.

Если присмотреться то, можно заметить, что модель C4 довольно неплохо ложиться на эти уровни.

Давай посмотрим:

Context - Уровень 5
Диаграмма контекста в C4 — это взгляд на систему в окружении: пользователи, внешние сервисы, интеграции. Это соответствует экосистемному уровню: кто с кем говорит, какие протоколы, какие точки отказа.

Container - Уровень 4
Контейнеры — это крупные исполняемые части: сервисы, базы, очереди, фронтенды. Здесь важны вопросы развертывания, деплоя, масштабирования, согласованности и отказоустойчивости. Это соответствует архитектуре приложения: entry point, разделение core-логики и инфраструктуры, сценарии деградации.

Component - Уровень 3
Компоненты — модули внутри контейнера. Это уже граница между архитектурой и проектированием модулей: что модуль знает о внешнем мире, где side-effect, где бизнесс логика.

Code - Уровень 2–1
C4 опционально доходит до уровня классов и функций, но это уже зона проектирования кода. Тут работает дисциплина имен, SRP, локализация ответственности — то, что в описании на уровне переменной и функции.

Что в итоге?

C4 неплохой инструмент для согласования картины системы между разными уровнями участников. Но он закрывает только вопрос как показать архитектуру.

Для ответа на вопрос: какой она должна быть — всё те же принципы: чёткие границы, минимальные зависимости, предсказуемое поведение при сбоях.

Полезное:
Simon Brown — Visualising Software Architecture
Architectural boundaries
👍42
Зачем ты везде тащишь очереди?

Вот ты проектируешь свою систему, базы, микросервисы, кэш - все красиво. И вот встает вопрос: как это все связать, как они будет общаться?

И тут со всех утюгов кричат:
- У тебя микросервисы - тебе нужны очереди!
- Очереди - это модно.
- Очереди - это полезно.
- Очереди - это масштаб и надежность.
- Очереди "корпоративный стандарт". (как и TS, ага да)

Ты берешь, допустим RabitMQ, поднимаешь брокер (+1 сервис и глобальная точка отказа), и получаешь большую асинхронную боль сквозь всю систему, рост когнитивной нагрузки и эксплуатационные издержки: лаг, дубликаты, краш консумера, ядовитые сообщения и дрейф схем. Да есть и полезное: буферизация, распределение по времени, изоляция сбоев и фан-аут. Но какой ценой?

Если у тебя CRUD между двумя сервисами и одинаковые требования по времени ответа и доступности - очередь только навредит.

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

Отсюда вылезают взрослые темы, которые почему-то игнорируют, когда «прикручивают Kafka/Rabbit ради моды»:
- Доставка: at-least-once - дубликаты - идемпотентность на приёмнике
- Порядок: забудь «строгий порядок», у тебя head-of-line blocking или вообще все перемешано.
- Нагрузка: «очередь поможет удержать пик нагрузки» — да, но хвост растёт. Хвост — это твой скрытый долг по времени отклика.
- Мониторинг: трассировка через брокер — это чёрный ящик и пляски с кореляторами.
- Контракты: cобытие — это публичное API. Версионирование, эволюция схем, миграции потребителей — отдельная боль.

Очередь приносит тебе асинхронность как обязательство. А это обязательство принуждает тебя проектировать идемпотентные операции, хранить входящие/исходящие события, жанглировать ретраями и дедупликацией, думать про повторный прогон истории (пересчёт из журнала). Если ты к этому не готов - будет больно, очередь сломает прод, или тебя.
🔥3
Пример

Сценарий: Создать задачу и отправить ее на исполнение в другой сервис.

Вариант А — MongoDB + outbox (без внешней очереди):

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

javascript
// Сервис "Планирователь"
/**
* @param {import('mongodb').MongoClient} client
* @param {import('mongodb').Db} db
* @param {Task} task
* @returns {{status: boolean, taskId: string}}
*/
export async function createTask (client, db, task) {
const session = client.startSession()

try {
const eventId = crypto.randomUUID()

session.startTransaction()

await db.collection('tasks').insertOne(
{
_id: task.id,
type: task.type,
payload: task.payload,
status: 'pending',
createdAt: Date.now()
},
{ session }
)

await db.collection('outbox').insertOne(
{
_id: eventId,
type: 'task.created',
payload: { taskId: task.id },
createdAt: Date.now(),
pickedAt: null,
doneAt: null
},
{ session }
)

await session.commitTransaction()

return { status: true, eventId }
} catch (error) {
console.error(`[createTask] ${task.id}:`, error)
await session.abortTransaction()
} finally {
await session.endSession()
}
}

// Сервис "Делатель"
/**
* @param {import('mongodb').MongoClient} client
* @param {import('mongodb').Db} db
* @returns {Promise<void>}
*/
export async function executor (client, db) {
// атомарно «забронировали» событие
const event = await db
.collection('outbox')
.findOneAndUpdate(
{ type: 'task.created', pickedAt: null },
{ $set: { pickedAt: Date.now() } },
{ sort: { createdAt: 1 }, returnDocument: 'after' }
)

if (!event) {
//нет событий
delay(100)
return
}

// идемпотентность на уровне обработчика
const processedId = `${event._id}:executor`
const processed = await db.collection('processed').findOne({ _id: processedId })

if (!processed) {
// уже делали — пропускаем
return
}

const task = await db.collection('tasks').findOne({ _id: event.payload.taskId, status: { $ne: 'done' } })

if (!task) {
// таск уже кто-то сделал
return
}

if (task) {
try {
// делаем таску, помним про идемпотентность
await doWork(task)
await db.collection('processed').insertOne({ _id: processedId, at: Date.now() })
await db.collection('tasks').updateOne({ _id: task._id }, { $set: { status: 'done' } })
await db.collection('outbox').updateOne({ _id: event._id }, { $set: { doneAt: Date.now() } })
} catch (error) {
// ретраи с backoff, лимит → DLQ
console.error(`[executor] ${task.id}:`, error)
await db.collection('outbox').updateOne({ _id: event._id }, { $set: { pickedAt: null } })
await db.collection('processed').deleteOne({ _id: processedId })
await delay(100)
}
}
}
// executor запускается setInterval или setTimeout выше по архитектуре

В примере важное: Задача и событие родились в одном коммите — значит, не потеряются. Исполнитель читает из той же БД — меньше инфраструктуры, меньше точек отказа. Цена — ты сам хозяин ретраев и DLQ, но это как раз то, что нужно контролировать.

Уже слышу крики: "А как же изоляция, границы, связность, какой "ужос" два сервиса работают с одной бд. Как же Чистая архитектура?!" Нормально, отвечу я, когда два сервиса читают одну БД: один владеет записью и инвариантами, остальные читают только стабильную проекцию/реплику по контракту — вот и вся изоляция. Ну это мы отвлеклись. Продолжим.
👍3🔥3
Вариант Б — RabbitMQ (внешняя очередь)

Наивно делать «insert в БД и затем publish в Rabbit» нельзя: между этими шагами всегда есть окна потерь/дублей. Значит… сюрприз… всё равно нужен outbox. Просто вместо прямого чтения его, будет «ретранслятор в Rabbit».


javascript
// Сервис "Планирователь"
export async function createTask (client, db, task) {
//createTask такой же как и в Варианте А, только меняем немного запись
//...
await db.collection('outbox').insertOne(
{
_id: eventId,
type: 'task.created',
payload: { taskId: task.id },
createdAt: Date.now(),
pickedAt: null,
publishedAt: null
},
{ session }
)
//...
}

// но тут нам нужен еще relay - штука по доставке события из бд в очередь

/**
* @param {import('mongodb').Db} db
* @param {import('amqplib').Channel} rmqChannel
* @param {string} exchange
* @returns {Promise<void>}
*/
export async function relay (db, rmqChannel, exchange) {
const event = await db
.collection('outbox')
.findOneAndUpdate(
{ type: 'task.created', pickedAt: null },
{ $set: { pickedAt: Date.now() } },
{ sort: { createdAt: 1 }, returnDocument: 'after' }
)

if (!event) {
await delay(50)
return
}

try {
await publishConfirm(rmqChannel, exchange, 'task.created', Buffer.from(JSON.stringify(event)))
await db.collection('outbox').updateOne({ _id: event._id }, { $set: { publishedAt: Date.now() } })
} catch (e) {
await db.collection('outbox').updateOne({ _id: event._id }, { $set: { pickedAt: null } })
await sleep(100)
}
}

// Сервис "Делатель"
/**
* @param {import('mongodb').Db} db
* @param {Object} msg
*/
export async function consumeAndExecute (db, msg) {
const event = JSON.parse(msg.content.toString())

// идемпотентность на уровне обработчика
const processedId = `${event._id}:executor`
const processed = await db.collection('processed').findOne({ _id: processedId })

if (!processed) {
//дубль, уже исполнили
return 'ack'
}

const task = await db.collection('tasks').findOne({ _id: event.payload.taskId, status: { $ne: 'done' } })

if (!task) {
return 'ack'
}

if (task) {
try {
// делаем таску, помним про идемпотентность
await doWork(task)
await db.collection('processed').insertOne({ _id: processedId, at: Date.now() })
await db.collection('tasks').updateOne({ _id: task._id }, { $set: { status: 'done' } })
return 'ack'
} catch (error) {
console.error(`[consumeAndExecute] ${task.id}:`, error)
// ретраи с backoff, DLX/TTL, лимит → DLQ, можно ответить 'nack' получим еще раз
await db.collection('processed').deleteOne({ _id: processedId })
return 'ack'
}
}
}

А где минусы спросишь ты. Так вот же, в полтора раза больше кода, больше мест, где, что-то может пойти не так. Дополнительный сервис с брокером. Транспорт между брокером и сервисами. Если не делаешь relay — потеряешь события при падениях между БД и брокером. Если не делаешь inbox и идемпотентность — словишь дубль при redelivery/повторной публикации. Rabbit сам по себе не решает твою бизнес-консистентность, он только перевозит байты.

Внутрисистемный асинхрон: Mongo + outbox закрывает 90% кейсов без зоопарка, и даже фан-аут тоже можно сделать. Но если хочешь фан-аут на внешних потребителей из коробки, независимые группы, интеграции или просто очень хочется очередь — бери Rabbit (или другой брокер), но не забывай: outbox/inbox, idempotency, confirm-публикации, DLQ. Очередь без этих штук — просто дорогая труба с иллюзией надёжности.
🔥2
Что в итоге?

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

Нужен буфер? Сделай backpressure и честные 429.
Нужна обработка «не в запросе»? Outbox и воркер.
Нужен событийный продукт между доменами? И тут все еще можно обойтись без брокера, но уже можно задуматься про него, выбрать, закатать рукава и решиться нырнуть в асинхронный персональный ад.

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

Хочешь поспорить — покажи, где у тебя backpressure, где идемпотентность и как ты репроцессишь хвост. Если ответ «никак» — очередь тебе не помогла, она просто спрятала проблему в тень.
👍3🔥2
4. Зачем тебе этот грязный TypeScript?

Продолжим полоскать твой TypeScript. Прошлые посты про него получились довольно мягкими и обтекаемыми. Это непорядок, попробую прямо донести мысль: «TypeScript несёт в твой код шум, грязь, и заставляет тебя гуглить»

Тебе правда нужны вот эти вот всё условные типы, дженерики и танцы с infer? Если начистоту, в 90% случаев ответ: «точно нет»

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

TS буквально раздувает площадь кода. Любая правка превращается в две: логика и инварианты + синтаксический ритуал вокруг этого. Дифы пухнут, мерж-конфликты из-за :Foo|Bar вместо реального поведения. Ошибки типов не ловят всё то, что реально валит прод: регресс в протоколе, гонки, backpressure, дедлоки.

Типы – это твоя статическая гипотеза.
Рантайм – динамический факт.


С ростом проекта, ты становишься заложником типовой сети. Ты просто боишься что-то менять, одно переименование и у тебя 200 ошибок в разных файлах, а если это DTO, то может и в разных проектах. IDE с другим проектом, поможет тебе только после сборки и синхронизации пакета с типами. Это значительно тормозит рефакторинг – главный инструмент борьбы с энтропией.

А если ты встречаешь что-то сложнее чем Record<string, User>, ты полезешь гуглить: “Как эту вашу лабубу выразить через TS”. И случается это чаще, чем ты хотел бы, чаще чем ты читаешь свой код.

Советы от IDE подменяют дисциплину инвариантов и валидации на входе.
👍2🔥2
Пройдемся по примерам. (Все примеры вымышленные, любые совпадения случайны)

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) Реестр обработчиков событий

 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 не та экосистема, которую нужно тащить по дефолту, 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 с сервис-мешем просто не пролезет.
🔥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, и тогда питонячие куски становятся сервисами внутри уже готовой распределённой платформы.
🔥3👍2
Что в итоге?

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

Монолит даст быстрый старт и контроль, но упрётся в масштабирование по частям.

Распределёнка даст гибкость и изоляцию, но привяжет к вечной возне с сетью и «дебагу призраков»

Нет «лучшей» топологии. Есть та, что подходит под твои задачи и ограничения.

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

Если коротко: монолит — «быстро запустили, потом разгребаем», распределёнка — «долго строили, потом разгребаем». Разница только в том, чем и где ты будешь грести.


А что еще почитать?
- Мартин Фаулер — Monolith First - почем для начала лучше монолит
- Roadmap for Managing Chaos — Planing Migration from a Monolith to Microservices - этапы перехода от монолита к микросервисам
- GOTO 2019 • Monoliths vs Microservices • Martin Fowler - когда и почему каждая топология работает или ломается
🔥2
Очереди все еще не нужны.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Кейс 2. Ingest DB

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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