write skew — anomaly ที่ snapshot isolation มองไม่เห็น
ไรเดอร์สองคนอยู่เวรในโซนเดียวกัน กฎที่ทั้งระบบต้องรักษาไว้มีข้อเดียว ต้องมีคนอยู่เวรอย่างน้อยหนึ่งคนเสมอ
ทั้งคู่กดถอนตัวพร้อมกัน ต่างคนต่างเปิดดูก่อนว่าอีกฝ่ายยังอยู่เวรไหม เห็นว่ายังอยู่ ก็เลยถอนตัวเอง
ไม่มีใครทำผิดสักคน ไม่มี error สักตัว ไม่มี transaction ไหนถูก abort และหลังจากนั้น โซนนั้นก็ไม่เหลือไรเดอร์อยู่เลย
สี่บทที่ผ่านมาสร้างของครบทุกชิ้นที่ต้องใช้เพื่อกันเรื่องแบบนี้ แล้วมันก็ยังกันไม่อยู่
snapshot isolation ให้อะไรก่อน แล้วค่อยถามว่ามันไม่ให้อะไร
หัวข้อที่มีชื่อว่า “snapshot isolation ให้อะไรก่อน แล้วค่อยถามว่ามันไม่ให้อะไร”หัวข้อนี้ให้นิยามของ snapshot isolation ตามต้นฉบับปี 1995 และสรุปว่ามันปิดปัญหาอะไรไปแล้วบ้าง
Snapshot IsolationSnapshot Isolationisolation level ที่ให้ทุก transaction อ่านจาก snapshot ของตัวเอง จึงไม่มี dirty read และไม่มี non-repeatable read แต่ยังปล่อยให้ write skew เกิดได้ ไม่ใช่ระดับที่มาตรฐาน ANSI SQL ระบุไว้ มันถูกตั้งชื่อและให้นิยาม ในบทความ A Critique of ANSI SQL Isolation Levels ของ Berenson และคณะ เมื่อปี 1995 ที่หัวข้อ 4.2
นิยามของมันสั้นกว่าที่ชื่อทำให้คิด ทุก transaction อ่านข้อมูลจาก SnapshotSnapshotภาพของฐานข้อมูล ณ จุดเวลาหนึ่งที่ transaction หนึ่งมองเห็นตลอดอายุของมัน ต่อให้คนอื่น commit อะไรเพิ่มระหว่างนั้น ภาพนี้ก็ไม่เปลี่ยน ของค่าที่ commit ไปแล้ว ณ เวลาที่มันเริ่ม ต้นฉบับเรียกเวลานั้นว่า Start-Timestamp
การแก้ของคนอื่นที่เกิดขึ้นหลังจากนั้นมองไม่เห็นทั้งหมด และสิ่งที่ transaction เขียนเองยังอ่าน กลับมาเจอเสมอ
กติกาข้อเดียวนี้ปิดปัญหาสองอย่างไปพร้อมกัน dirty read เกิดไม่ได้เพราะค่าที่ยังไม่ commit ไม่เคยเข้ามาอยู่ในภาพ ส่วน non-repeatable read เกิดไม่ได้เพราะภาพไม่ขยับตลอดอายุของ transaction
ต้นฉบับยังใส่กติกาข้อที่สองไว้ด้วย ชื่อว่า first-committer-wins ถ้าสอง transaction เขียนคีย์ เดียวกัน โดยที่ช่วงเวลาทำงานของทั้งคู่ทับกัน คนที่ commit ทีหลังต้องแพ้ เพื่อไม่ให้ใครเขียนทับงานของอีกฝ่ายแบบเงียบๆ
ข้อความ “ช่วงเวลาทับกัน” ตัดทิ้งไม่ได้ ถ้าตัดไป กติกานี้จะไปปฏิเสธ transaction ที่เปิดขึ้นมา หลัง อีกฝ่าย commit จบไปแล้ว ซึ่งไม่ใช่ snapshot isolation อีกต่อไป
snapshot ที่พูดถึงในหัวข้อนี้ไม่ใช่ของใหม่ มันคือสำเนาทะเบียน committed ที่ txn::begin
จับไว้ตอนเปิด บวกกับกฎการมองเห็นสองข้อที่บทที่ 4 ไล่ทีละบรรทัดไปแล้ว
บทนี้จึงไม่ได้เพิ่ม code ให้เอนจินเลยสักบรรทัด มันแค่เอาของเดิมไปวางในสถานการณ์ที่กฎ สองข้อนั้นไม่พอ
กฎหนึ่งข้อ สองคนอ่านครบทั้งสองแถว แล้วเขียนคนละแถว
หัวข้อที่มีชื่อว่า “กฎหนึ่งข้อ สองคนอ่านครบทั้งสองแถว แล้วเขียนคนละแถว”หัวข้อนี้วาง scenario ที่ทำให้กฎพัง และชี้ว่าจุดชี้ขาดอยู่ที่ทั้งคู่เขียนคนละคีย์
Write SkewWrite Skewanomaly ที่ transaction สองตัวอ่านสิ่งเดียวกัน ตัดสินใจถูกต้องตามที่ตัวเองเห็น แล้วเขียนคนละแถว จนกฎที่ทั้งคู่ตั้งใจรักษาไว้พังพร้อมกัน snapshot isolation มองไม่เห็น anomaly ชนิดนี้ คือกรณีที่ transaction สองตัวอ่านข้อมูลชุดเดียวกัน ตัดสินใจถูกต้องตามภาพที่ ตัวเองเห็น แล้วเขียนคนละแถว จนกฎที่ทั้งคู่ตั้งใจรักษาไว้พังพร้อมกัน
ต้นฉบับปี 1995 ตั้งรหัสให้มันว่า A5B และยกตัวอย่างเป็นบัญชีธนาคาร ที่ยอดของแต่ละบัญชี ติดลบได้ ตราบใดที่ผลรวมของทั้งคู่ยังไม่ติดลบ
โดเมนของไซต์นี้ใช้ไรเดอร์แทนบัญชี ค่า 1 แปลว่าอยู่เวร ค่า 0 แปลว่าไม่อยู่ และกฎคือ
ผลรวมของทั้งสองแถวต้องไม่เป็นศูนย์
/// ไรเดอร์สองคนอยู่เวร แต่ละคนอ่าน *ทั้งสองแถว* เห็นว่าอีกคนอยู่เวร แล้วถอนตัวเอง////// ทั้งคู่ตัดสินใจถูกต้องตามภาพที่ตัวเองเห็น และทั้งคู่เขียนคนละแถว/// ด่านที่ตรวจแค่ "เขียนชนกันไหม" จึงไม่มีอะไรให้จับเลยpub fn write_skew(level: Level) -> Skew { let mut s = Store::new(); let mut setup = txn::begin(&mut s, Level::Snapshot); setup.write(&mut s, ANAN, 1); setup.write(&mut s, BUNMA, 1); setup.commit(&mut s, &[]);
let mut t1 = txn::begin(&mut s, level); let mut t2 = txn::begin(&mut s, level); let seen1: i64 = t1.read_all(&s).values().sum(); let seen2: i64 = t2.read_all(&s).values().sum(); if seen1 >= 2 { t1.write(&mut s, ANAN, 0); } if seen2 >= 2 { t2.write(&mut s, BUNMA, 0); } let ok1 = t1.commit(&mut s, &[&t2]); let ok2 = t2.commit(&mut s, &[&t1]);ทั้งคู่เรียก read_all ซึ่งอ่าน ทุกแถวที่ตัวเองมองเห็น ไม่ใช่แค่แถวของตัวเอง ผลรวมที่ได้คือ
2 ทั้งคู่ เพราะต่างเปิด snapshot ของตัวเองก่อนที่อีกฝ่ายจะเขียนอะไรลงไป
สองบรรทัดที่ต้องอ่านช้าๆ คือ t1.write(&mut s, ANAN, 0) กับ
t2.write(&mut s, BUNMA, 0) — t1 แตะแถวของ anan อย่างเดียว ส่วน t2 แตะแถวของ
bunma อย่างเดียว
flowchart TD R1["แถว anan<br/>อยู่เวร = 1"] R2["แถว bunma<br/>อยู่เวร = 1"] T1["t1 · snapshot ของตัวเอง<br/>อ่านทั้งสองแถว ผลรวม = 2"] T2["t2 · snapshot ของตัวเอง<br/>อ่านทั้งสองแถว ผลรวม = 2"] W1["t1 เขียน anan = 0"] W2["t2 เขียน bunma = 0"] RULE["กฎ ต้องมีคนอยู่เวรอย่างน้อย 1 คน<br/>ผลจริง ไม่เหลือสักคน"] R1 ==> T1 R2 ==> T1 R1 --> T2 R2 --> T2 T1 ==> W1 T2 --> W2 W1 ==> RULE W2 --> RULE
คำบรรยายภาพ: ทั้งคู่อ่านครบทั้งสองแถว แต่เขียนคนละแถว เส้นเขียนสองเส้นจึงไม่เคยชนกันเลย
ผลรันจริง และบรรทัดที่ควรทำให้หยุดอ่าน
หัวข้อที่มีชื่อว่า “ผลรันจริง และบรรทัดที่ควรทำให้หยุดอ่าน”หัวข้อนี้แสดงผลรันที่ระดับ snapshot แล้วชี้ตัวเลขสามตัวที่เป็นประเด็นของทั้งบท
== บทที่ 5 write skew — anomaly ที่ snapshot isolation มองไม่เห็น == กฎ ต้องมีไรเดอร์อยู่เวรอย่างน้อย 1 คน · เริ่มต้นอยู่เวรทั้ง 2 คน
ระดับ snapshot commit ได้ ["t1", "t2"] abort 0 ไรเดอร์เหลืออยู่เวร 0 คน กฎยังอยู่ไหม **พัง** version ที่ค้าง 4
ไม่มีใครเขียนทับคีย์เดียวกันเลย ทั้งคู่จึง commit ผ่านทั้งคู่ แต่ละคนตัดสินใจถูกตามภาพที่ตัวเองเห็น ผลรวมกลับผิดสองบรรทัดบนสุดของรายงานบอกว่าทั้ง t1 และ t2 ผ่านทั้งคู่ และ abort 0 ครั้ง เอนจินไม่ได้ ปฏิเสธงานของใครสักชิ้นเดียว
บรรทัดที่ควรหยุดอ่านคือบรรทัดถัดมา ไม่เหลือไรเดอร์อยู่เวรสักคน กฎที่ทั้งระบบต้องรักษาไว้ พังไปแล้ว โดยไม่มีอะไรส่งเสียงสักครั้ง
บรรทัดสุดท้ายเป็นของที่ต่อจากบทที่ 4 โดยตรง version ที่ค้าง 4 ตัวทั้งที่มีอยู่แค่สองคีย์ เพราะการเขียนแต่ละครั้งต่อของใหม่เข้าไปโดยไม่ลบของเดิม
สองตัวที่เกินมาคือ version แรกของทั้งสองแถว ซึ่งถูกปิดไปแล้วและไม่มีใครอ่านอีก แต่ยังกินที่อยู่จริง เหมือนที่บทที่ 1 แสดงไว้
ทำไมไม่มีด่านไหนจับมันได้เลย
หัวข้อที่มีชื่อว่า “ทำไมไม่มีด่านไหนจับมันได้เลย”หัวข้อนี้อธิบายว่าด่านที่ถามเรื่องการเขียนทับคีย์เดียวกัน ไม่มีอะไรให้ตรวจในการรันนี้
ด่านตรวจการชนกันที่ทุกคนคุ้นเคยถามคำถามเดียว มีคีย์ไหนที่คนสองคนเขียนทับกันหรือเปล่า
ในการรันนี้คำตอบคือไม่มีสักคีย์ t1 เขียนแถวของ anan ส่วน t2 เขียนแถวของ bunma รายการคีย์ที่ทั้งคู่เขียนไม่ซ้อนกันแม้แต่ตัวเดียว
first-committer-wins จึงไม่มีอะไรให้จับ ไม่ใช่เพราะกติกานั้นหลวมเกินไป แต่เพราะไม่มี การชนเกิดขึ้นให้มันเห็นตั้งแต่ต้น
เอนจินของเราที่ระดับ snapshot ยังไม่มีด่านนั้นด้วยซ้ำ ด่าน commit ทั้งด่านมีเงื่อนไขเดียว และเงื่อนไขนั้นทำงานเฉพาะที่ระดับ serializable
/// พยายาม 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 ทุก transaction เดินตรงไปเข้าทะเบียน committed นั่นคือเหตุผลที่รายงาน บอกว่า abort 0 ครั้ง
ประเด็นที่สำคัญกว่าคือ ต่อให้เติม first-committer-wins เข้าไปเต็มรูปตามนิยามของ Berenson ตัวเลขทุกตัวก็ไม่ขยับ เพราะสิ่งที่กติกานั้นเปรียบเทียบคือรายการคีย์ที่เขียน และรายการนั้น ไม่ซ้อนกัน
ไม่มีใครตัดสินใจผิดสักคน ความผิดอยู่ที่ผลรวม
หัวข้อที่มีชื่อว่า “ไม่มีใครตัดสินใจผิดสักคน ความผิดอยู่ที่ผลรวม”หัวข้อนี้เทียบผลของการรันพร้อมกันกับผลของการรันเรียงทีละตัว แล้วเรียกชื่ออาการที่ได้
ลองเรียงให้ t1 ทำงานจบก่อน มันเห็นว่าทั้งคู่ยังอยู่เวร ผลรวมเป็น 2 มันจึงถอนตัวเอง
พอถึงคิวของ t2 ภาพที่มันเปิดขึ้นมามีคนอยู่เวรเหลือคนเดียว เงื่อนไข seen2 >= 2 ไม่ผ่าน
มันจึงไม่เขียนอะไรลงไปเลย
สลับลำดับก็ได้ผลแบบเดียวกัน ไม่ว่าจะเรียงทางไหน ผลลัพธ์คือยังมีไรเดอร์อยู่เวรหนึ่งคนเสมอ
การรันพร้อมกันให้ผลที่ไม่ตรงกับลำดับใดเลยสักลำดับ เอกสารของ PostgreSQL เรียกอาการนี้ ตรงๆ ว่า serialization anomaly คือผลของกลุ่ม transaction ที่ commit สำเร็จ ไม่สอดคล้อง กับลำดับการรันทีละตัวแบบใดเลย
ย้อนกลับไปดูการตัดสินใจของแต่ละคนอีกครั้ง ไม่มีใครอ่านค่าที่ยังไม่ commit ไม่มีใครอ่านค่าเดิม ซ้ำแล้วได้คนละคำตอบ ทั้งคู่ทำตามกฎที่ระบบให้ไว้ครบทุกข้อ
ความผิดพลาดไม่ได้อยู่ในตัวใครสักคน มันมีอยู่แค่ในผลรวมเท่านั้น และนั่นคือเหตุผลที่หา bug ตัวนี้จาก log ของ transaction ทีละตัวไม่เจอ
บทที่ 6 จะจับมันด้วยคำถามคนละข้อ
หัวข้อที่มีชื่อว่า “บทที่ 6 จะจับมันด้วยคำถามคนละข้อ”หัวข้อนี้บอกว่าด่านชนิดใหม่ต้องถามอะไร และเตือนล่วงหน้าว่าราคาที่มันเรียกเก็บไม่ใช่สิ่งที่คนคาด
คำถาม “เขียนชนกันไหม” ใช้กับอาการนี้ไม่ได้แล้ว คำถามที่ยังใช้ได้คือ มีใครเขียนทับค่าที่ฉันอ่านไปแล้วหรือเปล่า
ในการรันนี้มีครบทั้งสองทาง t1 เขียนทับแถวที่ t2 อ่านไป และ t2 เขียนทับแถวที่ t1 อ่านไป
ข้อมูลที่ต้องใช้ตอบคำถามนั้นถูกเก็บไว้ตั้งแต่บทที่ 4 แล้ว ทุกครั้งที่ read ถูกเรียก คีย์นั้น
ถูกจดลงในรายการของ transaction เอง บทที่ 6 แค่หยิบสองรายการนั้นมาเทียบกัน
ด่านนั้นจับ write skew ได้จริง แต่ สิ่งที่มันคืนกลับมาไม่ใช่คำตอบที่ถูก มันคือการปฏิเสธงาน และเมื่อเอาไปใช้กับ anomaly ที่บทที่ 2 วัดไว้ คำตอบที่ได้ก็ไม่ได้ถูกขึ้นเลยแม้แต่นิดเดียว
- A Critique of ANSI SQL Isolation Levels — Berenson, Bernstein, Gray, Melton, O’Neil, O’Neil (ACM SIGMOD 1995, รายงาน MSR-TR-95-51 ของ Microsoft Research — ตรวจแล้ว 2026-08-13) หัวข้อ 4.2 ให้นิยาม snapshot isolation ด้วย Start-Timestamp และหัวข้อเดียวกันตั้งรหัส A5B ให้ write skew พร้อมตัวอย่างบัญชีธนาคารที่ยอดรวมของสองบัญชีห้ามติดลบ
- PostgreSQL 18 Documentation — 13.2 Transaction Isolation (ตรวจแล้ว 2026-08-13) — ระบุว่า Repeatable Read ของ PostgreSQL ทำด้วยเทคนิคที่ วงวิชาการเรียกว่า snapshot isolation และกันได้ทุกปรากฏการณ์ในตารางของหน้านั้น ยกเว้น serialization anomaly
เช็กความเข้าใจ — บทที่ 5
ข้อ 1 / 3ผลรันของบทนี้บอกว่าที่ระดับ snapshot ทั้ง t1 และ t2 commit ผ่านทั้งคู่ และไม่มีการ abort เลย ข้อใดอธิบายได้ถูกต้องว่าทำไมไม่มีด่านไหนจับได้