Опирается на правила: R-DIST-EC-1R-DIST-EC-4 и R-DIST-EC-X1R-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. Это не масштабируется. Варианты:

  1. Redesign boundary — если две операции должны быть атомарны, возможно они в одном Bounded Context. Объединить в один сервис.
  2. Принять EC — задекларировать в API, реализовать read-your-writes одним из способов выше.
  3. Modular monolith — один процесс, один PG, локальный @Transactional.

Подробнее — Distributed transactions.

Что запрещено — таблица

АнтипаттернПравилоЧто взамен
Молчаливая eventual consistencyR-DIST-EC-X1декларация в OpenAPI description
2PC для immediate consistencyR-DIST-EC-X2redesign boundary либо EC
Read-model без SLOR-DIST-EC-3bounded staleness + алерт
Out-of-order events применяются как естьR-DIST-EC-4version-check на receiver
«Просто polling» без timeoutR-DIST-EC-2timeout + fallback на write-side
Read-your-writes для нагрузки на каждый GETR-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 не вариант.