ทำให้ serializable แล้วได้อะไรกลับมา
บทที่ 5 จบลงด้วยรายงานที่ทุกบรรทัดดูปกติ transaction ทั้งสองตัว commit ผ่าน ไม่มี error สักตัว และไม่มีไรเดอร์เหลืออยู่เวรเลยแม้แต่คนเดียว
กฎที่ทั้งระบบตั้งใจรักษาไว้พังไปแล้ว โดยไม่มีอะไรส่งเสียง
บทนี้เพิ่มด่านตรวจหนึ่งชุดเข้าไป แล้วกฎก็รอด แต่สิ่งที่ได้กลับมาไม่ใช่คำตอบที่ถูก
ประโยคที่ต้องเอากลับไปจากบทนี้ serializable ไม่ได้แก้คำตอบให้คุณ มันปฏิเสธงาน แล้วคืน abort กลับมาให้คุณลองใหม่เอง
บทนี้เพิ่ม file เดียวคือ src/ssi.rs แล้วแทรกด่านตรวจหนึ่งชุดเข้าไปใน commit ของ src/txn.rs
ไม่มีโครงสร้างข้อมูลใหม่เลยสักตัว เพราะทุกอย่างที่ด่านต้องใช้ถูกเก็บไว้ตั้งแต่บทที่ 4 แล้ว
รันด้วย cargo run --quiet -- 06 ใน scripts/transaction-engine/ ทั้งคอร์สยังเป็น
Rust standard library ล้วน ไม่มี external crate สักตัว
serializable วัดที่ผลลัพธ์ ไม่ได้วัดที่ลำดับจริง
หัวข้อที่มีชื่อว่า “serializable วัดที่ผลลัพธ์ ไม่ได้วัดที่ลำดับจริง”หัวข้อนี้ให้นิยามของ serializability แล้วชี้สองช่องในนิยามที่หัวข้อท้ายบทจะกลับมาใช้
SerializabilitySerializabilityเกณฑ์ความถูกต้องที่บอกว่า ผลของการรัน transaction พร้อมกันต้องเท่ากับผลของการรันเรียงทีละตัวในลำดับใดลำดับหนึ่ง ไม่ได้เรียกร้องให้ transaction รันทีละตัว มันเรียกร้องแค่ว่า ผล ที่ได้ ต้องเท่ากับผลของการรันเรียงทีละตัวในลำดับใดลำดับหนึ่ง
ช่องแรกอยู่ที่คำว่าลำดับใดลำดับหนึ่ง นิยามไม่ได้ระบุว่าลำดับไหน และไม่ได้สัญญาว่าจะเป็นลำดับ ที่คุณอยากได้
ช่องที่สองอยู่ที่สิ่งที่นิยามไม่พูดถึงเลย คือ transaction ที่ไม่ได้ commit เกณฑ์นี้วัดเฉพาะ ตัวที่ commit สำเร็จ ตัวที่ถูก abort ไม่ได้อยู่ในลำดับตั้งแต่ต้น
สองช่องนี้ฟังดูเป็นรายละเอียดของนิยาม หัวข้อเรื่อง lost update ข้างล่างจะแสดงว่ามันคือทั้งเรื่อง
ด่านที่ถามว่าเขียนชนกันไหม ไม่มีอะไรให้จับเลย
หัวข้อที่มีชื่อว่า “ด่านที่ถามว่าเขียนชนกันไหม ไม่มีอะไรให้จับเลย”หัวข้อนี้บอกว่าทำไม write skew หลุดด่านเขียนชน และความสัมพันธ์แบบไหนที่มองเห็นได้แทน
ใน scenario ของบทที่ 5 t1 เขียน rider:anan ส่วน t2 เขียน rider:bunma สองคีย์นี้ไม่ทับกันเลย
ด่านที่ถามว่ามีใครเขียนคีย์เดียวกันไหม จึงตอบว่าไม่มี แล้วปล่อยผ่านทั้งคู่ ด่านนั้นไม่ได้พัง มันแค่ถามผิดข้อ
คำถามที่ถูกอยู่ฝั่งการอ่าน Read-Write AntidependencyRead-Write Antidependencyความสัมพันธ์ที่ transaction หนึ่งอ่านค่าไว้ แล้วอีกตัวเขียนทับค่านั้นทีหลัง SSI ใช้การพบความสัมพันธ์นี้ต่อกันสองทอดเป็นสัญญาณว่า anomaly กำลังจะเกิด คือความสัมพันธ์ที่ฉันอ่านคีย์หนึ่งไว้ แล้วมีคนอื่นเขียนทับคีย์นั้นทีหลัง
ตอนที่ฉันอ่าน ค่านั้นจริง ตอนที่ฉัน commit ค่านั้นไม่จริงแล้ว คำตอบที่ฉันคิดมาทั้งก้อน จึงยืนอยู่บนของที่ไม่มีอยู่แล้ว
/// คีย์ที่ทำให้ `me` ชนกับตัวที่ commit ไปแล้ว — คืนรายการเพื่อให้รายงานบอกได้ว่าชนที่ไหนpub fn conflict_keys(me: &Txn, store: &Store, concurrent: &[&Txn]) -> Vec<String> { let mut keys: Vec<String> = Vec::new(); for other in concurrent { if other.xid == me.xid || !store.committed.contains(&other.xid) { continue; } for key in me.reads.intersection(&other.writes) { if !keys.contains(key) { keys.push(key.clone()); } } } keys}หัวใจของด่านอยู่ที่ me.reads.intersection(&other.writes) คือคีย์ที่เราอ่านไว้
ซ้อนกับคีย์ที่คนอื่นเขียน
มันคืนเป็นรายการคีย์ ไม่ใช่แค่ true หรือ false เพราะตอนตามหาสาเหตุจริง สิ่งเดียวที่ใช้ได้
คือชื่อคีย์ที่ชน
flowchart LR T1["t1<br/>อ่านทั้งสองแถว<br/>เขียน rider:anan = 0<br/>commit ก่อน"] T2["t2<br/>อ่านทั้งสองแถว<br/>เขียน rider:bunma = 0<br/>commit ทีหลัง"] A["abort"] T1 -.->|"t1 อ่าน rider:bunma ไว้ · แล้ว t2 เขียนทับ"| T2 T2 -.->|"t2 อ่าน rider:anan ไว้ · แล้ว t1 เขียนทับ"| T1 T2 ==>|"ด่านจับได้ที่คีย์ rider:anan"| A
คำบรรยายภาพ: ความสัมพันธ์แบบ read-write antidependency เกิดพร้อมกันทั้งสองทิศ จึงไม่มีลำดับ เรียงทีละตัวลำดับไหนให้ผลแบบนี้ได้ · และคนที่ถูกจับคือคนที่ commit ทีหลัง
ด่านนี้ทำงานตอน commit ไม่ใช่ตอนอ่าน
หัวข้อที่มีชื่อว่า “ด่านนี้ทำงานตอน commit ไม่ใช่ตอนอ่าน”หัวข้อนี้แสดงว่าด่านแทรกอยู่ตรงไหนของ commit แล้วบอกราคาของการตรวจที่ปลายทาง
วิธีที่พา snapshot isolation ขึ้นไปถึง serializable ด้วยการเฝ้าดูความสัมพันธ์แบบนี้ มีชื่อว่า Serializable Snapshot IsolationSerializable Snapshot Isolation (SSI)วิธีทำให้ snapshot isolation ไปถึงระดับ serializable ด้วยการเฝ้าดูความสัมพันธ์ระหว่าง transaction ที่รันอยู่ แล้ว abort ตัวที่ทำให้เกิดรูปแบบอันตราย
ในเอนจินนี้มันไม่ได้เพิ่มโครงสร้างข้อมูลใหม่เลย ทุกอย่างที่ด่านต้องใช้ถูกเก็บมาตั้งแต่บทที่ 4 คือรายการคีย์ที่แต่ละ transaction อ่านและเขียนไว้
/// พยายาม commit · คืน `false` เมื่อถูก abort /// /// ที่ระดับ snapshot ด่านนี้ไม่ตรวจอะไรเลย ทุก transaction ผ่านหมด /// ที่ระดับ serializable เราตรวจ read-write antidependency กับตัวที่ commit ไปแล้ว /// รายละเอียดของด่านนั้นอยู่ใน `ssi.rs` pub fn commit(&self, store: &mut Store, concurrent: &[&Txn]) -> bool { if self.level == Level::Serializable && crate::ssi::conflicts(self, store, concurrent) { store.aborts += 1; return false; } store.committed.insert(self.xid); true }ที่ระดับ snapshot เงื่อนไขแรกไม่ผ่าน ด่านจึงไม่ทำงานเลยสักครั้ง ทุกตัวผ่านหมดเหมือนบทที่ 5
ที่ระดับ serializable transaction ทำงานจนจบก่อน แล้วค่อยรู้ตอน commit ว่าถูกปฏิเสธ งานที่ทำไปทั้งหมดถูกทิ้ง
นี่คือราคาของการไม่ lock อะไรเลยระหว่างทาง เราแลกการรอที่บทที่ 3 วัดไว้ ด้วยงานที่เสียเปล่า เมื่อชนกันจริง
ลำดับการ commit เป็นตัวตัดสินว่าใครจ่าย
หัวข้อที่มีชื่อว่า “ลำดับการ commit เป็นตัวตัดสินว่าใครจ่าย”หัวข้อนี้ชี้เงื่อนไขข้อที่สองของด่าน ซึ่งทำให้ผลขึ้นกับลำดับการ commit ไม่ใช่ลำดับการเปิด
/// `true` เมื่อ `me` ต้องถูก abort////// เงื่อนไขสามข้อต้องครบพร้อมกัน ตัวอื่นต้องไม่ใช่ตัวเราเอง ตัวอื่นต้อง commit ไปแล้วจริง/// และสิ่งที่เราอ่านต้องซ้อนกับสิ่งที่ตัวนั้นเขียน////// ข้อที่สองคือเหตุผลที่ผลลัพธ์ขึ้นกับ *ลำดับการ commit* ไม่ใช่ลำดับการเปิด/// คนที่ commit ก่อนจะรอด คนที่มาทีหลังเป็นคนจ่ายpub fn conflicts(me: &Txn, store: &Store, concurrent: &[&Txn]) -> bool { !conflict_keys(me, store, concurrent).is_empty()}store.committed.contains(&other.xid) คือเงื่อนไขที่อ่านผ่านง่ายที่สุด และเป็นข้อที่ตัดสินผลจริง
ตอน t1 เรียก commit หมายเลขของ t2 ยังไม่อยู่ในทะเบียน committed ด่านจึงข้าม t2 ไปทั้งตัว t1 ผ่านฉลุย
ตอน t2 เรียก commit หมายเลขของ t1 อยู่ในทะเบียนแล้ว คีย์ที่ t2 อ่านไว้ซ้อนกับคีย์ที่ t1 เขียน ด่านจึงจับได้
คนที่ถึงเส้น commit ก่อนคือคนที่รอด ไม่ใช่คนที่เปิด transaction ก่อน ในการทดลองนี้ทั้งคู่ เปิดในสองบรรทัดที่ติดกัน
รันจริง กฎรอด แต่มีคนต้องจ่าย
หัวข้อที่มีชื่อว่า “รันจริง กฎรอด แต่มีคนต้องจ่าย”หัวข้อนี้อ่านผลรันของ write skew ที่ระดับ serializable แล้วเทียบกับผลของบทที่ 5
== บทที่ 6 ทำให้ serializable แล้วได้อะไรกลับมา == ระดับ serializable commit ได้ ["t1"] abort 1 ไรเดอร์เหลืออยู่เวร 1 คน กฎยังอยู่ไหม อยู่ version ที่ค้าง 4ผลที่ด่านทำให้เกิดขึ้นโดยตรงมีสองบรรทัด รายชื่อที่ commit ได้เหลือตัวเดียว และ abort ขึ้นจากศูนย์ ของบทที่ 5 มาเป็นหนึ่ง
ผลที่ตามมาคือไรเดอร์เหลืออยู่เวร 1 คน กฎที่ทั้งระบบต้องรักษาไว้จึงยังอยู่
t2 ถูก abort เพราะอ่านคีย์ที่ t1 เขียนทับ ["rider:anan"] t1 รอดเพราะ commit ก่อน · ลำดับการ commit เป็นตัวตัดสินว่าใครจ่ายเอนจินบอกได้ด้วยว่าชนที่คีย์ไหน rider:anan คือคีย์ที่ t2 อ่านไว้ แล้ว t1 เขียนทับ
บรรทัดที่ควรหยุดอ่านคือบรรทัดสุดท้ายของรายงาน version ที่ค้าง 4 ตัว เท่ากับของบทที่ 5 พอดี
การ abort ไม่ได้เก็บกวาดอะไรเลย ของที่ t2 เขียนลงไปยังกินที่อยู่ในที่เก็บ เหมือนของที่ ghost ทิ้งไว้ในบทที่ 1
แล้ว lost update ล่ะ ตรงนี้คือส่วนที่ไม่สบายใจ
หัวข้อที่มีชื่อว่า “แล้ว lost update ล่ะ ตรงนี้คือส่วนที่ไม่สบายใจ”หัวข้อนี้เอาด่านเดียวกันไปใส่การทดลอง lost update แล้วอ่านยอดสุดท้ายที่ได้กลับมา
เอาด่านเดิมไปใส่การทดลองของบทที่ 2 ซึ่งสองคนอ่านยอดเดียวกันแล้วบวกคนละก้อน
lost update ที่ระดับ serializable commit ได้ ["t1"] abort 1 ยอดสุดท้าย 150 คำตอบเรียงลำดับ 180t2 ถูก abort ตามที่คาดไว้ แต่สองบรรทัดล่างคือประเด็นของทั้งบท
**serializable ไม่ได้แก้ยอดให้ถูก** มันได้ 150 ไม่ใช่ 180 สิ่งที่มันให้คือ abort ที่คุณต้องเขียน retry เองยอดสุดท้ายคือ 150 ซึ่งเป็นผลของการรัน t1 ตัวเดียว ไม่ใช่ 180 ที่เป็นผลของการรันทั้งสองตัวเรียงกัน
และตามนิยามในหัวข้อแรก 150 ถูกต้อง มันตรงกับลำดับที่มี t1 อยู่ตัวเดียว เพราะ t2 ไม่ได้ commit มันจึงไม่เคยอยู่ในลำดับไหนเลย
เทียบกันตรงๆ ที่ระดับ snapshot บทที่ 2 ได้ยอด 130 โดยไม่มีใครถูกปฏิเสธ ที่ระดับ serializable ได้ยอด 150 พร้อม abort 1 ครั้ง
ไม่มีระดับไหนได้ 180 ระดับที่สูงขึ้นไม่ได้ซื้อคำตอบที่ถูกให้คุณ มันซื้อสิทธิ์ที่จะปฏิเสธงาน
สิ่งที่มันโยนกลับมาให้คุณคือ retry
หัวข้อที่มีชื่อว่า “สิ่งที่มันโยนกลับมาให้คุณคือ retry”หัวข้อนี้บอกว่าเมื่อ commit คืนค่าปฏิเสธ งานที่เหลือเป็นของผู้เขียนโปรแกรม ไม่ใช่ของเอนจิน
commit คืน false แล้วจบแค่นั้น มันไม่ได้แก้ยอดให้ ไม่ได้ลองใหม่ให้ และไม่ได้บอกว่า
คำตอบที่ถูกคืออะไร
เอนจินไม่รู้ด้วยซ้ำว่างานของคุณกำลังบวกหรือลบ มันรู้แค่ว่าคำตอบที่คุณคิดมายืนอยู่บนค่าที่ ไม่จริงแล้ว
ทางที่เหลือมีทางเดียวคือ RetryRetryการเริ่ม transaction ที่ถูก abort ใหม่ตั้งแต่ต้น งานนี้ตกมาถึงคนเขียนโปรแกรม เพราะเอนจินบอกได้แค่ว่าทำไม่ได้ ไม่ได้บอกว่าคำตอบที่ถูกคืออะไร เปิด transaction ใหม่ทั้งก้อน อ่านค่าที่สด แล้วคิดใหม่ บนของที่จริง
ถ้าเขียน retry ให้ t2 มันจะเปิดหลังจาก t1 commit ไปแล้ว snapshot ของมันจึงมี t1 อยู่ด้วย และมันจะเห็นยอดที่ t1 เขียนไว้
แต่วงจรนั้นไม่ได้อยู่ในเอนจิน มันเป็น code ของคุณ และนี่คือความหมายจริงของประโยคว่า serializable โยนงานกลับมา
คอร์สเก็บ aggregate ลงฐานข้อมูลด้วย EF Core บทที่ 5 เรื่อง concurrency สอนวงจรนี้ที่ชั้น ORM
ตรงๆ จับ DbUpdateConcurrencyException แล้วเลือกว่าจะ reload ค่าล่าสุดมาลองใหม่
หรือส่งให้ผู้ใช้ตัดสิน
กลไกที่ใช้ตรวจต่างกัน บทนั้นตรวจด้วย concurrency token ประจำแถว ส่วนบทนี้ตรวจว่าคีย์ที่เราอ่าน ซ้อนกับคีย์ที่คนอื่นเขียนหรือไม่
แต่รูปของทางออกเหมือนกันทุกจุด ฐานข้อมูลปฏิเสธ แล้วโปรแกรมของคุณตัดสินใจว่าจะทำอะไรต่อ ถ้าคุณเคยเขียนวงจรนั้นมาก่อน คุณเขียนมันเพราะเหตุผลในบทนี้
ด่านของเราหยาบกว่าของจริง และมันปฏิเสธงานที่ปลอดภัยด้วย
หัวข้อที่มีชื่อว่า “ด่านของเราหยาบกว่าของจริง และมันปฏิเสธงานที่ปลอดภัยด้วย”หัวข้อนี้บอกข้อแลกที่ด่านในคอร์สนี้เลือกไว้ แล้วเทียบกับ SSI ที่ PostgreSQL ใช้จริง
ด่านที่เราเพิ่งสร้างเป็นรูปแบบที่ย่อลงมาจาก SSI ของจริง ซึ่งมาจากงานของ Cahill, Röhm และ Fekete ที่ SIGMOD 2008 และเป็นฐานของระดับ Serializable ใน PostgreSQL
คอมเมนต์ที่หัวของ src/ssi.rs เขียนข้อแลกนั้นไว้เอง ก่อนถึงบรรทัด code บรรทัดแรกด้วยซ้ำ
//! ของจริงจะรอให้พบความสัมพันธ์นี้ *ต่อกันสองทอด* ก่อนจึงตัดสินใจ abort//! ส่วนของเรา abort ตั้งแต่ทอดแรก แปลว่าเราปฏิเสธงานบางชิ้นที่จริงๆ แล้วปลอดภัย//! **นั่นเป็นข้อแลกที่ยอมรับไว้โดยตั้งใจ ไม่ใช่ความบังเอิญ** และคอร์สต้องพูดข้อนี้ตรงๆSSI ของจริงไม่ได้ abort ทันทีที่พบความสัมพันธ์เส้นเดียว มันรอให้เส้นแบบนี้ต่อกันสองทอด จนมี transaction ตัวกลางที่มีทั้งเส้นเข้าและเส้นออก แล้วจึงเลือกตัวที่จะ abort
ด่านของเราตัดสินตั้งแต่เส้นแรกที่เจอตอน commit ผลคือมันปฏิเสธงานบางชิ้นที่จริงๆ แล้ว รันพร้อมกันได้อย่างปลอดภัย
ราคาของความหยาบนี้จ่ายเป็นจำนวน abort กับ retry ที่มากเกินจำเป็น ไม่ได้จ่ายเป็นคำตอบที่ผิด
ยังมีอีกข้อที่ของจริงต้องทำและเราไม่ได้ทำ คือการเฝ้าการอ่านที่เป็นช่วงด้วย predicate lock เพราะแถวที่ยังไม่มีอยู่ตอนอ่านก็ทำให้คำตอบผิดได้ ส่วนเราเก็บแค่ชื่อคีย์ที่อ่านไปจริง
Serializable ของ PostgreSQL คือ SSI ตัวเต็ม และเมื่อมันปฏิเสธ มันคืนข้อความนี้กลับมา
could not serialize access due to read/write dependencies among transactions
ข้อความนั้นไม่ได้บอกว่าคำตอบที่ถูกคืออะไร มันบอกแค่ว่าปฏิเสธ ฐานข้อมูลจริงกับเอนจินของเรา จึงส่งของแบบเดียวกันกลับมาให้ผู้เขียนโปรแกรม
บทที่ 7 จะไปนับราคาอีกด้านที่ยังไม่ได้แตะเลย คือจำนวนที่ transaction หนึ่งทำให้ระบบต้องเขียน ลง disk จริง
- Serializable Isolation for Snapshot Databases — Cahill, Röhm, Fekete (ACM SIGMOD 2008 หน้า 729–738 — ตรวจแล้ว 2026-08-13) ต้นทางของ SSI และของกฎที่ว่า ต้องพบความสัมพันธ์ read-write antidependency ต่อกันสองทอดก่อนจึงตัดสินใจ abort
- PostgreSQL 18 Documentation — 13.2 Transaction Isolation (ตรวจแล้ว 2026-08-13) ยืนยันว่าระดับ Serializable ของ PostgreSQL ทำด้วยเทคนิคที่วงวิชาการ เรียกว่า Serializable Snapshot Isolation และเป็นที่มาของข้อความ error ที่ยกมาในบทนี้ รวมทั้งประโยคเรื่อง predicate lock
เช็กความเข้าใจ — บทที่ 6
ข้อ 1 / 3ที่ระดับ serializable การทดลอง lost update ให้ยอดสุดท้าย 150 ทั้งที่คำตอบของการรันเรียงทีละตัวคือ 180 ข้อใดอธิบายตัวเลขนี้ได้ถูกต้อง