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

Quorum R/W/N และ read-repair — ทำไม W+R>N ถึง​การันตี​การ​ทับ​กัน

บท 1 ให้​เวลา​เชิง​ตรรกะ (vector clock) ไว้​ตรวจ​ว่า​เหตุการณ์​ไหน​เกิด​ก่อน​ไหน บท 2 ให้ consistent hashing ไว้​ตอบ​ว่า key แต่ละ​ตัว ควร​อยู่ node ไหน — และ​ตอน​วาง​แต่ละ key เรา​ไม่​ได้​วาง​แค่​สำเนา​เดียว แต่​วาง​ลง N replica เพื่อ​ให้​ทน node ล่ม บท​นี้​ตอบ​คำถาม​ที่​ตาม​มา​ทันที: ถ้า key หนึ่ง​มี​สำเนา​อยู่ N ที่ เวลา​เขียน​ต้อง​เขียน​กี่​ที่ เวลา​อ่าน​ต้อง​อ่าน​กี่​ที่ ถึง​จะ มั่นใจ​ว่า​อ่าน​เจอ​ค่าที่​เพิ่ง​เขียน โดย​ไม่​ต้อง​รอ​ครบ​ทุก node (ซึ่ง​จะ​ทำให้​ระบบ​ตาย​ทั้ง cluster เมื่อ node เดียว​ช้า)

คำ​ตอบ​คือ quorumquorumเขียน​ต้อง ack ≥W อ่าน​ต้อง​ตอบ ≥R; ถ้า W+R>N อ่าน​จะ​ทับ write ล่าสุด​เสมอ: เขียน​ให้ ack อย่าง​น้อย W ที่ อ่าน​ให้​ตอบ​อย่าง​น้อย R ที่ กติกา​ข้อ​เดียว​ที่​ทำให้​มัน​เวิร์ก​คือ W + R > N และ​เหตุผล​ที่​มัน​เวิร์ก​สวยงาม​กว่า​ที่​คิด — มัน​ไม่ใช่​คุณสมบัติ runtime ที่​ต้อง​ภาวนา​ให้​ถูก แต่​เป็น ข้อเท็จจริง​เชิง​การ​จัด​หมู่​ที่​สถิต (static) ที่​เรา เอ็น​นิว​เมอเรต​ครบ​ทุก​กรณี​ได้​จริง บท​นี้​จึง​พิสูจน์​มัน​แบบ exhaustive ไม่ใช่​แบบ​สุ่ม​ตัวอย่าง

📦 kaen-kvstore

คอร์ส​นี้ ต่อยอด repo kaen-kvstore จาก #22 (code ตัวอย่าง​กำลัง​จัด​ทำ) — บท​นี้​ยก​แต่ละ key จาก​สำเนา​เดียว​ขึ้น​เป็น N replica แล้ว​เพิ่ม​ชั้น quorum coordinator ทับ store เดิม: เขียน​กระจาย​ไป W replica อ่าน​รวม​จาก R replica ทั้งหมด Rust std ล้วน ไม่มี serde/tokio/proptest — สุ่ม​ด้วย splitmix64 ที่ seed ซ้ำ​ได้ 100% THE SCOPE LINE: quorum arithmetic เป็น​ข้อเท็จจริง​เชิง​การ​จัด​หมู่​ที่ เอ็น​นิว​เมอเรต​ครบ​ได้ เรา​จึง​ลง code รัน​จริง​และ​ตรวจ​ครบ​ทุก subset — ต่าง​จาก consensus/Raft ที่​ตรวจ​ครบ​ไม่​ได้​และ​คง​ไว้​เป็น​ไดอะแกรม (บท 8) code ใน​บท​นี้​แตะ std เพียง std::vec/std::iter (ไม่มี import นอก std เลย); ทุก​โปรแกรม compile แบบ zero-warnings บน Rust 1.97.1 / edition 2024 และ รัน​จริง — เลข​ทุก​ตัว​ด้าน​ล่าง​คือ output จริง​ที่ seed ล็อก​ไว้ รัน​ซ้ำ​ได้ byte-identical

ตั้ง​ค่า​ให้​ชัด: key หนึ่ง​มี​สำเนา​อยู่ N replica; การ​เขียน​หนึ่ง​ครั้ง​จะ​ถือว่า​สำเร็จ (ack กลับ​หา client) เมื่อ​ได้ ack จาก​อย่าง​น้อย W replica; การ​อ่าน​หนึ่ง​ครั้ง​จะ​รวบ​คำ​ตอบ​จาก​อย่าง​น้อย R replica แล้ว​เลือก​ค่าที่​ใหม่​ที่สุด W คือ write quorum R คือ read quorum

หัวใจ​อยู่​ที่​ประโยค​เดียว: ถ้า W + R > N แล้ว​ทุก write-set ขนาด W กับ​ทุก read-set ขนาด R จะมี node ร่วม​กัน​อย่าง​น้อยหนึ่ง​ตัว​เสมอ เพราะ node ร่วม​ตัว​นั้น​เคย​รับ write ล่าสุด​ไว้ การ​อ่าน​จึง ต้อง เห็น​ค่า​นั้น​อย่าง​น้อยหนึ่ง​สำเนา — อ่าน​ไม่มี​ทาง​พลาด write ที่ ack ไป​แล้ว

พิสูจน์​ด้วย pigeonhole ตรงๆ ไม่​ต้อง​อ้อม ให้ W และ R เป็น​เซ็ต​ของ replica ที่​ถูก​เขียน/ถูก​อ่าน:

|W ∩ R| = |W| + |R| − |W ∪ R| ≥ w + r − n > 0

บรรทัด​กลาง​ใช้​ข้อเท็จจริง​ว่า |W ∪ R| ≤ n (ทั้ง​สอง​เซ็ต​เป็น subset ของ replica ทั้ง N ตัว) ดังนั้น​ถ้า w + r > n ขนาด​ของ intersection จะ​มากกว่า 0 เสมอ — มี node ร่วม​แน่นอน นี่​คือ​กฎ​ของ Gifford (Weighted Voting for Replicated Data, SOSP 1979) ที่ Dynamo และ Cassandra ใช้​จริง​จนถึง​ทุก​วัน​นี้ (DDIA บท 5, Kleppmann 2017)

graph LR
    W["write quorum<br/>W = 3"] -->|เขียน| N0["replica 0"]
    W -->|เขียน| N1["replica 1"]
    W -->|เขียน| N2["replica 2 ★"]
    R["read quorum<br/>R = 3"] -->|อ่าน| N2
    R -->|อ่าน| N3["replica 3"]
    R -->|อ่าน| N4["replica 4"]
    N2 -->|"node ร่วม → อ่านเห็น write ล่าสุด"| OUT["ค่าที่ใหม่ที่สุด"]

คำ​บรรยาย​ภาพ: N=5, write-set {0,1,2} (W=3) กับ read-set {2,3,4} (R=3) — W+R = 6 > 5 จึง​บังคับ​ให้​มี node ร่วม​อย่าง​น้อยหนึ่ง​ตัว (ที่​นี่​คือ replica 2 ★) การ​อ่าน​ที่​แตะ replica 2 จึง​เห็น write ล่าสุด​เสมอ ต่อ​ให้ read-set กับ write-set จะ​เลือก node ต่าง​กัน​อย่างไร​ก็ตาม

จุด​ที่​คน​มัก​พลาด: W + R > N เป็น​คุณสมบัติ สถิต — มัน​ไม่​ขึ้น​กับ​เวลา ข้อความ หรือ​ลำดับ runtime เลย มัน​เป็น​แค่​ข้อเท็จจริง​เรื่อง subset ของ​เซ็ต​จำกัด เมื่อ​ของ​มัน​สถิต​และ เอ็น​นิว​เมอเรต​ครบ​ได้ การ​เอา property test แบบ​สุ่ม​มา​ยิง​คือ​การ​ตอบ​ผิด​เครื่องมือ — random test อาจ พลาด​ขอบ​ที่​คับ​ที่สุด คือ​กรณี w + r = n + 1 ที่ intersection เหลือ​แค่ 1 พอดี ถ้า​สุ่ม​ไม่​โดน​คู่​นั้น เรา​จะ​เข้าใจ​ผิด​ว่า “ผ่าน” ทั้ง​ที่​ยัง​ไม่​เคย​ตรวจ​กรณี​วิกฤต

เรา​จึง ตัดสิน มัน​ด้วย​การ enumerate: สำหรับ n แต่ละ​ค่า แทน replica แต่ละ​ตัว​ด้วย1 bit ของ​เลข n bit แล้ว​ไล่​ทุก write-set (a) คู่​กับ​ทุก read-set (b) ครบ​ทั้ง 2^n × 2^n คู่ ตรวจ​สอง​อย่าง (1) ถ้า w + r > n แต่ a & b ว่าง (intersection = 0) นับ​เป็น counterexample; (2) เก็บ intersection ที่ เล็ก​ที่สุด ของ​แต่ละ (w, r) ไว้​เทียบ​กับ oracle รูป​ปิด max(0, w+r−n) — ค่า​นี้​คือ intersection ที่​น้อย​ที่สุด​ที่ เป็น​ไป​ได้​จริง ของ w-subset กับ r-subset (และ​มี disjoint pair ก็​ต่อ​เมื่อ w + r ≤ n)

// Quorum W+R>N intersection: EXHAUSTIVE combinatorial verification (pure std).
// STATIC, DECIDABLE fact -- verify by ENUMERATION, not sampling. Enumerate EVERY
// (W-subset, R-subset) pair for all n up to N_MAX (n-bit masks) and cross-check
// the enumerated minimum intersection against the closed-form oracle max(0,w+r-n).
const N_MAX: usize = 12;
fn main() {
let mut counterexamples: u64 = 0; // W+R>N pairs that were nonetheless disjoint
let mut oracle_mismatch: u64 = 0; // enumeration disagreed with max(0,w+r-n)
let mut pairs_checked: u64 = 0;
for n in 1..=N_MAX {
// min_int[w][r] = smallest |A ∩ B| seen over all w-subsets A, r-subsets B.
let mut min_int = vec![vec![usize::MAX; n + 1]; n + 1];
let total: u32 = 1u32 << n;
for a in 0..total {
let w = a.count_ones() as usize;
if w == 0 {
continue;
}
for b in 0..total {
let r = b.count_ones() as usize;
if r == 0 {
continue;
}
pairs_checked += 1;
let inter = (a & b).count_ones() as usize;
// SAFETY INVARIANT under test: W + R > N => quorums intersect.
if w + r > n && inter == 0 {
counterexamples += 1;
}
if inter < min_int[w][r] {
min_int[w][r] = inter;
}
}
}
// Independent oracle: the achievable minimum intersection of a w-subset
// and an r-subset of an n-set is EXACTLY max(0, w+r-n); a disjoint pair
// exists iff w+r <= n. Cross-check the whole (w,r) table.
for w in 1..=n {
for r in 1..=n {
let oracle = (w + r).saturating_sub(n); // = max(0, w+r-n)
if min_int[w][r] != oracle {
oracle_mismatch += 1;
}
}
}
}
println!("N_MAX = {N_MAX}");
println!("subset pairs exhaustively checked : {pairs_checked}");
println!("W+R>N intersection counterexamples: {counterexamples}");
println!("closed-form oracle mismatches : {oracle_mismatch}");
assert_eq!(counterexamples, 0, "found a DISJOINT quorum pair despite W+R>N");
assert_eq!(oracle_mismatch, 0, "enumeration disagreed with max(0,w+r-n) oracle");
println!("PASS: for every n<=N_MAX and every (w,r), W+R>N guarantees intersection");
}

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

N_MAX = 12
subset pairs exhaustively checked : 22353252
W+R>N intersection counterexamples: 0
closed-form oracle mismatches : 0
PASS: for every n<=N_MAX and every (w,r), W+R>N guarantees intersection

22,353,252 คู่ subset ตรวจ​ครบ ได้ 0 counterexample และ enumeration ตรง​กับ oracle รูป​ปิด​ทุก​ช่อง — สอง​บรรทัด​นี้​ยืนยัน​คนละ​ทาง: counterexample = 0 บอกว่า “ไม่มี​คู่​ที่ W+R>N แล้ว​ยัง disjoint”; oracle mismatch = 0 บอกว่า “intersection ที่​เล็ก​ที่สุด​ที่​เจอ​ตรง​กับ​สูตร​ทุก​กรณี” รวม​กัน​คือ​หลักฐาน​ว่า​เรา​ตรวจ ขอบ​ที่​คับ​ที่สุด (w+r = n+1) แล้ว​จริง ไม่​ได้​ผ่าน​เพราะ​โชค​ดี

ระวัง O(4^n) ระเบิด — เหตุผล​ที่ cap ไว้​ที่ N_MAX = 12

การ enumerate นี้​เป็น O(4^n) เพราะ​ไล่ 2^n write-set คูณ 2^n read-set: N_MAX = 12 คือ ~22.4 ล้าน​คู่ รัน​ใน​ราว 0.4 วินาที แต่ n = 16 ขึ้น​ไป​จะ​ระเบิด​เป็น​หลัก​พัน​ล้าน​ทันที ถ้า​ต้อง​พิสูจน์ n ใหญ่​กว่า​นี้ อย่า​ไล่​ทุก subset — จัด​กลุ่ม​ด้วย popcount (จำนวน bit) แล้ว​พึ่ง oracle รูป​ปิด max(0, w+r−n) ที่​เรา​ครอสเช็ก​ไว้​แล้ว​แทน สำหรับ​สอน​หลักการ N_MAX = 12 ครอบ​ขอบ​วิกฤต​ครบ​และ​รัน​ไว

read-repair: เขียน​ค่าที่ merge แล้วกลับ​ไป​กลบ​ตัว​ที่​เก่า

หัวข้อ​ที่​มีชื่อ​ว่า “read-repair: เขียน​ค่าที่ merge แล้วกลับ​ไป​กลบ​ตัว​ที่​เก่า”

quorum การันตี​ว่า อ่าน​เจอ ค่า​ล่าสุด แต่​ตอน​อ่าน​เรา​อาจ​เจอ replica ที่​ยัง เก่า ปน​อยู่​ใน read-set (มัน​ไม่​ได้​อยู่​ใน write-set ครั้ง​ก่อน) จะ​ปล่อย​ไว้​ก็​เสี่ยง​ว่า​รอบ​หน้า​มัน​ตอบ​ค่า​เก่า​อีก read-repairread-repairตอน​อ่าน​เจอ replica ต่าง​กัน คำนวณ LUB แล้ว​เขียน​ค่า​ใหม่​กลับ​ไป​กลบ​ตัว​ที่​เก่า คือ​กลไก​ซ่อม​ตรง​นี้: ระหว่าง​ที่​อ่าน​อยู่​แล้ว ให้​คำนวณ​ค่าที่​ใหม่​ที่สุด​ของ read-set แล้ว เขียน​มัน​กลับ ไป​กลบ replica ที่​เก่า​หรือ​ที่​ยัง​ไม่มี​ค่า — เป็น anti-entropy แบบ​ฉวย​โอกาส​บน​เส้นทาง​อ่าน

แต่ “ใหม่​ที่สุด” คำนวณ​ยังไง​เมื่อ replica เก็บ version vectorversion vectorvector clock ที่ key ด้วย​ผู้​เขียน ใช้​ตรวจ​ว่า write สอง​อัน​เป็น​ลำดับ​กัน​หรือ concurrent (sibling) (จาก​บท 1: vector clock ที่ key ด้วย​ผู้​เขียน ใช้​แยก​ว่า write สอง​อัน​เป็น​ลำดับ​กัน​หรือ concurrent)? เรา​เอา LUB (least upper bound = join แบบ element-wise max) ของ version vector ทั้ง read-set นั่น​คือ version ที่ dominate ทุก​ตัว แล้ว​เลือก ค่า ที่​ถือ version เท่ากับ LUB นั้น จุด​ตาย​ที่​ต้อง​รักษา​ให้​ถูก​คือ function relate() ที่​เทียบ version vector สอง​ตัว — มัน​ต้อง​แยก​ออก​ได้​สี่​ทาง (Equal / Greater / Less / Concurrent) โดย​ต้อง​เช็ก ทั้ง any_gt และ any_lt; ถ้า​เผลอ​เทียบ​ด้วย >= อย่าง​เดียว มัน​จะ​ยัด​ให้ sibling ที่ concurrent กลาย​เป็น​มี​ลำดับ​กัน​เงียบๆ — bug เดียว​กับ partial_cmp ที่​ยัด Some ในบท 1

// Version vector: state-based semilattice, join = elementwise max.
#[derive(Clone, PartialEq, Eq, Debug)]
struct Vv(Vec<u64>);
#[derive(PartialEq, Debug)]
enum Rel {
Equal,
Greater, // self is strictly newer (dominates)
Less, // self is strictly older
Concurrent, // siblings: neither dominates -> conflict
}
impl Vv {
fn zero(n: usize) -> Vv {
Vv(vec![0; n])
}
// LUB / join: the least upper bound in the version-vector lattice.
fn join(&self, other: &Vv) -> Vv {
Vv(self.0.iter().zip(&other.0).map(|(a, b)| (*a).max(*b)).collect())
}
// MUST track BOTH any_gt AND any_lt: a >=-only compare silently orders siblings.
fn relate(&self, other: &Vv) -> Rel {
let mut any_gt = false;
let mut any_lt = false;
for (a, b) in self.0.iter().zip(&other.0) {
if a > b {
any_gt = true;
}
if a < b {
any_lt = true;
}
}
match (any_gt, any_lt) {
(false, false) => Rel::Equal,
(true, false) => Rel::Greater,
(false, true) => Rel::Less,
(true, true) => Rel::Concurrent,
}
}
}

ตัว cluster เก็บ (Vv, u64) ต่อ replica (version + ค่า) การ​เขียน​แบบ quorum จะ​บวก​ช่อง​ของ​ผู้​เขียน​หนึ่ง (เป็น causal event ใหม่) แล้ว​ประทับ​ลง​ทุก replica ใน write-set; การ​อ่าน​แบบ read-repair ทำ​สาม​สเต็ป — คำนวณ LUB ของ read-set, เลือก​ค่าที่​ถือ version = LUB (ถ้า​ไม่มี​เพราะ concurrent ก็ ตั้ง​ธง ไว้​ให้ LWW/CRDT จัดการ), แล้ว​เขียน (LUB, value) กลับ​ไป​ทุก replica ที่​ยัง​ไม่​เท่ากับ LUB:

fn read_repair(&mut self, read_set: &[usize]) -> (Vv, u64, bool) {
let mut lub = Vv::zero(self.n);
for &i in read_set {
lub = lub.join(&self.replicas[i].0);
}
// concurrency detection among the read set's versions
let mut concurrent = false;
for x in 0..read_set.len() {
for y in (x + 1)..read_set.len() {
if let Rel::Concurrent =
self.replicas[read_set[x]].0.relate(&self.replicas[read_set[y]].0)
{
concurrent = true;
}
}
}
// value carried by a replica whose version already == LUB (the causal newest).
// If none exists (concurrent), fall back to a placeholder that lessons 4-5
// replace with an LWW clock or a CRDT merge.
let value = read_set
.iter()
.find(|&&i| self.replicas[i].0 == lub)
.map(|&i| self.replicas[i].1)
.unwrap_or_else(|| read_set.iter().map(|&i| self.replicas[i].1).max().unwrap());
// write the merged version + value back to stale replicas
for &i in read_set {
if self.replicas[i].0 != lub {
self.replicas[i] = (lub.clone(), value);
}
}
(lub, value, concurrent)
}

พิสูจน์​ด้วย code: convergence, freshness และ​ความ​ซื่อสัตย์​เรื่อง concurrency

หัวข้อ​ที่​มีชื่อ​ว่า “พิสูจน์​ด้วย code: convergence, freshness และ​ความ​ซื่อสัตย์​เรื่อง concurrency”

เรา​ตรวจ​สี่​อย่าง​ด้วย property test (seed คงที่ = รัน​ซ้ำ​ได้ 100%): (A) กฎ semilattice ของ join (commutative / associative / idempotent) บน 3000 triple — กฎ​เดียว​กับ​บท 1 ที่​บท 4–5 จะ​ใช้​ซ้ำ; (B) convergence ของ read-repair — หลัง​อ่าน​ซ่อม​แล้ว​ทุก replica ใน read-set ต้อง​ถือ LUB; (C) freshness ภาย​ใต้ W+R>N — เขียน1 write ล่าสุด​ลง W-quorum สุ่ม แล้ว​อ่าน​จาก R-quorum สุ่ม​ที่ W+R>N ต้อง ไม่มี​ทาง​พลาด write นั้น (นี่​คือ​ผล​ตอบแทน​ของ intersection invariant ที่ enumerate ไว้​ข้าง​บน แต่​คราว​นี้​ทดสอบ​บน cluster จริง​ที่​มี read-repair); (D) ความ​ซื่อสัตย์​เรื่อง concurrency — write สอง​อัน​ที่ concurrent ต้อง​ถูก ตรวจ​เจอ​และ​ตั้ง​ธง ไม่ใช่​แอบ​เลือก​ค่า​ให้​เงียบๆ

(A) semilattice laws : 3000 triples PASS (commutative/associative/idempotent)
(B) read-repair convergence: 2000/2000 read sets converge to LUB
(C) freshness W+R>N : 5000 reads, 0 missed committed writes
(of those, 0 read sets were concurrent -> LWW/CRDT territory)
(D) concurrency honesty : sibling writes detected and FLAGGED, not silently linearized
PASS: read-repair converges, W+R>N is fresh-not-linearizable, concurrency is surfaced

สังเกต​บรรทัด (C): 5000 การ​อ่าน 0 ครั้ง​ที่​พลาด committed write — ยืนยัน​ว่า intersection invariant ที่​เรา enumerate ใน​เชิง​ทฤษฎี​ทำงาน​จริง​บน cluster ที่​มี​สถานะ และ “of those, 0” ที่​ต่อ​ท้าย​คือ​ความ​จงใจ: ใน​การ​ทดสอบ (C) เรา​เขียน write ล่าสุด ครั้ง​เดียว ก่อน​อ่าน — write เดียว​สร้าง sibling ไม่​ได้ จึง​ไม่มี concurrent เป็น​เรื่อง​ปกติ ส่วน​กรณี concurrent จริง​ถูก​แยก​ไป​ทดสอบ​ใน (D) ต่างหาก ซึ่ง​เขียน2 write จาก​ผู้​เขียน​คนละ​คน​แล้ว​ยืนยัน​ว่า relate() คืน Rel::Concurrent และ read-repair ตั้ง​ธง conc = true จริง

จุด​ที่​ขาย​กัน​เกิน​บ่อย​ที่สุด: W+R>N คุม​แค่ staleness ไม่ใช่ linearizability มัน​การันตี​ว่า read-set แตะ write-set ล่าสุด จึง เห็น ค่า​ใหม่ — แต่​มัน​ไม่​ได้​จัด​ลำดับ​รวม​ของ operation ทั้งหมด แม้ strict quorum ก็ ไม่ linearizable: การ​อ่าน​สอง​ครั้ง​ที่ concurrent กัน​สามารถ​เห็น​ค่า “ถอย​หลัง” ได้ (read แรก​เจอ replica ที่​ซ่อม​แล้ว read ที่​สอง​ไป​โดน replica ที่​ยัง​ไม่​ถูก​ซ่อม) พูด​ให้​ตรง​คือ “fresh, not linearizable” — quorum ซื้อ intersection มา​ให้ ไม่​ได้​ซื้อ consensus (DDIA บท 5/บท 9)

และ​เมื่อ write สอง​อัน concurrent จริง version vector จะ ตรวจ​เจอ ว่า​มัน​เป็น sibling (neither dominates) — read-repair คำนวณ LUB ที่ dominate ทั้ง​คู่​ได้ แต่ ค่า ที่​ควร​อยู่​คือ conflict แท้ๆ ที่​ระบบ​ต้อง​เลือก เรา​จึง DETECT แล้ว FLAG ไม่​แอบ “เอา​ค่า max” เพราะ​การ​ทำ​แบบ​นั้น​ทำ​ข้อมูล​หาย​เงียบๆ การ​ตัดสิน​ว่า​จะ​เลือก​ค่า​ไหน​คือ​หน้าที่​ของ LWW-Register หรือ CRDT ในบท 4–5 — ไม่ใช่​หน้าที่​ของ read-repair

sloppy quorum + hinted handoff — availability ที่​แลก​มา​ด้วย intersection

ระบบ​จริง​อย่าง Dynamo เพิ่ม​ลูกเล่น​ชื่อ sloppy quorum: ตอน partition ถ้า W replica “ที่​ถูกต้อง” เอื้อม​ไม่​ถึง มัน​จะ​เขียน​ลง node สำรอง​ที่​เอื้อม​ถึง​ชั่วคราว​แทน แล้ว​ให้ node นั้น​ถือ hinted handoff — เก็บ hint ไว้​ว่า “ค่า​นี้จริงๆ เป็น​ของ node โน้น” แล้ว​ส่ง​คืน​เมื่อ​เครือข่าย​กลับ​มา ข้อดี​คือ​เขียน​ยัง​สำเร็จ​ระหว่าง partition (availability ไม่​ตก) แต่​ราคา​ที่​ต้อง​พูดตรงๆ คือ: ระหว่าง​ที่ handoff ยัง​ไม่​เสร็จ การ​การันตี W+R>N intersection พัง​ชั่วคราว เพราะ write ไป​ตก​ที่ node ที่​ไม่​ได้​อยู่​ใน​เซ็ต N เดิม อย่า​อ้าง strict guarantee ทับ sloppy quorum — เรียก​ชื่อ​มันตรงๆ ว่า​มัน​แลก intersection เอา availability (Dynamo SOSP 2007 / DDIA บท 5)

honesty spine ของ​บท​นี้

เส้น scope (Thread B): quorum arithmetic เป็น​ข้อเท็จจริง​เชิง​การ​จัด​หมู่​ที่ เอ็น​นิว​เมอเรต​ครบ​ทุก subset ได้ (22,353,252 คู่, 0 counterexample) เรา​จึง “ลง code รัน​จริง” — ต่าง​จาก consensus/Raft ที่ state space ของ partition/reorder ระเบิด ตรวจ​ครบ​ไม่​ได้ จึง​อยู่​ใน “กล่อง​ไดอะแกรม” (บท 8) เท่านั้น quorum ≠ consensus

ตรวจ​แบบ​มี​ขอบเขต ไม่ใช่​บท​พิสูจน์ (Thread A): ส่วน enumerate ของ quorum นั้น ครบ​จริง สำหรับ n ≤ 12 (จึง​เป็น​บท​พิสูจน์​ใน​ช่วง​นั้น) แต่ property test ของ read-repair (B/C/D) คือ​การ ตรวจ​แบบ​มี​ขอบเขต — ผ่าน 5000 seed แปล​ว่า​สำรวจ​เฉพาะ​สถานะ​ที่ splitmix64 เดิน​ไป​ถึง ไม่ใช่​ทุก​ความ​เป็น​ไป​ได้ (FLP 1985)

นี่​คือ​กลไก​จริง​ของ Dynamo/Cassandra/Riak (Thread D): quorum (W+R>N) + version vector + read-repair + hinted handoff คือ stack จริง​ของ Amazon Dynamo — สอน​ตรง​ตาม​ต้นฉบับ​ที่​สเกล toy บน kaen-kvstore ไม่ใช่​ของ​กุ​ขึ้น​เพื่อ​สอน

บท​นี้​เพิ่ม​ชั้น replication ทับ kaen-kvstore: แต่ละ key มี​สำเนา N ตัว เขียน​ให้ ack ≥ W อ่าน​ให้​ตอบ ≥ R; กติกา W + R > N การันตี​ว่า write-set กับ read-set ทับ​กัน​เสมอ​อย่าง​น้อย1 node (pigeonhole |W∩R| ≥ w+r−n > 0) — และ​เพราะ​มัน​เป็น​ข้อเท็จจริง สถิต เรา​จึง enumerate ครบ​ทุก​คู่ subset (22,353,252 คู่, n ≤ 12) เทียบ oracle max(0, w+r−n) ได้ 0 counterexample แทนที่​จะ​สุ่ม​ทดสอบ​ซึ่ง​อาจ​พลาด​ขอบ w+r = n+1; read-repair เอา LUB ของ version vector ที่​อ่าน​มา​เขียน​กลับ​ไป​กลบ replica ที่​เก่า โดย relate() ต้อง​แยก Concurrent ออก​จริง (เช็ก​ทั้ง any_gt/any_lt ไม่ใช่ >= อย่าง​เดียว); property test ยืนยัน convergence 2000/2000, freshness 5000 reads 0 missed และ concurrency ถูก​ตั้ง​ธง​ไม่​แอบ​กลบ ที่​สำคัญ​ที่สุด — W+R>N คุม staleness ไม่ใช่ linearizability: fresh ไม่ใช่ linearizable, write ที่ concurrent ยัง​ต้อง​รอ LWW/CRDT และ quorum ไม่ใช่ consensus ทุก snippet compile zero-warnings บน Rust 1.97.1 / edition 2024 / std ล้วน และ​รัน​ได้​เลข​ตาม​ที่​เห็น

บท 4 เรา​จะ​ตอบ​คำถาม​ที่​บท​นี้​ค้าง​ไว้: เมื่อ write สอง อัน concurrent จริงๆ จะ เลือก​ค่า อย่างไร​ให้ replica ลู่​เข้าหา​กันเอง​โดย​ไม่​ต้อง coordinate — คำ​ตอบ​คือ CRDT และ​กฎ semilattice (merge = join ที่ commutative/associative/idempotent) ที่​เรา​เห็น​เค้าโครง​มา​ตั้งแต่​บท 1 จะ​เป็น​ทฤษฎีบท​เต็ม​ตัว​ใน​บท​หน้า


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

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

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

ข้อ 1 / 3

ทำไมเราถึง enumerate ทุกคู่ (write-set, read-set) แทนที่จะสุ่มทดสอบว่า W+R>N การันตีการทับกัน?