Распределённые данные — самая сложная часть
Проблема: В монолите данные лежат в одной базе — просто, предсказуемо, ACID. Когда вы разносите логику по сервисам, данные остаются на месте. И тут начинается настоящая боль. Как гарантировать целостность, когда каждая часть системы видит только свой кусок данных?
Транзакции — это, пожалуй, единственное, по чему ты будешь реально скучать, переходя от монолита к распределённой системе.
В монолите всё просто: открыл транзакцию, обновил три таблицы, закоммитил. Если что-то пошло не так — rollback, и система снова консистентна. Один процесс, один connection, одна база. ACID в чистом виде.
В распределённой системе — всё иначе. Заказ создаётся в Order Service, деньги списываются в Payment Service, товар резервируется в Inventory Service, уведомление летит в Notification Service. Каждый сервис — со своей базой. Атомарной транзакции через все эти сервисы не существует.
И вот вы сидите и думаете: «А что, если заказ создался, а деньги не списались? Или деньги списались, а товар не зарезервировался?»
Это не теоретические вопросы. Это — ваша новая реальность. Добро пожаловать в мир распределённых данных.
#Database-per-service — почему это важно
Начнём с базы. Если вы переходите на микросервисы, первый вопрос, который вы обязаны себе задать: «Где будут жить данные?»
У вас есть два пути:
Shared Database — все сервисы пишут в одну базу. Разные таблицы, иногда даже разные схемы, но физически — одна БД.
Database-per-service — каждый сервис владеет своей базой. Только этот сервис имеет право писать в неё напрямую.
Выглядит как очевидный выбор, правда? На практике — почти каждый второй «переход на микросервисы» начинается с shared database. Почему? Потому что это проще. Не нужно думать, как синхронизировать данные между сервисами. Можно делать JOIN-ы через базу. Миграции — централизованные.
И это — ловушка.
#Чем опасна общая база
Общая база данных сводит на нет практически все преимущества микросервисов:
- Связанность. Кто-то поменял схему таблицы — и ваш сервис упал. У вас нет изоляции.
- Отсутствие владельца. Кто отвечает за
usersтаблицу? «Ну, вроде User Service». А почему Order Service пишет туда напрямую? «Потому что быстрее». - Одна точка отказа. База упала — упали все сервисы разом. Вы проиграли по availability.
- Независимые релизы? Не выйдет. Migration требует координации между командами.
Database-per-service решает все эти проблемы. Каждый сервис — полный хозяин своих данных.
#Как это выглядит на практике
// Order Service — владеет заказами
class OrderService {
constructor(
private orderRepo: OrderRepository, // своя БД
private paymentClient: PaymentServiceClient, // чужой сервис через API
private inventoryClient: InventoryServiceClient
) {}
async placeOrder(cart: Cart): Promise<OrderResult> {
// 1. Создаём заказ в своей БД
const order = await this.orderRepo.create(cart);
// 2. Вызываем PaymentService (он сам решит, как списать деньги)
const payment = await this.paymentClient.charge(
order.id, cart.total, cart.paymentMethod
);
// 3. InventoryService сам обновит свой склад
await this.inventoryClient.reserve(order.id, cart.items);
return { order, payment };
}
}Обратите внимание: OrderService не знает, как PaymentService хранит платежи. PostgreSQL, DynamoDB, текстовый файл — не его дело. Контракт — только через API.
#Когда shared database всё-таки ок
Гранитная истина нарушается в двух сценариях:
Отчётность и аналитика. Да, вам нужна база, где данные сведены вместе. Но это не shared database для сервисов — это отдельная аналитическая БД, которая заполняется асинхронно через CDC (Change Data Capture) или ETL.
Read-only копии. Некоторые сервисы могут читать из реплик чужой базы, если это read-only и есть явное соглашение. Но писать — никогда.
Cross-reference: Подробнее про CDC и синхронизацию данных между сервисами — см. Главу 16 «Event-Driven Architecture». Про аналитические базы на масштабе — Главу 28.
#Saga Pattern: choreography vs orchestration
Итак, у каждого сервиса — своя база. Но как гарантировать, что заказ либо создан полностью (деньги списаны, товар зарезервирован), либо не создан вообще?
В мире монолита за это отвечает ACID-транзакция. В мире микросервисов — Saga.
Saga — это последовательность локальных транзакций, где каждая следующая публикует событие или вызывает следующий шаг. Если шаг не удался — saga запускает компенсирующие транзакции (compensating transactions), которые отменяют предыдущие шаги.
#Choreography (хореография)
Каждый сервис сам решает, на какие события реагировать и какие публиковать. Нет центрального координатора.
// Order Service: создал заказ → опубликовал событие
class OrderService {
async placeOrder(command: PlaceOrderCommand): Promise<void> {
const order = Order.create(command);
await this.orderRepo.save(order);
await this.eventBus.publish(new OrderCreatedEvent({
orderId: order.id,
customerId: order.customerId,
total: order.total,
items: order.items,
}));
}
// Компенсация: если оплата не прошла
async onPaymentFailed(event: PaymentFailedEvent): Promise<void> {
const order = await this.orderRepo.findById(event.orderId);
order.cancel(`Payment failed: ${event.reason}`);
await this.orderRepo.save(order);
await this.eventBus.publish(new OrderCancelledEvent({ orderId: order.id }));
}
}// Payment Service: получил событие → списал деньги → опубликовал результат
class PaymentService {
async onOrderCreated(event: OrderCreatedEvent): Promise<void> {
try {
const payment = await this.paymentGateway.charge(
event.customerId, event.total
);
await this.paymentRepo.save(payment);
await this.eventBus.publish(new PaymentSucceededEvent({
orderId: event.orderId,
paymentId: payment.id,
}));
} catch (err) {
await this.eventBus.publish(new PaymentFailedEvent({
orderId: event.orderId,
reason: err.message,
}));
}
}
}Плюсы:
- Простая архитектура — нет оркестратора
- Слабая связанность — сервисы ничего не знают друг о друге
- Легко добавлять новые шаги
Минусы:
- Логика процесса размазана по сервисам
- Трудно понять, «как работает оформление заказа», читая код одного сервиса
- Циклические зависимости событий — когда A реагирует на B, B реагирует на C, а C — снова на A
- Сложно отлаживать и тестировать end-to-end
#Orchestration (оркестрация)
Центральный оркестратор — отдельный сервис, который говорит каждому участнику, что делать и в каком порядке.
// Оркестратор — знает всю последовательность шагов
class OrderOrchestrator {
async executeSaga(command: PlaceOrderCommand): Promise<void> {
const sagaId = uuid();
try {
// Шаг 1: Создать заказ
await this.orderClient.createOrder(sagaId, command);
// Шаг 2: Списать деньги
await this.paymentClient.charge(sagaId, command.customerId, command.total);
// Шаг 3: Зарезервировать товары
await this.inventoryClient.reserve(sagaId, command.items);
// Шаг 4: Отправить уведомление
await this.notificationClient.sendConfirmation(sagaId, command.customerId);
// Saga выполнена
await this.sagaRepo.complete(sagaId);
} catch (err) {
// Компенсация в обратном порядке
await this.compensate(sagaId, err);
}
}
private async compensate(sagaId: string, error: Error): Promise<void> {
// Шаг назад: отменить резервирование
await this.inventoryClient.release(sagaId);
// Ещё назад: вернуть деньги
await this.paymentClient.refund(sagaId);
// Ещё назад: отменить заказ
await this.orderClient.cancelOrder(sagaId);
// Saga неудачна
await this.sagaRepo.fail(sagaId, error);
}
}Плюсы:
- Вся логика процесса — в одном месте
- Понятно, какие шаги выполняются и в каком порядке
- Легко отлаживать: одна точка входа и выхода
- Тестируемо: можно тестировать оркестратор в изоляции
Минусы:
- Оркестратор — новая точка отказа и потенциальный бутылочный горлышко
- Оркестратор знает слишком много — это нарушение domain isolation
- Сложнее добавлять новые шаги (нужно менять оркестратор, а не просто подписаться на событие)
#Что выбирать
| Критерий | Choreography | Orchestration |
|---|---|---|
| Количество шагов | 2-3 | Любое |
| Сложность логики | Простая | Сложная, с условиями |
| Требуется visibility | Не очень | Да, нужно видеть весь процесс |
| Команда | Маленькая (< 5 чел) | Большая, нужна координация |
| Изменения в процессе | Редкие | Частые |
| Примеры | Уведомления, логирование | Payment flow, onboarding |
Эмпирическое правило: choreography для простых цепочек из 2-3 шагов. Orchestraition для всего, что сложнее. В сложных сценариях (многошаговые процессы, stateful паузы, retry с экспоненциальной задержкой) используйте выделенные workflow-движки вроде Temporal, Camunda, AWS Step Functions, или ZK Serverless Workflow.
#Компенсирующие транзакции — это не rollback
Важный нюанс: компенсирующая транзакция — это не rollback. Вы не можете просто «откатить» состояние, потому что между шагом и компенсацией могут быть другие операции (включая действия пользователя, который уже увидел «заказ создан» и пошёл пить чай).
Пример: вы списали деньги с карты, а потом решили отменить заказ. «Компенсирующая транзакция» — это возврат денег. Но между списанием и возвратом:
- Мог пройти день
- Банк мог начислить комиссию
- Пользователь мог уже получить кэшбэк
- Курс валют мог измениться
Компенсация — это бизнес-операция, а не технический откат. Она должна быть спроектирована как часть бизнес-логики.
Cross-reference: Про Event-Driven архитектуру и очереди — см. Главу 16. Про компромиссы ACID vs BASE — Главу 3 «Первые принципы архитектуры».
#2PC и почему это не работает в продакшене
Если вы когда-нибудь гуглили «распределённые транзакции», вы наверняка наткнулись на Two-Phase Commit (2PC). Выглядит как решение: есть координатор, который сначала спрашивает «готовы?», а потом говорит «делай!». Все участники блокируют ресурсы на этапе Prepare и ждут команды Commit или Rollback от координатора.
Звучит логично. На практике — почти никогда не работает.
#Что идёт не так
Фаза 1 (Prepare):
Coordinator → OrderService: "Готов создать заказ?"
OrderService: "Готов" (блокирует запись в таблице orders)
Coordinator → PaymentService: "Готов списать деньги?"
PaymentService: "Готов" (блокирует запись в payments)
Coordinator → InventoryService: "Готов зарезервировать товар?"
InventoryService: "Готов" (блокирует запись в inventory)Фаза 2 (Commit):
Coordinator → OrderService: "Коммить!"
Coordinator → PaymentService: "Коммить!"
Coordinator → InventoryService: "Коммить!"Теперь представьте, что InventoryService упал на шаге Prepare. У вас заблокированы ресурсы в OrderService и PaymentService на время таймаута (который может быть 30-60 секунд). А если таких запросов сотни в секунду? База задыхается от заблокированных строк.
#Реальные проблемы 2PC
-
Координатор — точка отказа. Если координатор упал между Prepare и Commit, все участники остаются заблокированными на неопределённый срок. Что делать? restart координатора? А если он потерял состояние?
-
Долгие блокировки. Участники держат блокировки на время всего распределённого транзакции. В распределённой системе время ответа непредсказуемо: другие транзакции блокируются, latency растёт, начинается каскад таймаутов.
-
Не все участники поддерживают 2PC. Redis — нет. S3 — нет. Kafka — нет. Elasticsearch — нет. Многие ваши сервисы используют хранилища, которые не умеют в распределённые транзакции.
-
Сетевые разделения. Если сеть между Coordinator и участником разорвана, участник не получает команду Commit. Он остаётся в подвешенном состоянии.
-
Разные СУБД. У PostgreSQL и MySQL разная реализация XA-транзакций. В распределённой системе с гетерогенными базами 2PC — это боль.
#Когда 2PC может работать
Единственный сценарий, где 2PC оправдан — когда все участники — одна СУБД (например, PostgreSQL через Postgres-XC или Citus) или в контролируемом окружении с гарантированной сетью и низкой latency. Например, в одном датацентре, на минимальной дистанции.
Во всех остальных случаях — забудьте. Используйте Saga + компенсирующие транзакции. Это сложнее проектировать, но оно работает в реальном мире.
#CQRS в распределённой системе
Если вы читали приложение к Главе 7 (07a — CQRS), вы уже знаете основной принцип: разделение моделей на запись и чтение. В распределённой системе CQRS — не опция, а необходимость.
Почему? Потому что в распределённой системе у вас нет единого источника данных для запросов. Данные размазаны по сервисам. Сделать JOIN через три сервиса по HTTP — это запрос на 5 секунд (если сервисы не упали).
CQRS решает это так: вы строите read-модели — денормализованные представления данных из нескольких сервисов. Они обновляются асинхронно через события.
// OrderService публикует событие
await this.eventBus.publish(new OrderCreatedEvent({
orderId: order.id,
customerId: order.customerId,
customerName: order.customerName, // денормализовано
items: order.items,
total: order.total,
status: 'pending',
createdAt: order.createdAt,
}));
// CustomerService публикует информацию о клиенте
await this.eventBus.publish(new CustomerUpdatedEvent({
customerId: customer.id,
name: customer.name,
email: customer.email,
status: customer.status,
}));
// ReadModel Projection — подписывается на оба сервиса и строит единую read-модель
class OrderListViewProjection {
async onOrderCreated(event: OrderCreatedEvent): Promise<void> {
await this.readDB.insert('order_views', {
id: event.orderId,
customerId: event.customerId,
customerName: event.customerName,
items: event.items,
total: event.total,
status: event.status,
createdAt: event.createdAt,
// Поля из других сервисов — заполнятся позже
customerEmail: null,
customerStatus: null,
});
}
async onCustomerUpdated(event: CustomerUpdatedEvent): Promise<void> {
// Обновляем денормализованные данные
await this.readDB.update('order_views', {
customerId: event.customerId,
}, {
customerName: event.name,
customerEmail: event.email,
customerStatus: event.status,
});
}
}Теперь запрос «Показать заказы пользователя со статусом VIP» не требует JOIN через три сервиса. Всё уже собрано в read-модели.
Цена: eventual consistency. Между созданием заказа и появлением его в read-модели проходит какое-то время (миллисекунды, секунды — зависит от вашей архитектуры).
Компромисс: вы жертвуете сильной согласованностью (strong consistency) в пользу производительности чтения. Для большинства UI это ок. Для платёжного терминала — нет.
Cross-reference: Полный разбор CQRS — от истоков до продакшен-реализации — см. Главу 07a «CQRS: Command Query Responsibility Segregation». Базовая теория CQS — в Главе 3.
#Transactional Outbox — гарантированная доставка
У вас есть сервис, который должен опубликовать событие после сохранения данных. Логичный код:
async createOrder(data: CreateOrderDTO): Promise<void> {
const order = Order.create(data);
await this.orderRepo.save(order); // 1. Сохраняем в БД
await this.eventBus.publish(new OrderCreatedEvent(order)); // 2. Публикуем событие
}Проблема: что, если БД сохранила данные, а event bus упал? Или наоборот — событие опубликовалось, а БД упала до commit? У вас гарантированно будет либо потерянное событие, либо заказ без события.
Решение — Transactional Outbox:
Вы пишете событие в ту же БД, что и данные, внутри одной транзакции. Отдельный процесс (poller или CDC) читает эти события и публикует их в брокер.
async createOrder(data: CreateOrderDTO): Promise<void> {
// Транзакция: и данные, и событие — одной atomic записью
await this.db.transaction(async (tx) => {
const order = Order.create(data);
await tx.orders.save(order); // данные
await tx.outbox.insert({ // событие — в той же транзакции
id: uuid(),
aggregateType: 'order',
aggregateId: order.id,
eventType: 'OrderCreated',
payload: JSON.stringify(order),
createdAt: new Date(),
});
});
// Транзакция закоммичена — событие гарантированно сохранено
}Отдельный процесс — опубликотор (Outbox Publisher) — читает из outbox таблицы и отправляет в брокер:
// Poller-based Outbox Publisher
class OutboxPublisher {
async poll(): Promise<void> {
// 1. Читаем неопубликованные события (с лимитом, пагинацией)
const events = await this.outboxRepo.findUnpublished({ limit: 100 });
for (const event of events) {
try {
// 2. Публикуем в брокер
await this.eventBus.publish(event.toDomainEvent());
// 3. Отмечаем как опубликованное
await this.outboxRepo.markAsPublished(event.id);
} catch (err) {
// 4. Логируем ошибку, событие останется в outbox
// Следующий poll повторит попытку
this.logger.error(`Failed to publish event ${event.id}`, err);
}
}
}
}#CDC как альтернатива поллингу
Вместо того чтобы опрашивать outbox-таблицу (poller-based), можно использовать Change Data Capture (CDC): Debezium читает WAL (Write-Ahead Log) PostgreSQL или binlog MySQL и автоматически публикует изменения в Kafka.
-- Outbox-таблица (простая, без ORM, без индексов на text полях)
CREATE TABLE outbox (
id UUID PRIMARY KEY,
aggregate_type VARCHAR(100) NOT NULL,
aggregate_id VARCHAR(100) NOT NULL,
event_type VARCHAR(100) NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
published_at TIMESTAMP
);
-- Индекс для poller'а
CREATE INDEX idx_outbox_unpublished ON outbox (created_at)
WHERE published_at IS NULL;Poller vs CDC:
| Критерий | Poller-based | CDC (Debezium) |
|---|---|---|
| Сложность | Низкая | Средняя (Kafka нужен) |
| Latency | Секунды (poll interval) | Миллисекунды |
| Нагрузка на БД | Есть (SELECT каждые N мс) | Нет (читает WAL) |
| Инфраструктура | Только БД | БД + Kafka + Debezium |
| Масштабирование | Конкурентные poller'ы — сложно | Партиции Kafka |
Для 80% проектов poller-based outbox — это нормально. CDC добавляет «ещё один движущийся поезд» (Kafka) в вашу архитектуру. Убедитесь, что вы готовы его обслуживать.
#Идемпотентный consumer — обязательное требование
Transactional Outbox гарантирует at-least-once delivery. Событие может быть опубликовано дважды (poller упал после публикации, но до markAsPublished). Поэтому consumer обязан быть идемпотентным.
Как? Через tracking идемпотентности:
// Идемпотентный consumer
async function handleOrderCreated(event: OrderCreatedEvent): Promise<void> {
// Проверяем, не обрабатывали ли уже
const processed = await this.processedEventsRepo.exists(event.id);
if (processed) {
return; // Уже обработали — игнорируем
}
// Выполняем бизнес-логику
await this.paymentService.charge(event.orderId, event.total);
// Отмечаем как обработанное
await this.processedEventsRepo.markAsProcessed(event.id);
}Cross-reference: Transactional Outbox — не единственный способ гарантированной доставки. В Главе 16 разобраны альтернативы с event sourcing и log-based delivery.
#Idempotency keys — как сделать запросы безопасными
Сеть ненадёжна. Вы отправили POST на создание платежа — и получили таймаут. Был ли платеж создан? Сервер мог ответить до таймаута, а мог упасть до обработки. Retry? А если это двойное списание?
Idempotency Key — это строка, которую клиент передаёт с каждым мутирующим запросом. Сервер гарантирует: если запрос с таким же ключом уже обработан, он вернёт тот же результат, не выполняя операцию повторно.
#Как это работает
// Платеж — самый страшный сценарий без идемпотентности
// POST /payments
// Без Idempotency-Key:
// POST /payments с телом { amount: 100 } — таймаут
// POST /payments с телом { amount: 100 } — дубль, списано 200
// С Idempotency-Key:
// POST /payments с заголовком Idempotency-Key: "req-abc-123"
// таймаут — клиент не знает результат
// POST /payments с тем же Idempotency-Key: "req-abc-123"
// сервер видит, что ключ уже есть, возвращает сохранённый результатРеализация на стороне сервера:
class PaymentController {
async charge(req: Request, res: Response): Promise<void> {
const idempotencyKey = req.headers['idempotency-key'];
if (!idempotencyKey) {
throw new BadRequest('Idempotency-Key header is required');
}
// 1. Проверяем, обрабатывали ли уже
const existing = await this.idempotencyRepo.findById(idempotencyKey);
if (existing) {
// Возвращаем сохранённый ответ (тот же статус, то же тело)
res.status(existing.statusCode).json(existing.body);
return;
}
// 2. Блокируем ключ (предотвращаем race condition)
// TTL — чтобы не хранить вечно
const lock = await this.idempotencyRepo.lock(idempotencyKey, ttl: 3600);
if (!lock.acquired) {
// Другой запрос обрабатывает этот же ключ
res.status(409).json({ error: 'Concurrent request detected' });
return;
}
try {
// 3. Выполняем операцию
const payment = await this.paymentGateway.charge(req.body);
// 4. Сохраняем результат по ключу
await this.idempotencyRepo.save(idempotencyKey, {
statusCode: 200,
body: { paymentId: payment.id, status: payment.status, amount: payment.amount },
});
res.status(200).json({ paymentId: payment.id });
} catch (err) {
// Ошибку тоже сохраняем — повторный запрос должен вернуть ту же ошибку
await this.idempotencyRepo.save(idempotencyKey, {
statusCode: 422,
body: { error: err.message },
});
res.status(422).json({ error: err.message });
}
}
}#Где хранить idempotency keys
Варианты:
- Redis (с TTL). Быстро, TTL автоматический. Но Redis — не durable хранилище. Если Redis умер, защита пропала.
- PostgreSQL (с уникальным индексом). Надёжно, долговечно. Но медленнее. TTL через фоновую очистку.
- DynamoDB (с TTL). Хороший компромисс: быстрый, durable, TTL на уровне таблицы.
- In-memory (HashMap). Только внутри одного процесса. Не работает для нескольких инстансов.
Для production обычно достаточно Redis + TTL. Но для платёжных систем (Stripe, Adyen) используют PostgreSQL или DynamoDB — потеря данных по idempotency ключам означает финансовые потери.
#Scope idempotency key
Idempotency key должен быть уникальным в рамках операции и истекать через разумное время.
- Stripe: ключ живёт 24 часа. После этого повторный запрос с тем же ключом — новая операция.
- Рекомендация: TTL = время, за которое клиент гарантированно получит ответ (обычно 1-24 часа).
#Common mistakes
-
Idempotency-key из данных запроса. SHA256(body) — плохо, потому что разные запросы с одинаковым телом будут считаться дубликатами. Ключ должен генерироваться клиентом как уникальный UUID.
-
Idempotency только для success. Если запрос упал с ошибкой 500, повторная отправка должна работать как первый запрос (потому что система могла не успеть сохранить состояние). Или наоборот — вы сохранили состояние, но ответ не ушёл клиенту.
-
Race condition. Два запроса с одинаковым idempotency key прилетают одновременно. Нужна блокировка на уровне БД (
SELECT ... FOR UPDATEили optimistic lock).
Cross-reference: В Главе 6 разобраны idempotency в контексте REST API. В Главе 16 — в контексте event-driven consumer'ов.
#Distributed caching — Redis Cluster, ElastiCache
В распределённой системе кэш — это не просто «ускорение», это часто единственный способ выдержать нагрузку. Но распределённый кэш добавляет новый слой сложности.
#Что кэшировать
Не всё подряд. Есть вещи, которые стоит кэшировать обязательно, и вещи, которые кэшировать нельзя:
Стоит:
- Read-heavy данные (посты, статьи, каталоги)
- Вычислительно дорогие результаты (агрегации, отчёты)
- Референсные данные (справочники, конфиги, словари)
- Session data
Не стоит (без специальной архитектуры):
- Финансовые транзакции
- Данные с high contention (счётчик лайков — это нормально, баланс счёта — нет)
- Данные, которые меняются каждую секунду (если stale-читание не приемлемо)
#Redis Cluster
Когда одного Redis-инстанса не хватает, вы идёте в Redis Cluster.
Redis Cluster автоматически шардирует данные по 16384 слотам (hash slots). Каждая нода отвечает за подмножество слотов. Клиент знает маппинг и отправляет запрос нужной ноде.
# Пример кластера из 6 нод (3 master, 3 replica)
redis-cli --cluster create \
10.0.1.1:6379 10.0.1.2:6379 10.0.1.3:6379 \
10.0.2.1:6379 10.0.2.2:6379 10.0.2.3:6379 \
--cluster-replicas 1Как работает запрос:
- Клиент вычисляет хеш ключа → hash slot (CRC16(key) % 16384)
- Клиент отправляет на ноду, которая владеет слотом
- Если нода не владеет слотом — возвращает MOVED редирект
- Клиент (через smart client) кэширует маппинг
Проблемы Redis Cluster:
- Multi-key операции работают только если все ключи в одном слоте. Cross-slot операции не поддерживаются (нельзя атомарно обновить два ключа из разных слотов)
- Resharding — при добавлении ноды нужно перераспределять слоты. Redis делает это онлайн, но latency на перемещаемые ключи растёт
- Сеть — каждая операция — сетевой вызов. Pipeline помогает, но не отменяет latency
#ElastiCache (AWS)
Managed Redis/Memcached от AWS. Снимает головную боль операций: автоматический failover, backup, patching, мониторинг.
Ключевые конфигурации:
- Cluster Mode Enabled — настоящий Redis Cluster (шардирование, несколько шардов с репликами)
- Cluster Mode Disabled — один Redis primary + реплики. Проще, но без шардирования
Что важно знать про ElastiCache:
- Cross-AZ replication — реплики в разных AZ дают отказоустойчивость, но добавляют latency на запись (синхронная репликация между AZ)
- Auto-failover — если primary упал, promoted replica берёт на себя запись. Но старый primary при возвращении становится replica — возможна потеря нереплицированных данных
- Scale up/out — можно менять тип ноды (scale up) или добавлять шарды (scale out). Scale out требует полного решардирования
# Terraform: ElastiCache Redis Cluster
resource "aws_elasticache_replication_group" "cache" {
replication_group_id = "my-app-cache"
description = "Redis cluster for my-app"
engine = "redis"
engine_version = "7.1"
node_type = "cache.r6g.large"
num_cache_clusters = 3 // 1 primary + 2 replicas
parameter_group_name = "default.redis7.cluster.on"
port = 6379
automatic_failover_enabled = true
multi_az_enabled = true
subnet_group_name = aws_elasticache_subnet_group.main.name
security_group_ids = [aws_security_group.cache.id]
tags = {
Environment = "production"
}
}#Redis vs Key-Value stores (DynamoDB, ScyllaDB)
Иногда разработчики используют Redis как «кэш, который никогда не удаляем». Это неправильно — Redis — это кэш, не база данных. Если вам нужно долговременное хранение с низкой latency, смотрите в сторону:
- DynamoDB DAX — in-memory cache для DynamoDB с прозрачной интеграцией
- ScyllaDB — drop-in replacement для Cassandra, низкая latency, персистентность
- TiKV — распределённый Key-Value store с транзакциями (используется в TiDB)
#Cache coherency и инвалидация
Есть знаменитая цитата Фила Карлтона:
«В компьютерной науке есть только две сложные проблемы: инвалидация кэша, именование и ошибка на единицу.»
В распределённой системе инвалидация кэша становится по-настоящему весёлой.
#Проблема: синхронизация между сервисами
У вас есть:
Catalog Service— хранит товарыOrder Service— хранит заказыRecommendation Service— агрегирует данные для рекомендаций- Redis Cluster — кэш для рекомендаций
Когда Catalog Service меняет цену товара, нужно инвалидировать кэш в Recommendation Service, который использует эту цену.
Варианты:
#1. TTL-based (ленивая инвалидация)
Просто ставим TTL на кэш. Когда TTL истёк — следующий запрос перечитает данные.
// Кэшируем рекомендации на 5 минут
const recommendations = await cache.get(`recommendations:${userId}`);
if (recommendations) {
return JSON.parse(recommendations);
}
const fresh = await recommendationService.generate(userId);
await cache.set(`recommendations:${userId}`, JSON.stringify(fresh), 'EX', 300);
return fresh;Плюсы: не нужна координация между сервисами. Минусы: stale-данные до 5 минут.
Когда ок: рекомендации, «популярное», тренды — данные, которые не обязаны быть точными до секунды.
#2. Event-driven инвалидация
Сервис публикует событие об изменении, и кэш-слой инвалидируется по событию.
// Catalog Service: цена изменилась
class CatalogService {
async updatePrice(productId: string, newPrice: number): Promise<void> {
await this.db.products.updatePrice(productId, newPrice);
await this.eventBus.publish(new ProductPriceChangedEvent({
productId,
newPrice,
}));
}
}
// Cache Invalidator Service: подписан на события
class CacheInvalidator {
async onProductPriceChanged(event: ProductPriceChangedEvent): Promise<void> {
// Инвалидируем все кэши, которые зависят от цены
await this.cache.del(`product:${event.productId}`);
await this.cache.del(`category:${event.categoryId}:summary`);
await this.cache.del(`search:results:*`); // всё, что содержит цену
}
}Проблема: как узнать, какие кэши инвалидировать? В большом проекте это превращается в спагетти из удалений кэша.
Решение: используйте versioned keys или tag-based invalidation.
#3. Versioned keys
Каждой сущности присваивается версия. Ключ кэша включает версию:
const productVersion = await this.versionStore.get(`product:${productId}:version`);
const cacheKey = `product:${productId}:v${productVersion}`;
const cached = await cache.get(cacheKey);
if (cached) return cached;
const product = await db.products.findById(productId);
await cache.set(cacheKey, JSON.stringify(product), 'EX', 3600);
return product;При изменении данных просто инкрементируем версию. Старый кэш автоматически становится недоступным (у него старая версия в ключе), новый запрос создаст новый кэш.
Нюанс: версии нужно где-то хранить. Redis отлично подходит для этого:
async function invalidateProduct(productId: string): Promise<void> {
// Инкрементируем версию и устанавливаем TTL
await cache.incr(`product:${productId}:version`);
await cache.expire(`product:${productId}:version`, 86400); // TTL для защиты от утечек
}#4. Write-Through cache
Когда данные записываются, кэш обновляется синхронно:
async function updateProductPrice(productId: string, price: number): Promise<void> {
// Пишем в БД и кэш в рамках "логической" транзакции
await db.products.updatePrice(productId, price);
await cache.set(`product:${productId}:price`, price);
}Проблема: если БД успешно записала, а кэш упал — данные рассогласованы. Нужен механизм синхронизации.
#5. Cache Stampede (и как с ним бороться)
Cache stampede — это когда кэш истекает, и N одновременных запросов летят в базу. База падает. Кэш не восстанавливается. Всё катится к чёрту.
// Без защиты: cache stampede
function getRecommendations(userId: string) {
let data = cache.get(`recommendations:${userId}`);
if (!data) {
// 100 запросов одновременно дойдут до этой строки
data = db.query(`SELECT ...`); // база умрёт
cache.set(`recommendations:${userId}`, data);
}
return data;
}Решение 1: Mutex-замок для перестроения кэша.
async function getRecommendations(userId: string) {
let data = await cache.get(`recommendations:${userId}`);
if (!data) {
// Пытаемся захватить блокировку на перестроение
const lockKey = `lock:recommendations:${userId}`;
const lockAcquired = await cache.set(lockKey, '1', 'NX', 'EX', 5);
if (lockAcquired) {
// Мы — тот самый поток, который перестроит кэш
data = await recomputeRecommendations(userId);
await cache.set(`recommendations:${userId}`, data, 'EX', 300);
await cache.del(lockKey);
} else {
// Кто-то уже перестраивает — подождём и возьмём из кэша
await sleep(50);
return getRecommendations(userId); // retry
}
}
return data;
}Решение 2: Stale-While-Revalidate.
Отдаём старый кэш, пока новый перестраивается:
async function getRecommendations(userId: string) {
const cached = await cache.get(`recommendations:${userId}`);
const staleCache = await cache.get(`recommendations:${userId}:stale`);
if (cached) {
// Кэш есть, проверяем возраст
const age = Date.now() - cached.timestamp;
if (age < 240_000) { // 4 минуты — свежий
return cached.data;
}
// 4-5 минут — stale, но отдаём его и перестраиваем фоново
backgroundRecompute(userId); // не ждём
return cached.data;
}
if (staleCache) {
// Кэша нет, есть stale — отдаём stale, перестраиваем
backgroundRecompute(userId);
return staleCache.data;
}
// Ничего нет — синхронно перестраиваем
return recomputeRecommendations(userId);
}Cross-reference: Многоуровневое кэширование от HTTP до CPU, Write-Through/Write-Behind и кэш на уровне CDN — см. Главу 21 «Кэширование в высоконагруженных системах».
#Когда это применимо
| Ситуация | Что использовать |
|---|---|
| Нужно сохранить ACID-гарантии через сервисы | Saga + компенсирующие транзакции (и смириться с eventual consistency) |
| Нужно, чтобы событие точно дошло до брокера | Transactional Outbox |
| Клиент может отправить POST дважды | Idempotency Keys |
| Read-запросы требуют данных из нескольких сервисов | CQRS + read projections |
| База не выдерживает read-load | Redis Cluster / ElastiCache |
| Данные меняются, кэш устаревает | Event-driven invalidation или versioned keys |
| Стартап, 3 разработчика | Shared database — да, но готовьтесь к разделению |
| Enterprise fintech | Database-per-service + Saga Orchestrator + Outbox + Idempotency |
#Практический кейс: Как Stripe обновляет базу без даунтайма
Stripe обрабатывает миллиарды долларов каждый день. Они не могут позволить себе даунтайм для обслуживания базы, миграции схемы или добавления индекса.
Их подход заслуживает отдельного разбора.
#Проблема
Stripe использует PostgreSQL + Vitess (горизонтальное шардирование) в качестве основной БД. Когда нужно обновить схему (например, добавить колонку или переименовать поле), классический подход — ALTER TABLE — блокирует таблицу на запись. Для Stripe это означает остановку платежей.
#Их стратегия: расширяй, а не изменяй
Stripe использует технику expand-contract (расширение-сжатие) для всех миграций схемы:
- Expand (расширение): Добавляем новое поле. Старое продолжает работать.
- Migrate (миграция): Заполняем новое поле данными (фоновый процесс, батчами).
- Dual-write (двойная запись): Некоторое время пишем и в старое, и в новое поле.
- Verify (верификация): Сравниваем данные, убеждаемся в консистентности.
- Cutover (переключение): Все читатели переключаются на новое поле.
- Remove (удаление): Старое поле удаляется, если оно больше не нужно.
Пример: переименование amount → net_amount:
-- Шаг 1: Добавляем новую колонку
ALTER TABLE charges ADD COLUMN net_amount BIGINT; -- не блокирует запись
-- Шаг 2: Фоновый процесс копирует данные
UPDATE charges SET net_amount = amount WHERE net_amount IS NULL;
-- (батчами по 1000 записей)
-- Шаг 3: Dual-write — код пишет в оба поля
UPDATE charges SET amount = ?, net_amount = ? WHERE id = ?;
-- Шаг 4: Верификация — мониторинг, что amount == net_amount для всех строк
-- Если расхождение > 0.01% — останавливаемся и разбираемся
-- Шаг 5: Переключение — код читает из net_amount
-- Все запросы обновлены на SELECT net_amount FROM charges
-- Шаг 6: Удаляем старую колонку (через месяц)
ALTER TABLE charges DROP COLUMN amount; -- всё ещё не блокирует запись#Что делает этот подход рабочим
- Никаких блокирующих ALTER TABLE. PostgreSQL с 11+ версии не блокирует запись при
ADD COLUMN(если нет DEFAULT с volatile выражением).DROP COLUMNтоже не блокирует.ALTER TABLE ... ADD CONSTRAINT— блокирует (нуженNOT VALID+VALIDATE). - Обратная совместимость на каждом шаге. На любом шаге можно откатиться — старые поля всё ещё есть и заполняются.
- Dual-write как страховка. Если на шаге cutover что-то пошло не так, старые поля всё ещё содержат корректные данные.
- Index creation без блокировок.
CREATE INDEX CONCURRENTLY— создаёт индексы без блокировки таблицы на запись.
#Как Stripe делает zero-downtime index creation
-- 1. Создаём индекс параллельно (не блокирует)
CREATE INDEX CONCURRENTLY idx_charges_created_at ON charges (created_at);
-- (может выполняться минуты, но запись не блокирует)
-- 2. Удаляем старый индекс (если заменяем)
DROP INDEX CONCURRENTLY idx_charges_created_at_old;#Уроки из Stripe
- Не ALTER'ьте production напрямую. Все изменения — через expand-contract, в несколько шагов.
- Пишите код, который resilient к обоим версиям схемы. Двойная запись — обязательна.
- Тестируйте миграции на нагрузке. Stripe запускает миграции на репликах, проверяет latency, только потом на primary.
- Мониторьте. Stripe имеет дашборды для каждой стадии миграции: расхождения данных, latency dual-write, ошибки.
- Boring technology. Несмотря на scale, Stripe использует PostgreSQL — проверенную технологию. Их инновации — в процессе, а не в инструменте.
Источник: Stripe Blog — «Online migrations at scale», Stripe Sessions 2023, Stripe Engineering (стенограммы). Mark [Anecdotal] где опыт личный.
#Чек-лист для самопроверки
- Каждый сервис владеет своей базой (database-per-service)?
- Для распределённых транзакций используется Saga, а не 2PC?
- Каждая saga имеет компенсирующие транзакции?
- События публикуются через Transactional Outbox?
- Все consumer'ы идемпотентны?
- Все мутирующие API-endpoint'ы требуют idempotency key?
- Кэш-инвалидация спроектирована, а не происходит «сама собой»?
- Есть защита от cache stampede?
- TTL на кэш-ключи установлен?
- Миграции схемы делаются через expand-contract с dual-write?
- Read-модели строятся асинхронно через события (CQRS)?
#«Не делай так»
#1. Shared database между микросервисами
Я видел проект, где 5 «микросервисов» работали с одной PostgreSQL. БД была единственным источником правды. Проблемы начались через месяц: одна команда переименовала колонку в Users — упали все запросы чтения в других сервисах. Ещё через месяц они поняли, что у них не микросервисы, а распределённый монолит, только с сетевыми вызовами и latency.
Как надо: database-per-service. Если не готовы — не делайте микросервисы вообще, делайте модульный монолит (Глава 12).
#2. 2PC в продакшене
Кто-то решил, что Two-Phase Commit решит проблему распределённых транзакций. Первый прод-инцидент случился через 3 дня: координатор упал между Prepare и Commit — все участники зависли на 60 секунд (таймаут). Traffic упал на 30%. Прод с этого момента — без 2PC.
Как надо: Saga + компенсирующие транзакции.
#3. Idempotency-key из тела запроса
«А давайте сделаем idempotency key = SHA256(body)». Теперь два разных запроса с одинаковыми данными считаются дубликатами. Идиллия, пока кто-то не заметил, что не может сделать два перевода на 100 рублей разным людям.
Как надо: UUID, сгенерированный клиентом.
#4. Отсутствие идемпотентности в consumer'ах
Transactional Outbox даёт at-least-once. Consumer не идемпотентен. Событие пришло дважды. Деньги списались дважды. Support в шоке.
Как надо: Все consumer'ы проверяют idempotency. Всегда.
#5. Cache stampede без защиты
«А давайте поставим TTL в 5 минут». Через 5 минут все N серверов одновременно запросили данные из БД. БД упала от перегрузки. Кэш опустел. БД не восстановилась. Даунтайм.
Как надо: Stale-While-Revalidate, Mutex-замки, или хотя бы случайный TTL (jitter).
#Ключевые выводы
- Database-per-service — не опция, а условие существования микросервисов. Shared database — это распределённый монолит.
- Забудьте про 2PC в продакшене. Используйте Saga с компенсирующими транзакциями. Choreography для простых цепочек, Orchestration — для сложных.
- Transactional Outbox + Idempotency Keys = фундамент надёжности. Каждое событие должно быть гарантированно доставлено, каждый запрос — безопасен для повторения.
- Кэш — это временно. Стройте архитектуру так, чтобы кэш можно было сбросить без фатальных последствий. TTL, versioned keys, event-driven invalidation.
- Миграции без даунтайма — expand-contract. Без блокирующих ALTER TABLE. Как Stripe.
#Ссылки на другие главы
- Глава 07a: «CQRS: Command Query Responsibility Segregation» — детальный разбор CQRS
- Глава 16: «Event-Driven Architecture» — событийная архитектура, Kafka, гарантии доставки
- Глава 14: «Дорога в распределённый мир» — когда и почему переходить на микросервисы
- Глава 15: «Микросервисы — правильный способ» — декомпозиция, коммуникация, circuit breaker
- Глава 20: «Database Scaling» — шардинг, репликация, Vitess
- Глава 21: «Кэширование в высоконагруженных системах» — многоуровневое кэширование
- Глава 3: «Первые принципы архитектуры» — CAP теорема, CQS, coupling/cohesion
- Глава 28: «Data Infrastructure» — аналитические базы, CDC, ETL