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

Merkle anti-entropy และ gossip/SWIM membership — ตาม​กัน​ให้​ทัน​แบบ sub-linear

ห้า​บท​ที่​ผ่าน​มา​เรา​ประกอบ​ชิ้น​ส่วน​ของ cluster ครบ​เกือบ​หมด: vector clock บอก​ลำดับ​เชิง​เหตุผล (บท 1), consistent hashing วาง key ลง node (บท 2), quorum + read-repair ทำให้​อ่าน​ทับ​เขียน​ล่าสุด (บท 3), และ CRDT ที่ merge กัน​แล้ว​ลู่​เข้า​เอง​โดย​ไม่​ต้อง coordinate (บท 4–5) แต่ read-repair ซ่อม​ได้​เฉพาะ key ที่ มี​คน​อ่าน เท่านั้น — key ที่​ไม่มี​ใคร​แตะ​เลย​หลัง partition หาย​จะ ดริฟต์ค้าง​ไว้​อย่าง​นั้น ไป​เรื่อยๆ บท​นี้​เติม​ชิ้น​สุดท้าย​ก่อน​ไป​ประกอบ​ร่าง​เป็น cluster จริง: กระบวนการ เบื้องหลัง ที่​ให้ replica เทียบ​สถานะ​กันเอง​แล้ว sync ส่วน​ที่​ต่าง​จน​ลู่​เข้า — เรียก​ว่า anti-entropyanti-entropyกระบวนการ​เบื้องหลัง​ที่​ให้ replica เทียบ​สถานะ​แล้ว sync ส่วน​ที่​ต่าง​กัน​จน​ลู่​เข้า​กัน

คำถาม​ที่​ต้อง​ตอบ​มี​สอง​ข้อ (1) จะ​รู้​ได้​อย่างไร​ว่า replica สอง​ตัว ต่าง​กัน​ตรง​ไหน โดย​ไม่​ต้อง​ส่ง​ทั้ง store มา​เทียบ​ที​ละ key — คำ​ตอบ​คือ Merkle tree ที่​หา​ช่วง key ที่ diverge แบบ sub-linear (2) จะ กระจาย ข่าว (ค่าที่​อัปเดต, ใคร​เข้า/ออก cluster) ให้​ทั่ว​ทั้ง N node อย่าง​ทนทาน​ได้​อย่างไร — คำ​ตอบ​คือ gossip แบบ epidemic ที่​ครบ​ทุก node ใน O(log N) รอบ ทั้ง​สอง​คือ​กลไก​จริง​ของ Dynamo/Cassandra/Riak ปิด​ท้าย​ด้วย​เส้น scope: การ ตรวจ​เจ๊ง (failure detection) ของ SWIM ที่​ผูก​กับ timing จริง เรา​คง​ไว้​เป็น ไดอะแกรม ไม่​ลง code — เหตุผล​อยู่​ท้าย​บท

📦 kaen-kvstore

คอร์ส​นี้ ต่อยอด repo kaen-kvstore จาก #22 (code ตัวอย่าง​กำลัง​จัด​ทำ) — บท​นี้​เพิ่ม​ชั้น anti-entropy เบื้องหลัง ให้​แต่ละ replica ของ kaen-kvstore: Merkle tree สร้าง​เหนือ key-space ของ store (leaf = ช่วง key ที่​ติด​กัน, hash ด้วย std DefaultHasher) เพื่อ​หา segment ที่​ต่าง​แล้ว sync เฉพาะ​ส่วน​นั้น, และ gossip กระจาย membership view ระหว่าง node ทั้งหมด Rust std ล้วน — ไม่มี serde, ไม่มี tokio, ไม่มี proptest ความ​สุ่ม​มา​จาก splitmix64 ที่​เขียน​เอง (seed ซ้ำ​ได้ 100%) ในบท 8 ชิ้น​นี้​จะ​กลาย​เป็น anti-entropy loop ที่​ซ่อม key ที่ read-repair เอื้อม​ไม่​ถึง

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

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

หลัง partition หาย​หรือ​หลัง node รี​บูต​กลับ​มา replica สอง​ตัว​ที่ ควร เก็บ key ชุด​เดียวกัน​อาจ​ต่าง​กัน​ไม่​กี่ key จาก​ทั้งหมด​เป็น​ล้าน วิธี​ดิบ​ที่สุด​คือ​ส่ง​ทั้ง key-space มา​เทียบ​ที​ละ​ตัว — นั่น​คือ O(keys) ทั้ง bandwidth และ​เวลา ต่อ​ให้​ต่าง​กัน​แค่ key เดียว ก็​ยัง​ต้อง​ขน​ทั้ง store มากอง version ดิบ​แบบ​นี้​ใช้​ไม่​ได้​บน cluster จริง​ที่​ต้อง​ทำ anti-entropy วน​ทุก node ตลอด​เวลา

สิ่ง​ที่​เรา​ต้องการ​คือ “บอก​ได้​เร็วๆ ว่า​ต่าง​กัน​ไหม และ​ถ้า​ต่าง ต่าง​ตรง​ช่วง​ไหน” โดย​จ่าย​ค่า​ใช้​จ่าย​เป็น​สัดส่วน​กับ ปริมาณ​ที่​ต่าง ไม่ใช่ ขนาด​ทั้งหมด — นี่​คือ​สิ่ง​ที่ Merkle tree ให้

Merkle treeMerkle treeต้นไม้ hash ที่​เทียบ root แล้ว​ลง​เฉพาะ subtree ที่​ต่าง หา​ช่วง key ที่ diverge แบบ sub-linear (Merkle, CRYPTO 1987) คือ​ต้นไม้​ที่ ใบ (leaf) เก็บ hash ของ​ข้อมูล​จริง และ node ภายใน เก็บ hash ของ​ลูก​ทั้ง​สอง ไล่​ขึ้น​ไป​จนถึง root ที่​เป็น hash สรุป​ของ​ทั้ง​ต้น คุณสมบัติ​ที่​ใช้​ประโยชน์​คือ: ถ้า root ของ​สอง​ต้น เท่า​กัน แสดง​ว่า​ทั้ง​ต้น​เหมือน​กัน (ภาย​ใต้ collision resistance ของ hash) — จบใน 1 การ​เทียบ; ถ้า root ต่าง​กัน เรา​รู้​ว่า​มี​อะไร​ต่าง แล้ว ลง​เฉพาะ subtree ที่ hash ต่าง เท่านั้น subtree ที่ hash ตรง​กัน​ตัด​ทิ้ง​ได้​ทั้งดุ้น​โดย​ไม่​ต้อง​แตะ​ข้าง​ใน

เรา​วาง​ต้นไม้​แบบ heap ใน Vec<u64> ตัว​เดียว: tree[1] = root, node ภายใน​อยู่​ช่วง [1, LEAVES), ใบ​อยู่​ช่วง [LEAVES, 2*LEAVES) ลูก​ของ node i คือ 2*i กับ 2*i+1 — ไม่​ต้อง​มี pointer เลย ให้ KEYSPACE = 4096, LEAVES = 64 (เป็น​กำลัง​ของ​สอง → ต้นไม้​สมบูรณ์ ลึก log2(64) = 6) แต่ละ​ใบ​ดูแล​ช่วง key ที่​ติด​กัน 64 key และ hash ของ​ใบ​คือ hash ของ​คู่ (key, value) ที่​เรียง​ตาม key — จุด​นี้​สำคัญ: ต้อง feed แบบ​เรียง (ผ่าน BTreeMap ที่ iterate ตาม​ลำดับ key) มิ​ฉะนั้น replica สอง​ตัว​ที่ เหมือน​กัน แต่ feed คนละ​ลำดับ​จะ​ได้ hash คนละ​ค่า แล้ว​ดูเหมือน​ต่าง​ทั้ง​ที่​ไม่​ต่าง

use std::collections::hash_map::DefaultHasher;
use std::collections::BTreeMap;
use std::hash::{Hash, Hasher};
const KEYSPACE: u64 = 4096; // keys are 0..KEYSPACE
const LEAVES: usize = 64; // power of two -> perfect binary tree, depth 6
// contiguous range: which leaf does a key fall into
fn leaf_of(key: u64) -> usize {
(key * LEAVES as u64 / KEYSPACE) as usize
}
fn hash_pair(a: u64, b: u64) -> u64 {
let mut h = DefaultHasher::new();
a.hash(&mut h);
b.hash(&mut h);
h.finish()
}
// Heap-laid-out Merkle tree: index 1 = root, leaves at [LEAVES .. 2*LEAVES).
// Leaf hash = hash of sorted (key,value) pairs in that leaf's range.
fn build_tree(store: &BTreeMap<u64, u64>) -> Vec<u64> {
let mut leaf_hashers: Vec<DefaultHasher> =
(0..LEAVES).map(|_| DefaultHasher::new()).collect();
// feed each (key,value) into its leaf, in key order (BTreeMap iterates sorted)
for (&k, &v) in store.iter() {
let l = leaf_of(k);
k.hash(&mut leaf_hashers[l]);
v.hash(&mut leaf_hashers[l]);
}
let mut tree = vec![0u64; 2 * LEAVES];
for i in 0..LEAVES {
tree[LEAVES + i] = leaf_hashers[i].finish();
}
for i in (1..LEAVES).rev() {
tree[i] = hash_pair(tree[2 * i], tree[2 * i + 1]);
}
tree
}

ต่อ​ด้วย​หัวใจ​ของ anti-entropy: diff_tree ที่​เริ่ม​จาก root แล้ว ลง​เฉพาะ subtree ที่​ต่าง — เรา​ใช้ stack ของ index node แทน recursion และ​นับ​จำนวน “การ​เทียบ hash ต่อ node” (node-hash compares) เพื่อ​วัด​ค่า​ใช้​จ่าย​จริง:

// Recurse from root, descending only into differing subtrees.
// Returns (differing leaf indices, count of node-hash comparisons performed).
fn diff_tree(a: &[u64], b: &[u64]) -> (Vec<usize>, usize) {
let mut differing_leaves = Vec::new();
let mut compares = 0usize;
let mut stack = vec![1usize]; // start at root (index 1)
while let Some(i) = stack.pop() {
compares += 1;
if a[i] == b[i] {
continue; // subtree identical -> prune (this is the O(log) win)
}
if i >= LEAVES {
differing_leaves.push(i - LEAVES);
} else {
stack.push(2 * i);
stack.push(2 * i + 1);
}
}
differing_leaves.sort_unstable();
(differing_leaves, compares)
}

บรรทัด if a[i] == b[i] { continue; } คือ​ทั้งหมด​ของ​กำไร: เจอ subtree ที่ hash ตรง = ตัด​ทิ้ง​ลูกหลาน​ทั้ง​กอ ไม่​แตะ key แม้แต่​ตัว​เดียว ที่​อยู่​ข้าง​ใน เมื่อ​ได้​ใบ​ที่​ต่าง​มา​แล้ว เรา​ค่อย​เปิด​ดู เฉพาะ​ช่วง key ใน​ใบ​เหล่า​นั้น ว่า key ตัว​ไหน​ต่าง​จริง (keys_in_leaves) แล้ว​เทียบ​กับ oracle ที่​สแกน​เต็ม (ground_truth_diff) — code 2 function นี้​ตรง​ไป​ตรง​มา (เดิน range ของ​แต่ละ​ใบ / เดิน​ทั้ง store) จึง​ขอ​ข้าม​มา​ดู​ผล​รัน

test รูปธรรม​ที่สุด: store ที่​มี​ครบ 4096 key เหมือนกันเป๊ะ ต่าง​กัน​แค่ key เดียว (b.insert(2718, 999999)) ต้นไม้​ลึก 6 การ​หา​ใบ​ที่​ต่าง ใบ​เดียว ต้อง​เทียบ root (1 ครั้ง) แล้ว​ที่​แต่ละ​ชั้น​ลง​ไป​เทียบ ทั้ง​ลูก​ซ้าย​และ​ลูก​ขวา (ลูก​ที่​ตรง​ตัด​ทิ้ง ลูก​ที่​ต่าง​ลง​ต่อ) รวม 1 + 2*6 = 13 การ​เทียบ — เทียบ​กับ 4096 ถ้า​สแกน​ทุก key:

graph TD
    R["root — เทียบก่อน: ต่าง!"]
    R --> A["subtree ซ้าย: hash ตรง ✓ ตัดทิ้ง ~2048 key"]
    R --> B["subtree ขวา: hash ต่าง → ลงต่อ"]
    B --> B1["ลูกหนึ่ง: ตรง ✓ ตัดทิ้ง"]
    B --> B2["อีกลูก: ต่าง → ลงต่อ"]
    B2 --> D["...ลงตามชั้นจนถึง..."]
    D --> L["leaf 42: ช่วง key 2688–2751 — key 2718 ต่าง"]

คำ​บรรยาย​ภาพ: เทียบ root ก่อน แล้ว​ลง​เฉพาะ subtree ที่​ต่าง — subtree ที่ hash ตรง (สี​เทา) ถูก​ตัด​ทิ้ง​ทั้ง​กอ ไม่​แตะ ~4032 key ที่​เหมือน​กัน​เลย ทาง​สี​แดง root→leaf คือ​เส้น​เดียว​ที่​เรา​เดิน​ลง​ไป​จริง จบ​ที่ leaf 42 ซึ่ง​ครอบ key 2718

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

=== MERKLE ANTI-ENTROPY ===
KEYSPACE=4096, LEAVES=64 (tree depth = log2 = 6)
property test: 2000 seeded trials, mismatches = 0
seed 7: 3 divergent keys, 2 of 64 leaves differ, 23 node-hash compares (full-tree = 127 nodes)
worst-case node-hash compares over all trials = 109
single-key divergence in 4096-key space: found=[2718], leaves-differing=1, node-hash compares=13 (vs 4096 keys if we scanned everything)
RESULT: OK — diff flags exactly the divergent keys; transfer is O(differing_leaves * log LEAVES), not O(keys).

found=[2718] และ node-hash compares=13 — ต้นไม้​ชี้​ตรง​ไป​ที่ key เดียว​ที่​ต่าง โดย​เทียบ hash แค่ 13 ครั้ง ไม่ใช่ 4096

การ​หา​ใบ​ที่​ต่าง​ถูกต้อง เป๊ะ หมายความ​ว่า: ชุด key ที่ Merkle diff รายงาน ต้อง​เท่ากับ symmetric difference จริง​ของ2 store ทุก​ครั้ง ไม่​ขาด (false negative) ไม่​เกิน (false positive) เรา​พิสูจน์​ด้วย property test 2000 trial: แต่ละ trial สร้าง store พื้นฐาน​แล้ว​สุ่ม mutate อีก​ฝั่ง​ด้วย​จำนวน​การ​เปลี่ยน 0–39 ครั้ง (เพิ่ม/แก้/ลบ) แล้ว​ยืนยัน​ว่า keys_in_leaves(diff) == ground_truth_diff — ผล​คือ mismatches = 0 seed แต่ละ trial derive จาก seed * 0x2545F4914F6CDD1D ^ 0x123456789ABCDEF0 (พิมพ์ seed = repro handle)

แต่​ตรง​นี้​ต้อง​พูด​ความ​จริง “O(log)” เป็น​จริง​เฉพาะ​ตอน​ที่​ความ​ต่าง กระจุก (ใบ​ไม่​กี่​ใบ) ค่า​ใช้​จ่าย​จริง​คือ O(differing_leaves × log LEAVES) ไม่ใช่ O(log) เฉยๆ ดู​สอง​บรรทัด​ใน​ผล​รัน: seed 7 มี 3 key ต่าง​ที่​ตก​อยู่ 2 ใบ → 23 การ​เทียบ (ยัง​ถูก​กว่า 127 มาก); แต่ worst-case = 109 จาก 127 node ทั้ง​ต้น — trial ที่​ความ​ต่าง กระจาย ไป​เกือบ​ทุก​ใบ​ทำให้ Merkle เกือบ​เสื่อม​เป็นการ​สแกน​เต็ม นี่​คือ​ธรรมชาติ​ของ​มัน: มัน​เร็ว​เมื่อ replica ใกล้​ตรง​กัน (ซึ่ง​เป็น​กรณี​ปกติ​ของ anti-entropy วน​บ่อยๆ) และ​ไม่​ได้​วิเศษ​เมื่อ​ทั้ง​คู่​ต่าง​กัน​มหาศาล

hash-sensitivity: เลข​นี้ pin กับ toolchain (เหมือน consistent hashing บท 2)

13, 23, 109 และ hash ทุก​ค่า​ใน​ต้นไม้ reproduce ได้ ก็​เพราะ เรา​ใช้ DefaultHasher::new() ของ std (SipHash-1-3, fixed keys — ไม่ใช่ RandomState ของ HashMap ที่​สุ่ม seed ต่อ process) และ feed ใบ​แบบ​เรียง key ผ่าน BTreeMap ถ้า​ใช้ RandomState เป็น hasher ของ​ใบ replica ที่ เหมือนกันเป๊ะ จะ​ได้ root คนละ​ค่า​แล้ว​ดูเหมือน diverge ทั้ง​ที่​ไม่ — anti-entropy จะขน store มา​เทียบ​มั่ว​ตลอด เช่น​เดียว​กับ​บท 2 std เตือน​ว่า algorithm ของ DefaultHasher “ไม่​รับประกัน​คงที่​ข้าม version” — เลข​ชุด​นี้​จึง pin กับ Rust 1.97.1 การ เทียบ ไม่​พัง​ข้าม version (สอง​ฝั่ง​ใช้ hasher เดียวกัน) แต่ ตัวเลข จะ​เลื่อน​ได้

Merkle บอกว่า ต่าง​ตรง​ไหน แต่​ยัง​ต้อง กระจาย ค่าที่​อัปเดต (และ​ข่าว​สมาชิก​ภาพ: ใคร​เข้า ใคร​ออก ใคร​สงสัย​ว่า​เจ๊ง) ให้​ทั่ว cluster วิธี​ที่​ทนทาน​ที่สุด​คือ gossipgossipการกระจาย​ข่าว​แบบ​ระบาด (epidemic): แต่ละ node สุ่ม​บอก​เพื่อน ๆ ครบ​ทั้ง N ใน O(log N) รอบ — การกระจาย​แบบ ระบาด (epidemic, Demers et al. PODC 1987): แต่ละ node ที่ “รู้​ข่าว​แล้ว” สุ่ม​บอก​เพื่อน fanout ตัว​ต่อ​รอบ เหมือน​ไวรัส​แพร่ ไม่มี​ตัวกลาง ไม่มี​ลำดับ​ตายตัว — node ล้ม​ไป​บ้าง​ก็​ยัง​กระจาย​ต่อ​ได้ ทฤษฎี​บอกว่า​ข่าว​เดียว​ถึง​ครบ​ทั้ง N node ใน O(log N) รอบ

เรา​จำลอง membership view: ทุก node เริ่ม​เชื่อ​ว่า peer เป้าหมาย {incarnation: 1, alive: true} แล้ว ฉีด​อัปเดต​หนึ่ง​ตัว ที่ node เดียว ({incarnation: 2, alive: false} — “peer นี้​ถูก​ตั้ง​เป็น suspect ที่ incarnation 2”) จาก​นั้น​รัน push-gossip จน​ทุก node เห็น​ตรง​กัน disseminate มี hard round cap = 200 เป็น​เพดาน​ตายตัว: harness ค้าง​ไม่​ได้​เด็ดขาด แม้ config จะ​ไม่​ยอม​ลู่​เข้า (compiler จับ deadlock ไม่​ได้ — เพดาน​นี้​คือ​กันชน​ที่​ต้อง​มี​ทุก​ครั้ง)

// One entry of a node's view of some peer's status.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
struct Status {
incarnation: u64,
alive: bool, // false = suspect/dead (dead wins ties, monotone)
}
// Semilattice merge (commutative, associative, idempotent): take the newer fact.
fn merge(a: Status, b: Status) -> Status {
if a.incarnation != b.incarnation {
if a.incarnation > b.incarnation { a } else { b }
} else {
// tie on incarnation -> "dead" (alive=false) dominates: suspicion is monotone
Status { incarnation: a.incarnation, alive: a.alive && b.alive }
}
}
// Run one dissemination, return rounds-to-converge (or None if it hit the cap).
// Every node that knows the update contacts `fanout` random peers this round and
// pushes the fact; contacted peers merge it in (epidemic push).
fn disseminate(n: usize, fanout: usize, round_cap: usize, seed: u64) -> Option<usize> {
let old = Status { incarnation: 1, alive: true };
let new = Status { incarnation: 2, alive: false }; // the injected update
let mut view = vec![old; n];
view[0] = merge(view[0], new); // inject at node 0 only
let mut rng = seed ^ 0xD1B54A32D192ED03;
for round in 1..=round_cap {
// snapshot who knows BEFORE this round (push based on this-round state)
let knows_before: Vec<bool> = view.iter().map(|s| *s == new).collect();
for i in 0..n {
if !knows_before[i] {
continue; // only informed nodes gossip
}
for _ in 0..fanout {
let peer = (splitmix64(&mut rng) % n as u64) as usize;
view[peer] = merge(view[peer], view[i]);
}
}
if view.iter().all(|s| *s == new) {
return Some(round);
}
}
None
}

หมายเหตุ: splitmix64(&mut rng) ที่​เรียก​ใน loop คือ PRNG แบบ hand-rolled ตัว​เดียว​กับ​ที่​นิยาม​ไว้​ใน​บท 1 (define-once-reuse — ไม่​กาง code ซ้ำ​ที่​นี่) seed คงที่​จึง​รัน​ซ้ำ​ได้ byte-identical

กวาด N แบบ log-spaced แล้ว​เฉลี่ย​รอบ​ต่อ 200 seed (fanout = 3) ได้​ตาราง​นี้:

=== GOSSIP / SWIM DISSEMINATION ===
merge = semilattice (max incarnation; dead wins ties). push-gossip, fanout=3.
N | log2(N) | avg rounds | max rounds
10 | 3.32 | 3.27 | 6
32 | 5.00 | 4.37 | 6
100 | 6.64 | 5.64 | 7
316 | 8.30 | 7.02 | 9
1000 | 9.97 | 8.21 | 11
3162 | 11.63 | 9.31 | 11
10000 | 13.29 | 10.56 | 13

จำนวน​รอบ​เฉลี่ย​เดิน​ตาม log2(N) เกือบ​เป๊ะ (จริงๆ ต่ำ​กว่า​เล็กน้อย​เพราะ fanout 3 ไม่ใช่ 1) — N โต 1000 เท่า (10 → 10000) รอบ​เพิ่ม​แค่​ราว 3 เท่า (3.27 → 10.56) นี่​คือ เหตุผล ที่ gossip สเกล​ได้: ค่า​ใช้​จ่าย​ใน​การกระจาย​โต​แบบ logarithmic ไม่ใช่ linear

ค่า​คงที่​ของ O(log N) ขึ้น​กับ model — อย่า overclaim

“O(log N)” เป็น​จริง แต่ ค่า​คงที่ ขึ้น​กับ fanout และ​รูปแบบ (push / pull / push-pull): push fanout-1 ล้วน ≈ log₂N + ln N (Pittel 1987), push-pull ≈ log₃N เรา​วัด fanout-3 push ได้​ตัวเลข​ข้าง​บน — รายงาน​เลข​ที่​วัด​จริง​พร้อม model ไม่ใช่​ยก​ทฤษฎี​มา​อ้างลอยๆ

สังเกต merge ของ Status: มัน​คือ join ของ semilattice ตัว​เดิม​จาก​บท 1/4 — เอา incarnation ที่มากกว่า​เป็น​หลัก, เสมอ​กัน​ให้ alive = a.alive && b.alive (dead/suspect ชนะ) กติกา​นี้​ทำให้ view เดิน​หน้า​ทาง​เดียว (monotone): สถานะ​ที่ merge เข้า​มา​ไม่​เคย​ทำให้ node “ย้อน​กลับ” ไป​คิด​ว่า peer ที่​ตาย​แล้ว​ยัง​มี​ชีวิต การ bump incarnation คือ​วิธี “ลบล้าง” ข่าว​เก่า (peer ที่​ถูก​หา​ว่า​ตาย​แต่จริงๆ ยัง​อยู่ ก็ bump incarnation ตัวเอง​ประกาศ​ว่า “ฉัน​ยัง​อยู่ version ใหม่​กว่า”)

เพราะ merge เป็น semilattice (commutative + associative + idempotent) การกระจาย​แบบ gossip ที่​ลำดับ​มั่ว ส่ง​ซ้ำ ส่ง​ซ้อน จึง​ไม่มี​ผล​ต่อ​ค่า​ปลายทาง — node ที่​เห็น​ชุด​ข่าว​เดียวกัน (ลำดับ​ใด​ก็ได้) ลู่​เข้า​สถานะ​เดียวกัน​เสมอ โดย​ไม่​ต้อง coordinate เรา​พิสูจน์ property + กฎ merge:

property test: 3000 seeded runs (N=8..507), non-converged = 0
round-cap sweep hit cap (any_fail) = false
merge laws: commutative + associative + idempotent — verified over 5000 seeded triples.
RESULT: OK — one injected update reaches all N nodes; rounds grow ~log2(N) (epidemic bound).

non-converged = 0 จาก 3000 run (N สุ่ม 8–507) — ทุก config ลู่​เข้า​ภายใน cap; any_fail = false — ไม่มี run ไหน​ชน round cap เลย; และ​กฎ semilattice ทั้ง​สาม​ผ่าน 5000 triple (domain ของ Status มี​แค่ 8 ค่า → 5000 ครอบคลุม​แทบ​ครบ​ทุก​ความ​เป็น​ไป​ได้)

SWIM: แยก “ตรวจ​เจ๊ง” ออก​จาก “กระจาย​ข่าว” — และ​เส้น scope

หัวข้อ​ที่​มีชื่อ​ว่า “SWIM: แยก “ตรวจ​เจ๊ง” ออก​จาก “กระจาย​ข่าว” — และ​เส้น scope”

protocol จริง​ที่​รวม​สอง​ส่วน​นี้​เข้า​ด้วย​กัน​คือ SWIMSWIMprotocol membership: แยก 'ตรวจ​เจ๊ง' (ping/ping-req + incarnation) ออก​จาก 'กระจาย​ข่าว' (gossip) (Das, Gupta, Motivala, DSN 2002) หัวใจ​ของ SWIM คือ แยก งาน​สอง​อย่าง​ออก​จาก​กัน​ชัดเจน: failure DETECTION (ตรวจ​ว่า node ไหน​เจ๊ง) กับ DISSEMINATION (กระจาย​ผล​ให้​ทั่ว) — ส่วน dissemination + membership merge คือ​สิ่ง​ที่​เรา​ลง code รัน​จริง​ไป​แล้ว​ข้าง​บน แต่​ส่วน detection ผูก​กับ timing จริง และ​นั่น​คือ​จุด​ที่​เส้น scope ของ​คอร์สลาก​ผ่าน

SWIM ตรวจ​เจ๊ง​ด้วย​จังหวะ​เวลา: node A ping node B แล้ว รอ timeout; ถ้า B เงียบ A ส่ง ping-req(B) ให้ node พยาน​ช่วย​ลอง ping แทน (กัน​เน็ต​ขาด​เฉพาะ​คู่ A–B); ถ้า​ยัง​เงียบ A ตั้ง B เป็น suspect แล้ว​ให้ gossip พา incarnation number ไป​กระจาย ก่อน​จะ ยืนยัน​ว่า​ตาย หลัง​เวลา​ผ่าน​ไป​อีก​ช่วง

sequenceDiagram
    participant A as node A
    participant B as node B
    participant C as node พยาน
    A->>B: ping แล้วรอ timeout
    Note over A,B: B ไม่ตอบภายในเวลา
    A->>C: ping-req(B) — ฝากพยานลอง ping แทน
    C->>B: ping (ยังเงียบ)
    Note over A,B: A ตั้ง B = suspect แล้ว bump incarnation
    Note over A,C: กระจาย suspect ผ่าน gossip — ส่วนนี้เท่านั้นที่ลง code

คำ​บรรยาย​ภาพ: SWIM แยก detection (ping/ping-req/suspicion ตาม​จังหวะ​เวลา — ไดอะแกรม​เท่านั้น) ออก​จาก dissemination (gossip + membership merge — ลง code รัน​จริง​ข้าง​บน) กล่อง​ซ้าย​ของ​แผนภาพ​ผูก​กับ wall-clock timeout จึง​พิสูจน์​ใน​เครื่อง​เดียว​ไม่​ได้

honesty spine: ทำไม detection timing ถึง​เป็น​ไดอะแกรม​เท่านั้น

C — ดี​เท​อร์มิ​นิส​ซึม​คือ​วินัย: dissemination + merge เรา เอ็น​นิว​เมอเรตด้วย property test ได้ (5000 triple ครอบ domain 8 ค่า, 3000 run ลู่​เข้า​ครบ) เพราะ​มัน​คือ semilattice บน state คงที่ — seed คงที่ + splitmix64 + hard round cap ทำให้​รัน​ซ้ำ​ได้​และ​ค้าง​ไม่​ได้ แต่ failure detection ของ SWIM ตัดสิน​จาก timeout เทียบ wall-clock — “B ตอบ​ช้า​เพราะ​เน็ต​หน่วง” กับ “B ตาย​จริง” แยก​จาก​กัน​ไม่​ได้​ใน​เครื่อง​เดียว เพราะ​ไม่มี partition/หน่วง/skew จริง​ให้ reproduce; green test ของ detection จึง​เป็น​คำ​โกหก เรา​จึง​คง​มัน​เป็น ไดอะแกรม

A — Rust พิสูจน์​ความ​ถูกต้อง “ใน​เครื่อง​เดียว” ไม่ใช่ “แบบ​กระจาย”: property test ที่​ผ่าน (2000 Merkle trial, 3000 gossip run) คือ ออราเคิล ของ​สมบัติ​แบบ​กระจาย และ​มัน​คือ​การ ตรวจ​แบบ​มี​ขอบเขต ไม่ใช่​บท​พิสูจน์ (FLP 1985) — ผ่าน​แปล​ว่า​สำรวจ​เฉพาะ interleaving ที่ seed เดิน​ไป​ถึง

D — นี่​คือ​กลไก​จริง​ของ Dynamo/Cassandra/Riak: Merkle anti-entropy คือ​กลไก sync replica จริง​ของ Dynamo, gossip membership คือ​ของ Cassandra/Riak เรา​สอน ตรง​ตาม​ต้นฉบับ ที่​สเกล toy บน kaen-kvstore ไม่ใช่​ของ​กุ​ขึ้น​เพื่อ​สอน

บท​นี้​เติม​ชั้น anti-entropy เบื้องหลัง ให้ cluster: Merkle treeMerkle treeต้นไม้ hash ที่​เทียบ root แล้ว​ลง​เฉพาะ subtree ที่​ต่าง หา​ช่วง key ที่ diverge แบบ sub-linear เหนือ key-space (leaf = ช่วง key ที่​ติด​กัน hash ด้วย DefaultHasher แบบ​เรียง, node ใน = hash ของ​ลูก) ให้​เรา​เทียบ root ก่อน แล้ว​ลง​เฉพาะ subtree ที่​ต่าง — key เดียว​ใน 4096 เจอ​ด้วย​การ​เทียบ hash 13 ครั้ง, diff ตรง​กับ oracle เป๊ะ​ทุก 2000 trial (mismatches=0) แต่​ค่า​ใช้​จ่าย​จริง​คือ O(differing_leaves × log LEAVES) เร็ว​เมื่อ replica ใกล้​ตรง​กัน (worst-case 109/127 เมื่อ​ความ​ต่าง​กระจาย) — และ​เลข​ทุก​ตัว pin กับ DefaultHasher fixed-key + Rust 1.97.1; gossipgossipการกระจาย​ข่าว​แบบ​ระบาด (epidemic): แต่ละ node สุ่ม​บอก​เพื่อน ๆ ครบ​ทั้ง N ใน O(log N) รอบ แบบ epidemic กระจาย​ข่าว​ถึง​ครบ​ทุก node ใน O(log N) รอบ (วัด​จริง fanout-3: N=10 → 3.27, N=10000 → 10.56) โดย membership merge เป็น monotone semilattice (max incarnation, tie → dead ชนะ) ที่​ลู่​เข้า​เอง​ไม่​ต้อง coordinate — กฎ merge ผ่าน 5000 triple, ลู่​เข้า​ครบ​ทุก 3000 run ภายใน hard round cap; และ SWIMSWIMprotocol membership: แยก 'ตรวจ​เจ๊ง' (ping/ping-req + incarnation) ออก​จาก 'กระจาย​ข่าว' (gossip) แยก detection (timing — ไดอะแกรม​เท่านั้น) ออก​จาก dissemination (code ที่​รัน​จริง) ทุก snippet compile zero-warnings บน std ล้วน + splitmix64 รัน​ซ้ำ​ได้ byte-identical

บท 7 เรา​สร้าง harness ฉีด partition: เรา​มี​ชิ้น​ส่วน​ครบ​แล้ว — clock, hashing, quorum, CRDT, anti-entropy บท​หน้า​เรา​จะ​สร้าง harness แบบดี​เท​อร์มิ​นิสติก (Jepsen-lite) ที่​ฉีด partition เข้าไปจริงๆ ด้วย single-scheduler + seed คงที่ + hard timeout แล้ว​ยืนยัน​สมบัติ​การ​ลู่​เข้า พร้อม linearizability checker ที่​บอกตรงๆ ว่า quorum+LWW ของ​เรา ไม่ linearizable — ก่อน​ประกอบ​ทุก​อย่าง​เป็น cluster จริง​ใน​บท 8


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

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

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

ข้อ 1 / 3

ทำไมการหา key ที่ diverge ด้วย Merkle tree ถึงเป็น sub-linear เมื่อความต่างกระจุก และค่าใช้จ่ายจริงคืออะไร?