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

Consistent hashing — วงแหวน, virtual node และ​การ​วัด​การ​ย้าย key

บท 1 ตอบ​คำถาม​ว่า เมื่อ event เกิด​คนละ​เครื่อง จะ​รู้​ลำดับ​เชิง​เหตุผล​ได้​อย่างไร — เรา​ได้ vector clock เป็น​เครื่องมือ ตอน​นี้​ขึ้น​คำถาม​ถัด​ไป​ของ​การ​ยก kaen-kvstore ขึ้น​เป็น cluster: key แต่ละ​ตัว​ควร​อยู่ replica ไหน ใน cluster N node เรา​ต้อง map key ทุก​ตัว​ไป​ยัง node ที่​รับผิดชอบ​มัน แบบ​ที่ กระจาย​โหลด​เรียบ และ — สำคัญ​กว่า​นั้น — เพิ่ม​หรือ​ลด node แล้ว​ไม่​รื้อ​ทั้ง cluster

คำถาม​นี้​ฟัง​ดู​ง่าย​จน​น่า​จะ​ตอบ​ด้วย​บรรทัด​เดียว node = hash(key) % N แต่​บรรทัด​นั้น​คือ​กับดัก บท​นี้​จะ​วัด​ให้​เห็นด้วย​เลข​จริง​ว่า​มัน​พัง​อย่างไร แล้ว​สร้าง consistent hashingconsistent hashingวาง node กับ key บน​วงแหวน hash เดียวกัน เพิ่ม node ที่ N+1 ย้าย key แค่ ~1/(N+1) ไม่ใช่​ทั้งหมด ที่​ระบบ​จริง (Dynamo, Cassandra, Riak) ใช้​จริง​ขึ้น​มา​แทน — วงแหวน hash เดียว​ที่​วาง​ทั้ง node และ key ลงไป พร้อม virtual nodevirtual nodeวาง node จริง​หนึ่ง​ตัว​ไว้ V จุด​บน​วงแหวน เพื่อ​เกลี่ย​โหลด​ให้​เรียบ (เหตุผล​ของ token ใน Dynamo) ที่​เกลี่ย​โหลด และ​เรา​จะ​พูด​ความ​จริง​เรื่อง hash-sensitivity กันตรงๆ ว่า​เลข “~1/(N+1)” ที่​จะ​เห็น​นั้น รัน​ซ้ำ​ได้​ก็​ต่อ​เมื่อ pin อะไร​ไว้​บ้าง

📦 kaen-kvstore

คอร์ส​นี้ ต่อยอด repo kaen-kvstore จาก #22 (code ตัวอย่าง​กำลัง​จัด​ทำ) — บท​นี้​เพิ่ม​ชั้น การ​วาง​ตำแหน่ง key (key placement) ทับ​บน store เดิม: แทนที่​แต่ละ key จะ​อยู่ node เดียว เรา​จะ​เลือก​ว่า​มัน​ควร​อยู่ replica ไหน​ด้วย​วงแหวน consistent hashing ยัง​ยึด THE SCOPE LINE เดิม — consistent hashing เป็น​ฝั่ง ลง code รัน​จริง เพราะ​การ​ย้าย key และ monotonicity นั้น วัด​และ property-test ได้ ทั้ง​บท​ใช้ Rust std ล้วน: std SipHash (DefaultHasher) เป็น​ตัว hash เพียง​ตัว​เดียว — ไม่มี serde, ไม่มี tokio, ไม่มี proptest วงแหวน​ที่​สร้าง​ใน​บท​นี้​จะ​กลับ​มา​เลือก replica set ให้​แต่ละ key อีก​ครั้ง​ใน capstone (บท 8)

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::BTreeMap กับ std::hash (DefaultHasher, Hash, Hasher) เท่านั้น ทุก​โปรแกรม compile แบบ zero-warnings บน musl และ รัน​จริง — เลข​ทุก​ตัว​ด้าน​ล่าง​คือ output จริง​จากรัน​เดียวกัน seed ล็อก​ไว้ รัน​ซ้ำ​ได้ byte-identical

สัญชาตญาณ​แรก​คือ: มี N node ก็​เอา hash(key) % N เป็น​เลข node กระจาย​ดี​มาก​ตอนนิ่งๆ ปัญหา​โผล่​ตอน cluster เปลี่ยน​ขนาด — ซึ่ง​เกิด​ตลอด​ใน​ชีวิต​จริง (node เจ๊ง, สเกล​ออก, เพิ่ม​เครื่อง)

ลอง​คิด​เลข: key ตัว​หนึ่ง hash = h เมื่อ N=4 มัน​อยู่ node h % 4 พอ​เพิ่ม​เป็น N=5 มัน​อยู่ node h % 5 — ค่า​สอง​อัน​นี้​ตรง​กัน​แค่​บังเอิญ​เท่านั้น สำหรับ key ส่วน​ใหญ่ h % 4 ≠ h % 5 แทบ​ทุก key ต้อง​ย้าย​บ้าน และ​การ​ย้าย​บ้าน​ใน​ระบบ​เก็บ​ข้อมูล​ไม่ใช่​เรื่อง​เล่นๆ — มัน​คือ copy ข้อมูล​ข้าม​เครื่อง​มหาศาล cache miss ทั้ง cluster ใน​จังหวะ​เดียว

วัด​จริง​ด้วย code เดียว​กับ​ที่​บท​นี้​ใช้ (100,000 key, hash ตัว​เดียวกัน):

let mut mod_moved = 0u64;
for kk in &keys {
let h = hash_u64(kk);
if h % 4 != h % 5 { // อยู่คนละ node เมื่อ N: 4 -> 5
mod_moved += 1;
}
}
// moved = 79759 (0.7976)
--- naive hash(key) % N, N: 4 -> 5 ---
moved=79759 (0.7976) — reshuffles ~all keys

เพิ่ม node เดียว ย้าย key เกือบ 80% — เป็น​สัดส่วน​ที่​ไม่​ขึ้น​กับ​ว่า​เพิ่ม​จาก 4 เป็น 5 หรือ 40 เป็น 41 ด้วย​ซ้ำ นี่​คือ​สิ่ง​ที่ consistent hashing แก้: Karger และ​คณะ​เสนอ​ใน​ปี 1997 (STOC’97) ว่า​มี​วิธี​จัด​วาง​ที่​การ​เพิ่ม node ที่ N+1 ย้าย key แค่ ~1/(N+1) ไม่ใช่​เกือบ​ทั้งหมด

ไอเดีย​หลัก​สวย​มาก แทนที่​จะ map key ไป ดัชนี node (ซึ่ง​เปลี่ยน​ความหมาย​ทุก​ครั้ง​ที่ N เปลี่ยน) เรา hash ทั้ง node และ key ลงบน ปริภูมิ​เดียวกัน คือ u64 ทั้ง​ช่วง แล้ว​จินตนาการ​ว่า u64 เป็น วงแหวน (0 ต่อ​กับ u64::MAX) กฎ​การ​หา​เจ้าของ key: จาก hash ของ key ให้ เดิน​ตาม​เข็ม​นาฬิกา​ไป​หา node ตัว​แรก​ที่​เจอ

โครงสร้าง​ที่​เหมาะเป๊ะ​คือ BTreeMap<u64, NodeId> — มัน​เรียง key ให้​อัตโนมัติ และ​มี range(k..).next() ที่​แปล​ว่า “node ตัว​แรก​ที่ hash ≥ k” พอดี ส่วน​การวน​ขอบ​วงแหวน (key ที่ hash สูง​กว่า node ทุก​ตัว) ก็​ห่อ​กลับ​ไป​หา node ที่ hash น้อย​ที่สุด:

use std::collections::hash_map::DefaultHasher;
use std::collections::BTreeMap;
use std::hash::{Hash, Hasher};
type NodeId = u32;
// hash เดียวของทั้งบท: std DefaultHasher = SipHash-1-3, fixed keys -> deterministic
fn hash_u64<T: Hash>(x: &T) -> u64 {
let mut h = DefaultHasher::new();
x.hash(&mut h);
h.finish()
}
struct Ring {
vnodes: u32,
points: BTreeMap<u64, NodeId>,
}
impl Ring {
fn new(vnodes: u32) -> Self {
Ring { vnodes, points: BTreeMap::new() }
}
fn add(&mut self, node: NodeId) {
for v in 0..self.vnodes {
// hash (node, v) — ไม่ใช่ node เดี่ยว ๆ (จะอธิบายในหัวข้อ virtual node)
self.points.insert(hash_u64(&(node, v)), node);
}
}
fn route(&self, key: &str) -> NodeId {
let k = hash_u64(&key);
match self.points.range(k..).next() {
Some((_, &n)) => n, // node ตัวแรกตามเข็ม (hash >= k)
None => *self.points.values().next().unwrap(), // วนขอบ: ห่อไป node hash ต่ำสุด
}
}
}

route คือ​หัวใจ​ทั้งหมด: hash key แล้ว range(k..).next() คว้า node ตัว​แรก​ที่ hash ≥ k ถ้า​ไม่มี (key อยู่​เลย node สุดท้าย​บน​วง) ก็ วน​ขอบ กลับ​ไป​หา values().next() คือ node hash ต่ำ​สุด จุด​ตาย​ที่​พลาด​กัน​คือ​ลืม​กรณี​วน​ขอบ​นี้ — range(k..).next() จะ​คืน None แล้ว​โปรแกรม panic หรือ route key ที่ hash สูงสุด​ผิด node

ทำไม key ที่​ย้าย​ถึง​ย้าย​เข้า node ใหม่ 'เท่านั้น' — monotonicity

สมบัติ​ที่​ทำให้ consistent hashing มี​ค่า​ไม่ใช่​แค่ “ย้าย​น้อย” แต่​คือ monotonicity: การ​เพิ่ม node ใหม่ ขโมย key มา​จาก node เดิม​ได้​เท่านั้น มัน ไม่มี​วัน​สับ key ไป​มาระหว่าง node เดิม​ด้วย​กัน (Karger 1997) เหตุผล​เชิง​เรขาคณิต​ชัด​มาก — node ใหม่​ลง​ไป​นั่ง​ที่​จุด​หนึ่ง​บน​วง มัน​รับผิดชอบ​เฉพาะ​ช่วง​ส่วน​โค้ง​ที่ ตกทอด​มา​จาก node ถัด​ไป​ตาม​เข็ม เท่านั้น key นอก​ช่วง​นั้น​ยัง​เดิน​ไป​เจอ node เดิม​ตัว​เดิม​เป๊ะ นี่​คือ invariant ที่ ตรวจ​ได้​จริง: ทุก key ที่​ย้าย ปลายทาง​ต้อง​เป็น node ใหม่ (illegal_reshuffle == 0)

ทีนี้​วัด​ของ​จริง เรา​ตั้ง​วงแหวน​ด้วย 4 node (id 0–3) แต่ละ node วาง V=100 จุด​บน​วง กระจาย K=100,000 key ("key:0" ถึง "key:99999") จด​ว่า​แต่ละ key อยู่ node ไหน แล้ว เพิ่ม node ที่ 5 (id 4) route ใหม่​ทั้งหมด แล้ว​นับ​ว่า​กี่ key ย้าย และ​ย้าย​ไป​ไหน:

let v = 100u32;
let n = 4u32; // 4 node เดิม: 0,1,2,3
let mut ring = Ring::new(v);
for node in 0..n { ring.add(node); }
let before = assign_all(&ring, &keys);
ring.add(n); // เพิ่ม node ที่ (N+1) id = 4
let after = assign_all(&ring, &keys);
let mut moved = 0u64;
let mut moved_to_new = 0u64;
let mut illegal_reshuffle = 0u64;
for i in 0..k {
if before[i] != after[i] {
moved += 1;
if after[i] == n { moved_to_new += 1; } else { illegal_reshuffle += 1; }
}
}
let frac = moved as f64 / k as f64;
assert_eq!(illegal_reshuffle, 0, "monotonicity: existing nodes must not reshuffle");
assert_eq!(moved, moved_to_new, "every moved key must go to the NEW node");
assert!((frac - 1.0 / (n as f64 + 1.0)).abs() < 0.03, "movement not near 1/(N+1)");
--- consistent hashing: V=100, K=100000, N=4 -> add node 4 ---
moved=19195 (0.1920) ideal 1/(N+1) = 0.2000
load=[21347, 19633, 21701, 18124, 19195] (max/min imbalance = 1.20x)
illegal_reshuffle=0 moved_to_new=19195
OK: both properties hold (monotonic + near 1/(N+1))

เทียบ​กับ​วิธี​ดิบ​ที่​ย้าย 0.7976 — consistent hashing ย้าย​แค่ 0.1920 ใกล้​ค่า​อุดมคติ 1/(N+1) = 1/5 = 0.20 มาก และ​สังเกต​สอง​บรรทัด​สำคัญ: illegal_reshuffle=0 (ไม่มี key ไหน​สับ​ไป​มาระหว่าง node เดิม) และ moved == moved_to_new == 19195 (key ที่​ย้าย​ทุก​ตัว ย้าย​เข้า node ใหม่ หมด) — นี่​คือ monotonicity ที่​วัด​ได้​เป็น​ตัวเลข ไม่ใช่​คำ​กล่าว​อ้าง

ตรวจ​ด้วย tolerance band ไม่ใช่ assert_eq เป๊ะๆ

สังเกต assert ตัว​ที่​สาม: (frac - 1.0/(N+1)).abs() < 0.03 — เรา ไม่ assert_eq!(moved, 19195) เพราะ 19195 เป็น​เลข​ที่ ขึ้น​กับ hasher + จำนวน vnode + keyset + Rust version ทั้ง​ชุด สิ่ง​ที่​ทฤษฎี​การันตี​คือ “ใกล้ 1/(N+1)” ไม่ใช่ “เท่ากับ 19195 เป๊ะ” การ assert แบบ band คือ​การ test สมบัติ​ที่ เป็น​จริง​ตาม​ทฤษฎี ส่วน 19195 คือ หลักฐาน​การ​รัน ที่ pin ไว้ — สอง​อย่าง​นี้​คนละ​เรื่อง​กัน

graph TD
    subgraph Ring["วงแหวน u64 (เดินตามเข็มนาฬิกา)"]
        A["node A · vnode หลายจุด"]
        B["node B · vnode หลายจุด"]
        C["node C · vnode หลายจุด"]
        NEW["node ใหม่ D (เพิ่งเพิ่ม)"]
    end
    K1["key: hash(k)"] -->|"เดินตามเข็มไปเจอ vnode ตัวถัดไป"| A
    NEW -.->|"ขโมยเฉพาะช่วงโค้งที่ตัวเองครอบ"| B
    K2["key ที่เคยไป B ในช่วงนั้น"] -->|"หลังเพิ่ม D"| NEW

คำ​บรรยาย​ภาพ: key เดิน​ตาม​เข็ม​นาฬิกา​ไป​หา vnode ตัว​ถัด​ไป​บน​วงแหวน เพิ่ม node D = วาง vnode ใหม่​หลาย​จุดลงวง แต่ละ​จุด ขโมย เฉพาะ key ใน​ช่วง​โค้ง​ที่​ตัวเอง​ครอบ​มา​จาก node ถัด​ไป — key นอก​ช่วง​ยัง​เดิน​ไป​เจอ node เดิม​เป๊ะ (นั่น​คือ monotonicity: illegal_reshuffle=0)

ใน code add เรา​วาง node หนึ่ง​ตัว​ลง V จุด บนวง ไม่ใช่​จุด​เดียว แต่ละ​จุด​คือ virtual node (Dynamo เรียก token) ทำไม​ต้อง​ทำ​แบบ​นี้? ลอง​คิด​ถ้า V=1: node 4 ตัว​คือ 4 จุด​สุ่ม​บน​วง ช่วง​โค้ง​ระหว่าง​จุด​ที่​อยู่​ติด​กัน​จะ ยาว​ไม่​เท่า​กัน​เลย — node ที่​บังเอิญ​มี​ช่วง​โค้ง​กว้าง​จะ​รับ key เยอะ​กว่า node ที่​ช่วง​แคบ​มาก เกิด hotspot

วิธี​แก้​คือ​วาง node เดียว​ไว้ หลาย​จุด: ยิ่ง V สูง ช่วง​โค้ง​รวม​ของ​แต่ละ node ยิ่ง​เข้า​ใกล้​ค่า​เฉลี่ย​ตาม​กฎ​ของ​เลขจำนวน​มาก โหลด​จึง​เรียบ​ขึ้น วัด​จริง​ด้วย​การกวาด​ค่า V แล้ว​ดู imbalance = โหลด​สูงสุด / โหลด​ต่ำ​สุด (ยิ่ง​ใกล้ 1.00 ยิ่ง​เรียบ) บน cluster 5 node หลัง​เพิ่ม:

--- virtual-node sweep (N=4 -> add node 4) ---
V movement imbalance(max/min, 5 nodes)
1 0.2181 3.26x
10 0.2121 1.63x
50 0.1735 1.56x
100 0.1920 1.20x
200 0.1938 1.21x
500 0.1991 1.05x

V=1 node ที่​โหลด​หนัก​สุด​รับ​งาน​มากกว่า node ที่​เบา​สุด 3.26 เท่า — ยอมรับ​ไม่​ได้​ใน​โปร​ดัก​ชัน พอ V=500 imbalance ลง​เหลือ 1.05 เท่า เกือบ​เรียบ​สนิท นี่​คือ​เหตุผลตรงๆ ที่ Dynamo (SOSP’07) วาง​แต่ละ node เป็น​หลาย token บนวง ไม่ใช่ token เดียว สังเกต​ด้วย​ว่า column movement แกว่ง​อยู่​แถว 0.17–0.22 รอบๆ 1/(N+1)=0.20 ทุก​ค่า V — การ​ย้าย key น้อย​เป็น​สมบัติ​ของ วงแหวน ส่วน​การกระจาย​โหลด​เรียบ​เป็น​สมบัติ​ของ จำนวน vnode คนละ​เรื่อง​ที่​จูน​แยก​กัน

จุด​พลาด​ที่ callout add เตือน​ไว้​เชื่อม​ตรง​นี้​พอดี: ถ้า hash แค่ node เดี่ยวๆ แทน (node, v) ทุก vnode ของ node เดียวกัน​จะ hash ไป​จุด​เดียวกัน​หมด — V ยุบ​เหลือ 1 ทันที กลับ​ไป hotspot 3.26× โดยที่ code ยัง ดูเหมือน วาง V จุด

ความ​จริง​เรื่อง hash-sensitivity: ทำไม 0.1920 ถึง​ไม่ใช่​เลข​ศักดิ์สิทธิ์

หัวข้อ​ที่​มีชื่อ​ว่า “ความ​จริง​เรื่อง hash-sensitivity: ทำไม 0.1920 ถึง​ไม่ใช่​เลข​ศักดิ์สิทธิ์”

เลข 19195 (0.1920) รัน​ซ้ำ​ได้ byte-identical ก็​ต่อ​เมื่อ สี่​อย่าง​นี้ pin ครบ: (1) ตัว hash เป็น DefaultHasher::new() — SipHash-1-3 ที่ std ตั้ง key คงที่ (0, 0) ให้ ทำให้ deterministic; (2) จำนวน vnode V=100; (3) keyset เป๊ะ "key:0".."key:99999"; (4) Rust version 1.97.1 เพราะ std เขียนกำกับไว้ตรงๆ ว่า algorithm ของ DefaultHasher “ไม่​รับประกัน​ว่า​จะ​คง​เดิม​ข้าม version”

กับดัก​ตัว​ใหญ่: DefaultHasher::new() ไม่ใช่ HashMap::new()

HashMap ปกติ​ใช้ RandomState ที่ สุ่ม key ของ SipHash ใหม่​ทุก​ครั้ง​ที่ process เริ่ม (กัน HashDoS) ถ้า​เผลอ​เอา hasher แบบ​นั้น​มาสร้าง​วงแหวน การ​วาง key จะ เปลี่ยน​ทุก​ครั้ง​ที่​รัน​โปรแกรม — เลข 0.1920 จะ​กลาย​เป็น​คนละ​ค่า​ทุกรัน property test จะ โกหก (รัน​แดง​ซ้ำ​ไม่​ได้) เรา​จึง​เรียก DefaultHasher::new() ตรงๆ ซึ่ง fixed-key เสมอ ไม่ใช่ RandomState — วิธี​ตรวจ​ว่า​เรา​ทำ​ถูก​คือ รัน​โปรแกรม​ซ้ำ 3 รอบ​แล้ว​ต้อง​ได้ 19195 เท่า​กัน​ทุกรอบ (ยืนยัน​แล้ว​ว่า​เป็น​เช่น​นั้น)

ที่​จริงจัง​กว่า​นั้น: ตอน​สร้าง framework ของ​คอร์ส แผง​ทดสอบ​วัด​ได้ 19.3% ส่วน​รัน​ของ​บท​นี้​วัด​ได้ 19.2% — ส่วน​ต่าง​เล็กๆ นี้ ไม่ใช่ bug มัน​คือ หลักฐาน​สด ว่า​เลข​นี้​ไว​ต่อ keyset/V ที่​ต่าง​กัน​แม้​เพียง​นิดเดียว บทเรียน​คือ: สอน​ว่า “ใกล้ 1/(N+1)” แล้ว assert ด้วย band abs < 0.03 เสมอ ห้าม assert_eq! บน​จำนวนเป๊ะๆ — นั่น​คือ​การ hard-code หลักฐาน​การ​รัน​ครั้ง​หนึ่ง​ขึ้น​เป็น​ทฤษฎี ซึ่ง​จะ​พัง​ทันที​ที่​ใคร​อัป Rust หรือ​เปลี่ยน keyset

honesty spine ของ​บท​นี้ (เส้น C + D)

C — ดี​เท​อร์มิ​นิส​ซึม​คือ​วินัย: เลข 19195 รัน​ซ้ำ​ได้​เพราะ DefaultHasher::new() fixed-key + V + keyset + Rust version pin ครบ ไม่ใช่ RandomState (ซึ่ง​สุ่ม​ใหม่​ทุก process → placement มั่ว, เลข​ไม่มี​วัน​ซ้ำ) — ตรวจ​ด้วย​การ​รัน​ซ้ำ​ต้อง​ได้​ค่า​เดิม และ assert ด้วย tolerance band ไม่​ใช่​ค่า​เป๊ะ

D — นี่​คือ​กลไก​จริง​ของ Dynamo/Cassandra/Riak: consistent hashing + virtual node/token คือ​วิธี​วาง key จริง​ของ Amazon Dynamo (SOSP’07) ไม่ใช่​ของ​กุ​ขึ้น​เพื่อ​สอน — imbalance 3.26×→1.05× ที่​วัด​ได้​คือ​เหตุผล​เชิง​ตัวเลข​ว่า​ทำไม Dynamo ถึง​วาง​แต่ละ node เป็น​หลาย token บนวง

บท​นี้​ตอบ​ว่า key ควร​อยู่ replica ไหน: วิธี​ดิบ hash(key) % N ย้าย key เกือบ​ทั้ง cluster ทุก​ครั้ง​ที่​เพิ่ม node (วัด​ได้ 0.7976 บน 4→5) เพราะ h % 4 กับ h % 5 ตรง​กัน​แค่​บังเอิญ; consistent hashingconsistent hashingวาง node กับ key บน​วงแหวน hash เดียวกัน เพิ่ม node ที่ N+1 ย้าย key แค่ ~1/(N+1) ไม่ใช่​ทั้งหมด วาง​ทั้ง node และ key ลง​บน​วงแหวน u64 เดียว (BTreeMap<u64, NodeId>) key เดิน​ตาม​เข็ม​ไป​หา node ตัว​แรก​ด้วย range(k..).next() แล้ว​วน​ขอบ​ที่ seam — เพิ่ม node ที่ N+1 ย้าย key แค่ ~1/(N+1) (วัด​ได้ 19195/100000 = 0.1920 บน 4→5) และ​ย้าย​เข้า node ใหม่ เท่านั้น (illegal_reshuffle=0, moved==moved_to_new) คือ monotonicity ที่​ตรวจ​ได้; virtual nodevirtual nodeวาง node จริง​หนึ่ง​ตัว​ไว้ V จุด​บน​วงแหวน เพื่อ​เกลี่ย​โหลด​ให้​เรียบ (เหตุผล​ของ token ใน Dynamo) (token ของ Dynamo) วาง​แต่ละ node เป็น V จุด​เพื่อ​เกลี่ย​โหลด — imbalance ลด​จาก 3.26× (V=1) เหลือ 1.05× (V=500); และ​เลข 0.1920 นั้น ไม่ใช่​เลข​ศักดิ์สิทธิ์ — มัน​รัน​ซ้ำ​ได้​ก็​เพราะ DefaultHasher::new() fixed-key + V + keyset + Rust 1.97.1 pin ครบ (แผง​วัด 19.3% รัน​นี้​วัด 19.2% = หลักฐาน​สด​ของ hash-sensitivity) จึง​ตรวจ​ด้วย tolerance band เสมอ ทุก snippet compile zero-warnings บน Rust 1.97.1 / edition 2024 / std ล้วน และ​รัน​ได้​เลข​ตาม​ที่​เห็น

บท 3 เรา​จะ​กระจาย​สำเนา: ตอน​นี้​เรา​รู้​แล้ว​ว่า key ตัว​หนึ่ง ควร​อยู่ node ไหน บท​หน้า​เรา​จะ​เก็บ​มัน​ไว้ หลาย replica แล้ว​ถาม​ว่า​ต้อง​เขียน​สำเร็จ​กี่​ตัว (W) อ่าน​ตอบ​กี่​ตัว (R) จาก N สำเนา ถึง​จะ​การันตี​ว่า อ่าน​เจอ​ค่า​ล่าสุด​เสมอ — คำ​ตอบ​คือ quorumquorumเขียน​ต้อง ack ≥W อ่าน​ต้อง​ตอบ ≥R; ถ้า W+R>N อ่าน​จะ​ทับ write ล่าสุด​เสมอ และ​เงื่อนไข W+R>N พร้อม read-repair ที่​เอา version vector จาก​บท 1 มา​ใช้​ตรวจ conflict


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

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

  • Karger, Lehman, Leighton, Panigrahy, Levine, Lewin — “Consistent Hashing and Random Trees” (STOC’97, 1997) (เข้าถึง 2026-07-24) — ต้น​กำเนิด​ของ consistent hashing: เพิ่ม node ที่ N+1 ย้าย key แค่ ~1/(N+1) เทียบ​กับ ~ทั้งหมด​ของ modulo-N และ​สมบัติ monotonicity (เพิ่ม node ไม่​สับ key ระหว่าง node เดิม)
  • DeCandia et al. — “Dynamo: Amazon’s Highly Available Key-value Store” (SOSP’07, 2007) (เข้าถึง 2026-07-24) — Dynamo ใช้ consistent hashing + virtual node/token จริง​ใน​การ​วาง key; เหตุผล​ของ token คือ​เกลี่ย​โหลด​ให้​เรียบ (ที่​บท​นี้​วัด​เป็น imbalance 3.26×→1.05×)
  • Rust std — std::collections::hash_map::DefaultHasher (เข้าถึง 2026-07-24) — SipHash-1-3 ที่​ตั้ง key คงที่​ให้ deterministic แต่ std กำกับ​ไว้​ว่า algorithm “ไม่​รับประกัน​ว่า​คง​เดิม​ข้าม version”; ต่าง​จาก RandomState ของ HashMap ที่​สุ่ม key ใหม่​ทุก process

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

ข้อ 1 / 3

ทำไม hash(key) % N ถึงย้าย key เกือบทั้ง cluster (วัดได้ 0.7976) ตอนเพิ่ม node จาก 4 เป็น 5?