Опирается на правила:
R-DIST-EC-1…R-DIST-EC-4иR-DIST-EC-X1…R-DIST-EC-X2из Distributed Patterns Style Guide → раздел 4. Eventual consistency.
Важно знать
- Eventual consistency — норма в распределённой системе, но требует явного контракта. Клиент должен знать «эти данные могут отставать».
- Декларация в OpenAPI — для endpoint, читающего eventual-consistent данные, в
descriptionуказать ожидаемую задержку.- Read-your-writes не работает «само»: нужен sticky session, polling, synchronous wait в handler или альтернативный endpoint, читающий из write-side.
- Bounded staleness SLO — у каждой read-model явный лимит задержки («не более 5 секунд»), алерт если превышается.
- Causal consistency через
versionили vector clock — для случаев когда порядок событий важен.- Молчаливая EC — главный антипаттерн: клиент делает write, сразу читает, получает stale data, теряет доверие к API.
- Strict immediate consistency через 2PC — не масштабируется. Либо EC, либо redesign boundary.
В распределённой системе невозможна одновременно строгая согласованность, доступность и устойчивость к разделению сети — теорема CAP. UCP выбирает доступность и устойчивость, поэтому согласованность у нас eventual. Это решение требует явного оформления, не молчаливого «ну как-нибудь догонит».
Декларация в API
R-DIST-EC-1: для endpoint, который читает eventual-consistent данные, в OpenAPI указываем ожидаемую задержку.
/customers/{id}/orders:
get:
summary: Получить список заказов клиента
description: |
Возвращает summary заказов клиента из денормализованной read-проекции.
**Eventual consistency**: задержка от write в order-service до появления
в этом endpoint обычно < 1 секунды (p99 < 5 секунд). Если клиенту нужна
immediate consistency после write — использовать `GET /orders/{id}` сразу
после `POST /orders`, этот endpoint читает write-side.
responses:
200:
description: Список заказов (могут отставать на 1-5 секунд)
Без этой декларации клиентский разработчик будет писать тесты POST /orders + сразу GET /customers/{id}/orders и обвинять backend в багах, когда заказ ещё не появился в проекции.
Read-your-writes — четыре способа
R-DIST-EC-2: «клиент после своего write сразу читает свой результат» — отдельная задача. Реализуется одним из четырёх способов:
1. Sticky session
Запросы одного клиента маршрутизируются на тот же pod, где был его write. Pod держит свежее значение в локальном кеше — read возвращает актуальное. Работает только пока pod жив и одна реплика. Применимо ограниченно.
2. Polling в client
Клиент делает POST /orders, получает orderId, потом polling-ом GET до появления в read-проекции:
var orderId = orderClient.create(request).orderId();
for (int i = 0; i < 20; i++) {
var summary = customerClient.getOrderSummary(orderId);
if (summary != null) return summary;
Thread.sleep(200);
}
throw new ReadModelTimeoutException(orderId);
Простой, но клиент платит latency. Применимо для UI с прогресс-индикатором.
3. Synchronous wait в command-handler
Handler команды после write выполняет короткий polling read-model на стороне сервера:
@Override
@Transactional
public Order handle(CreateOrderCommand command) {
var order = orderRepository.save(...);
outboxEventPublisher.publish(new OrderCreated(order.id()));
return order;
}
@RestController
public class OrderController {
@PostMapping("/orders")
public OrderSummary create(@RequestBody CreateOrderRequest r) {
var order = dispatcher.dispatch(new CreateOrderCommand(r));
return waitForProjection(order.id(), Duration.ofSeconds(2))
.orElseGet(() -> OrderSummary.fromWriteSide(order));
}
}
Подходит для критичных flow «после создания заказа сразу показать summary».
4. Альтернативный endpoint из write-side
Для случаев read-your-writes — отдельный endpoint, читающий из write-Repository (полный агрегат), не из read-проекции:
POST /orders → CreateOrderHandler
GET /orders/{id} → write-side, immediate consistency
GET /customers/{id}/orders → read-projection, eventual consistency
Это рабочая комбинация: read-проекция держит нагрузку, immediate-endpoint обслуживает «только что после write».
Bounded staleness SLO
R-DIST-EC-3: у каждой read-model есть явный SLO на максимальную задержку: «p99 задержка между commit в write-side и появлением в read-проекции — не более 5 секунд».
Измеряем через метрику:
@Component
@RequiredArgsConstructor
public class OrderProjectionListener {
private final MeterRegistry meterRegistry;
private final OrderProjectionRepository repository;
@KafkaListener(topics = "order.events", groupId = "order-projection")
public void onOrderCreated(OrderCreatedEvent event) {
var staleness = Duration.between(event.occurredAt(), Instant.now());
meterRegistry.timer("read_model_staleness",
"model", "order_summary",
"event_type", event.eventType()
).record(staleness);
repository.upsert(toProjection(event));
}
}
Алерт в Prometheus:
- alert: ReadModelStalenessHigh
expr: histogram_quantile(0.99, sum by (le, model) (rate(read_model_staleness_seconds_bucket[5m]))) > 5
for: 5m
Без SLO read-model может тихо отстать на час, и никто не заметит до жалоб клиентов.
Causal consistency через version
R-DIST-EC-4: когда порядок событий важен, receiver проверяет монотонность version-поля и пропускает out-of-order events.
@KafkaListener(topics = "order.events", groupId = "order-projection")
@Transactional
public void onOrderUpdated(OrderUpdatedEvent event) {
var current = repository.findByOrderId(event.orderId());
if (current.isPresent() && event.aggregateVersion() <= current.get().version()) {
return;
}
repository.upsert(
event.orderId(),
event.aggregateVersion(),
event.payload()
);
}
aggregateVersion — монотонно растущий счётчик, увеличивается на write-side при каждом изменении агрегата (через @Version или явный inkrement). Если consumer получает события не в том порядке (rebalance, retry topic), out-of-order events игнорируются — финальное состояние корректное.
Vector clocks нужны редко, обычно достаточно скалярного version на агрегат.
Что запрещено
Молчаливая eventual consistency
R-DIST-EC-X1: endpoint возвращает stale data без декларации. Клиент удивляется, баг-репорты сыпятся, доверие к API падает.
# ПЛОХО — нет упоминания EC
/customers/{id}/orders:
get:
summary: Получить заказы клиента
responses:
200:
description: Список заказов
# ХОРОШО — явная декларация
/customers/{id}/orders:
get:
summary: Получить заказы клиента
description: |
**Eventual consistency**: задержка обычно < 1s, p99 < 5s.
Для immediate consistency после POST /orders — использовать GET /orders/{id}.
Strict immediate consistency через 2PC
R-DIST-EC-X2: «но клиент хочет immediate consistency между двумя сервисами» — не повод за 2PC. Это не масштабируется. Варианты:
- Redesign boundary — если две операции должны быть атомарны, возможно они в одном Bounded Context. Объединить в один сервис.
- Принять EC — задекларировать в API, реализовать read-your-writes одним из способов выше.
- Modular monolith — один процесс, один PG, локальный
@Transactional.
Подробнее — Distributed transactions.
Что запрещено — таблица
| Антипаттерн | Правило | Что взамен |
|---|---|---|
| Молчаливая eventual consistency | R-DIST-EC-X1 | декларация в OpenAPI description |
| 2PC для immediate consistency | R-DIST-EC-X2 | redesign boundary либо EC |
| Read-model без SLO | R-DIST-EC-3 | bounded staleness + алерт |
| Out-of-order events применяются как есть | R-DIST-EC-4 | version-check на receiver |
| «Просто polling» без timeout | R-DIST-EC-2 | timeout + fallback на write-side |
| Read-your-writes для нагрузки на каждый GET | R-DIST-EC-2 | отдельный endpoint из write-side только для «после write» |
Куда дальше
- Distributed Patterns → раздел 4. Eventual consistency — нормативные формулировки.
- Outbox + Inbox — главный механизм синхронизации в EC.
- Idempotency — consumer должен быть идемпотентным при retry.
- Saga — sagaId сквозной, in-flight саги — отдельный случай EC.
- CQRS → read-model — где хранить и как обновлять денормализованную проекцию.
- CQRS → sync via events — outbox + Kafka + idempotent consumer.
- Distributed transactions — почему 2PC/XA не вариант.