version chain กับกฎที่ตัดสินว่าใครเห็นอะไร
คีย์ order:total ในที่เก็บนี้ไม่ได้มีค่าเดียว มันมีทั้ง 100 และ 250 อยู่พร้อมกัน
ถ้าถาม transaction สองตัวที่เปิดค้างอยู่ว่าคีย์นี้เท่าไร คุณจะได้คนละคำตอบ
ทั้งสองคำตอบถูก ประโยคนี้คือใจกลางของ MVCCMultiversion Concurrency Control (MVCC)การเก็บค่าเดิมไว้หลาย version แทนการเขียนทับ ผู้อ่านจึงอ่าน version ที่ตัวเองควรเห็นได้โดยไม่ต้องรอผู้เขียน และผู้เขียนไม่ต้องรอผู้อ่าน และมันไม่ใช่การเล่นคำ
บทที่ 1 เปิดโครงของ version กับกฎการมองเห็นให้ดูอย่างละครั้ง บทนี้ลงไปดูว่าสองอย่างนั้น ทำงานร่วมกันอย่างไร เพราะอีกสี่บทที่เหลือยืนอยู่บนกลไกนี้ทั้งหมด
คำสั่งของบทนี้คือ cargo run --quiet -- 04 สั่งจาก scripts/transaction-engine/
ผลรันทุกบรรทัดที่ยกมาข้างล่างคัดมาทีละ byte จาก expected/04-mvcc-versions-and-visibility.txt
ซึ่งเป็นผลของคำสั่งนั้นที่ตรึงไว้แล้ว และมีเทสต์คอยเทียบให้ทุกครั้งที่ CI รัน
หนึ่งคีย์ ผูกกับรายการ ไม่ใช่ผูกกับค่า
หัวข้อที่มีชื่อว่า “หนึ่งคีย์ ผูกกับรายการ ไม่ใช่ผูกกับค่า”หัวข้อนี้แสดงว่าที่เก็บผูกหนึ่งคีย์ไว้กับรายการของหลาย version และอ่านป้าย begin กับ end ของแต่ละตัว
ที่เก็บของเราไม่มีช่องไหนเก็บ “ค่าปัจจุบัน” ของคีย์เลยสักช่อง
/// ที่เก็บทั้งหมด รวมทั้งทะเบียนว่า transaction ไหน commit ไปแล้วบ้าง#[derive(Debug, Default)]pub struct Store { pub versions: BTreeMap<String, Vec<Version>>, pub committed: BTreeSet<Xid>, pub next_xid: Xid, pub aborts: u32,}ชนิดของค่าคือ Vec<Version> ไม่ใช่ Version รายการนี้เรียงจากเก่าไปใหม่ตามลำดับที่ถูกต่อเข้ามา
และมันคือสิ่งที่เรียกว่า Version ChainVersion Chainลำดับของทุก version ของแถวเดียวกัน เรียงจากเก่าไปใหม่ตามลำดับที่ถูกต่อเข้ามา พร้อมข้อมูลกำกับว่า transaction ไหนสร้างและ transaction ไหนลบ version นั้น
เอนจินพิมพ์ chain จริงออกมาให้ดูได้ทั้งเส้น
== บทที่ 4 version chain กับกฎการมองเห็น == chain ของ order:total begin=1 end=3 value=100 begin=3 end=- value=250สองบรรทัดล่างคือ chain ทั้งเส้น ตัวบนถูกสร้างโดย transaction หมายเลข 1 และถูกปิดโดยหมายเลข 3 ส่วนตัวล่างถูกสร้างโดยหมายเลข 3 และยังไม่มีใครปิด
end=- คือการพิมพ์แทน end == None มันแปลว่า version นี้ยังเป็นตัวท้ายสุดของ chain อยู่
สังเกตว่าไม่มีบรรทัดไหนหายไปตอนมีคนแก้ค่า การแก้ค่าจาก 100 เป็น 250 เพิ่มบรรทัด ไม่ได้ทับบรรทัดเดิม
สองคนอ่านคีย์เดียวกัน แล้วทั้งคู่ถูก
หัวข้อที่มีชื่อว่า “สองคนอ่านคีย์เดียวกัน แล้วทั้งคู่ถูก”หัวข้อนี้แสดงว่า transaction สองตัวได้คนละค่าจากคีย์เดียวกัน แล้วอธิบายว่าอะไรทำให้คำตอบทั้งคู่ถูก
transaction ที่เปิดก่อน bump เห็น 100 transaction ที่เปิดหลัง bump เห็น 250 ทั้งสองอ่านคีย์เดียวกัน ณ เวลาเดียวกัน และทั้งคู่ถูกตัวที่เปิดก่อนจับ snapshot ไว้ตอนที่ยังไม่มีใคร commit ค่า 250 ในภาพของมันจึงมีแค่หมายเลข 1
ตัวที่เปิดทีหลังจับภาพหลังจากนั้น หมายเลข 3 จึงติดมาด้วย และมันเห็น 250
จุดที่คนมักคิดว่าเป็นข้อผิดพลาดคือ ทั้งคู่ยังมีชีวิตอยู่พร้อมกัน ถามพร้อมกันเมื่อไรก็ได้คนละคำตอบ เหมือนเดิมทุกครั้ง
เหตุผลอยู่ที่ begin มันคัดลอกทะเบียน committed ออกมาเป็นสำเนา ไม่ใช่การอ้างอิงไปที่ทะเบียน
ภาพที่ตัวแรกถืออยู่จึงไม่ขยับ ต่อให้ใครจะ commit อะไรเพิ่มทีหลัง
นี่คือสิ่งที่ Visibility RuleVisibility Ruleกฎที่ตัดสินว่า transaction หนึ่งจะเห็น version ไหนของแถวหนึ่ง โดยดูจากหมายเลขของ transaction ที่สร้าง version นั้นและสถานะของมันตอนที่ snapshot ถูกสร้าง ซื้อมาให้ ผู้อ่านไม่ต้องรอผู้เขียน และผู้เขียนไม่ต้องรอผู้อ่าน โดยไม่มีใครต้องยอมรับคำตอบที่ผิด
flowchart LR V1["version ตัวแรก<br/>begin 1 · end 3 · value 100"] V2["version ตัวที่สอง<br/>begin 3 · end - · value 250"] E["early · xid 2<br/>snapshot มีแค่ 1"] L["late · xid 4<br/>snapshot มี 1 กับ 3"] V1 -->|ต่อท้าย chain| V2 E -.->|ก้าวแรก ข้ามไป เพราะ 3 ไม่อยู่ใน snapshot| V2 E ==>|ก้าวที่สอง หยุดตรงนี้ ได้ 100| V1 L ==>|ก้าวแรก หยุดตรงนี้เลย ได้ 250| V2
คำบรรยายภาพ: ผู้อ่านสองคนเดินบน chain เส้นเดียวกัน แต่ snapshot ที่ต่างกันทำให้หยุดคนละที่ · เส้นทึบคือ version ที่ถูกอ่านจริง
read หยุดที่ตัวใหม่สุดที่มองเห็น
หัวข้อที่มีชื่อว่า “read หยุดที่ตัวใหม่สุดที่มองเห็น”หัวข้อนี้ตามรอยลำดับการเดินของ read และชี้ว่าลำดับนั้นทำให้การแก้ค่าไม่ต้องพึ่งการตรวจ end เลย
การเดิน chain ทั้งหมดของเอนจินอยู่ในบรรทัดสุดท้ายของ read บรรทัดเดียว
/// อ่านค่าล่าสุดที่ *เรา* มองเห็น ไม่ใช่ค่าล่าสุดที่มีอยู่จริง pub fn read(&mut self, store: &Store, key: &str) -> Option<Value> { self.reads.insert(key.to_string()); let chain = store.versions.get(key)?; chain.iter().rev().find(|v| visible(v, &self.snapshot, self.xid)).map(|v| v.value) }rev() กลับทิศการเดินให้เริ่มจากตัวใหม่สุด ส่วน find หยุดทันทีที่เจอตัวแรกที่ผ่านกฎ
กติกาจึงเป็น ตัวใหม่สุดที่มองเห็นได้ชนะ
ถ้าไม่เจอสักตัว find คืน None และ read ก็คืน None ตามไปด้วย จำข้อนี้ไว้
เพราะการลบทั้งหมดจบลงที่ None ตัวนี้
รายละเอียดของลำดับนี้อธิบายเรื่องที่ค้างมาจากบทที่ 1 ได้ นั่นคือทำไมกฎข้อที่สอง
ซึ่งตรวจช่อง end ถึงดูเหมือนของแถมเวลามีคนแก้ค่า
pub fn write(&mut self, store: &mut Store, key: &str, value: Value) { self.writes.insert(key.to_string()); let snapshot = self.snapshot.clone(); let chain = store.versions.entry(key.to_string()).or_default(); for v in chain.iter_mut() { if visible(v, &snapshot, self.xid) { v.end = Some(self.xid); } } chain.push(Version { begin: self.xid, end: None, value }); }คนที่ปิด version เดิมกับคนที่ต่อ version ใหม่คือคนเดียวกันเสมอ และหมายเลขที่ไปอยู่ในช่อง end
ของตัวเก่า คือหมายเลขเดียวกับที่ไปอยู่ในช่อง begin ของตัวใหม่
ผลที่ตามมาคือกฎข้อที่สองไม่มีวันเป็นตัวตัดสินในกรณีนี้ ถ้าคุณเห็นคนที่ปิด แปลว่าคุณเห็น
version ใหม่ของเขาด้วย และตัวนั้นอยู่ท้ายกว่า read จึงเจอมันก่อนแล้วหยุด
ถ้าคุณไม่เห็นคนที่ปิด กฎข้อที่สองก็ปล่อยผ่านอยู่แล้ว ไม่ว่าจะทางไหน คำตอบเท่าเดิม
การลบคือการปิด ไม่ใช่การเอาออก
หัวข้อที่มีชื่อว่า “การลบคือการปิด ไม่ใช่การเอาออก”หัวข้อนี้เทียบ delete กับ write ทีละบรรทัด แล้วชี้ว่าความต่างเพียงจุดเดียวคือเหตุผลทั้งหมดของกฎข้อที่สอง
/// ลบคีย์ — ปิด version เดิมโดยไม่ต่อของใหม่ ผลที่ค้างไว้คือ tombstone pub fn delete(&mut self, store: &mut Store, key: &str) { self.writes.insert(key.to_string()); let snapshot = self.snapshot.clone(); if let Some(chain) = store.versions.get_mut(key) { for v in chain.iter_mut() { if visible(v, &snapshot, self.xid) { v.end = Some(self.xid); } } } }วนปิดเหมือน write ทุกบรรทัด แล้วจบตรงนั้น ไม่มี chain.push(...) ต่อท้ายให้
อีกจุดที่ต่างเป็นเรื่องเล็กกว่า write ใช้ entry ซึ่งสร้าง chain ให้ถ้าคีย์ยังไม่เคยมี
ส่วน delete ใช้ get_mut ซึ่งไม่ทำอะไรเลยกับคีย์ที่ไม่มีอยู่
สิ่งที่ค้างอยู่ใน chain หลังการลบคือ version ที่มีป้าย end ติดอยู่โดยไม่มีตัวใหม่ตามมา
ป้ายใบนั้นคือ TombstoneTombstoneเครื่องหมายที่เขียนเพิ่มเข้าไปเพื่อบอกว่าแถวนี้ถูกลบแล้ว แทนการลบทิ้งจริง เพราะ transaction ที่ยังอ่าน snapshot เก่าอยู่ต้องยังเห็นค่าเดิมได้ และมันคือหลักฐานชิ้นเดียวที่บอกว่าคีย์นี้ถูกลบไปแล้ว
ความไม่สมมาตรตรงนี้คือเหตุผลทั้งหมดที่กฎข้อที่สองมีอยู่
match v.end { Some(end) => end != me && !snapshot.contains(&end), None => true, }การแก้ค่าแสดงตัวได้สองทาง ผ่านช่อง end ของตัวเก่าและผ่าน version ใหม่ท้าย chain
ส่วนการลบแสดงตัวได้ทางเดียว คือผ่านช่อง end เท่านั้น เพราะมันไม่ได้ทิ้งอะไรไว้ท้าย chain เลย
ตัดกฎข้อนี้ทิ้งแล้วรันทั้งแปดบทเทียบกับผลที่ตรึงไว้ มีบรรทัดเดียวในทั้งคอร์สที่เปลี่ยน คือจำนวนคีย์ที่มองเห็นหลังลบในบทนี้ จาก 0 กลายเป็น 1
บทที่ 1 เขียนไว้ว่ากฎข้อที่สองดูเหมือนของแถม แล้วชี้มาที่บทนี้ ตอนนี้คำตอบครบแล้ว
กฎข้อแรกกัน version ที่คนสร้างยังไม่ commit ส่วนกฎข้อที่สองทำงานอยู่กรณีเดียวคือการลบ และการทดลองข้างบนวัดออกมาเป็นบรรทัดเดียวพอดี
นี่คือทรงของกฎที่ดูเหมือนตายแล้วแต่ไม่ตาย ถ้าไม่มี scenario ที่ลบจริง จะไม่มีเทสต์ไหนแดง เวลาใครเผลอลบกฎนี้ออก
ลบครบแล้ว ของยังอยู่ครบ
หัวข้อที่มีชื่อว่า “ลบครบแล้ว ของยังอยู่ครบ”หัวข้อนี้แสดงผลรันหลังการลบ ที่มองไม่เห็นคีย์เลยสักคีย์ ทั้งที่ในที่เก็บยังมี version สองตัว
หลังลบ · จำนวนคีย์ที่มองเห็น 0 จำนวน version ที่ยังอยู่ในที่เก็บ 2 การลบไม่ได้ลบอะไรทิ้ง มันแค่ปิด version เดิมไว้ ของยังกินที่อยู่คีย์หายไปจากผลของ read_all เพราะ read คืน None ไม่ใช่เพราะมีใครไปเอาอะไรออกจากที่เก็บ
ตัวเลขสองบรรทัดแรกจึงไม่ขัดกัน มันวัดคนละอย่าง บรรทัดแรกวัดสิ่งที่ผู้อ่านคนนี้มองเห็น บรรทัดที่สองวัดสิ่งที่กินที่อยู่จริง
การลบในเอนจินแบบนี้จึงไม่ได้คืนที่ให้ใครสักหน่วยเดียว ตรงกันข้าม มันเพิ่มงานให้ตัวเก็บกวาด
นี่คือราคาที่ยังไม่ได้จ่ายตอนลบ แต่ต้องจ่ายทีหลัง ใน PostgreSQL คนที่มาเก็บของพวกนี้คือ VACUUM
และของที่มันเก็บคือ version ที่ไม่มี transaction ไหนต้องใช้อีกแล้ว
เอกสารของ PostgreSQL เขียนไว้ตรงๆ ว่า VACUUM แบบมาตรฐานทำเครื่องหมายที่ว่างไว้ให้แถวใหม่
มาใช้ซ้ำได้ แต่ไม่คืนที่ว่างนั้นกลับไปให้ระบบปฏิบัติการ
ยกเว้นกรณีพิเศษที่หน้าท้ายตารางว่างทั้งหน้าและขอ lock ระดับตารางได้ง่าย งานคืนที่จริงจัง
เป็นของ VACUUM FULL ซึ่งเป็นคนละคำสั่งและมีราคาคนละแบบ
ข้อนี้ตรงกับสิ่งที่เอนจินของเราแสดงพอดี เลข 2 ในผลรันข้างบนไม่ลดลงเอง เพียงเพราะไม่มีใคร มองเห็นของสองตัวนั้นแล้ว
ทำไมคุณต้องเห็นสิ่งที่ตัวเองเพิ่งเขียน
หัวข้อที่มีชื่อว่า “ทำไมคุณต้องเห็นสิ่งที่ตัวเองเพิ่งเขียน”หัวข้อนี้อธิบายเงื่อนไข v.begin != me และเหตุผลที่ฐานข้อมูลจริงเลือกทำแบบเดียวกัน
กฎข้อแรกมีเงื่อนไขสั้นๆ อยู่ท่อนหนึ่งที่เปลี่ยนพฤติกรรมของทั้งเอนจิน
if v.begin != me && !snapshot.contains(&v.begin) { return false; }ถ้าไม่มี v.begin != me ตรงนั้น transaction จะมองไม่เห็นสิ่งที่ตัวเองเพิ่งเขียน เพราะหมายเลข
ของตัวเองไม่มีวันอยู่ใน snapshot ของตัวเอง
snapshot ถูกจับตอนเปิด และตอนนั้นตัวเองยังไม่ commit จึงยังไม่อยู่ในทะเบียน
ผลที่ได้ถ้าไม่มีข้อยกเว้นนี้ ไม่ใช่การอ่านได้ค่าเก่า แต่คืออ่านไม่เจออะไรเลย read
คืน None สำหรับคีย์ที่ตัวเองเพิ่งเขียน
เหตุผลคือกฎทั้งสองข้อปฏิเสธพร้อมกัน version ใหม่ตกกฎข้อแรกเพราะหมายเลขของเราไม่อยู่ใน snapshot ส่วน version เดิมตกกฎข้อที่สอง เพราะคนที่ปิดมันคือเราเอง
แปลว่า BEGIN แล้ว UPDATE แล้ว SELECT จะคืนผลลัพธ์ว่าง ไม่มีใครเขียนโปรแกรม
บนพฤติกรรมแบบนั้นได้
ฐานข้อมูลจริงเลือกทางเดียวกัน งานที่ยังไม่ commit มองเห็นได้จากข้างในเสมอ และมองไม่เห็นเลย จากข้างนอก ข้อยกเว้นบรรทัดเดียวนี้คือเส้นแบ่งนั้น
ถึงตรงนี้กติกาชุดหนึ่งครบแล้ว ทุกคนอ่านจาก snapshot ของตัวเอง ไม่มีใครรอใคร และไม่มีใคร เห็นของที่ยังไม่ commit ของคนอื่น
บทที่ 5 จะเอากติกาชุดนี้ไปเจอกับ scenario ที่ทุกคนตัดสินใจถูกตามภาพที่ตัวเองเห็น แล้วผลรวมพังลงโดยไม่มีใครถูก abort สักครั้ง
- PostgreSQL 18 Documentation — 13.1. Introduction (Concurrency Control) (ตรวจแล้ว 2026-08-13) — หน้าที่ระบุว่าทุกคำสั่งเห็น snapshot ของข้อมูล และระบุข้อได้เปรียบ ของ MVCC ไว้ว่า “reading never blocks writing and writing never blocks reading”
- PostgreSQL 18 Documentation — 24.1. Routine Vacuuming
(ตรวจแล้ว 2026-08-13) — อธิบายว่า
UPDATEและDELETEไม่ลบ version เก่าทิ้งทันที เพราะ transaction อื่นอาจยังต้องเห็นมัน และระบุว่าVACUUMมาตรฐานทำที่ว่างให้ใช้ซ้ำได้ แต่ไม่คืนที่ว่างกลับไปให้ระบบปฏิบัติการ
เช็กความเข้าใจ — บทที่ 4
ข้อ 1 / 3เอนจินนี้ตรวจช่อง end ในกฎการมองเห็น แต่การแก้ค่าธรรมดาให้ผลเหมือนเดิมทุกบรรทัดถึงแม้จะตัดการตรวจนั้นทิ้ง ข้อใดอธิบายเหตุผลได้ถูกต้อง