anomaly ที่ทำซ้ำได้ทุกครั้ง — สามชนิดเกิดไม่ได้ หนึ่งชนิดเกิดจริง
ปัญหาจากการรันพร้อมกันมีชื่อเสียงเรื่องการโผล่มาครั้งเดียวแล้วหายไป
รันซ้ำอีกสิบรอบก็ไม่เจอ สุดท้ายทุกคนเลิกเถียงกันโดยไม่มีใครถูก และไม่มีใครแก้อะไรเลย
บทนี้ทำให้ปรากฏการณ์แต่ละชนิดเกิดขึ้นเหมือนเดิมทุกรอบก่อน เพราะสิ่งที่ทำซ้ำไม่ได้ ไม่ใช่หลักฐาน
ผลที่ออกมาไม่ตรงกับที่คนส่วนใหญ่คาด สามชนิดแรกเกิดไม่ได้เลยในเอนจินที่บทที่ 1 สร้างไว้ ส่วนชนิดที่สี่ เกิดจริงโดยไม่มีใครโยน error ออกมาสักตัว
ทุกบล็อก code ในบทเรียนนี้ถูกคัดมาจาก scripts/transaction-engine/src/ ทีละ byte
และผลรันทุกบรรทัดคัดมาจาก expected/02-anomalies-you-can-reproduce.txt ที่รันจริง
รันตามเองได้ด้วย cargo run --quiet -- 02 ที่ scripts/transaction-engine/
ถ้าแก้ในบทเรียนอย่างเดียวโดยไม่แก้ที่ซอร์ส เทสต์จะแดงทันที
ทำไมบทนี้กล้าพิมพ์ตัวเลขลงหน้ากระดาษ
หัวข้อที่มีชื่อว่า “ทำไมบทนี้กล้าพิมพ์ตัวเลขลงหน้ากระดาษ”หัวข้อนี้บอกว่าเอนจินไม่มี thread และไม่มีการหน่วงเวลา ลำดับก้าวจึงเขียนไว้เป็น code ธรรมดา
วิธีสาธิตปัญหาแบบที่พบบ่อยคือเปิดสอง thread แล้วหวังให้มันแทรกกันตรงจุดที่ต้องการพอดี
วิธีนั้นได้ผลบ้างไม่ได้ผลบ้างตามภาระของเครื่อง สิ่งที่ได้กลับมาคือเทสต์ที่แดงสลับเขียว ซึ่งไม่มีใครเชื่อถือมันได้อีก
เอนจินนี้ไม่มี thread สักตัวและไม่มีการหน่วงเวลาสักจุด ลำดับก้าวของ transaction ทั้งสองตัว ถูกเขียนเรียงไว้เป็นคำสั่งธรรมดาบรรทัดต่อบรรทัด
นั่นคือเหตุผลเดียวที่บทนี้พิมพ์ตัวเลขลงหน้ากระดาษได้ เทสต์ของคอร์สเทียบผลรันของทุกบท กับที่ตรึงไว้ทีละ byte และรันบทหนึ่งซ้ำห้ารอบเพื่อยืนยันว่าได้ผลเดิม
ราคาที่จ่ายคือ scenario ในบทนี้เป็นการสลับคิวที่เลือกไว้แล้ว ไม่ใช่ทุกลำดับที่เป็นไปได้ มันแสดงว่าอะไรเกิดขึ้นได้ ไม่ได้พิสูจน์ว่าอะไรเกิดไม่ได้
dirty read เกิดไม่ได้ ไม่ใช่เพราะโชคดี
หัวข้อที่มีชื่อว่า “dirty read เกิดไม่ได้ ไม่ใช่เพราะโชคดี”หัวข้อนี้แสดงว่าค่า 999 อยู่ในที่เก็บจริง แต่กฎการมองเห็นไม่ยอมให้การอ่านคืนมันออกมา
Dirty ReadDirty Readการอ่านค่าที่ transaction อื่นเขียนไว้แต่ยังไม่ commit ถ้าอีกฝั่ง rollback ค่าที่อ่านไปแล้วก็กลายเป็นค่าที่ไม่เคยมีอยู่จริง คือการอ่านค่าที่คนอื่นเขียนไว้แต่ยังไม่ commit ถ้าอีกฝั่งยกเลิกทีหลัง ค่าที่อ่านไปแล้วก็กลายเป็นค่าที่ไม่เคยมีอยู่จริง
scenario ของมันเขียนได้ในสิบบรรทัด writer เขียน 999 ทิ้งไว้โดยไม่ commit แล้ว reader เปิดขึ้นมาทีหลัง
// dirty read — เอนจินนี้ทำให้เกิดไม่ได้เลยโดยโครงสร้าง let mut s = Store::new(); let mut setup = txn::begin(&mut s, Level::Snapshot); setup.write(&mut s, TOTAL, 100); setup.commit(&mut s, &[]); let mut writer = txn::begin(&mut s, Level::Snapshot); writer.write(&mut s, TOTAL, 999); let mut reader = txn::begin(&mut s, Level::Snapshot); let raw = s.versions[TOTAL].last().map(|v| v.value).unwrap_or(0); let seen = reader.read(&s, TOTAL).unwrap_or(0);สองบรรทัดสุดท้ายคือทั้งหมดของการทดลอง raw ล้วงเข้าไปหยิบ version ตัวท้าย chain ตรงๆ
โดยไม่ผ่านกฎอะไรเลย ส่วน seen เรียก read ซึ่งเดินผ่านกฎการมองเห็น
== บทที่ 2 anomaly ที่ทำซ้ำได้ทุกครั้ง == dirty read version ล่าสุดในที่เก็บจริง 999 ค่าที่กฎการมองเห็นยอมให้อ่าน 100 → เกิดไม่ได้เลข 999 อยู่ในที่เก็บจริงตามที่บรรทัดแรกรายงาน การเขียนของเอนจินนี้ลงที่เก็บทันที มันไม่ได้รอ commit
สิ่งเดียวที่กันไม่ให้ใครเห็นมันคือเงื่อนไขข้อแรกของ visible ซึ่งตัดทุก version
ที่คนสร้างไม่อยู่ใน snapshot ของผู้อ่านออกไป
หมายเลขของ writer จะเข้าไปอยู่ใน snapshot ของ reader ได้ทางเดียว คือ writer ต้อง commit ให้เสร็จก่อนที่ reader จะเปิด
จึงไม่มีการสลับคิวแบบไหนที่ทำให้ dirty read เกิดในเอนจินนี้ได้ นี่เป็นสมบัติของการออกแบบ ไม่ใช่ผลของ scenario ที่เลือกมาดี
อ่านสองครั้งได้ค่าเดิม เพราะ snapshot ไม่ขยับ
หัวข้อที่มีชื่อว่า “อ่านสองครั้งได้ค่าเดิม เพราะ snapshot ไม่ขยับ”หัวข้อนี้แสดงว่าการอ่านซ้ำใน transaction เดียวได้ 100 ทั้งสองครั้ง ส่วนตัวที่เปิดทีหลังเห็น 500
Non-Repeatable ReadNon-Repeatable Readการอ่านแถวเดิมสองครั้งใน transaction เดียวแล้วได้ค่าต่างกัน เพราะมี transaction อื่น commit คั่นกลาง คือการอ่านแถวเดิมสองครั้งใน transaction เดียวแล้วได้ค่าไม่เท่ากัน เพราะมีคนอื่น commit คั่นกลาง
การสลับคิวเขียนไว้ตรงๆ แบบเดิม other แทรกเข้ามาระหว่างการอ่านสองครั้งของ long_reader
แล้ว fresh เปิดขึ้นมาเป็นตัวสุดท้าย
let mut long_reader = txn::begin(&mut s, Level::Snapshot); let first = long_reader.read(&s, TOTAL).unwrap_or(0); let mut other = txn::begin(&mut s, Level::Snapshot); other.write(&mut s, TOTAL, 500); other.commit(&mut s, &[]); let second = long_reader.read(&s, TOTAL).unwrap_or(0); let mut fresh = txn::begin(&mut s, Level::Snapshot); let after = fresh.read(&s, TOTAL).unwrap_or(0); non-repeatable read อ่านครั้งแรก 100 · มีคน commit 500 คั่น · อ่านซ้ำ 100 → เกิดไม่ได้ transaction ที่เปิดใหม่หลังจากนั้นเห็น 500บรรทัดที่สองคือด่านกันตัวเองหลอกตัวเอง other commit ค่า 500 ไปแล้วจริง เพราะ fresh
ที่เปิดทีหลังอ่านค่านั้นได้
สิ่งที่ทำให้ long_reader ไม่เห็นคือ snapshot ของมัน ซึ่งเป็น สำเนา ของทะเบียน committed
ณ ตอนที่มันเปิด ไม่ใช่การอ้างอิงไปที่ทะเบียน
สำเนานั้นจึงไม่มีหมายเลขของ other อยู่เลย version ที่ other เขียนไว้ก็ตกเงื่อนไขข้อแรก
เหมือนกับของ writer ในหัวข้อที่แล้วทุกประการ
ราคาต้องพูดตรงๆ ด้วย long_reader อ่านค่าที่ล้าสมัยไปแล้ว มันได้ภาพที่คงที่
ไม่ใช่ภาพที่ใหม่ที่สุด
นับสองครั้งได้จำนวนแถวเท่าเดิม
หัวข้อที่มีชื่อว่า “นับสองครั้งได้จำนวนแถวเท่าเดิม”หัวข้อนี้แสดงว่าแถวที่เพิ่มเข้ามาใหม่อยู่ในที่เก็บจริง แต่ไม่ถูกนับ เพราะไม่มี version ที่มองเห็นได้
Phantom ReadPhantom Readการถามด้วยเงื่อนไขเดิมสองครั้งแล้วได้จำนวนแถวไม่เท่ากัน เพราะมีคนเพิ่มหรือลบแถวที่เข้าเงื่อนไขคั่นกลาง ต่างจาก non-repeatable read ตรงที่จำนวนแถวเปลี่ยน ไม่ใช่ค่าในแถวเดิมเปลี่ยน ต่างจากชนิดที่แล้วตรงที่จำนวนแถวเปลี่ยน ไม่ใช่ค่าในแถวเดิมเปลี่ยน คนที่ถามด้วยเงื่อนไขเดิมสองครั้งจึงได้จำนวนไม่เท่ากัน
counter นับไรเดอร์ที่มองเห็นสองครั้ง โดยมี inserter เพิ่มไรเดอร์คนที่สองแล้ว commit
คั่นกลาง
let mut counter = txn::begin(&mut s, Level::Snapshot); let n1 = counter.read_all(&s).len(); let mut inserter = txn::begin(&mut s, Level::Snapshot); inserter.write(&mut s, BUNMA, 1); inserter.commit(&mut s, &[]); let n2 = counter.read_all(&s).len(); phantom read นับครั้งแรก 1 แถว · มีคนเพิ่มไรเดอร์แล้ว commit · นับซ้ำ 1 แถว → เกิดไม่ได้คีย์ของไรเดอร์คนที่สองอยู่ในที่เก็บแล้วจริง read_all วนผ่านมันทุกครั้งที่ถูกเรียก
แต่ read_all เรียก read ทีละคีย์ และคีย์ที่ไม่มี version ไหนมองเห็นได้เลยจะคืน None
แล้วหายไปจากผลที่ส่งกลับ
จุดที่คุ้มกับการหยุดคิดคือ phantom ในเอนจินนี้ไม่ต้องการกลไกเพิ่มสักชิ้น กฎเดียวกับที่กัน dirty read กันมันไปด้วยแล้ว
ในเอนจินที่คุมด้วย lock ปัญหานี้ยากกว่านั้นมาก เพราะแถวที่ยังไม่มีอยู่ก็ยังไม่มีอะไรให้ lock
lost update เกิดจริง และไม่มีใครส่งเสียง
หัวข้อที่มีชื่อว่า “lost update เกิดจริง และไม่มีใครส่งเสียง”หัวข้อนี้แสดงว่าทั้งสอง transaction commit ผ่าน แล้วยอดสุดท้ายหายไป 50 จากคำตอบของการรันเรียงทีละตัว
Lost UpdateLost Updateการที่การแก้ค่าของ transaction หนึ่งถูกเขียนทับหายไป เพราะอีกตัวอ่านค่าเดิมไปก่อนแล้วเขียนทับทีหลัง ผลรวมที่ได้จึงน้อยกว่าที่ควรเป็น คือการที่การแก้ค่าของ transaction หนึ่งหายไป เพราะอีกตัวอ่านค่าเดิมไปก่อน แล้วเขียนทีหลัง
โจทย์คือสองคนบวกยอดคนละก้อนบนยอดตั้งต้น 100 คนแรกบวก 50 คนที่สองบวก 30
/// สองคนอ่านยอด 100 แล้วบวกคนละก้อน · ถ้ารันเรียงทีละตัวต้องได้ 180pub const SERIAL_ANSWER: i64 = 180;
pub fn lost_update(level: Level) -> Lost { let mut s = Store::new(); let mut setup = txn::begin(&mut s, Level::Snapshot); setup.write(&mut s, TOTAL, 100); setup.commit(&mut s, &[]);
let mut t1 = txn::begin(&mut s, level); let mut t2 = txn::begin(&mut s, level); let v1 = t1.read(&s, TOTAL).unwrap_or(0); let v2 = t2.read(&s, TOTAL).unwrap_or(0); t1.write(&mut s, TOTAL, v1 + 50); t2.write(&mut s, TOTAL, v2 + 30); let ok1 = t1.commit(&mut s, &[&t2]); let ok2 = t2.commit(&mut s, &[&t1]);ทั้งคู่เปิดก่อนที่อีกฝ่ายจะ commit ทั้งคู่จึงอ่านได้ 100 เท่ากัน แล้วต่างคนต่างคำนวณ จากเลขตัวเดียวกันนั้น
lost update ทั้งคู่ commit ได้ ["t1", "t2"] ยอดสุดท้าย 130 · คำตอบถ้ารันเรียงทีละตัว 180 หายไป 50 → **เกิดจริง**180 คือคำตอบที่ควรได้ ไม่ว่าเอนจินจะรันสองตัวนี้เรียงทีละตัวในลำดับไหนก็ตาม
ผลจริงคือ 130 ไม่มี error สักตัว ไม่มีใครถูก abort และทั้งคู่รายงานกลับมาว่าสำเร็จ
การเขียนของ t1 ไม่ได้ถูกลบทิ้งไปไหน version ที่มีค่า 150 ยังอยู่ใน chain ตลอด
แต่ read ไล่ chain จากท้ายมาหน้า แล้วหยุดที่ตัวใหม่สุดที่มองเห็นได้ ซึ่งคือ version
ของ t2 ที่มีค่า 130
flowchart TD S["setup · xid 1<br/>ยอด 100 แล้ว commit"] T1["t1 · xid 2<br/>อ่าน 100 แล้วเขียน 150"] T2["t2 · xid 3<br/>อ่าน 100 แล้วเขียน 130"] CH["chain ของ order:total<br/>100 → 150 → 130"] R["ผู้อ่านคนถัดไปได้ 130<br/>คำตอบถ้ารันเรียงทีละตัว 180"] S --> T1 S --> T2 T1 -->|commit ผ่าน| CH T2 -->|commit ผ่าน ต่อท้าย chain| CH CH ==>|หยุดที่ตัวใหม่สุดที่มองเห็นได้| R
คำบรรยายภาพ: ทั้งคู่ตัดสินใจบนเลข 100 ตัวเดียวกัน version ที่ t1 เขียนยังอยู่ใน chain แต่ผู้อ่านคนถัดไปหยุดที่ตัวท้ายสุด จึงได้ 130
50 บาทหายไปเงียบๆ และระบบก็เดินต่อไปบนเลข 130 นั้นเหมือนไม่มีอะไรเกิดขึ้น
Berenson และคณะนิยาม snapshot isolation ไว้พร้อมด่านหนึ่งชื่อ first-committer-wins ตัวที่ commit ทีหลังจะถูก abort ถ้ามันเขียนคีย์ที่คนอื่น commit ไปแล้วในช่วงเวลาที่ทับกัน บทความนั้นระบุตรงๆ ว่าด่านนี้ตัด lost update ทิ้งตั้งแต่ต้น
commit ของเราที่ระดับ Level::Snapshot ยังไม่ตรวจอะไรเลยสักข้อ เอนจินตอนนี้จึงให้
ภาพการอ่านแบบ snapshot มาแล้ว แต่ยังไม่มีด่านนั้น
นี่เป็นความตั้งใจ ไม่ใช่ของที่ลืม บทนี้ต้องการ lost update ที่เกิดจริงให้ดูก่อน แล้วบทที่ 6 จะติดด่านเข้าไปแล้ววัดว่าได้อะไรกลับมา คำตอบไม่ใช่ 180
ตาราง ANSI SQL-92 อธิบายเอนจินตัวนี้ไม่ได้
หัวข้อที่มีชื่อว่า “ตาราง ANSI SQL-92 อธิบายเอนจินตัวนี้ไม่ได้”หัวข้อนี้เทียบผลรันกับตารางสามช่องของ ANSI แล้วชี้ว่าป้ายที่ตารางให้ ไม่ตรงกับสิ่งที่วัดได้
ANSI SQL-92 นิยาม isolation level ไว้สี่ระดับด้วยตารางเดียว แต่ละระดับคือรายชื่อ ปรากฏการณ์ที่ระดับนั้นห้ามไม่ให้เกิด
| ระดับ | dirty read | non-repeatable read | phantom read |
|---|---|---|---|
| READ UNCOMMITTED | เกิดได้ | เกิดได้ | เกิดได้ |
| READ COMMITTED | ห้าม | เกิดได้ | เกิดได้ |
| REPEATABLE READ | ห้าม | ห้าม | เกิดได้ |
| SERIALIZABLE | ห้าม | ห้าม | ห้าม |
เอนจินของเราห้ามครบทั้งสามช่อง ตามที่ผลรันสามหัวข้อแรกแสดงไว้ ถ้าใช้ตารางนี้เป็นเกณฑ์ ตัดสิน มันจะได้ป้าย SERIALIZABLE
แต่ 130 ไม่เท่ากับ 180 ผลของการรันพร้อมกันจึงไม่ตรงกับผลของการรันเรียงทีละตัวลำดับไหนเลย ตามนิยามแล้วมันไม่ serializable
Berenson และคณะเขียนเรื่องนี้ไว้ตั้งแต่ปี 1995 พวกเขาเรียกประวัติที่ห้ามครบสามช่อง แต่ยังไม่ serializable ว่า ANOMALY SERIALIZABLE ซึ่งตรงกับสภาพของเอนจินเราพอดี
บทความเดียวกันชี้ด้วยว่าทำไมคนถึงเข้าใจผิดกันทั้งวงการ ตัวมาตรฐาน ANSI เองมีข้อกำกับ อยู่ว่าระดับ SERIALIZABLE ต้องให้ผลที่ serializable จริงด้วย
ข้อกำกับนั้นเสียงเบากว่าตาราง คนจึงอ่านตารางแล้วสรุปว่าห้ามครบสามช่องเท่ากับ serializable ซึ่งไม่จริง
lost update ไม่ได้อยู่ในตารางนั้นเลยสักช่อง มันเป็นปรากฏการณ์ที่บทความนั้นเพิ่มเข้ามาเอง ในชื่อ P4
นี่คือเหตุผลที่บทความชื่อ A Critique of ANSI SQL Isolation Levels มีอยู่บนโลก
บทต่อไป ลองแก้ด้วยวิธีที่เก่าที่สุด
หัวข้อที่มีชื่อว่า “บทต่อไป ลองแก้ด้วยวิธีที่เก่าที่สุด”หัวข้อนี้บอกว่าบทที่ 3 จะแก้ปัญหาที่บทนี้พบด้วย lock และจะนับราคาของมัน
ตอนนี้เรามีปัญหาหนึ่งข้อที่ทำซ้ำได้ทุกครั้ง และมีเลขสองตัวที่ใช้เถียงกันได้ คือ 130 กับ 180
ทางแก้ที่เก่าที่สุดและตรงที่สุดคือไม่ให้ใครแตะคีย์เดียวกันพร้อมกัน ใครมาก่อนได้ไปก่อน คนที่มาทีหลังรอ
บทที่ 3 จะสร้าง lock manager ตัวนั้นขึ้นมาจริง แล้วนับว่ามีกี่ก้าวที่ถูกบล็อก และเกิด deadlock กี่ครั้ง
การรอไม่ใช่ของฟรี และสองตัวเลขนั้นคือราคาที่นับได้ ไม่ใช่ความรู้สึก
- A Critique of ANSI SQL Isolation Levels — Berenson, Bernstein, Gray, Melton, O’Neil, O’Neil (ACM SIGMOD 1995 หน้า 1–10, รายงาน MSR-TR-95-51 ของ Microsoft Research — ตรวจแล้ว 2026-08-13) ต้นทางของ P4 (Lost Update) ซึ่งไม่มีอยู่ในตาราง ANSI, ของคำว่า ANOMALY SERIALIZABLE และของข้อสังเกตว่ามาตรฐาน ANSI ไม่ได้นิยาม SERIALIZABLE ด้วยตารางนั้นอย่างเดียว
- PostgreSQL 18 Documentation — 13.2 Transaction Isolation (ตรวจแล้ว 2026-08-13) — ตารางที่ 13.1 ระบุว่าปรากฏการณ์ชนิดไหนเกิดได้ที่ระดับไหน และมีประโยคที่ยืนยันว่า Repeatable Read ของ PostgreSQL สร้างด้วยเทคนิคที่วงการเรียกว่า snapshot isolation ซึ่งบทที่ 5 จะไปถึง
เช็กความเข้าใจ — บทที่ 2
ข้อ 1 / 3ผลรันบอกว่า version ล่าสุดในที่เก็บจริงคือ 999 แต่ค่าที่กฎการมองเห็นยอมให้อ่านคือ 100 ข้อใดอธิบายได้ถูกต้องว่าทำไม dirty read จึงเกิดในเอนจินนี้ไม่ได้