Happens-Before relation (Lamport 1978): partial order on events in distributed systems. Three processes A, B, C with intra-process program-order edges and a cross-process send/receive message from A2 to B2. Demonstrates causal chain via transitivity, concurrent events without communication, and the pitfall of using wall-clock timestamps.
В одиночной системе порядок событий очевиден: всё, что происходит, происходит на одних часах одной программы. В распределённой системе этой роскоши нет. У каждой ноды свои wall-clock с расхождением до десятков миллисекунд при хорошем NTP, секунд при NTP-fail, и до минут при leap-second-баге или VM live migration. Когда ты держишь два события A и B с разных нод и хочешь спросить «что случилось раньше», ответ из physical timestamps будет в проценте случаев ложью, а в другом проценте — бессмыслицей: «A было на 0.5 мс раньше B» ничего не говорит про то, повлияло ли A на B.
Happens-before (1978, Leslie Lamport) — формальная модель причинности, не зависящая от physical clocks. Она отвечает не на вопрос «когда?», а на вопрос «было ли A причиной (потенциальной) B?». Без этой модели ты будешь либо доверять wall-clock в распределённой системе (катастрофически), либо изобретать причинность заново под каждый конкретный баг (медленно и неконсистентно).
Happens-before — это фундамент, на котором стоит почти всё в распределённых системах: ::concept{slug="lamport-clocks"} (scalar реализация), ::concept{slug="vector-clocks"} (различает causal от concurrent), ::concept{slug="hybrid-logical-clocks"} (HLC, в CockroachDB/MongoDB/Yugabyte), ::concept{slug="crdts"} (concurrent merge без conflict), causal consistency, snapshot isolation, Git DAG (commits — события, parent edges — happens-before), distributed tracing (span-ordering в OpenTelemetry), и ::concept{slug="consistency-models"} в целом.
Если ты собираешься читать DDIA Ch.5 / Ch.9, проектировать любую систему с репликацией, или дебажить race condition в multi-region setup — без happens-before никак.
Отношение
→("happens-before") определяется индуктивно тремя правилами:
- Program order: если
AиBна одном процессе иAраньше в исходном порядке выполнения —A → B.- Send/receive: если
A = send(msg)на одном процессе иB = receive(msg)на другом —A → B.- Transitivity: если
A → CиC → B, тоA → B.Если ни
A → B, ниB → A—AиBназывают concurrent и записываютA || B.
Ключевое свойство — это partial order, не total. Не все пары событий сравнимы. Concurrent — это не «одновременно по часам», а «между ними нет ни одного пути из правил выше». На двух нодах без обмена сообщениями ВСЕ события concurrent, даже если по wall-clock одно случилось через час после другого.
Это контр-интуитивно для людей, привыкших к single-machine programming, но критично для корректности. Causal consistency обязана сохранять порядок только для тех пар, где есть →. Для concurrent пар порядок может быть любым на разных репликах — и это не баг, а математически корректное поведение.
Wall-clock дал бы total order (все пары сравнимы по числу), но врёт: physical timestamps не гарантируют, что ts(A) < ts(B) ⇒ A → B. Happens-before даёт partial order, но не врёт: если A → B, то любой causal-consistent наблюдатель увидит A до B.
Три параллельных timeline'а (Process A, Process B, Process C), каждый — group с тремя последовательными events:
A1: write x → A2: send msg1 → A3: local opB1: local op → B2: recv msg1 → B3: write yC1: write z → C2: local opEdges — это rules происхождения, не network channels. Внутри одного процесса edges помечены po (program order, правило 1). Между процессами один edge A2 → B2 помечен msg1 (правило 2 — send/receive). Process C не имеет edges к A или B — у него нет communication, и поэтому ВСЕ его события concurrent с A и B.
ADR-001 на узле A1 фиксирует ключевое архитектурное решение: «happens-before — partial order causality, partial, не total; основа для всех логических clocks и causal consistency».
Транзитивный замыкатель этого графа даёт всё множество пар, для которых → определено. Всё остальное — concurrent.
1. Causal Chain (A1 → B3) — демонстрация транзитивности. A1 → A2 (program order), A2 → B2 (send/recv), B2 → B3 (program order). По правилу 3: A1 → B3. Значит, B3 (write y) причинно зависит от A1 (write x). Любая causal-consistent система ОБЯЗАНА показать всем наблюдателям A1 до B3. Учит: «причинность строится индуктивно через цепочку send/recv и program order; transitivity делает короткие правила мощными».
2. Concurrent Events (A1 || C1) — фундаментальное ограничение partial order. A1 пишет x на процессе A. C1 пишет z на процессе C. Между A и C нет никакой коммуникации. Проверяем правила: A1 → C1? Нет пути. C1 → A1? Нет пути. ⇒ A1 || C1. Любая ordering между ними valid: реплика 1 может применить A1 перед C1, реплика 2 — наоборот, и обе правы. Учит: «concurrent — это не баг, а feature, дающая возможность CRDTs мерджить без conflict если operations commute».
3. Pitfall: wall-clock ≠ causality — production-grade ловушка. C1 имеет wall-clock 12:00
A1 — 12:00.105 (на 5 мс позже). A2 (send) — 12:00.106. B2 (receive того же msg!) — 12:00.090, потому что часы B отстают на 16 мс. Если ты сортируешь по wall-clock: B2(090) < C1(100) < A1(105) < A2(106). Получаешь: B2 «раньше» A2, хотя B2 = receive(A2) — causality violated. И A1 || C1 (concurrent) получают фиктивный order. Учит: «NTP даёт ~10 мс в LAN, до 100 мс в WAN — недостаточно для ordering; нужны logical clocks; Spanner решает иначе через TrueTime интервалы + commit-wait».
Context. Нам нужна модель «было ли A причиной B» в распределённой системе. Альтернативы: (a) использовать physical wall-clock и получить total order; (b) построить partial order на основе observable причин (send/recv, program order); (c) глобальный sequencer для всех событий.
Decision. Lamport (1978): partial order через три индуктивных правила. Concurrent события — first-class concept.
Trade-off.
A → B, то реально A могло повлиять на B); concurrent явный — даёт возможность CRDT-merge без conflict; работает на любом размере кластера без coordination.Когда partial OK: всегда, когда нужна корректность распределённой системы. Это базовая модель — total order строится поверх через tie-break или consensus.
Когда нужен total order: distributed locks, leader election, LWW merge, regulatory ordering. Получается из partial через scalar Lamport clock + tie-break на node_id, либо через consensus (Raft/Paxos), либо через TrueTime (Spanner).
Context. Правило 2 говорит только про send/receive. Почему не «оба обращаются к одному файлу», «оба читают одну переменную в shared memory», «один пишет в БД, второй читает»?
Decision. Lamport ограничил cross-process causality message-passing'ом, потому что это единственный механизм, который наблюдаем в pure distributed system без shared state.
Trade-off.
Применение к БД: Cassandra writeTime, BigTable cell-timestamps, version vectors в Riak — это application-level encoding happens-before через timestamps на data. Spanner делает иначе: TrueTime + commit-wait дают external consistency без message-passing causality.
Context. Слово «concurrent» в happens-before — не «одновременно по часам». Это «между ними нет причинного пути». Два события, разделённые часом по wall-clock, могут быть concurrent, если за этот час не было ни одного сообщения между процессами.
Decision. Принять, что concurrent — чисто causal свойство, не temporal. И не путать с concurrency в multi-threaded programming.
Trade-off.
Practical implication: при write-conflict resolution в Cassandra/DynamoDB ты НЕ можешь спросить «было ли действительно одновременно». Ты можешь только спросить «есть ли causal link». Нет link — это concurrent, мерджи по бизнес-логике (CRDT, last-write-wins с явным risk acceptance, или sibling resolution).
Context. Happens-before — model potential causality. Если A → B, A могло повлиять на B (информация физически могла дойти). Но не значит, что реально повлияло. И если A || B, они не были causally connected через observable пути — но могли быть связаны через hidden channel (общая БД, side channel через файл, человек-оператор).
Decision. Принять, что happens-before — модель видимой через message-passing causality. Hidden channels — отдельная категория, требующая других механизмов.
Trade-off.
Решение в проде: session tokens (read-your-writes guarantees), causal context tokens (Cassandra LWT + paxos для compare-and-set), bounded staleness reads (Spanner), external causal context (передавать version через все каналы, включая «человеческие»).
Apache Cassandra — каждая cell хранит writeTime (микросекунды, по сути Lamport scalar). При read с нескольких реплик возвращается значение с max writeTime. Сознательный trade-off: LWW поверх happens-before-aware timestamps, accepting silent data loss на concurrent writes как цена operational simplicity.
Riak / Voldemort / early Dynamo — vector clocks как прямая реализация happens-before для conflict detection. При read возвращаются all sibling versions, app решает merge (shopping cart union, last-write-wins, custom logic). Использует partial order напрямую: concurrent = sibling.
Git — DAG commits — буквальная реализация happens-before. Parent edges — это →. Merge commits с несколькими parents — concurrent branches. git merge/git rebase — стратегии resolve'а concurrent updates. git log --graph визуализирует causality.
Google Spanner / TrueTime — отказ от чисто-логических clocks в пользу bounded-uncertainty physical clocks. TrueTime даёт interval [earliest, latest] с GPS+atomic clock backing. Commit-wait гарантирует external consistency, что сильнее causal: если commit A завершён до начала commit B по wall-clock у любого наблюдателя, то A → B в системе.
CockroachDB / MongoDB / YugabyteDB — Hybrid Logical Clocks (HLC): (physical, logical). Physical part ≈ wall-clock, logical bump'ится при concurrent в одну мс. Получают approximate wall-clock + happens-before guarantees за O(1) bytes.
OpenTelemetry / Jaeger / Zipkin — distributed tracing. Span timestamps дополнены parent_span_id (явный → через causality propagation в headers). Trace = DAG, where → известно из инструментации, а не выводится из timestamps.
Kafka idempotent producer + transactions — per-partition sequence number в духе Lamport scalar. Broker rejects out-of-order или duplicate sequences. Cross-partition ordering обеспечивается transactions через coordinator.
if (event_a.ts > event_b.ts) ... где-то в коде распределённой системы — стоп, читай Lamport 1978 и используй проверенные primitives (vector clocks, HLC, version vectors).A → B означает «A causes B». Это значит «A could have caused B» (информация физически могла дойти). Реальная causation требует semantic анализа.Date.now() или autoincrement.