Перейти к содержанию

Консистентность и CAP

В одном инстансе всё просто: записал — прочитал. В распределённой системе у тебя сразу две головные боли: «успели ли реплики догнать?» и «что мне показать клиенту, пока не успели?». Ответы — модели консистентности и теоремы CAP / PACELC.

Модели консистентности

🧩 Простыми словами. Консистентность — это правила игры: что клиент видит после записи. Можешь видеть мгновенно везде (strong), а можешь «через минуту, плюс-минус» (eventual). Чем строже правила — тем дороже система.

Strong consistency

Любое чтение после успешной записи видит запись. Звучит просто, но требует координации между всеми репликами (consensus, кворум, синхронная репликация) — дорого по latency.

sequenceDiagram
    Client->>Primary: write x=10
    Primary->>R1: replicate
    Primary->>R2: replicate
    R1-->>Primary: ACK
    R2-->>Primary: ACK
    Primary-->>Client: OK (committed everywhere)
    Client->>R1: read x
    R1-->>Client: 10

Примеры: Spanner, etcd, Zookeeper, Postgres synchronous_commit=remote_apply.

Eventual consistency

Запись попадёт на все реплики «когда-нибудь». Между записью и пропагацией клиент может увидеть старое значение.

sequenceDiagram
    Client->>Primary: write x=10
    Primary-->>Client: OK (только primary знает)
    Note over R1,R2: async replication
    Client->>R1: read x
    R1-->>Client: 9 (старое!)
    Note over Primary,R2: пропагация догнала
    Client->>R1: read x
    R1-->>Client: 10

Примеры: DynamoDB (по умолчанию), Cassandra, S3 (раньше), Postgres async replicas.

Read-your-writes

Слабее strong, но сильнее eventual: клиент сразу видит свои собственные записи, но чужие — eventually. Реализуется через session pinning (читаешь с того же шарда / реплики, куда писал) или через high-water-mark в куках.

Monotonic reads

Если клиент увидел версию 5, потом он не увидит версию 4. Тоже sticky-сессия.

Causal consistency

Если событие B зависит от события A, то все увидят сначала A, потом B. Реализуется через vector clocks или version vectors. Сложно, но иногда нужно (комменты, threads).

📊 Иерархия.

Strong (linearizable)
Sequential
Causal
Read-your-writes  /  Monotonic reads
Eventual

Чем выше — тем строже и дороже.

CAP-теорема

🧩 Простыми словами. В распределённой системе при сетевом сбое (Partition) ты можешь иметь либо строгую согласованность (Consistency), либо доступность (Availability) — но не оба одновременно. P случается всегда, выбор — между C и A.

graph TD
    P((Partition tolerance<br/>не выбирается, реальность))
    C[Consistency]
    A[Availability]
    P --- C
    P --- A
    C -.-> CP[CP: etcd, HBase,<br/>Spanner *]
    A -.-> AP[AP: Cassandra,<br/>DynamoDB, Couch]

⚙️ Под капотом. Когда ноды не видят друг друга:

  • CP — отказывает в записях/чтениях, чтобы не отдать рассогласованные данные. Выбираешь, если страшно отдать неверный баланс счёта.
  • AP — продолжает отвечать с локальных данных, потенциально устаревших. Выбираешь, если лучше показать слегка старую ленту, чем 503.

💥 Ловушки.

  1. CAP не про обычную работу. Это про partition. Если сети ок — ты обычно получаешь и C, и A.
  2. «Spanner ломает CAP» — нет, не ломает: он CP. Когда сеть рвётся, отказывает. Просто его сеть Google настолько надёжна, что вы это редко замечаете.
  3. Неверная подмена. «Eventual ⇔ AP» — нет, eventual — это про модель данных, AP/CP — про поведение в partition.

PACELC — как читать систему в обычное время

CAP молчит про latency. PACELC говорит:

Partition? — выбирай A or C. Else — выбирай Latency or Consistency.

Система Partition Else
Spanner PC (отказ) EC (медленно, но точно)
DynamoDB PA EL (быстро, eventual)
Cassandra PA EL
Postgres (single primary) PC EC
MongoDB (default) PA EC (доминантно)

🧩 Простой смысл. PACELC помогает понять не только «что в шторм», но и «какова цена твоего обычного запроса». Hot path с Cassandra — миллисекунды. Hot path со Spanner — десятки миллисекунд (consensus стоит).

Реальные системы и их выбор

DynamoDB / Cassandra (AP, EL)

  • Eventual consistency by default. Можно явно попросить strong consistent read ценой latency.
  • Quorum: write ack от W нод, read от R нод; если R + W > N — strong-ish consistency.
  • Для Go — официальный AWS SDK или gocql для Cassandra.

etcd / Zookeeper (CP, EC)

  • Raft / Zab consensus.
  • Маленькие кластера (3–5 нод). Используются для service discovery, coordination, config.
  • Не для большого throughput.

Postgres (CP, EC)

  • Один primary, sync или async реплики. Sync = strong, но lag убивает throughput.
  • Под высокий throughput читают с реплик с риском stale read.

Spanner / CockroachDB (CP, EC)

  • Globally distributed strong consistency через TrueTime / hybrid logical clocks.
  • Дорого по latency (commit ≈ 100ms cross-region).

🛠 Применение в Go-проекте

// Postgres: явно используем primary для read-after-write
type DB struct {
    primary *pgxpool.Pool
    replica *pgxpool.Pool
}

func (db *DB) GetUser(ctx context.Context, id int64, opts ReadOpts) (*User, error) {
    pool := db.replica
    if opts.MustBeFresh {
        pool = db.primary // strong consistency
    }
    var u User
    err := pool.QueryRow(ctx, "SELECT ... FROM users WHERE id=$1", id).Scan(...)
    return &u, err
}

Для read-your-writes без хитрых клиентских ухищрений: пиши и читай с primary короткое время после записи, потом перекидывайся на реплику.

🔥 Пример: «корзина в e-commerce»

graph LR
    U[User adds item] --> A[API]
    A --> Cart[(Cart store<br/>Redis / Dynamo)]
    A --> Q[Kafka events]
    Q --> Inv[Inventory service]
    Inv --> InvDB[(Postgres strong)]
  • Cart — AP, eventual. Если две вкладки добавили одинаковый товар — мерж по client-side. Latency важнее точности.
  • Inventory — CP, strong. Списание со склада строго: иначе oversell.
  • Checkout — saga (см. microservices.md) объединяет оба.

❌ Типичные ошибки

  • «Сделаем eventual для всего, так быстрее» → потом на собесе клиент видит списание денег, но не видит купленный билет.
  • «Spanner = CA» — нет, CP. Никогда не CA в распределённой системе.
  • Sticky session как единственное решение read-your-writes — ломается при failover.
  • Игнорят replica lag в монитоинге → редкие баги «то есть, то нет».

🤖 Что спрашивает AI-ментор

  • Объясни на пальцах разницу между strong и eventual consistency.
  • Сформулируй CAP-теорему. Что значит «теорема не работает в обычной жизни»?
  • Зачем PACELC, если есть CAP?
  • Когда выбирать AP, а когда CP? Приведи по примеру из жизни.
  • Что такое read-your-writes consistency и как его реализовать в Go-сервисе?

📊 Уровни глубины

L1. Знаешь термин CAP, можешь сказать «strong и eventual». Понимаешь, что после записи в Postgres replica может не успеть.

L2. Различаешь все 5 моделей (strong, eventual, RYW, monotonic, causal). Можешь обосновать выбор для конкретного use-case. Знаешь PACELC, отличаешь от CAP.

L3. Спорил с командой о выборе AP vs CP с реальными цифрами latency. Помнишь расхождение primary/replica в incident'е. Знаешь quorum (R + W > N), vector clocks, hybrid logical clocks. Читал Spanner paper.

📝 Подумай

  1. Сервис лайков. Нужна ли strong consistency на инкременте счётчика лайков? Что выбираешь?
  2. У тебя Postgres с 3 async-репликами. Как сделать read-your-writes для пользователя после редактирования профиля?
  3. Что вернёт Cassandra при R=1, W=1, N=3 после записи и сразу чтения с другой ноды?
Ответ
  1. Не нужна. Лайки — eventual. Считаешь counter в Redis/Cassandra, точные числа никому не критичны. Расхождение в 1–2 — норма. Если хочешь точно — пишешь в Kafka и агрегируешь, но это медленнее.
  2. Варианты: (a) на короткий период после записи (например 5 секунд) читать с primary; (b) хранить last_write_ts пользователя в куке/JWT, и пока replica не догнала эту метку — читать с primary; (c) sticky to primary через session. Самый частый прод-вариант — (a) или (c).
  3. Может вернуть старое значение. R=1 + W=1 = 2, но N=3, и R+W > N не выполняется (2 < 3+1). Чтение с реплики, куда запись ещё не пришла, увидит старое.

Дальше: caching.md — как делать быстро, не теряя консистентности.