ของจริงเลือกทางไหน — และทำไม anomaly ต้องมาจากเอนจินของเราเอง
บทนี้ไม่มีโครงสร้างใหม่ให้สร้าง มีแต่การเอาของที่ประกอบมาเจ็ดบทไปวางข้างของจริงแล้วอ่านผล
คำถามที่ต้องตอบให้ได้ไม่ใช่ว่าใครถูก แต่คือ ของจริงเลือกจ่ายด้วยอะไรแทน
และคำตอบของ SQLite นั้นตรงข้ามกับที่คนส่วนใหญ่คาด มันไม่ได้แก้ปัญหาที่ทั้งคอร์สนี้อธิบายอยู่ มันตัดฉากที่ทำให้ปัญหาเกิดทิ้งไปเลย
บทนี้อ่านตัวเลขจากเครื่องมือสองตัวที่คนละภาษากัน และเป็นเรื่องที่ต้องรู้ก่อนอ่านตัวเลขทุกตัว
ตัวแรกคือ crate ของคอร์สที่ scripts/transaction-engine/ เขียนด้วย Rust standard library ล้วน
ผลรันของมันตรึงไว้ที่ expected/08-what-real-engines-do.txt และมีเทสต์เทียบทีละ byte
ตัวที่สองคือ scripts/isolation-spike/probe.py ซึ่งเป็น Python เพราะ crate เป็น std ล้วน
จึง link กับ SQLite ไม่ได้ ตรวจผลของมันด้วย npm run verify:sqlite-isolation
ตัวเลขของเจ็ดบท วางเรียงกันในหน้าเดียว
หัวข้อที่มีชื่อว่า “ตัวเลขของเจ็ดบท วางเรียงกันในหน้าเดียว”หัวข้อนี้เอาผลของสองการทดลองที่ระดับ snapshot กับ serializable มาวางเทียบกันบรรทัดต่อบรรทัด
บทที่ 5 กับบทที่ 6 รันการทดลองเดียวกันคนละระดับ แล้วรายงานคนละหน้า บทนี้เรียกทั้งสองระดับ ในการรันเดียว เพื่อให้ความต่างอยู่ในสายตาพร้อมกัน
pub fn ch08() { println!("== บทที่ 8 สรุปทั้งคอร์ส แล้วเทียบกับของจริง =="); println!(" เอนจินของเรา · write skew"); for level in [Level::Snapshot, Level::Serializable] { let r = write_skew(level); println!( " {:<13} เหลืออยู่เวร {} คน · abort {} · กฎ{}", r.level.label(), r.on_shift, r.aborts, if r.invariant_held { "อยู่" } else { "พัง" } ); }ไม่มี branch ไหนแยกตามระดับใน code ที่พิมพ์รายงาน ระดับถูกส่งเข้าไปเป็นค่าตัวเดียว ความต่างทั้งหมดที่เห็นข้างล่างจึงมาจากเอนจิน ไม่ได้มาจากผู้รายงาน
== บทที่ 8 สรุปทั้งคอร์ส แล้วเทียบกับของจริง == เอนจินของเรา · write skew snapshot เหลืออยู่เวร 0 คน · abort 0 · กฎพัง serializable เหลืออยู่เวร 1 คน · abort 1 · กฎอยู่ เอนจินของเรา · lost update (คำตอบเรียงลำดับ 180) snapshot ยอด 130 · abort 0 serializable ยอด 150 · abort 1สองบรรทัดบนคือธีสิสของคอร์สในรูปที่สั้นที่สุด ที่ระดับ snapshot ไม่มีใครถูกปฏิเสธสักคน และไรเดอร์หายไปจากเวรทั้งสองคน ที่ระดับ serializable มีคนถูกปฏิเสธหนึ่งคน แล้วกฎก็รอด
สองบรรทัดล่างคือบรรทัดที่ขายยากกว่า คำตอบที่ถูกถ้ารันเรียงทีละตัวคือ 180 ระดับ snapshot ให้ 130 ส่วนระดับ serializable ให้ 150 ไม่มีระดับไหนให้ 180
นี่คือจุดที่คำว่า “ระดับสูงกว่าคือถูกต้องกว่า” พังลง สิ่งที่ serializable เปลี่ยนไม่ใช่ยอด มันเปลี่ยนคำตอบที่ผิดเงียบๆ ให้กลายเป็นงานที่ถูกปฏิเสธและมีคนรู้ตัว
ค่า 150 ไม่ใช่ bug ของเอนจิน มันคือผลของการรันเรียงลำดับที่มี transaction เดียวรอด งานที่เหลือคือ retry ซึ่งเอนจินไม่มีทางเขียนแทนคุณได้ เพราะมันไม่รู้ว่าคุณตั้งใจจะบวกอะไร
ตัวเลขฝั่ง SQLite ไม่ได้ออกมาจากโปรแกรมเดียวกัน
หัวข้อที่มีชื่อว่า “ตัวเลขฝั่ง SQLite ไม่ได้ออกมาจากโปรแกรมเดียวกัน”หัวข้อนี้บอกว่าตัวเลขสองชุดในบทนี้มาจากเครื่องมือคนละตัวคนละภาษา และโปรแกรมพูดข้อนี้เอง
ทุกบรรทัดข้างบนออกมาจาก crate ของคอร์ส บรรทัดสุดท้ายที่โปรแกรมเดียวกันพิมพ์ คือการประกาศว่ามันวัดฝั่ง SQLite ให้ไม่ได้
println!(); println!(" ตัวเลขฝั่ง SQLite ไม่ได้อยู่ในโปรแกรมนี้ · crate นี้เป็น std ล้วน คุยกับ SQLite ไม่ได้"); println!(" ของฝั่งนั้นวัดด้วย scripts/isolation-spike/ กับ scripts/write-amp/ ซึ่ง commit แยกไว้");} ตัวเลขฝั่ง SQLite ไม่ได้อยู่ในโปรแกรมนี้ · crate นี้เป็น std ล้วน คุยกับ SQLite ไม่ได้ ของฝั่งนั้นวัดด้วย scripts/isolation-spike/ กับ scripts/write-amp/ ซึ่ง commit แยกไว้คำสัญญาข้อแรกของคอร์สคือ crate นี้ไม่มี external crate สักตัว ซึ่งแปลว่ามัน link กับ SQLite ไม่ได้ ทางเลือกจึงมีสองทาง คือผิดคำสัญญา หรือแยกเครื่องมือ
คอร์สนี้เลือกทางที่สอง ตัวเลขฝั่ง SQLite ทุกตัวในหน้านี้มาจาก scripts/isolation-spike/probe.py
ซึ่งเป็น Python และตรวจซ้ำด้วย npm run verify:sqlite-isolation
ตัวเลขของเอนจินกับตัวเลขของ SQLite ไม่ได้ผลิตจากโปรแกรมเดียวกัน และวัดคนละสิ่ง
ตัวเลขของเอนจินตอบว่า ของที่เราสร้างทำอะไรได้บ้าง ส่วนตัวเลขของ SQLite ตอบว่า ของจริงยอมให้เกิดอะไรบ้าง เอามาลบกันหรือหารกันไม่ได้
ภาษาของเครื่องมือวัดไม่ใช่ข้ออ้างของคอร์ส probe.py วัด SQLite มันไม่ได้วัดเอนจินของเรา
เทสต์ประจำคอร์สบังคับเฉพาะ crate ส่วนบล็อกผลรันของ probe ตรวจด้วยมือกับ
scripts/isolation-spike/expected/sqlite-isolation.txt
SQLite สาธิต write skew ไม่ได้เลย
หัวข้อที่มีชื่อว่า “SQLite สาธิต write skew ไม่ได้เลย”หัวข้อนี้แสดงผลวัดจริงว่าผู้เขียนคนที่สองถูกปฏิเสธทั้งสอง journal mode จึงตั้งฉากของ anomaly ไม่ขึ้น
probe เปิดสอง connection ค้างไว้พร้อมกัน แล้วถามคำถามเดียวกันสองรอบ รอบละ journal mode คำถามคือ SQLite ยอมให้เกิดอะไรบ้างระหว่างสองคนนั้น
journal_mode = delete writer ขณะ reader เปิดค้าง OperationalError: database is locked reader เห็น (2, 2) non-repeatable read False สองคนเขียนพร้อมกัน OperationalError: database is locked
journal_mode = wal writer ขณะ reader เปิดค้าง ผ่าน reader เห็น (2, 2) non-repeatable read False สองคนเขียนพร้อมกัน OperationalError: database is lockedสองทางนี้ต่างกันจริงหนึ่งบรรทัด ที่ delete ผู้เขียนที่มาระหว่างมีผู้อ่านเปิดค้างถูกปฏิเสธ
ส่วนที่ wal มันผ่าน นี่คือสิ่งที่ WAL ซื้อมาให้ ผู้อ่านไม่บล็อกผู้เขียนอีกต่อไป
แต่บรรทัดสุดท้ายเหมือนกันทั้งสองทาง สองคนเขียนพร้อมกันได้ database is locked เท่ากัน
และเอกสารของ SQLite เองอธิบายเหตุผลไว้ตรงๆ ว่า “since there is only one WAL file,
there can only be one writer at a time”
write skew ต้องการฉากที่เจาะจงมาก คือสองคนอ่านภาพเดียวกัน เขียนคนละแถว แล้ว commit ได้ทั้งคู่ บน SQLite คนที่สองไม่เคยไปถึงขั้นเขียน ฉากนั้นจึงตั้งไม่ขึ้นตั้งแต่ต้น
นี่ไม่ใช่ข้อจำกัดของ probe และไม่ใช่ bug ของ SQLite มันคือการออกแบบ SQLite เลือกจ่าย ด้วยการมีผู้เขียนได้ทีละคนเดียว แล้วได้ความง่ายทั้งก้อนกลับมา
ข้อสรุปที่กลับด้านกับคอร์สก่อนหน้าในเสาเดียวกัน
หัวข้อที่มีชื่อว่า “ข้อสรุปที่กลับด้านกับคอร์สก่อนหน้าในเสาเดียวกัน”หัวข้อนี้เทียบรูปทรงของ capstone สองบท และอธิบายว่าทำไม anomaly ทุกตัวในคอร์สนี้ต้องมาจากเอนจินที่เราสร้างเอง
คอร์สก่อนหน้าในเสานี้ปิดด้วยการเทียบกับ SQLite เหมือนกัน แต่มันเทียบเพื่อถามว่า planner ของเราเลือกแผนเดียวกับ SQLite ไหม ป้อนคำถามชุดเดียวกันสองฝั่ง แล้วอ่านแผนทั้งสอง
ที่นั่นสองฝั่งตอบได้ทั้งคู่ ทุกจุดที่ไม่ตรงกันย้อนกลับไปหาบทที่อธิบายมันได้ อ่านได้ที่ capstone — planner ของคุณ เทียบกับ sqlite3
คอร์สนี้เทียบด้วยรูปทรงเดียวกัน แล้วได้ผลกลับด้าน ฝั่ง SQLite ไม่มีคำตอบให้เทียบ เพราะมันปฏิเสธการทดลองตั้งแต่ก้าวที่สอง
นั่นคือคำตอบ ไม่ใช่ความล้มเหลวของการทดลอง SQLite เลือกจ่ายด้วยผู้เขียนคนเดียว ปัญหาที่ทั้งคอร์สนี้อธิบายจึงไม่เคยเกิดกับมัน
และข้อนี้อธิบายว่าทำไมเจ็ดบทที่ผ่านมาต้องสร้างเอนจินขึ้นมาเอง จะยืมฐานข้อมูลจริงมาโชว์ anomaly ไม่ได้ เพราะฐานข้อมูลที่ยอมให้ anomaly เกิด คือฐานข้อมูลที่ต้องมีสองคนเขียนพร้อมกันได้ก่อน
flowchart TD
subgraph QE["คอร์ส query engine · เทียบ planner"]
Q1["query ชุดเดียวกัน"] --> Q2["planner ของเรา"]
Q1 --> Q3["planner ของ SQLite"]
Q2 --> Q4["ได้แผนสองฝั่ง<br/>อธิบายทุกจุดที่ต่างกันได้"]
Q3 --> Q4
end
subgraph TE["คอร์สนี้ · เทียบ isolation"]
T1["ไรเดอร์สองคนถอนตัวพร้อมกัน"] --> T2["เอนจินของเรา"]
T1 --> T3["SQLite"]
T2 --> T4["write skew เกิดจริง<br/>เหลืออยู่เวร 0 คน · กฎพัง"]
T3 --> T5["database is locked<br/>ผู้เขียนคนที่สองไม่ได้เขียน"]
end
T5 -.->|"ไม่มีอะไรให้เทียบ จึงต้องสร้างเอง"| T2
คำบรรยายภาพ: คอร์ส query engine ได้คำตอบจากสองฝั่งแล้วเอามาเทียบกันได้ · คอร์สนี้ได้คำตอบจากฝั่งเดียว เพราะ SQLite ปฏิเสธผู้เขียนคนที่สอง · เส้นประคือเหตุผลที่ anomaly ทุกตัวในคอร์สนี้ต้องมาจากเอนจินที่เราสร้างเอง
คอร์ส query engine เขียนเส้นขอบเขตของตัวเองไว้ในหน้าแรกว่า “ไม่ได้สร้างฐานข้อมูลที่ใช้งานได้ ไม่มี SQL parser ไม่มี transaction ไม่มี WAL ไม่มี MVCC ไม่มี concurrency”
คอร์สนี้คือชั้นที่ประโยคนั้นตัดออก และตอนนี้คุณเห็นแล้วว่าทำไมมันต้องเป็นคอร์สแยก สามคำที่มันตัดทิ้งคือสิ่งที่กินเวลาทั้งแปดบทของที่นี่
สองคอร์สไม่ได้ใช้ code ร่วมกันสักบรรทัด อ่านสลับลำดับกันได้ อ่านที่ สร้าง query engine ตั้งแต่ศูนย์
PostgreSQL เลือกเดินอีกทาง
หัวข้อที่มีชื่อว่า “PostgreSQL เลือกเดินอีกทาง”หัวข้อนี้ยกคำในเอกสารของ PostgreSQL ที่ระบุว่า Repeatable Read ของมันคือ snapshot isolation และ Serializable ของมันคือ SSI
SQLite ตัดฉากทิ้ง PostgreSQL ไม่ได้ตัด มันยอมให้หลายคนเขียนพร้อมกัน แล้วรับปัญหาทั้งชุด ที่คอร์สนี้อธิบายมาเป็นงานของตัวเอง
เอกสารของมันบอกวิธีแก้ไว้ตรงๆ สองประโยค ประโยคแรกคือ “The Repeatable Read isolation level is implemented using a technique known in academic database literature and in some other database products as Snapshot Isolation”
ประโยคที่สองคือ “The Serializable isolation level is implemented using a technique known in academic database literature as Serializable Snapshot Isolation, which builds on Snapshot Isolation by adding checks for serialization anomalies”
อ่านสองประโยคนี้แล้วย้อนดูบทที่ 5 กับบทที่ 6 จะเห็นว่าเรากับเขาเดินทางเดียวกัน ชื่อระดับต่างกัน แต่กลไกใต้ชื่ออยู่ในตระกูลเดียวกัน
“ตระกูลเดียวกัน” ไม่ได้แปลว่าเหมือนกัน และสองช่องว่างที่เหลือคือของที่บทก่อนหน้า บอกไว้แล้ว
Level::Snapshotของเรา ไม่มีด่านกันผู้เขียนสองคนที่คีย์เดียวกันเลย ส่วน PostgreSQL ปฏิเสธผู้เขียนคนที่สองด้วยข้อความcould not serialize access due to concurrent update— ตารางที่ให้ยอด 130 โดยไม่มี abort จึงเป็นผลของเอนจินเรา ไม่ใช่ผลของ Repeatable Read- SSI ของเรา abort ตั้งแต่พบความสัมพันธ์ทอดแรก ส่วนของจริงรอให้ครบสองทอด เราจึงปฏิเสธงานบางชิ้นที่ปลอดภัยอยู่แล้ว
ตารางในเอกสารเดียวกันระบุผลของสองระดับนั้นไว้ชัด และช่องขวาสุดคือช่องที่ทั้งคอร์สนี้พูดถึง
| ระดับของ PostgreSQL | dirty read | non-repeatable read | phantom read | serialization anomaly |
|---|---|---|---|---|
| Repeatable Read | เกิดไม่ได้ | เกิดไม่ได้ | มาตรฐานยอมให้เกิด แต่ไม่เกิดใน PostgreSQL | เกิดได้ |
| Serializable | เกิดไม่ได้ | เกิดไม่ได้ | เกิดไม่ได้ | เกิดไม่ได้ |
ช่อง เกิดได้ ช่องเดียวนั้นคือที่นั่งของ write skew ฐานข้อมูลที่ใช้กันอยู่จริงประกาศไว้ในตาราง ของตัวเองว่าที่ระดับนั้นมันปล่อยผ่าน
และเมื่อขึ้นไประดับ Serializable ราคาก็มาในรูปเดิมกับที่เอนจินของเราเก็บ คือการปฏิเสธงาน
เอกสารระบุข้อความที่มันคืนกลับมาไว้ว่า could not serialize access due to read/write dependencies among transactions
พร้อมรหัส SQLSTATE 40001
โปรแกรมที่ตั้ง Serializable แล้วไม่ได้เขียนทางรับข้อความนี้ ก็แค่ย้ายจุดที่จะพังจากยอดที่ผิด ไปเป็น exception ที่ไม่มีใครจับ
งานปิดคอร์ส — รันสองระดับ แล้วอธิบายทุกความต่าง
หัวข้อที่มีชื่อว่า “งานปิดคอร์ส — รันสองระดับ แล้วอธิบายทุกความต่าง”หัวข้อนี้ให้งานที่ตรวจตัวเองได้ คือรันทั้งชุดแล้วอธิบายที่มาของทุกบรรทัดที่ต่างกัน
เกณฑ์ผ่านของคอร์สนี้ไม่ใช่การอ่านจบ มันคือการอธิบายทุกตัวเลขในตารางสรุปได้ว่ามาจากไหน ทำสี่ข้อนี้ตามลำดับ
- รัน
cargo run --quiet -- 08แล้วเทียบกับexpected/08-what-real-engines-do.txtทีละบรรทัด ถ้าไม่ตรง แปลว่ามีอะไรผิด ไม่ใช่แล้วแต่เครื่อง - เขียนคำอธิบายสี่บรรทัดของตารางนั้น บรรทัดละหนึ่งประโยค ตอบว่าบทไหนผลิตมัน อะไรเปลี่ยนระหว่างสองระดับ และใครเป็นคนจ่ายราคา
- เปิด
src/demos.rsที่write_skewแล้วสลับลำดับ commit ของt1กับt2ทำนายก่อนว่าใครจะถูก abort แล้วค่อยรันบทที่ 6 เพื่อตรวจคำทำนาย - รัน
npm run verify:sqlite-isolationบนเครื่องตัวเอง บรรทัด[env]จะไม่ตรงกับของเรา เพราะ SQLite คนละรุ่น ตัวตรวจตัดบรรทัดนั้นทิ้งก่อนเทียบอยู่แล้ว
ข้อ 3 คือข้อที่วัดความเข้าใจจริง ถ้าทำนายถูก แปลว่าคุณเข้าใจแล้วว่า SSI ไม่ได้เลือกเหยื่อ จากความถูกต้อง มันเลือกจากลำดับ
ถ้าอธิบายบรรทัดไหนไม่ได้ อย่าข้าม บรรทัด write skew ย้อนไปบทที่ 5 กับบทที่ 6 ส่วนบรรทัด lost update ย้อนไปบทที่ 2
สิ่งที่คอร์สนี้ไม่ได้สร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่คอร์สนี้ไม่ได้สร้าง”หัวข้อนี้ระบุสามอย่างที่จงใจตัดออก และบอกว่าแต่ละอย่างไปอ่านต่อที่ไหน
เอนจินที่คุณเพิ่งสร้างรันได้จริงและสาธิต anomaly ได้จริง แต่มันไม่ใช่ฐานข้อมูล สามข้อข้างล่างคือของที่หายไป และไม่มีข้อไหนเป็นบทที่ 9
- ไม่มี distributed transaction ไม่มี two-phase commit และไม่มี consensus ทุกอย่างในคอร์สนี้ เกิดบนเครื่องเดียว process เดียว เรื่องข้ามเครื่องอยู่ที่ ระบบกระจายด้วย Rust
- ไม่มี SQL parser เราเรียก API ของเอนจินตรงๆ ทุกครั้ง ชั้นที่แปล SQL เป็นแผนแล้วเลือกทางอ่าน อยู่ที่ สร้าง query engine ตั้งแต่ศูนย์
- ไม่มีการกู้คืนแบบเต็มรูป เราแตะ write-ahead log เท่าที่บทที่ 7 ต้องใช้เพื่ออธิบายราคา ส่วน durability กับการกู้หลังเครื่องดับอยู่ที่ ทน crash แล้วยังตอบถูก
และของที่ใช้ได้พรุ่งนี้เลยคือสิ่งที่บทที่ 6 กับบทนี้พูดตรงกัน ระดับที่สูงขึ้นคืนงานกลับมาให้คุณ ในรูป abort ทางรับ abort ที่เขียนใน application จริงอยู่ที่ จัดการการแก้ไขพร้อมกัน
- Write-Ahead Logging — เอกสารทางการของ SQLite (ตรวจแล้ว 2026-08-13) — หัวข้อ 2.2 Concurrency คือที่มาของประโยค “since there is only one WAL file, there can only be one writer at a time” ซึ่งอธิบายผลวัดของ probe ทั้งสอง journal mode
- PostgreSQL 18 Documentation — 13.2 Transaction Isolation
(ตรวจแล้ว 2026-08-13) — ที่มาของประโยคว่า Repeatable Read คือ snapshot isolation
และ Serializable คือ Serializable Snapshot Isolation ของตารางระดับ isolation
และของข้อความ
could not serialize access due to read/write dependencies among transactions
เช็กความเข้าใจ — บทที่ 8
ข้อ 1 / 3ในตารางสรุปของบทนี้ การทดลอง lost update ให้ยอด 150 ที่ระดับ serializable โดยมี abort 1 ครั้ง ทั้งที่คำตอบถ้ารันเรียงทีละตัวคือ 180 ข้อใดคือการอ่านผลนี้ที่ซื่อสัตย์ที่สุด