ข้าม​ไป​ยัง​เนื้อหา

จาก​นาฬิกา Lamport สู่ vector clock — กู้​ลำดับ​เชิง​เหตุผล​ที่ scalar ทำ​ไม่​ได้

คุณ​จบ #21 (rust-from-scratch) กับ #22 (rust-kvstore) มา​แล้ว — ใน​มือ​คุณ​มี kaen-kvstore: key-value store บน​เครือข่าย​ที่​เป็น node เดียว เขียน SET/GET/DELETE ผ่าน wire protocol ที่​ออกแบบ​เอง, append-only log + hash index, write→fsync→ack, tombstone/compaction และ thread pool ครบ​แล้ว คอร์ส​นี้ ต่อยอด ของ​ชิ้น​นั้น​โดยตรง ไม่​สอน Rust ซ้ำ​และ​ไม่​รื้อ store เขียน​ใหม่ — เรา​จะ เลื่อน kaen-kvstore จาก node เดียว​ขึ้น​เป็น cluster ที่​หลาย replica เก็บ​สำเนา key เดียวกัน แล้ว​ยัง​ลู่​เข้าหา​ค่า​เดียวกัน​ได้​แม้​เครือข่าย​จะ​ขาด

แต่​ก่อน​จะ​พูด​เรื่อง replica, quorum หรือ CRDT ได้​เลย เรา​ติด​ปัญหา​ที่​เก่า​แก่​ที่สุด​ของ​ระบบ​กระจาย​ก่อน: เมื่อ event เกิด​ขึ้น​คนละ​เครื่อง เรา​จะ​รู้​ได้​อย่างไร​ว่า​อะไร​เกิด​ก่อน​อะไร คำ​ตอบ​ไม่ใช่ “ดู​นาฬิกา” — บท​นี้​วาง​รากฐาน เวลา​เชิง​ตรรกะ (logical time) ที่​ทุก​บท​หลัง​จาก​นี้​ต้อง​ยืน​อยู่​บน​มัน: version vector ที่ read-repair (บท 3) และ CRDT (บท 4–5) ใช้​ตรวจ conflict ล้วน​เป็น vector clock ที่​เรา​สร้าง​ใน​บท​นี้

📦 kaen-kvstore

คอร์ส​นี้ ต่อยอด repo kaen-kvstore จาก #22 (code ตัวอย่าง​กำลัง​จัด​ทำ) — ตลอด 8 บท​เรา​เลื่อน kaen-kvstore จาก single-node ขึ้น​เป็น replicated cluster ด้วย Rust std ล้วน: splitmix64 ที่​เขียน​เอง​เป็น​แหล่ง​สุ่ม​ที่ seed ซ้ำ​ได้ 100% + std SipHash (DefaultHasher) — ไม่มี serde, ไม่มี tokio, ไม่มี proptest เลย​ทั้ง​คอร์ส THE SCOPE LINE: เรา​ลง code รัน​จริง เฉพาะ​ส่วน​ที่ property-test ได้ (logical clock, consistent hashing, quorum, CRDT, anti-entropy, harness ฉีด partition) — ส่วน consensus/Raft ที่​ตรวจ​ครบ​ไม่​ได้ คง​ไว้​เป็น ไดอะแกรม เท่านั้น บท​นี้​วาง​รากฐาน causal ordering (vector clock) ที่​บท 3–8 ใช้​ซ้ำ และ​ตั้ง precedent ของ honesty spine กับ 🔗 callout อ้างอิง​ต้นทาง​ให้​ทุก​บท​เดิน​ตาม

toolchain ที่ pin ไว้ + std ที่​บท​นี้​ใช้

ทุก snippet pin ที่ Rust stable 1.97.1 (ออก 2026-07-16) และ edition = “2024” — เช็ก​เอง​ด้วย rustc --version ได้​เสมอ [dependencies] ใน Cargo.toml ว่างเปล่า​ตลอด​คอร์ส — ZERO external crate code ใน​บท​นี้​แตะ std เพียง std::collections (HashMap, HashSet) กับ std::cmp (Ordering) เท่านั้น ทุก​โปรแกรม​ใน​บท​นี้ compile แบบ zero-warnings และ รัน​จริง — เลข​ทุก​ตัว​ที่​เห็น​ด้าน​ล่าง​คือ output จริง​ที่ seed ล็อก​ไว้ รัน​ซ้ำ​ได้ byte-identical

สัญชาตญาณ​แรก​ของ​ทุก​คน​คือ “ก็​ประทับ​เวลา (timestamp) ลง​ทุก event สิ แล้ว​เรียง​ตาม​เวลา” — บน node เดียว​มัน​ใช้ได้ แต่​พอ​ข้าม​เครื่อง มัน​พัง​ด้วย​เหตุผล​ที่​ลึก​กว่า​ที่​คิด

นาฬิกา​จริง​ของ​สอง​เครื่อง​ไม่มี​ทาง​ตรง​กัน​เป๊ะ มัน​ไหล (drift) คนละ​อัตรา ต่าง​กัน​ได้​เป็น​หลัก​มิลลิ​วินาที​ถึง​วินาที นั่น​คือ clock skew ที่​แย่​กว่า​นั้น: ตัว NTP ที่​คอย​ดึง​นาฬิกา​ให้​ตรง​เอง เดิน​ถอย​หลัง​ได้ — เมื่อ​มัน​แก้​ค่าที่​เพี้ยน นาฬิกา​อาจ​กระโดด​ย้อน​กลับ ผล​คือ​เหตุการณ์​ที่​เกิด ทีหลัง อาจ​ได้ timestamp น้อย​กว่า เหตุการณ์​ที่​เกิด​ก่อน พูด​ให้​ชัด: ถ้า​เครื่อง A ส่ง​ข้อความ​ไป​เครื่อง B แล้ว​เรา​เทียบ timestamp ดิบๆ เรา​อาจ​สรุป​ว่า ข้อความ​มา​ถึง​ก่อน​ที่​มัน​จะ​ถูก​ส่ง — ซึ่ง​เป็น​ไป​ไม่​ได้​เชิง​เหตุผล

Lamport ชี้​ใน​ปี 1978 ว่า​ปัญหา​ที่แท้​จริง​ไม่ใช่ “เวลา” แต่​คือ ลำดับ — และ​ลำดับ​ที่​เราสนใจจริงๆ คือ​ลำดับ เชิง​เหตุผล (causal): event a เกิด​ก่อน b ใน​ความหมาย​ที่ มีผล ก็​ต่อ​เมื่อ a อาจ​ส่ง​ผล ถึง b ได้ ความ​สัมพันธ์​นี้​เรียก​ว่า happens-beforehappens-beforeลำดับ 'บาง​ส่วน' (partial order) ของ event ที่​เชื่อม​ด้วย​การ​ส่ง​ข้อความ; คู่​ที่​ไม่​เชื่อม​กัน​คือ concurrent เขียน​แทน​ด้วย a → b นิยาม​ด้วย​กฎ​ง่ายๆ สาม​ข้อ: (1) ถ้า a กับ b อยู่ node เดียวกัน​และ a มา​ก่อน แล้ว a → b; (2) ถ้า a คือ​การ​ส่ง​ข้อความ​และ b คือ​การ​รับ​ข้อความ​นั้น แล้ว a → b; (3) transitive — ถ้า a → b และ b → c แล้ว a → c คู่​ที่ ไม่ เชื่อม​กัน​ด้วย​กฎ​เหล่า​นี้​เลย​คือ concurrent (เขียน a ‖ b) — ไม่ใช่​ว่า​มัน​เกิด​พร้อม​กัน​ตาม​เวลา​จริง แต่​คือ ไม่มี​ทาง​ที่​ตัว​หนึ่ง​จะ​รู้เรื่อง​อีก​ตัว

ประเด็น​คือ happens-before เป็น partial order (ลำดับ​บาง​ส่วน) ไม่ใช่ total order — บาง​คู่​เทียบ​กัน​ไม่​ได้​เลย และ​นั่น​คือ​คือ​หัวใจ เรา​ต้องการ​นาฬิกา​เชิง​ตรรกะ​ที่ เคารพ ความ​เป็น partial order นี้ ไม่ใช่​บด​มัน​ให้​แบน​เป็น​เส้น​เดียว

นาฬิกา Lamport: เวลา​เชิง​ตรรกะ​ที่​การันตี “ทาง​เดียว”

หัวข้อ​ที่​มีชื่อ​ว่า “นาฬิกา Lamport: เวลา​เชิง​ตรรกะ​ที่​การันตี “ทาง​เดียว””

ก้าว​แรก​ของ Lamport คือ Lamport clockLamport clockนาฬิกา​สเกลาร์​ตัว​เดียว: tick ตอน event, `max(local,msg)+1` ตอน​รับ — รับประกัน​ทาง​เดียว a→b ⇒ L(a)<L(b) แต่​แยก concurrent ไม่​ได้ — ตัว​นับ integer ตัว​เดียว (scalar) ต่อ node กฎ​มี​แค่​สอง​ข้อ: ทุก event ใน​เครื่อง (รวม​การ​ส่ง) ให้ tick คือ​บวก​หนึ่ง; เวลา​รับ​ข้อความ​ที่​พก timestamp msg มา ให้​ตั้ง​ค่า​เป็น max(local, msg) + 1 การ max ดึง​ให้​ผู้รับ “ก้าว​นำ” ผู้​ส่ง​เสมอ

// Lamport scalar clock: tick on a local event; on receive, L = max(local, msg)+1.
// Guarantee (one-way): a -> b => L(a) < L(b).
// Limitation: the converse is FALSE -- L(a) < L(b) does not imply a -> b,
// because a scalar imposes a TOTAL order and cannot express concurrency.
#[derive(Clone, Copy, Debug)]
struct LamportClock {
t: u64,
}
impl LamportClock {
fn new() -> Self {
Self { t: 0 }
}
fn event(&mut self) -> u64 {
self.t += 1;
self.t
}
fn recv(&mut self, msg: u64) -> u64 {
self.t = self.t.max(msg) + 1;
self.t
}
}
fn main() {
let mut p = LamportClock::new();
let mut q = LamportClock::new();
// Causal chain P: p1 (send) ; Q receives it as r.
let p1 = p.event(); // 1, P sends a message stamped 1
let r = q.recv(p1); // max(0,1)+1 = 2, so p1 -> r and L(p1)=1 < L(r)=2. GOOD.
println!("causal link: L(p1)={p1} < L(r)={r} (p1 -> r holds)");
assert!(p1 < r, "a->b must imply L(a)<L(b)");
// Now an event on P that is CONCURRENT with r (no message links them).
let p2 = p.event(); // 2
println!("concurrent pair: L(p2)={p2}, L(r)={r} (p2 and r have NO causal path)");
// Scalar clock still forces a numeric comparison between concurrent events.
// Here they tie at 2; a real system breaks ties by node id to get a total
// order -- but that order is arbitrary, NOT causal. And in the general case
// L(x) < L(y) can hold for a concurrent pair, falsely suggesting x -> y.
println!("scalar clock yields a TOTAL order; it CANNOT flag p2 || r as concurrent.");
println!("=> need vector clocks to recover the causal PARTIAL order.");
}

รัน​จริง​บน musl ได้​ผล​ตาม​นี้:

causal link: L(p1)=1 < L(r)=2 (p1 -> r holds)
concurrent pair: L(p2)=2, L(r)=2 (p2 and r have NO causal path)
scalar clock yields a TOTAL order; it CANNOT flag p2 || r as concurrent.
=> need vector clocks to recover the causal PARTIAL order.

สิ่ง​ที่ Lamport clock การันตี คือ​กฎ ทาง​เดียว: ถ้า a → b แล้ว L(a) < L(b) เสมอ (Lamport, CACM 1978) — ใน​ตัวอย่าง p1 → r จริง และ​ได้ L(p1)=1 < L(r)=2 สมจริง กฎ​นี้​มี​ประโยชน์​มาก: ถ้า​เห็น L(a) ≥ L(b) เรา​มั่นใจ​ได้​ว่า a ไม่​ได้ เกิด​ก่อน b

แต่ บท​กลับ​เป็น​เท็จL(a) < L(b) ไม่​ได้ แปล​ว่า a → b ใน​ตัวอย่าง​เดียวกัน p2 กับ r เป็น concurrent (ไม่มี​ข้อความ​เชื่อม​กัน) แต่ scalar clock ก็​ยัง​ยัด​ให้​ทั้ง​คู่​มี​ค่า​เทียบ​กัน​ได้ — ที่​นี่​บังเอิญ​เสมอ​กัน​ที่ 2 ใน​กรณี​ทั่วไป L(x) < L(y) อาจ​เป็น​จริง​สำหรับ​คู่ concurrent ด้วย​ซ้ำ ทำให้​เข้าใจ​ผิด​ว่า x → y ต้นตอ​ของ​ข้อ​จำกัด​นี้​คือ scalar หนึ่ง​ค่า บังคับ​ให้​เกิด total order เสมอ — มัน​บด​ลำดับ partial ของ happens-before ให้​แบน​เป็น​เส้น​เดียว ข้อมูล​ว่า “คู่​นี้​เทียบ​กัน​ไม่​ได้” หาย​ไป​ตั้งแต่​ตอน​บันทึก​ลงตัว​นับ​ตัว​เดียว

ทาง​แก้​คือ​เก็บ ตัว​นับ​ต่อ node แทน​ตัว​เดียว — vector clockvector clockเวกเตอร์​ตัว​นับ​ต่อ node (ที่​หาย​ไป = 0) จับ causality ครบถ้วน: a→b ก็​ต่อ​เมื่อ V(a)<V(b) คือ map จาก node_id → counter โดยที่ key ที่​หาย​ไป​หมาย​ถึง 0 โดย​ปริยาย (สำคัญ​มาก จะ​อธิบาย​ต่อ) กฎ​ขยับ​จาก​ของ Lamport ตรงๆ: event ใน​เครื่อง​ให้ tick เฉพาะ​ช่อง​ของ ตัวเอง; เวลา​รับ​ข้อความ​ให้ merge ก่อน​แล้ว​ค่อย tick ช่อง​ตัวเอง โดย merge คือ​เอา element-wise max ที​ละ​ช่อง​เหนือ union ของ key ทั้ง​สอง​ฝั่ง

code ต่อ​ไป​นี้​คือ​หัวใจ​ของ​ทั้ง​คอร์ส — SplitMix64 (แหล่ง​สุ่ม seed ได้ที่​ใช้​ซ้ำ​ทุก​บท) กับ VectorClock:

use std::cmp::Ordering;
use std::collections::HashMap;
// ---------- splitmix64: seed-reproducible PRNG (pure std, no crates) ----------
struct SplitMix64 {
state: u64,
}
impl SplitMix64 {
fn new(seed: u64) -> Self {
Self { state: seed }
}
fn next_u64(&mut self) -> u64 {
self.state = self.state.wrapping_add(0x9E3779B97F4A7C15);
let mut z = self.state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
z ^ (z >> 31)
}
fn below(&mut self, n: u64) -> u64 {
self.next_u64() % n
}
}
// ---------- VectorClock: node_id -> counter; a MISSING entry means 0 ----------
#[derive(Clone, Debug)]
struct VectorClock {
entries: HashMap<u64, u64>,
}
impl VectorClock {
fn new() -> Self {
Self { entries: HashMap::new() }
}
fn get(&self, node: u64) -> u64 {
*self.entries.get(&node).unwrap_or(&0)
}
// Local event at `node`: bump this node's own component.
fn tick(&mut self, node: u64) {
*self.entries.entry(node).or_insert(0) += 1;
}
// LUB / join: element-wise max over the union of node ids.
fn merge(&self, other: &Self) -> Self {
let mut out = self.clone();
for (&n, &c) in &other.entries {
let e = out.entries.entry(n).or_insert(0);
if c > *e {
*e = c;
}
}
out
}
// Deliver a message carrying clock `msg` at receiver `node`: join then tick.
fn receive(&mut self, msg: &Self, node: u64) {
*self = self.merge(msg);
self.tick(node);
}
fn union_keys(&self, other: &Self) -> Vec<u64> {
let mut ks: Vec<u64> = self.entries.keys().copied().collect();
for k in other.entries.keys() {
if !self.entries.contains_key(k) {
ks.push(*k);
}
}
ks
}
}

tick ใช้ += ธรรมดา (ไม่ใช่ wrapping_add) โดย​ตั้งใจ — นาฬิกา​เชิง​ตรรกะ ห้าม วน (wrap) ถ้า​มัน​วน​กลับ​เป็น 0 ได้ ลำดับ​เหตุผล​จะ​พัง​ทันที ต่าง​จาก​ตัว hash ในบท 2 ที่​จงใจ​ให้​ล้น​ได้

นี่​คือ​จุด​ที่​คน​พลาด​กัน​มาก​ที่สุด ถ้า​คุณ​เผลอ #[derive(PartialEq)] บน VectorClock ที่​หลัง​บ้าน​เป็น HashMap มัน​จะ​เทียบ​ว่า 2 map มี key-value ตรง​กัน​เป๊ะ​ไหม — แต่​นั่น ผิด​ความหมาย​ของ vector clock เพราะ {a:1} ต้อง เท่ากับ {a:1, b:0} (ช่อง​ที่​หาย​ไป = 0 โดย​ปริยาย) derived eq จะ​บอกว่า​มัน​ไม่​เท่า​กัน​เพราะ​จำนวน key ต่าง​กัน เรา​จึง​ต้อง​เขียน​เอง​ให้​เทียบ​ทุก key ใน union โดย​ถือ key ที่​ขาด = 0 ผ่าน get:

// Semantic equality: equal on every component (implicit zeros included), so
// {a:1} == {a:1, b:0}. Derived PartialEq on the HashMap would get this WRONG.
impl PartialEq for VectorClock {
fn eq(&self, other: &Self) -> bool {
self.union_keys(other).iter().all(|&k| self.get(k) == other.get(k))
}
}
impl Eq for VectorClock {}
// happens-before PARTIAL order:
// a < b (a -> b) iff a[k] <= b[k] for all k AND a != b
// a || b (concurrent) iff neither a <= b nor b <= a -> partial_cmp is None
impl PartialOrd for VectorClock {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
let (mut lt, mut gt) = (false, false);
for k in self.union_keys(other) {
let (a, b) = (self.get(k), other.get(k));
if a < b {
lt = true;
}
if a > b {
gt = true;
}
}
match (lt, gt) {
(false, false) => Some(Ordering::Equal),
(true, false) => Some(Ordering::Less),
(false, true) => Some(Ordering::Greater),
(true, true) => None, // concurrent
}
}
}

partial_cmp นี่แหละ​คือ​สิ่ง​ที่ Lamport clock ทำ​ไม่​ได้ มัน​ไล่​ทุก key ใน union แล้ว​ดู​ว่า​มี​ช่อง​ไหน​ที่​ฝั่ง​ซ้าย น้อย​กว่า (lt) และ​มี​ช่อง​ไหน​ที่ มากกว่า (gt) หรือ​ไม่ ผลลัพธ์​แยก​ได้​สี่​ทาง: ไม่มี​ทั้ง​คู่ = Equal; มี​แต่ lt = Less (คือ a → b); มี​แต่ gt = Greater; มี​ทั้ง​คู่ = None = concurrent จุด​ตาย​ที่​ต้อง​ระวัง: partial_cmp ที่​ให้​ผล​เป็น total เสมอ (เช่น​ไป​คืน Some(Less) แทน None ใน​กรณี (true, true)) จะ​ทำลาย​การ​ตรวจ​จับ concurrency ทั้งหมด — คุณ​จะ​ได้ scalar clock กลับ​มา​ใน​คราบ vector

เอา​สถานการณ์​เดิม​จาก​หัวข้อ Lamport มา​รัน​ด้วย vector clock: P ส่ง p1, Q รับ​เป็น r, แล้ว P ทำ p2 โดย​ไม่รู้เรื่อง r:

// P sends p1; Q receives -> r; P then does p2 concurrently with r.
let mut p = VectorClock::new();
let mut q = VectorClock::new();
p.tick(0); // p1 => {0:1}
let p1_msg = p.clone();
q.receive(&p1_msg, 1); // r => {0:1, 1:1}
p.tick(0); // p2 => {0:2}
let r = q.clone();
println!("p1 -> r ? {:?}", p1_msg.partial_cmp(&r)); // Some(Less)
println!("p2 vs r ? {:?}", p.partial_cmp(&r)); // None => concurrent (correct!)
assert_eq!(p1_msg.partial_cmp(&r), Some(Ordering::Less));
assert_eq!(p.partial_cmp(&r), None);
p1 -> r ? Some(Less)
p2 vs r ? None

p1 = {0:1} เทียบ​กับ r = {0:1, 1:1} ได้ Some(Less) — vector clock ยืนยัน p1 → r เหมือน Lamport ส่วน p2 = {0:2} เทียบ​กับ r = {0:1, 1:1}: ช่อง 0 ฝั่ง p2 มากกว่า (2 > 1) แต่​ช่อง 1 ฝั่ง r มากกว่า (1 > 0) — มี​ทั้ง lt และ gt จึง​ได้ None = concurrent นี่​คือ​สิ่ง​ที่ scalar ทำ​หล่น​ไป: มัน​บอกได้ตรงๆ ว่า p2 กับ r เทียบ​กัน​ไม่​ได้

นี่​คือ ทฤษฎีบท Fidge–Mattern (Fidge 1988 / Mattern 1989): vector clock จับ causality ได้ ครบถ้วน​พอดีa → b ก็​ต่อ​เมื่อ V(a) < V(b) และ a ‖ b ก็​ต่อ​เมื่อ เวกเตอร์​ทั้ง​สอง​เทียบ​กัน​ไม่​ได้ (incomparable) ไม่ใช่​แค่​ทาง​เดียว​อย่าง Lamport แต่​เป็น isomorphism — เวกเตอร์​เก็บ​โครง partial order ของ happens-before ไว้​ทั้งดุ้น

sequenceDiagram
    participant P as โพรเซส P
    participant Q as โพรเซส Q
    Note over P: p1 — tick(0) → V={0:1}
    P->>Q: ส่งข้อความพก V={0:1}
    Note over Q: r — merge แล้ว tick(1) → V={0:1, 1:1}
    Note over P: p2 — tick(0) → V={0:2} (P ยังไม่รู้เรื่อง r)
    Note over P,Q: p1 → r (มีลูกศรเชื่อม) แต่ p2 ‖ r (ไม่มีเส้นเหตุผลถึงกัน)

คำ​บรรยาย​ภาพ: เส้น​เหตุผล vs เหตุการณ์​ที่ concurrent — p1 → r เชื่อม​ด้วย​ลูกศร​ข้อความ (V(p1) < V(r)), ส่วน p2 ห้อย​อยู่​บน​เส้น​เวลา​ของ P โดย​ไม่มี​เส้น​เหตุผล​ถึง r: scalar แยก​ไม่​ออก (ยัด​ให้​เทียบ​กัน​ได้​เสมอ), vector แยก​ออก (คืน None)

ย้อน​กลับ​ไป​ดู merge: มัน​คือ element-wise max เหนือ union ของ key เท่านั้น​เอง แต่ operation นี้​มี​โครงสร้าง​พีชคณิต​ที่​ทรง​พลัง มัน​คือ join ของ semilatticesemilatticeโครง​พีชคณิต​ที่ merge เป็น join: commutative + associative + idempotent → ให้ least-upper-bound — คือ least upper bound (LUB) ของ​สอง​สถานะ และ​มัน​มี​สมบัติ​สาม​ข้อ​ที่​จะ​เป็น​หัวใจ​ของ​ทั้ง​คอร์ส:

  • commutativemerge(a, b) == merge(b, a): ลำดับ​ที่ merge ไม่มี​ผล
  • associativemerge(merge(a, b), c) == merge(a, merge(b, c)): การ​จับ​กลุ่ม​ไม่มี​ผล
  • idempotentmerge(a, a) == a: merge ซ้ำ​กี่​ครั้ง​ก็​เท่า​เดิม

สาม​ข้อ​นี้​แปล​ว่า ลำดับ​การ​ส่ง, การ​ส่ง​ซ้ำ, การ​ส่ง​ซ้อน ล้วน​ไม่มี​ผล​ต่อ​ค่า​ปลายทาง — replica ที่​เห็น​ชุด​ข้อความ​เดียวกัน (ไม่​ว่า​ลำดับ​ใด) จะ​ลู่​เข้าหา​สถานะ​เดียวกัน​เสมอ โดย​ไม่​ต้อง coordinate กัน​เลย พูด​อีก​อย่าง grow-only vector clock มี​โครง​เหมือน G-Counter map เป๊ะ นี่​คือ กฎ​ลู่​เข้า​เดียว​กับ CRDT ที่​บท 4–5 จะ​สร้าง​บน​มัน และ​เป็น​เหตุผล​ว่า​ทำไม read-repair (บท 3) ถึง​เอา LUB มา​เขียน​กลับ​ได้​อย่าง​ปลอดภัย เรา​จะ​พิสูจน์​สาม​กฎ​นี้​ด้วย code ใน​หัวข้อ​ถัด​ไป

การ​เขียน distributed algorithm ให้ “ดูเหมือน​ถูก” นั้น​ง่าย — มัน compile ผ่าน​และ​เดโม​สวย แต่ subtle bug ซ่อน​ได้​ทน เรา​จึง​ใช้ property test: สุ่ม input เป็น​พันๆ ชุด​ด้วย splitmix64splitmix64PRNG ตัว​เล็ก​เขียน​เอง seed ได้ ผล​ซ้ำ​ได้ 100% — แทน proptest/quickcheck ใน​แซนด์บ็อกซ์ std ล้วน (seed คงที่ = รัน​ซ้ำ​ได้ 100%) แล้ว ยืนยัน​สมบัติ ทุก​ชุด ไม่ใช่​เช็ค​แค่​ตัวอย่าง​เดียว

ชุด​แรก: กฎ semilattice ทั้ง​สาม + upper-bound + การ​ตรวจ LUB แบบ ไม่​เป็น​สุญญากาศ seed ล็อก​ที่ 0xDDD_C0FFEE, 5 node, 3000 เคส:

fn random_clock(rng: &mut SplitMix64, n_nodes: u64, max_c: u64) -> VectorClock {
let mut vc = VectorClock::new();
for node in 0..n_nodes {
match rng.below(4) {
0 => {} // leave absent (implicit 0)
1 => { vc.entries.insert(node, 0); } // explicit 0 (eq must handle)
_ => { vc.entries.insert(node, rng.below(max_c + 1)); }
}
}
vc
}
fn main() {
// ... worked example above ...
let seed: u64 = 0xDDD_C0FFEE;
let mut rng = SplitMix64::new(seed);
let (n_nodes, max_c, cases) = (5u64, 6u64, 3000u32);
let (mut comm, mut assoc, mut idem, mut ub) = (0u32, 0u32, 0u32, 0u32);
let (mut least_qualified, mut least_probes) = (0u32, 0u32);
for _ in 0..cases {
let a = random_clock(&mut rng, n_nodes, max_c);
let b = random_clock(&mut rng, n_nodes, max_c);
let c = random_clock(&mut rng, n_nodes, max_c);
assert!(a.merge(&b) == b.merge(&a), "commutativity failed");
comm += 1;
assert!(a.merge(&b).merge(&c) == a.merge(&b.merge(&c)), "associativity failed");
assoc += 1;
assert!(a.merge(&a) == a, "idempotency failed");
idem += 1;
// upper bound: m >= a and m >= b (never concurrent with an input)
let m = a.merge(&b);
assert!(a <= m && b <= m, "merge is not an upper bound");
ub += 1;
// LEAST upper bound (honest conditional): draw random candidates; for any
// candidate d that HAPPENS to be a common upper bound of {a,b}, m must
// divide it (m <= d). Count how many candidates actually qualified so the
// test cannot pass vacuously.
for _ in 0..3 {
let d = random_clock(&mut rng, n_nodes, max_c + 3); // wider => more qualify
least_probes += 1;
if a <= d && b <= d {
assert!(m <= d, "merge is not the LEAST upper bound");
least_qualified += 1;
}
}
}
// ... prints, then: assert!(least_qualified > 0) ...
}
--- splitmix64 property test (seed=0xdddc0ffee, 3000 cases) ---
commutative merge(a,b)==merge(b,a) : 3000/3000 PASS
associative (a|b)|c == a|(b|c) : 3000/3000 PASS
idempotent merge(a,a)==a : 3000/3000 PASS
upper bound a<=merge & b<=merge : 3000/3000 PASS
least u.b. m<=d for every common upper bound : 440 qualifying of 9000 probes PASS
ALL semilattice/LUB laws hold on 3000 random clocks.

สังเกต​บรรทัด​สุดท้าย: จาก 9000 candidate ที่​สุ่ม​มา มี​แค่ 440 ตัว​ที่ เข้า​เงื่อนไข เป็น common upper bound ของ {a, b} จริง (แล้ว m ≤ d ทุก​ตัว) การ​พิมพ์ least_qualified ออก​มา​แล้ว assert!(least_qualified > 0) คือ​การ​กัน​ไม่​ให้ test ผ่าน​แบบ​สุญญากาศ — ถ้า​ไม่มี candidate ไหน​เข้า​เงื่อนไข​เลย if ข้าง​ใน​จะ​ไม่​เคย​รัน แล้ว “PASS” จะ​ไม่มี​ความหมาย 440 คือ​หลักฐาน​ว่า​เรา​ได้​ตรวจ LUB จริงๆ

ชุด​ที่​สอง​คือ gold standard: causality.rs สร้าง ground-truth ancestor set แยก​ต่างหาก​จาก​ตัว clock แล้ว​เทียบ​ทฤษฎีบท Fidge–Mattern บน ทุก​คู่​ที่​เรียง​ลำดับ มัน​จำลอง execution แบบ​สุ่ม 60 รอบ (per-run seed = 0xA11CE*(run+1)+0x5EED) แต่ละ​รอบ 60 event บน 4 node แล้ว​สำหรับ​ทุก​คู่ (i, j) ตรวจ​ว่า​คำ​ตอบ​ของ clock (partial_cmp) ตรง​กับ ground truth (i อยู่​ใน​เซ็ตบรรพบุรุษ​ของ j ไหม) หรือ​ไม่:

// A recorded event: the node it ran on, its VC snapshot after, and the GROUND-TRUTH
// set of event indices that causally precede it (built independently of the VC).
struct Event { vc: VectorClock, ancestors: HashSet<usize> }
fn simulate(seed: u64, n_nodes: u64, steps: usize) -> Vec<Event> {
let mut rng = SplitMix64::new(seed);
let mut clocks: Vec<VectorClock> = (0..n_nodes).map(|_| VectorClock::new()).collect();
let mut last_on_node: Vec<Option<usize>> = vec![None; n_nodes as usize];
let mut inflight: Vec<(usize, VectorClock)> = Vec::new(); // (send event idx, clock)
let mut log: Vec<Event> = Vec::new();
for _ in 0..steps {
let p = rng.below(n_nodes);
// If messages are waiting, sometimes deliver one (random order = interleaving).
let do_recv = !inflight.is_empty() && rng.below(2) == 0;
let mut anc: HashSet<usize> = HashSet::new();
if let Some(prev) = last_on_node[p as usize] {
anc.extend(log[prev].ancestors.iter().copied());
anc.insert(prev); // program-order ancestry
}
if do_recv {
let pick = rng.below(inflight.len() as u64) as usize;
let (s_idx, msg) = inflight.swap_remove(pick);
clocks[p as usize].receive(&msg, p);
anc.extend(log[s_idx].ancestors.iter().copied());
anc.insert(s_idx); // message-order ancestry
} else {
clocks[p as usize].tick(p);
}
let idx = log.len();
// 50% of local events also send a message carrying the current clock.
if !do_recv && rng.below(2) == 0 {
inflight.push((idx, clocks[p as usize].clone()));
}
last_on_node[p as usize] = Some(idx);
log.push(Event { vc: clocks[p as usize].clone(), ancestors: anc });
}
log
}

หัวใจ​อยู่​ที่ ancestors สร้าง​จาก​กฎ happens-before ตรงๆ (program order + message order + transitive closure) โดย​ไม่​แตะ vector clock เลย — มัน​คือ oracle อิสระ แล้ว main ก็​เทียบ​ทุก​คู่:

for i in 0..log.len() {
for j in 0..log.len() {
if i == j { continue; }
let causal = log[j].ancestors.contains(&i); // ground truth: e_i -> e_j
let ord = log[i].vc.partial_cmp(&log[j].vc);
let vc_before = ord == Some(Ordering::Less);
assert_eq!(causal, vc_before, ...); // Fidge-Mattern, both ways
assert_ne!(ord, Some(Ordering::Equal), ...); // distinct events, distinct VC
if !log[j].ancestors.contains(&i) && !log[i].ancestors.contains(&j) {
assert_eq!(ord, None, ...); // concurrent <=> incomparable
n_conc += 1;
}
if causal { n_before += 1; }
}
}
--- Fidge-Mattern theorem check over random interleavings ---
runs=60 nodes=4 events/run=60
ordered pairs checked : 212400
causal (e_i -> e_j) : 63346 -> all matched VC strict-less
concurrent pairs : 85708 -> all matched VC = None
VC partial order == happens-before, on every pair. PASS.

212,400 คู่ (60 รอบ × 60 event × 59) ผ่าน​ทุก​คู่: 63,346 คู่​ที่​เป็น causal ตาม ground truth → clock คืน Some(Less) ทุก​ตัว; 85,708 คู่​ที่ concurrent → clock คืน None ทุก​ตัว; และ​ไม่มี event สอง​ตัว​ที่​ต่าง​กัน share เวกเตอร์​เดียวกัน​เลย ทฤษฎีบท Fidge–Mattern จับ​ต้อง​ได้​ใน code ที่​รัน​จริง ไม่ใช่​แค่​คำ​กล่าว​อ้าง

ความ​แม่นยำ​นี้​มี​ราคา Charron-Bost พิสูจน์​ใน​ปี 1991 ว่า ไม่มี​นาฬิกา​เชิง​ตรรกะ​ที่​ใช้ integer น้อย​กว่า N ตัว​แล้ว​ยัง​จับ happens-before ของ​ระบบ N โพรเซส​ได้​ครบ — พูด​ง่ายๆ vector clock ที่​ยาว N ช่อง​นั้น เล็ก​ที่สุด​เท่า​ที่​เป็น​ไป​ได้ ถ้า​อยาก​ได้ causality เป๊ะๆ ย่อ​ไม่​ได้​อีก​แล้ว บน cluster ที่ node เข้า-ออก​เรื่อยๆ เวกเตอร์​จะ​โต​ไม่​หยุด

ระบบ​จริง (Dynamo, Riak) จึง​ใช้ version vector ที่ key ด้วย ผู้​เขียน (ไม่ใช่​ทุก node) แล้ว prune ทิ้ง​ช่อง​เก่า​เมื่อ causally stable — เรา​จะ เอ่ย​ถึง มัน​ใน​บท 3–5 แต่ บท​นี้​ยึด​เส้น scope ชัด: เรา​ลง code vector clock ที่ property-test ได้​จริง (แบบ​เต็ม N ช่อง ตรง​ตาม​ทฤษฎี) ส่วน pruning ของ real store เป็น การ​กล่าว​ถึง เท่านั้น และ consensus ไม่​แตะ​ใน​คอร์ส​นี้​เลย — เหตุผล​อยู่​ใน callout ถัด​ไป

honesty spine 4 เส้น ที่​ทั้ง​คอร์ส​ยึด (บท​นี้​ตั้ง precedent ครบ​ทั้ง​สี่)

A — Rust พิสูจน์​ความ​ถูกต้อง “ใน​เครื่อง​เดียว” ไม่ใช่ “แบบ​กระจาย”: borrow checker การันตี​ว่า​ไม่มี data race ใน process เดียว แต่​มัน มอง​ไม่​เห็น การ​สลับ​ลำดับ​ข้อความ, partition, หรือ clock skew — vector clock ที่ subtle-wrong ก็​ยัง compile ผ่าน​และ​เดโม​สวย​ได้ property test กับ harness (บท 7) คือ “ออราเคิล” ของ​สมบัติ​แบบ​กระจาย และ​มัน​คือ​การ ตรวจ​แบบ​มี​ขอบเขต ไม่ใช่​บท​พิสูจน์ (FLP 1985) — ผ่าน 212,400 คู่​แปล​ว่า​สำรวจ​เฉพาะ interleaving ที่ seed เดิน​ไป​ถึง ไม่ใช่​ทุก​ความ​เป็น​ไป​ได้

B — เส้น scope: สิ่ง​ที่ เอ็น​นิว​เมอเรต​หรือ property-test ได้ (logical clock, quorum arithmetic, กฎ semilattice) เรา​ลง code รัน​จริง; ส่วน consensus/Raft ที่ ตรวจ​ครบ​ไม่​ได้ (state space ของ partition/reorder ระเบิด) คง​ไว้​เป็น กล่อง​ไดอะแกรม เท่านั้น — บท​นี้​อยู่​ฝั่ง​ซ้าย​ของ​เส้น​ทั้ง​บท

C — ดี​เท​อร์มิ​นิส​ซึม​คือ​วินัย: seed คงที่ (0xDDD_C0FFEE) + single-scheduler + พิมพ์ seed ออก​มา​เป็น repro handle มิ​ฉะนั้น test จะ โกหก (รัน​แดง​ซ้ำ​ไม่​ได้) เรา​ใช้ splitmix64 เขียน​เอง​แทน proptest และ​จะ​ใช้ BTreeMap แทน HashMap ตรง​ที่​ลำดับ iteration ป้อน logic (บท​หลัง)

D — นี่​คือ​กลไก​จริง​ของ Dynamo/Cassandra/Riak: vector/version vector + quorum + CRDT + Merkle + gossip คือ stack จริง​ของ Amazon Dynamo — เรา​สอน ตรง​ตาม​ต้นฉบับ ที่​สเกล toy บน kaen-kvstore ไม่ใช่​ของ​กุ​ขึ้น​เพื่อ​สอน

บท​นี้​วาง​รากฐาน เวลา​เชิง​ตรรกะ ของ​ทั้ง​คอร์ส: นาฬิกา wall-clock บอก​ลำดับ event ข้าม​เครื่อง​ไม่​ได้ (skew + NTP เดิน​ถอย​หลัง → “มา​ถึง​ก่อน​ถูก​ส่ง”) เรา​จึง​หัน​ไป​หา happens-before ที่​เป็น partial order; Lamport clockLamport clockนาฬิกา​สเกลาร์​ตัว​เดียว: tick ตอน event, `max(local,msg)+1` ตอน​รับ — รับประกัน​ทาง​เดียว a→b ⇒ L(a)<L(b) แต่​แยก concurrent ไม่​ได้ แบบ scalar การันตี​ทาง​เดียว a → b ⇒ L(a) < L(b) แต่​บท​กลับ​เป็น​เท็จ​เพราะ scalar บังคับ total order แยก concurrent ไม่​ออก; vector clockvector clockเวกเตอร์​ตัว​นับ​ต่อ node (ที่​หาย​ไป = 0) จับ causality ครบถ้วน: a→b ก็​ต่อ​เมื่อ V(a)<V(b) ที่​เก็บ​ตัว​นับ​ต่อ node (key หาย = 0) จับ causality ครบ​ตาม​ทฤษฎีบท Fidge–Mattern — a → b ก็​ต่อ​เมื่อ V(a) < V(b), concurrent ก็​ต่อ​เมื่อ incomparable; merge = element-wise max = join ของ semilattice (LUB) ที่ commutative/associative/idempotent — กฎ​ลู่​เข้า​เดียว​กับ CRDT ที่​บท 3–8 ใช้​ซ้ำ; เรา​ต้อง​เขียน PartialEq/PartialOrd เอง​เพราะ derive บน HashMap เป็น bug (implicit-zero) และ partial_cmp ต้อง​คืน None ให้ concurrent ไม่ใช่​ยัด Some; ราคา​คือ O(N) (Charron-Bost) ย่อ​กว่า​นี้​ไม่​ได้ ทุก snippet compile zero-warnings บน Rust 1.97.1 / edition 2024 / std ล้วน และ​รัน​ได้​เลข​ตาม​ที่​เห็น

บท 2 เรา​วางข้อมูล​ลง cluster: ตอน​นี้​เรา​รู้​แล้ว​ว่า​จะ เรียง​ลำดับ เหตุการณ์​ข้าม node อย่างไร บท​หน้า​เรา​จะ​ถาม​ว่า key แต่ละ​ตัว ควร​อยู่ node ไหน — ด้วย consistent hashingconsistent hashingวาง node กับ key บน​วงแหวน hash เดียวกัน เพิ่ม node ที่ N+1 ย้าย key แค่ ~1/(N+1) ไม่ใช่​ทั้งหมด + virtual node ที่​ทำให้​เพิ่ม node ใหม่​แล้ว​ย้าย key แค่ ~1/(N+1) ไม่ใช่​ทั้ง cluster โดย​ใช้ std DefaultHasher (SipHash) ที่​เรา​จะ​พูด​ความ​จริง​เรื่อง hash-sensitivity กันตรงๆ


🔗 อ้างอิง​ต้นทาง​ของ​บท​นี้

บท​นี้​อิง​ต้นทาง​ที่​ลง​วัน​ที่​กำกับ อ่าน​ต่อ​ได้​โดยตรง:

เช็กความเข้าใจ — บทที่ 1

ข้อ 1 / 3

ทำไมนาฬิกา Lamport แบบ scalar ถึงบอกไม่ได้ว่า event สองตัวเป็น concurrent?