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

CRDT แบบ state-based — semilattice, G-Counter, PN-Counter, LWW-Register

บท 3 ทิ้ง​ปัญหา​ไว้​ค้าง​คา​โดย​ตั้งใจ: quorum แบบ W+R>N การันตี​ว่าการ​อ่าน​จะ เจอ write ล่าสุด​เสมอ และ read-repair ตรวจ​จับ ได้​ว่า2 replica มี version vector ที่ concurrent กัน — แต่​พอ​เจอ​แล้ว มัน​ได้​แค่ flag ว่า “นี่​คือ conflict” ยัง แก้ ไม่​เป็น เมื่อ​สอง​ลูกค้า​เขียน key เดียวกัน​คนละ replica ใน​เวลา​ที่​ไม่รู้เรื่อง​กัน (concurrent) เรา​จะ​เลือก​ค่า​ปลายทาง​อย่างไร​ให้ ทุก replica ลู่​เข้าหา​ค่า เดียวกัน โดย​ไม่​ต้อง​หยุด​รอ coordinate กัน​ก่อน?

คำ​ตอบ​ที่ Amazon Dynamo, Riak และ Cassandra ใช้​จริง​คือ CRDTCRDTชนิด​ข้อมูล​ที่ replica merge กัน​แล้ว​ลู่​เข้า​เอง​โดย​ไม่​ต้อง coordinate — หัวใจ​คือ​กฎ merge — ชนิด​ข้อมูล​ที่​ออกแบบ​ให้ merge ของ​มัน ลู่​เข้า​เอง​โดย​พีชคณิต บท​นี้​เจาะ CRDT ตระกูล state-based (เรียก​ว่า CvRDT) แล้ว​สร้าง​สาม​ชนิด​พื้นฐาน — G-Counter, PN-Counter, LWW-Register — บนกฎ semilatticesemilatticeโครง​พีชคณิต​ที่ merge เป็น join: commutative + associative + idempotent → ให้ least-upper-bound เดียว​กับ​ที่ merge ของ vector clock ในบท 1 เชื่อฟัง​อยู่​แล้ว นี่​ไม่ใช่​เรื่อง​ใหม่​ทั้งหมด: กฎ​ลู่​เข้า​ที่​บท​นี้​พิสูจน์​คือ กฎ​เดียวกัน กับ element-wise max ที่​คุณ​เขียน​ไป​แล้ว​ใน​บท 1 — เรา​แค่​ยก​มัน​ขึ้น​เป็น abstraction ที่​ตั้ง​ชื่อ​ได้ และ​เอา​ไป​ประกอบ​เป็น​ชนิด​ข้อมูล​จริง

📦 kaen-kvstore

คอร์ส​นี้ ต่อยอด repo kaen-kvstore จาก #22 (code ตัวอย่าง​กำลัง​จัด​ทำ) — บท​นี้​สร้าง CRDT ที่​บท 5 จะ​เสียบ​เข้าไป​เป็น ชนิด​ของ value ใน store โดยตรง (write จะ merge แทน overwrite) ทุก snippet ใช้ Rust std ล้วน: splitmix64 ที่​เขียน​เอง​เป็น​แหล่ง​สุ่ม seed ซ้ำ​ได้ 100% + BTreeMap/BTreeSet ที่​เรียง​ลำดับ (canonical bytes) — ไม่มี serde, ไม่มี tokio, ไม่มี proptest ตาม​เส้น scope ของ​คอร์ส: CRDT อยู่ ฝั่ง​ซ้าย (property-test ได้​จริง) เพราะ​กฎ semilattice เอ็น​นิว​เมอเรต/สุ่ม​ตรวจ​ได้ ต่าง​จาก consensus/Raft ที่​คง​ไว้​เป็น​ไดอะแกรม​เท่านั้น

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

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

ปัญหา: concurrent write ต้องการ​ค่า​ปลายทาง​ที่​ทุก replica เห็น​ตรง​กัน

หัวข้อ​ที่​มีชื่อ​ว่า “ปัญหา: concurrent write ต้องการ​ค่า​ปลายทาง​ที่​ทุก replica เห็น​ตรง​กัน”

ลอง​นึก​ภาพ shopping cart กระจาย​บน3 replica ลูกค้า​กด​เพิ่ม​ของ​สอง​ครั้ง​เกือบ​พร้อม​กัน คำขอ​วิ่ง​ไป​คนละ replica เพราะ load balancer — ตอน​นี้ replica A เห็น {milk} ส่วน replica B เห็น {bread} ทั้ง​คู่ ack กลับ​ไป​แล้ว ไม่มี​ใคร​ผิด แล้ว​ค่า​จริง​ของ cart คือ​อะไร?

ทาง​เลือก​แบบ “ให้ replica คุย​กัน​ก่อน​แล้ว​โหวต” คือ consensus — มัน​ถูกต้อง แต่ ต้อง​หยุด​รับ write ระหว่าง partition (ความ​พร้อม​ใช้​พัง) และ​เป็น​สิ่ง​ที่​คอร์ส​นี้​จงใจ​ไม่​ลง code (เส้น scope) CRDT เดิน​อีก​ทาง: ออกแบบ​ชนิด​ข้อมูล​ให้ merge สอง​สถานะใดๆ ได้​ผลลัพธ์​ที่​ถูกต้อง​เสมอ ไม่​ว่า​จะ merge เมื่อไร ลำดับ​ใด ซ้ำ​กี่​ครั้ง ถ้า​ทำได้ replica ไม่​ต้อง coordinate เลย — เขียน​ได้​ตลอด​แม้​ตอน partition แล้ว​ค่อย merge กัน​ทีหลัง​ตอน​เครือข่าย​กลับ​มา ผลลัพธ์​รับประกัน​ว่า​ลู่​เข้า

คำถาม​คือ: merge แบบ​ไหน​ที่​ให้การ​รับประกัน​นั้น? คำ​ตอบ​เป็น​ทฤษฎีบท​พีชคณิต​ที่​คม​ชัด​มาก

Shapiro และ​คณะ (2011) พิสูจน์​ว่า state-based object จะ​ลู่​เข้า (ทุก replica ที่​เห็น​ชุด update เดียวกัน​จบ​ที่​ค่า​เดียวกัน) ก็​ต่อ​เมื่อ สถานะ​ของ​มัน​เป็น join-semilattice และ merge คำนวณ least upper bound (LUB) ของ​สอง​สถานะ พูด​เป็น​ภาษา code: merge ต้อง​มี​สมบัติ​สาม​ข้อ

  • commutativemerge(a, b) == merge(b, a): ลำดับ​ที่ state มา​ถึง​ไม่มี​ผล (เครือข่าย​ส่ง​ข้อความ​สลับ​ลำดับ)
  • associativemerge(merge(a, b), c) == merge(a, merge(b, c)): การ​จับ​กลุ่ม​ไม่มี​ผล (จะ merge ที​ละ​คู่​แบบ​ไหน​ก็ได้)
  • idempotentmerge(a, a) == a: merge สถานะ​เดิม​ซ้ำ​กี่​ครั้ง​ก็​เท่า​เดิม

สาม​ข้อ​นี้​แปล​ตรง​ตัว​เป็น​สาม​ภัย​ของ​ระบบ​กระจาย​ที่​ถูก ทำให้​ไม่มี​ผล: commutative ลบล้าง​การ สลับ​ลำดับ, associative ลบล้าง​การ จับ​กลุ่ม​ต่าง​กัน, idempotent ลบล้าง​การ ส่ง​ซ้ำ/redelivery ผล​รวม​คือ replica ไม่​ต้อง​ประสาน​งาน​กัน​บน write path เลย — คุณสมบัติ​นี้​เรียก​ว่า Strong Eventual Consistency และ​มัน​ไม่​ต้อง​ใช้ consensus นี่​คือ เหตุผล​เชิง​พีชคณิต ว่า​ทำไม CRDT ถึง​ลง code รัน​จริง​ได้​ใน​คอร์ส​นี้ ใน​ขณะ​ที่ Raft ทำ​ไม่​ได้: สาม​กฎ​นี้​สุ่ม​ตรวจ​ได้ แต่ correctness ของ Raft ต่อ adversarial scheduler เอ็น​นิว​เมอเรต​ไม่​ได้

idempotence คือ​กุญแจ​ที่​คน​มอง​ข้าม

commutative กับ associative นั้น​คน​มัก​เดา​ถูก แต่ idempotence คือ​ข้อ​ที่​แยก CRDT ออก​จาก​ตัว​นับ​ธรรมดา มัน​คือ​สิ่ง​ที่​ทำให้ anti-entropyanti-entropyกระบวนการ​เบื้องหลัง​ที่​ให้ replica เทียบ​สถานะ​แล้ว sync ส่วน​ที่​ต่าง​กัน​จน​ลู่​เข้า​กัน (บท 6) ส่ง​สถานะ​ทั้ง​ก้อน​ซ้ำๆ ได้​อย่าง​มั่นใจ โดย​ไม่​กลัว​นับ​ซ้ำ — ถ้า merge ไม่ idempotent การ retry หนึ่ง​ครั้ง​ก็​ทำ​ค่า​เพี้ยน​ถาวร (Shapiro et al. 2011 §3.2)

เรา​จะ​เขียน​กฎ​สาม​ข้อ​นี้​เป็น harness เดียว ที่​ใช้​ซ้ำ​ได้​กับ CRDT ทุก​ชนิด ผ่าน trait:

// A state-based CRDT converges IFF `merge` is a join on a semilattice:
// commutative + associative + idempotent (Shapiro et al. 2011).
trait Crdt: Clone {
fn merge(&self, other: &Self) -> Self;
// Semantic equality (implicit-zero aware for counters): two states that
// MEAN the same must compare equal even if their maps differ in shape.
fn equals(&self, other: &Self) -> bool;
}
// one reusable harness that checks the 3 laws over random triples
fn semilattice_laws<T: Crdt>(
rng: &mut SplitMix64,
cases: u32,
mut gen_val: impl FnMut(&mut SplitMix64) -> T,
) -> (u32, u32) {
let (mut checks, mut violations) = (0u32, 0u32);
for _ in 0..cases {
let a = gen_val(rng);
let b = gen_val(rng);
let c = gen_val(rng);
// commutative: merge(a,b) == merge(b,a) -> reordering has no effect
if !a.merge(&b).equals(&b.merge(&a)) { violations += 1; }
checks += 1;
// associative: (a|b)|c == a|(b|c) -> grouping has no effect
if !a.merge(&b).merge(&c).equals(&a.merge(&b.merge(&c))) { violations += 1; }
checks += 1;
// idempotent: merge(a,a) == a -> re-delivery / duplication has no effect
if !a.merge(&a).equals(&a) { violations += 1; }
checks += 1;
}
(checks, violations)
}
edition 2024: gen เป็น​คำ​สงวน​แล้ว

parameter ที่​นี่​ชื่อ gen_val ไม่ใช่ gen — ใน edition 2024 คำ​ว่า gen กลาย​เป็น reserved keyword (สำหรับ gen block) ถ้า​ตั้ง​ชื่อ field หรือ parameter ว่า gen code จะ compile ไม่​ผ่าน​ทันที เป็น​กับดัก​ที่​เจอ​จริง​ใน​รอบ​นี้

สังเกต​ว่า equals เขียน​เอง​ไม่ derive — ด้วย​เหตุผล​เดียว​กับ PartialEq ของ vector clock ในบท 1: counter ที่​ช่อง​หาย​ไป​หมาย​ถึง 0 โดย​ปริยาย {0:3} ต้อง​เท่ากับ {0:3, 1:0} เชิง​ความหมาย ถ้า derive บน BTreeMap ตรงๆ มัน​จะ​บอกว่า​ต่าง​กัน​เพราะ​จำนวน key ไม่​เท่า

G-CounterG-Counterตัว​นับ​เพิ่ม​อย่าง​เดียว: เวกเตอร์​ต่อ node, merge = max ที​ละ​ช่อง, ค่า = ผล​รวม (grow-only counter) คือ CRDT ที่​ง่าย​ที่สุด: map จาก node → ตัว​นับ กฎ​มี​ข้อ​เดียว​ที่​ต้อง​จำ — แต่ละ node บวก​ได้​เฉพาะ​ช่อง​ของ ตัวเอง ค่า​รวม​ของ counter คือ ผล​รวม ทุก​ช่อง แต่ merge คือ max ที​ละ​ช่อง — ไม่ใช่​ผล​รวม

#[derive(Clone)]
struct GCounter {
counts: BTreeMap<u64, u64>,
}
impl GCounter {
fn new() -> Self { Self { counts: BTreeMap::new() } }
// A node bumps ONLY its own slot -- never anyone else's.
fn inc(&mut self, node: u64, by: u64) {
*self.counts.entry(node).or_insert(0) += by;
}
fn value(&self) -> u64 { self.counts.values().sum() }
}
impl Crdt for GCounter {
fn merge(&self, other: &Self) -> Self {
let mut out = self.clone();
for (&n, &c) in &other.counts {
let e = out.counts.entry(n).or_insert(0);
if c > *e {
*e = c; // MAX, never SUM: SUM double-counts on re-delivery (not idempotent)
}
}
out
}
fn equals(&self, other: &Self) -> bool {
let mut keys: BTreeSet<u64> = self.counts.keys().copied().collect();
keys.extend(other.counts.keys().copied());
keys.iter().all(|k| {
self.counts.get(k).copied().unwrap_or(0) == other.counts.get(k).copied().unwrap_or(0)
})
}
}

ทำไม merge ต้อง​เป็น max ไม่ใช่ sum? เพราะ sum ไม่ idempotent ลอง​คิด​ตาม: node 0 บวก​ไป 3 ครั้ง ช่อง​ของ​มัน​เป็น {0:3} ถ้า anti-entropy ส่ง​สถานะ​นี้​ให้​เพื่อน​แล้ว​เพื่อน​ตอบ​กลับ​สถานะ​เดิม — ถ้า merge เป็น sum ค่า​จะ​กลาย​เป็น {0:6} ทั้ง​ที่ node 0 บวก​จริง​แค่ 3 การ​ส่ง​ซ้ำ (ซึ่ง​ใน​ระบบ​กระจาย​เกิด ตลอด​เวลา) ทำ​ค่า​เพี้ยน max ไม่มี​ปัญหา​นี้: max(3, 3) = 3 ส่ง​ซ้ำ​กี่​ครั้ง​ก็​เท่า​เดิม แต่ละ node เป็น​เจ้าของ​ช่อง​ตัวเอง​คน​เดียว ค่า​ใน​ช่อง​นั้น​จึง​เพิ่ม​ขึ้น อย่าง​เดียว (monotone) — max จึง​กู้​ค่า​ล่าสุด​ของ​ทุก​เจ้าของ​กลับ​มา​ได้​ครบ นี่​คือ​โครง​เดียว​กับ vector clock ในบท 1 เป๊ะ (vector clock ที่ grow-only คือ G-Counter map นั่นเอง)

flowchart LR
    A["replica A<br/>inc(0,3)<br/>{0:3}"]
    B["replica B<br/>inc(1,5)<br/>{1:5}"]
    M["merge = max ทีละช่อง<br/>{0:3, 1:5}<br/>value = 3+5 = 8"]
    A -->|"ส่ง state ให้ B"| M
    B -->|"ส่ง state ให้ A"| M
    M -->|"merge ซ้ำ / สลับลำดับ<br/>ก็ได้ 8 เท่าเดิม"| M

คำ​บรรยาย​ภาพ: replica A กับ B ต่าง​บวก​ช่อง​ของ​ตัวเอง แล้ว merge เข้าหา​กัน​ทั้ง​สอง​ทิศ — merge = max ทีละช่อง ให้ {0:3, 1:5} ค่า​รวม 8 เท่า​กัน​ทั้ง​สอง​ฝั่ง; ลำดับ​ที่ merge และ​การ merge ซ้ำ (idempotent) ไม่มี​ผล​ต่อ 8

G-Counter บวก​ได้​อย่าง​เดียว จะ​ให้​ลบ (เช่น​ลด​จำนวน​สินค้า​ใน cart) ไม่​ได้ — ถ้า​อนุญาต​ให้​ค่า​ใน​ช่อง​ลด​ลง max จะ “กิน” การ​ลด​หาย​ไป (max ระหว่าง​ค่า​เก่า​ที่มากกว่า​กับ​ค่า​ใหม่​ที่​น้อย​กว่า จะ​ได้​ค่า​เก่า) ทาง​แก้​แบบ CRDT คลาสสิก​คือ PN-Counter: เก็บ G-Counter สอง​ตัวP สำหรับ​ยอด​บวก​สะสม, N สำหรับ​ยอด​ลบ​สะสม — ค่า​จริง​คือ P.sum − N.sum ทั้ง​สอง​ตัว​ยัง​เป็น grow-only (การ​ลบ = เพิ่ม ยอด​ใน​ตัว​นับ N) กฎ semilattice จึง​สืบทอด​มาฟรีๆ

#[derive(Clone)]
struct PNCounter {
p: GCounter, // increments
n: GCounter, // decrements
}
impl PNCounter {
fn new() -> Self { Self { p: GCounter::new(), n: GCounter::new() } }
fn inc(&mut self, node: u64, by: u64) { self.p.inc(node, by); }
fn dec(&mut self, node: u64, by: u64) { self.n.inc(node, by); }
fn value(&self) -> i64 { self.p.value() as i64 - self.n.value() as i64 }
}
impl Crdt for PNCounter {
fn merge(&self, other: &Self) -> Self {
Self { p: self.p.merge(&other.p), n: self.n.merge(&other.n) }
}
fn equals(&self, other: &Self) -> bool {
self.p.equals(&other.p) && self.n.equals(&other.n)
}
}

merge แค่ merge ตัว P กับ N แยก​กัน — เพราะ CRDT ประกอบ​กัน​ได้ (compose): product ของ2 semilattice ก็​ยัง​เป็น semilattice จุด​ที่​ห้าม​พลาด​คือ ห้าม​อนุญาต by ที่​เป็น​ลบ​ใน inc — ถ้า​ปล่อย​ให้​ยอด​ใน​ช่อง P ลด​ลง​ได้ max จะ​กลืน​มัน​หาย ต้อง​บังคับ​ว่า inc เข้า P, dec เข้า N เท่านั้น

ตอน​นี้​เอา G-Counter กับ PN-Counter ป้อน​เข้า semilattice_laws seed ล็อก​ที่ 0xDEADBEEF (G-Counter) และ 0x12345678 (PN-Counter) อย่าง​ละ 3000 triples — แต่ละ triple ตรวจ 3 กฎ = 9000 checks ต่อ​ชนิด:

fn main() {
// worked examples
let mut a = GCounter::new();
a.inc(0, 3); // replica on node 0 -> {0:3}
let mut b = GCounter::new();
b.inc(1, 5); // replica on node 1 -> {1:5}
let g = a.merge(&b).merge(&a); // merge both ways + a DUPLICATE delivery
println!("G-Counter merge {{0:3}} | {{1:5}} (+ dup) -> value={}", g.value());
let mut pn = PNCounter::new();
pn.inc(0, 4); pn.inc(1, 6); pn.dec(0, 4); // +4 +6 -4
println!("PN-Counter +4 +6 -4 -> value={}", pn.value());
let cases = 3000u32;
let mut rng_g = SplitMix64::new(0xDEADBEEF);
let (gc, gv) = semilattice_laws(&mut rng_g, cases, |r| random_gcounter(r, 5, 6));
let mut rng_p = SplitMix64::new(0x12345678);
let (pc, pv) = semilattice_laws(&mut rng_p, cases, |r| random_pncounter(r, 5, 6));
println!("G-Counter seed=0xdeadbeef : checks={gc} violations={gv}");
println!("PN-Counter seed=0x12345678 : checks={pc} violations={pv}");
assert_eq!(gv, 0);
assert_eq!(pv, 0);
println!("ALL SEMILATTICE LAWS HOLD.");
}

รัน​จริง​บน musl:

G-Counter merge {0:3} | {1:5} (+ dup) -> value=8
PN-Counter +4 +6 -4 -> value=6
--- semilattice laws (comm/assoc/idem), 3000 triples each ---
G-Counter seed=0xdeadbeef : checks=9000 violations=0
PN-Counter seed=0x12345678 : checks=9000 violations=0
ALL SEMILATTICE LAWS HOLD.

worked example ยืนยัน​ความหมาย: G-Counter {0:3} merge {1:5} แล้ว merge {0:3} ซ้ำ​อีก​ครั้ง (จำลอง redelivery) ได้​ค่า 8 เท่า​เดิม — idempotence ทำงาน​จริง; PN-Counter บวก 4 บวก 6 ลบ 4 ได้ 6 ถูกต้อง และ​กฎ​ทั้ง​สาม​ผ่าน​ทุก triple: 9000 checks, 0 violations ต่อ​ชนิด

negative control: harness ที่ปริ้​นต์ 0 ได้​อย่าง​เดียว​คือ harness ที่​ไร้​ค่า

หัวข้อ​ที่​มีชื่อ​ว่า “negative control: harness ที่ปริ้​นต์ 0 ได้​อย่าง​เดียว​คือ harness ที่​ไร้​ค่า”

test ที่​ผ่าน​เสมอ​ไม่​ได้​พิสูจน์​อะไร — มัน​อาจ​จะ ตรวจ​ไม่​เจอ อะไร​เลย​ก็ได้ เรา​จึง​ต้อง​รัน negative control อย่าง​น้อยหนึ่ง​ครั้ง: สลับ merge ของ G-Counter จาก max เป็น sum (bug คลาสสิก) แล้ว​ดู​ว่า harness จับ​ได้​ไหม sum ยัง commutative และ associative แต่ ไม่ idempotent:

#[derive(Clone)]
struct BadCounter { counts: BTreeMap<u64, u64> }
impl Crdt for BadCounter {
fn merge(&self, other: &Self) -> Self {
let mut out = self.clone();
for (&n, &c) in &other.counts {
*out.counts.entry(n).or_insert(0) += c; // SUM: merge(a,a) DOUBLES -> not idempotent
}
out
}
// equals identical to GCounter's ...
}
// ... run BadCounter through the SAME semilattice_laws harness, seed 0xDEADBEEF:
--- negative control: G-Counter with merge = SUM (wrong) ---
BadCounter seed=0xdeadbeef : checks=9000 violations=2952
harness CAUGHT the broken merge (2952 violations) -> the check has teeth.

2952 จาก 9000 — ทุก violation คือ triple ที่ merge(a, a) != a (idempotence พัง) การ​เห็น​เลข​นี้​มากกว่า 0 คือ​หลักฐาน​ว่า harness มี​ฟัน จริง ถ้ามันปริ้​นต์ 0 ให้​ทั้ง​ของ​ถูก​และ​ของ​ผิด มัน​ก็​ไม่​ได้​ตรวจ​อะไร​เลย — จำนวน violation ที่​แน่นอน (2952) ขึ้น​กับ generator และ seed ที่ pin ไว้ จึง​รัน​ซ้ำ​ได้​เท่า​เดิม​ทุก​ครั้ง

counter ทั้ง​สอง​ด้าน​บน ไม่​เสีย​ข้อมูล — ทุก inc ถูก​นับ​ครบ แต่​บาง​ครั้ง​เรา​อยาก แทนที่ ค่า ไม่ใช่​สะสม (เช่น​เก็บ “ที่​อยู่​ล่าสุด​ของ​ผู้​ใช้”) นั่น​คือ​งาน​ของ LWW-RegisterLWW-Registerรี​จิส​เตอร์ค่า​เดียว​ที่ timestamp สูงสุด​ชนะ — ทิ้ง write ที่ concurrent (lossy โดย design) (last-writer-wins): เก็บ​ค่า​เดียว​พร้อม timestamp, merge = เลือก​ตัว​ที่ ts สูง​กว่า ฟัง​ดู​ตรง​ไป​ตรง​มา แต่​มี​กับดัก​ลึก

ปัญหา​คือ ts อย่าง​เดียว ไม่​เป็น total order — 2 replica ประทับ ts เดียวกันได้สบายๆ (นาฬิกา​หยาบ หรือ logical clock ชน​กัน) ถ้า merge เจอ ts เท่า​กัน​แล้ว​ไม่มี​กฎ​ตัดสิน​ที่ deterministic มัน​จะ​เลือก​คนละ​ค่า​บน​คนละ replica → merge ไม่ commutative → ไม่​ลู่​เข้า ทาง​แก้​คือ​ทำ tuple (ts, node, val) ให้​เป็น total order เต็ม โดย​ใช้ node id (และ​ค่า​เป็น​ด่าน​สุดท้าย) เป็น tie-break:

#[derive(Clone)]
struct Lww {
ts: u64,
node: u64,
val: Vec<u8>,
}
impl Crdt for Lww {
fn merge(&self, other: &Self) -> Self {
// Tuple/Vec<u8> lexicographic Ord IS the total order. The tie can only
// happen when the two are byte-identical, so the pick is deterministic
// and commutative either way.
if (self.ts, self.node, &self.val) >= (other.ts, other.node, &other.val) {
self.clone()
} else {
other.clone()
}
}
fn equals(&self, other: &Self) -> bool {
self.ts == other.ts && self.node == other.node && self.val == other.val
}
}

หัวใจ​คือ​บรรทัด (self.ts, self.node, &self.val) >= (...): Rust เทียบ tuple แบบ lexicographic ให้​ฟรี และ Vec<u8> เทียบ​แบบ byte lexicographic — 3 field รวม​กัน​จึง​เป็น total order จริง ไม่มี​ทาง​เสมอ​เว้น​แต่​ทั้ง​ก้อน​เท่า​กัน​เป๊ะ merge จึง​เลือก​ตัว​เดิม​เสมอ​ไม่​ว่า​เรียง​ลำดับ argument แบบ​ไหน = commutative

รัน (seed 0x0C0FFEE0, 3000 triples):

LWW concurrent {red@n0, blue@n1} -> "blue" (the other write is SILENTLY LOST)
--- LWW-Register semilattice laws, 3000 triples ---
LWW seed=0x0c0ffee0 : checks=9000 violations=0
ALL SEMILATTICE LAWS HOLD (tie-break makes merge a real total-order join).

worked example: 2 write concurrent ประทับ ts=7 เท่า​กัน — red จาก node 0, blue จาก node 1 tie-break ด้วย node id: 1 > 0blue ชนะ และ red ถูก​ทิ้ง​เงียบๆ นี่​คือ​จุด​ที่​ต้อง​พูด​ตรง: LWW lossy โดย design — มัน​ไม่​ได้ แก้ conflict แต่ เลือก​ฝ่าย​ชนะ​แล้ว​โยน​อีก​ฝ่าย​ทิ้ง (Kleppmann, DDIA ch5) เหมาะ​กับ​ข้อมูล​ที่ “ค่า​ล่าสุด​คือ​ความ​จริง” (last-seen presence, config flag) แต่​ถ้า​เอา​ไป​ใช้​กับ cart หรือ balance คุณ​จะ เสีย order ที่​ลูกค้า​กด​จริง เงียบๆ โดย​ไม่มี error งาน​แบบ​นั้น​ต้อง​ใช้ PN-Counter (นับ​ครบ) หรือ OR-SetOR-Setเซ็ต add-wins: ทุก add ติด tag ไม่​ซ้ำ, remove ลบ​ได้​เฉพาะ tag ที่​เห็น​แล้ว → re-add ที่ concurrent รอด ที่​บท 5 จะ​สร้าง (add-wins ไม่​ทิ้ง concurrent add)

honesty spine: บท​นี้​อยู่​ฝั่ง​ไหน​ของ​เส้น scope

Thread A — Rust พิสูจน์​ความ​ถูกต้อง “ใน​เครื่อง​เดียว” ไม่ใช่ “แบบ​กระจาย”: property test 9000 checks/ชนิด คือ ออราเคิล ของ​สมบัติ​ลู่​เข้า และ​มัน​คือ​การ ตรวจ​แบบ​มี​ขอบเขต ไม่ใช่​บท​พิสูจน์ — ผ่าน 3000 triples แปล​ว่า​สำรวจ​เฉพาะ​สถานะ​ที่ splitmix64 สุ่ม​ไป​ถึง กฎ semilattice นั้น​พิสูจน์​ได้​ทาง​คณิตศาสตร์ (Shapiro 2011) แต่ code implementation ของ​เรา​ตรวจ​ด้วย​การ​สุ่ม negative control (2952 violations) คือ​หลัก​ประกัน​ว่า harness มี​ฟัน

Thread B — เส้น scope: CRDT อยู่ ฝั่ง​ซ้าย (ลง code รัน​จริง) เพราะ​สาม​กฎ comm/assoc/idem สุ่มตรวจได้ตรงๆ ต่าง​จาก consensus/Raft ที่ state space ของ partition/reorder ระเบิด​จน​เอ็น​นิว​เมอเรต​ไม่​ได้ — จึง​คง​เป็น​ไดอะแกรม​ใน​บท 8

Thread D — นี่​คือ​กลไก​จริง​ของ Dynamo/Riak/Cassandra: G-Counter/PN-Counter/LWW-Register คือ CRDT ที่​ระบบ​เหล่า​นี้​ใช้​จริง (Cassandra ใช้ LWW เป็น default conflict resolution) — เรา​สอน​ตรง​ตาม​ต้นฉบับ Shapiro et al. 2011 ที่​สเกล toy บน kaen-kvstore ไม่ใช่​ของ​กุ​ขึ้น​เพื่อ​สอน

บท​นี้​แก้​ปัญหา​ที่​บท 3 ทิ้ง​ไว้: concurrent write ที่ read-repair ตรวจ​เจอ แต่ แก้​ไม่​เป็น CRDTCRDTชนิด​ข้อมูล​ที่ replica merge กัน​แล้ว​ลู่​เข้า​เอง​โดย​ไม่​ต้อง coordinate — หัวใจ​คือ​กฎ merge แบบ state-based (CvRDT) แก้​ด้วย​พีชคณิต — ทฤษฎีบท Shapiro et al. 2011: replica ลู่​เข้าหา​ค่า​เดียวกัน ก็​ต่อ​เมื่อ merge เป็น join ของ semilatticesemilatticeโครง​พีชคณิต​ที่ merge เป็น join: commutative + associative + idempotent → ให้ least-upper-bound คือ commutative (ลบล้าง​การ​สลับ​ลำดับ) + associative (ลบล้าง​การ​จับ​กลุ่ม) + idempotent (ลบล้าง​การ​ส่ง​ซ้ำ) → Strong Eventual Consistency ไม่​ต้อง​ใช้ consensus เรา​สร้าง​สาม​ชนิด​บน​กฎ​เดียว​กับ element-wise max ของ vector clock ในบท 1: G-CounterG-Counterตัว​นับ​เพิ่ม​อย่าง​เดียว: เวกเตอร์​ต่อ node, merge = max ที​ละ​ช่อง, ค่า = ผล​รวม (แต่ละ node บวก​ช่อง​ตัวเอง, merge = max ที​ละ​ช่อง, ค่า = ผล​รวม — max ไม่ใช่ sum เพราะ sum ไม่ idempotent), PN-Counter (G-Counter สอง​ตัว P−N, ห้าม inc ค่า​ลบ), LWW-RegisterLWW-Registerรี​จิส​เตอร์ค่า​เดียว​ที่ timestamp สูงสุด​ชนะ — ทิ้ง write ที่ concurrent (lossy โดย design) (ts สูงสุด​ชนะ + tie-break (ts, node, val) ที่ deterministic — lossy โดย design ทิ้ง concurrent write ที่​แพ้​เงียบๆ) property test splitmix64 ยืนยัน​กฎ​ทั้ง​สาม 9000 checks/0 violations ต่อ​ชนิด และ negative control (merge = sum) โดน harness จับ​ได้ 2952 ครั้ง — พิสูจน์​ว่าการ​ตรวจ​มี​ฟัน​จริง ทุก snippet compile zero-warnings บน Rust 1.97.1 / edition 2024 / std ล้วน

บท 5 เรา​เอา CRDT ไป​เสียบ​เป็น ชนิด​ของ value จริง​ใน kaen-kvstore: เรา​จะ​สร้าง OR-SetOR-Setเซ็ต add-wins: ทุก add ติด tag ไม่​ซ้ำ, remove ลบ​ได้​เฉพาะ tag ที่​เห็น​แล้ว → re-add ที่ concurrent รอด (add-wins ที่ ไม่ ทิ้ง concurrent add แบบ LWW) แล้ว​เก็บ​มัน​เป็น value ที่ write จะ merge แทน overwrite — โดย​เข้า​รหัส​ด้วย wire format length-prefixed จาก #22 (ไม่​ใช้ serde) ทำให้ convergence พิสูจน์​ได้​ถึง​ระดับ byte


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

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

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

ข้อ 1 / 3

ทำไม merge ของ G-Counter ต้องเป็น max ทีละช่อง ไม่ใช่ผลรวม (sum)?