Concept page: Hierarchy of consistency models — linearizability, serializability, snapshot isolation, causal, eventual. One topology with 2 clients + coordinator + 3 DB replicas. Four scenarios: linearizable strong-quorum read/write; eventual stale read from replica-2; causal read-your-writes via session token; write-skew bug under snapshot isolation (doctor on-call invariant).
«Consistency» — это не одна вещь. Это спектр от полного беспорядка (eventual) до строгой линеаризуемости. Маркетинговые таблички вендоров — «strongly consistent!», «ACID compliant!» — без указания уровня означают ровно ничего. Каждая ступень иерархии снимает одни anomalies и оставляет другие, и за каждую ступень платишь координацией: round-trips, кворумом, блокировками, отказом писать при partition.
Знание иерархии = умение читать спецификации БД, корректно проектировать распределённые транзакции и не удивляться lost update в проде через полгода после запуска.
Чем строже consistency — тем меньше anomalies видит клиент, но тем больше координации требуется и тем выше latency.
Иерархия (сверху — строже, дороже):
Strict serializability ← Spanner, FaunaDB (Lin + Ser)
Linearizability ← etcd, ZooKeeper (single-object, real-time)
Serializability ← Postgres SERIALIZABLE (multi-object, any serial order)
Snapshot isolation ← Postgres default, MVCC (НЕ защищает от write skew)
Repeatable Read ← MySQL InnoDB default
Read Committed ← большинство SQL по умолчанию
Read Uncommitted ← dirty reads разрешены
Causal consistency ← MongoDB causal sessions, vector clocks
Monotonic reads/writes ← session-level гарантии
Eventual consistency ← Cassandra ONE, DynamoDB eventual
Weak ← никаких гарантий
Ключевой водораздел проходит между snapshot isolation и serializable: первое выглядит как «почти serializable», но допускает write skew — две транзакции читают одни данные, пишут разные строки, индивидуально корректны, вместе ломают invariant.
Кластер из трёх реплик за координатором, два клиента: A (writer) и B (reader). Реплики имеют разный replication lag (~100ms, ~150ms) — это и есть источник всех anomalies. На этой топологии прогоняются четыре сценария, демонстрирующие, что одни и те же физические соединения дают разный observable behavior в зависимости от выбранного уровня.
ADR-001 на координаторе фиксирует правило выбора: брать слабейший уровень, который покрывает invariants домена.
Linearizability — quorum write + quorum read. Client A пишет x=5 с consistency=LINEARIZABLE: координатор реплицирует на все три реплики параллельно, ждёт ACK от quorum (2/3) и только тогда возвращает 200. Client B читает с тем же уровнем — координатор делает quorum read, выбирает latest по версии, возвращает x=5. Real-time order соблюдён: как только A получил подтверждение, любой последующий read у любого клиента увидит новое значение.
Цена: каждая операция — round-trip к большинству реплик. Cross-DC linearizability упирается в speed of light: 100ms между регионами × quorum = десятки миллисекунд на каждую операцию.
Eventual consistency — ACK от одной реплики, остальные догоняют асинхронно. Client A пишет с consistency=ONE: leader отвечает за ~3ms, fanout на followers идёт в фоне. Client B читает спустя 1ms — координатор попадает на replica-2, которая ещё не получила update, возвращает x=0. Stale read. Через 100-150ms реплики догонят, и B при повторном чтении увидит x=5.
Это не баг — это контракт. AP-системы (Cassandra ONE, DynamoDB eventual) явно говорят: «availability + latency важнее свежести». Для счётчиков лайков, рекомендаций, аналитики — приемлемо. Для денег — катастрофа.
Causal consistency — happens-before order preserved через session-токены (vector clocks, hybrid logical clocks). Client A пишет x=5, получает session_token = T1. Последующий read от A несёт требование «version >= T1»: координатор пропускает replica-2 (она на T0), маршрутизирует на replica-1, возвращает x=5. A гарантированно видит свои writes.
Client B читает без causal token — попадает на любую реплику, видит eventual snapshot. Но если бы B имел зависимость от write A (например, увидел уведомление, которое A сгенерировал), система маршрутизировала бы и B на достаточно свежую реплику.
Этого достаточно для большинства соцсетей, мессенджеров, collaborative editors. Не требует глобального ordering, дёшево, scales horizontally.
Write skew на Snapshot isolation — классический баг, на котором ломаются разработчики, считающие SI ≈ Serializable.
Invariant: «как минимум 1 врач on-call». Текущее состояние: Alice, Bob. Обе хотят уйти.
[Alice, Bob], видит count=2 → разрешает Alice уйти.[Alice, Bob], count=2 → разрешает Bob уйти.on_call = []. Invariant нарушен.SERIALIZABLE поймал бы это: Postgres SSI отслеживает read-write зависимости и откатил бы одну из транзакций. Snapshot isolation — нет.
| Уровень | Anomalies, которые остаются | Координация | Cross-DC |
|---|---|---|---|
| Eventual | всё (stale, lost updates, conflicts) | 0 round-trips | да |
| Causal | stale для concurrent writes | logical clocks, 0 sync | да |
| Snapshot isolation | write skew, phantom (зависит) | 1 MVCC локально | сложно |
| Serializable | нет | locking / SSI, 1-2 RT | дорого |
| Linearizability | нет (single-object) | quorum N/2+1 | возможно через consensus |
| Strict serializability | нет | global ordering | только Spanner-class |
Правило выбора: берёшь слабейший уровень, покрывающий invariants домена. Не «strongest available because why not» — это превращает БД в bottleneck.
readConcern: linearizable.Считать Snapshot Isolation эквивалентом Serializable. Нет, write skew допустим. Если в твоей логике есть invariant через несколько строк (счётчик, баланс, on-call list, бронирование) — нужен SERIALIZABLE.
Использовать REPEATABLE READ для финансов. Lost update возможен. Классика: «прочитал баланс, посчитал, записал новый» из двух транзакций — одна перезаписывает другую. Решения: SELECT FOR UPDATE, SERIALIZABLE, или atomic UPDATE с CAS-like условием.
«MongoDB has transactions = ACID». Транзакции есть, но default isolation слабее, чем у Postgres SERIALIZABLE. Read your writes гарантирован в session, остальное — нужно явно настраивать.
Eventual consistency «достаточна» для счётчиков. Без CRDT — потеря инкрементов. Два клиента читают counter=10, оба пишут counter=11, итог 11 вместо 12. Решения: атомарный INCR (Redis), CRDT (G-Counter), либо strong consistency.
Linearizable reads everywhere, потому что «безопаснее». Каждый read становится quorum-операцией, throughput падает в разы. Используй там, где реально нужно (after-leader-change reads, distributed locks), остальное — eventual или causal.
Игнорировать read-your-writes. Юзер опубликовал пост, refresh страницы — поста нет (читалка попала на follower с lag). Решение: session affinity, либо чтение из write-replica для текущей сессии, либо causal tokens.
Strict serializability и linearizability — дорого. Не бери их «на всякий случай».
PACELC напоминание: даже без partition выбор между Latency и Consistency остаётся. «AP-системы» в нормальной сети могут давать сильную консистентность ценой latency — настоящий выбор делается только при partition.