กลไก transaction ของ PostgreSQL — MVCC กับ WAL เป็นเรื่องเดียวกัน
ตำราแยกสองเรื่องนี้ออกจากกันเสมอ MVCC อยู่บทที่ว่าด้วยการมองเห็น ส่วน WAL อยู่บทที่ว่าด้วย การกู้คืน คนอ่านจึงจำมาว่ามันคือสองกลไกที่ทำงานคนละหน้าที่
หน้านี้ไปวัดของจริง แล้วพบว่าเส้นแบ่งนั้นไม่มีอยู่ ทุกครั้งที่ MVCC สร้างเวอร์ชันใหม่ WAL คือคนที่ต้องแบกราคาของเวอร์ชันนั้น และราคาที่ว่ามีตัวเลขกำกับได้ทุกบรรทัด
ตัวเลขทุกตัวในหน้านี้มาจาก PostgreSQL 18.6 ตัวจริงที่รันใน container ไม่มีตัวไหนถูกพิมพ์เอง
หน้านี้ต่อจากที่คอร์สทิ้งไว้ ไม่ได้เริ่มใหม่
หัวข้อที่มีชื่อว่า “หน้านี้ต่อจากที่คอร์สทิ้งไว้ ไม่ได้เริ่มใหม่”คอร์สที่ 28 สร้างเอนจิน transaction ขึ้นมาเองแล้ว บทปิดของมันยกเอกสารของ PostgreSQL มาสองประโยค แต่ไม่ได้รันอะไรเลย
คอร์ส สร้าง transaction engine เอง พาเขียน MVCC กับ SSI ด้วยมือทั้งแปดบท และ บทปิดของมัน ยกประโยคจากเอกสาร PostgreSQL มาว่า Repeatable Read ของมันคือ snapshot isolation ส่วน Serializable คือ SSI
ประโยคนั้นถูก แต่มัน เป็นการอ่าน ไม่ใช่การวัด และส่วนที่การอ่านให้ไม่ได้คือคำถามว่า เอนจินที่คุณเพิ่งเขียนกับเอนจินที่คุณกำลังจะ deploy ต่างกันตรงไหนบ้าง
หน้านี้ตอบคำถามนั้นด้วยการรัน
วิธีวัด ทุกอย่างเป็นจำนวนนับ ไม่มีวินาที
หัวข้อที่มีชื่อว่า “วิธีวัด ทุกอย่างเป็นจำนวนนับ ไม่มีวินาที”สอง session จริงที่สลับคิวตายตัวในโค้ด ไม่มี sleep และไม่มี thread แข่งกัน
การสาธิต anomaly ด้วย thread จริงจะได้ผลที่เปลี่ยนตามความเร็วเครื่อง อ่านครั้งหน้าแล้ว ไม่เหมือนเดิม สิ่งที่วัดได้จึงต้องมาจากลำดับที่กำหนดเองทุกก้าว
ตัววัดเปิด psql ค้างไว้สอง session แล้วป้อนคำสั่งสลับกันตามลำดับที่เขียนไว้ในสคริปต์
หน่วยของหลักฐานคือ จำนวนแถวที่รอด จำนวน transaction ที่ถูกปฏิเสธ และรหัส SQLSTATE
ไม่มีตัวเลขเวลาสักตัวเดียว
เซิร์ฟเวอร์ที่ใช้วัดตั้ง autovacuum=off และ
checkpoint_timeout=1h ไว้
สองตัวนี้ไม่ใช่ของประดับ ถ้าปล่อยไว้ตามค่าเริ่มต้น มันจะทำงานแทรกกลางการวัดแล้วตัวเลข ฝั่ง WAL เพี้ยนได้ถึง 1.7 เท่า โดยไม่มีอะไรฟ้อง
ส่วน full_page_writes=on กับ
wal_level=replica เป็นค่าเริ่มต้นอยู่แล้ว ไม่ได้แก้
write skew ทั้งสามระดับ
หัวข้อที่มีชื่อว่า “write skew ทั้งสามระดับ”หมอสองคนอยู่เวร ต่างคนต่างเห็นว่าอีกคนยังอยู่ จึงต่างคนต่างออกเวร ผลที่ถูกต้องคือต้องเหลืออย่างน้อยหนึ่งคน
การทดลองแรกคือ write skew แบบมาตรฐาน ทั้งสอง transaction อ่านเงื่อนไขเดียวกัน แล้วเขียนคนละแถว เงื่อนไขที่แต่ละคนตรวจจึงยังจริงตอนที่ตัวเองเขียน แต่ผลรวมผิด
| ระดับ | สิ่งที่ทั้งคู่อ่านเห็น | เหลืออยู่เวร | ถูกปฏิเสธ | SQLSTATE |
|---|---|---|---|---|
read committed | 2 คน | 0 | 0 | — |
repeatable read | 2 คน | 0 | 0 | — |
serializable | 2 คน | 1 | 1 | 40001 |
สองระดับแรกให้ผลเหมือนกันเป๊ะ คือเหลือหมออยู่เวร 0 คน ทั้งที่ทั้งคู่อ่านเห็นว่ามีคนอยู่ 2 คนตอนที่ตัดสินใจ
ระดับ serializable เป็นระดับเดียวที่รักษาเงื่อนไขไว้ได้ และวิธีที่มันใช้ไม่ใช่การแก้ให้ถูก
มันคือ การปฏิเสธงานกลับมาให้ผู้เรียก ด้วยรหัส 40001
ข้อที่ต่างจากเอนจินในคอร์ส และการอ่านเอกสารบอกไม่ได้
หัวข้อที่มีชื่อว่า “ข้อที่ต่างจากเอนจินในคอร์ส และการอ่านเอกสารบอกไม่ได้”lost update คือจุดที่คำว่า snapshot isolation ในเอกสาร กับใน code ที่คุณเพิ่งเขียน แปลไม่เหมือนกัน
การทดลองที่สองคือ lost update สองคนอ่านยอด 100 มาเท่ากัน คนหนึ่งบวก 30 อีกคนบวก 50 คำตอบที่ถูกต้องแบบเรียงลำดับคือ 180
| ระดับ | ยอดสุดท้าย | ถูกปฏิเสธ | SQLSTATE |
|---|---|---|---|
read committed | 150 | 0 | — |
repeatable read | 130 | 1 | 40001 |
serializable | 130 | 1 | 40001 |
แถวแรกคือ lost update เต็มรูปแบบ ยอด 150 แปลว่างานของคนแรกหายไป ทั้งก้อนโดยไม่มีใครได้รับแจ้ง
แถวที่สองคือของที่ต้องหยุดอ่าน เอกสารของ PostgreSQL เรียก Repeatable Read ว่า snapshot isolation และเอนจินที่คอร์สที่ 28 พาสร้างก็ชื่อ snapshot เหมือนกัน
แต่ของสองอย่างนี้ให้ผลคนละอย่าง เอนจินในคอร์ส จงใจไม่ทำ first-committer-wins
lost update จึงเกิดได้ ส่วน PostgreSQL กันไว้ด้วย first-updater-wins แล้วคืน
40001 แทน ยอดจึงจบที่ 130
สิ่งที่มาตรฐานกำหนดคือ อะไรห้ามเกิด ไม่ใช่ ต้องทำด้วยวิธีไหน เอนจินสองตัวจึงเรียก ระดับเดียวกันได้โดยที่ยอมให้เกิดอะไรไม่เหมือนกัน ตราบใดที่ยังอยู่ในกรอบของมาตรฐาน
ผลข้างบนบอกว่า Repeatable Read ของ PostgreSQL เข้มกว่า snapshot isolation ในตำราหนึ่งข้อ คือกัน lost update ให้ด้วย แต่ยังเหมือนกันในข้อที่แพงกว่า คือ ยอมให้เกิด write skew ตามตารางก่อนหน้า
อ่านเอกสารอย่างเดียวจะได้ข้อแรกผิด และการเขียน retry ตามสมมติฐานที่ผิดนั้นคือ bug ที่รอเวลาอยู่
MVCC ไม่เคยเขียนทับของเก่า และไฟล์เป็นคนบอก
หัวข้อที่มีชื่อว่า “MVCC ไม่เคยเขียนทับของเก่า และไฟล์เป็นคนบอก”update ทั้งตารางหนึ่งครั้ง ไฟล์โตขึ้น และ vacuum ก็ไม่ทำให้มันเล็กลง
ถึงตรงนี้ย้ายจากคำถามว่า ใครเห็นอะไร ไปหาคำถามว่า ราคาอยู่ที่ไหน
การ update แถวใน PostgreSQL ไม่ใช่การแก้ค่าในที่เดิม มันคือการเขียนแถวใหม่ทั้งแถว แล้วทำเครื่องหมายว่าแถวเก่าตายแล้ว นี่คือสิ่งที่ทำให้ transaction อื่นยังอ่านค่าเดิมได้ และมันมีราคาที่วัดได้
| สิ่งที่วัด | ค่า |
|---|---|
| ขนาดไฟล์ตาราง 1000 แถว ก่อน update | 40,960 B |
หลัง update ทั้งตารางหนึ่งครั้ง | 73,728 B |
หลัง vacuum | 73,728 B |
| แถวตายที่ค้างอยู่ หลัง update | 1,000 แถว |
| แถวตายที่ค้างอยู่ หลัง vacuum | 0 แถว |
update หนึ่งคำสั่งทำให้ไฟล์โตขึ้น 32,768 B เพราะทุกแถวถูกเขียนใหม่ โดยที่แถวเดิมยังอยู่ครบ
vacuum ล้างแถวตายจนเหลือ 0 แต่ ขนาดไฟล์ไม่ขยับเลย
สองบรรทัดนี้ไม่ได้ขัดกัน vacuum คืนที่ว่างให้แถวใหม่ใช้ต่อ ไม่ได้คืนพื้นที่ให้ระบบไฟล์
คอร์ส EF Core แนะนำ
UseXminAsConcurrencyToken() โดยเรียก xmin ว่าเป็นของฟรีที่ PostgreSQL แถมมาให้ทุกแถว
มันไม่ใช่ของแถม มันคือ transaction id ที่สร้างเวอร์ชันนั้น ซึ่งเป็นตัวเดียวกับที่ เอนจินใช้ตัดสินว่าใครเห็นแถวไหน วัดแล้วมันเลื่อนทีละ 1 ทุกครั้งที่แถวถูก update
optimistic concurrency ฝั่ง ORM จึงไม่ได้เพิ่มกลไกอะไรใหม่เลย มันแค่หยิบตัวเลขที่ MVCC
ใช้อยู่แล้วมาใส่ใน WHERE
WAL คือที่ที่ราคาของ MVCC ไปโผล่
หัวข้อที่มีชื่อว่า “WAL คือที่ที่ราคาของ MVCC ไปโผล่”การแตะหน้าแรกหลัง checkpoint แพงกว่าการแตะหน้าเดิมซ้ำร้อยเท่า และนี่คือจุดที่สองกลไกเป็นเรื่องเดียวกัน
ทุกเวอร์ชันที่ MVCC สร้าง ต้องลง WAL ก่อนถึงจะถือว่า commit ได้ คำถามคือมันลงไปเท่าไร
การวัดคือ update แถวเดียวสองครั้ง ครั้งแรกทำทันทีหลัง checkpoint ครั้งที่สองทำต่อเลย
โดยยังไม่ checkpoint คั่น
| สิ่งที่ทำ | ลง WAL |
|---|---|
แตะแถวเดียว ครั้งแรกหลัง checkpoint | 19,408 B |
| แตะแถวเดิมซ้ำ โดยยังไม่ checkpoint | 168 B |
งานเดียวกันบนแถวเดียวกัน ต่างกันประมาณ 116 เท่า
เหตุผลคือ full_page_writes การแตะหน้าหนึ่งเป็นครั้งแรกหลัง checkpoint ทำให้ PostgreSQL
เขียน ทั้งหน้า ลง WAL ไม่ใช่แค่ส่วนที่เปลี่ยน เพื่อกันหน้าที่เขียนค้างตอนไฟดับ
ส่วนการแตะซ้ำในรอบเดียวกันเขียนแค่ระเบียนของการเปลี่ยนแปลง
นี่คือจุดที่คนที่รู้ว่า “PostgreSQL เขียน WAL ระดับ record” จะทายผิด ประโยคนั้นถูก แต่มันจริงเฉพาะกับการแตะครั้งที่สองเป็นต้นไป
ขนาด transaction ราคาไม่ได้ต่างอย่างที่คาด
หัวข้อที่มีชื่อว่า “ขนาด transaction ราคาไม่ได้ต่างอย่างที่คาด”update 1000 แถวโดย commit ทีละแถว เทียบกับ commit ครั้งเดียว
การทดลองสุดท้ายคือ update ทั้ง 1000 แถวสองแบบ แบบแรกยิงทีละคำสั่งจาก client ได้ 1,000 transaction แบบที่สองคำสั่งเดียวจบใน transaction เดียว
| แบบ | ลง WAL |
|---|---|
| commit ทีละแถว (1,000 transaction) | 328,136 B |
| commit ครั้งเดียว | 276,688 B |
ต่างกัน 1.2 เท่า ซึ่งน้อยกว่าที่คนส่วนใหญ่คาดมาก
เหตุผลคือ WAL ของ PostgreSQL เก็บ ระเบียนของการเปลี่ยนแปลง ไม่ใช่หน้าเต็ม การเพิ่ม จำนวน commit จึงเพิ่มแค่ระเบียน commit ไม่ได้ทำให้ข้อมูลเดิมถูกเขียนซ้ำทั้งหน้า
SQLite เขียน WAL เป็น หน้าเต็มทุก commit ตัวเลขฝั่งนั้นจึงอยู่คนละอันดับ และการหารตัวเลขของสองเอนจินเทียบกันตรง ๆ จะได้ข้อสรุปที่ผิด เพราะงานที่วัดคนละงานกัน
สิ่งที่หน้านี้ยืนยันได้คือ ทิศ ไม่ใช่ตัวคูณข้ามเอนจิน คือบน PostgreSQL การรวม transaction ให้ใหญ่ขึ้นช่วยเรื่อง WAL น้อยกว่าที่ประสบการณ์จากเอนจินหน้าเต็มบอกไว้
และคำแนะนำ ให้ transaction เล็ก ที่คอร์ส EF Core สอนไว้ ยังใช้ได้เหมือนเดิม เพราะเหตุผลของมันคือขอบเขตความถูกต้อง ไม่ใช่ราคา I/O
สิ่งที่หน้านี้ยืนยันไม่ได้
หัวข้อที่มีชื่อว่า “สิ่งที่หน้านี้ยืนยันไม่ได้”ขอบเขตของการวัดแคบกว่าที่หัวข้ออาจทำให้เข้าใจ และเส้นพวกนี้ต้องพูด ไม่ใช่ซ่อน
- ตัวเลขทั้งหมดมาจากเครื่องเดียว PostgreSQL 18.6 บน
aarch64-unknown-linux-muslที่ตั้งค่าไว้ตามกล่องข้างบน เอนจินคนละรุ่นหรือคนละสถาปัตยกรรมให้ byte ไม่เท่ากันได้ - ตัวเลข byte ของ WAL ทำซ้ำได้ไม่ครบทุกหลัก วัดห้ารอบแล้วช่องเดียวกันแกว่งราว 0.03 % ส่วนช่อง “แตะซ้ำ” แกว่งได้ถึงหนึ่งในสาม ขึ้นกับว่าหน้านั้นยังมีที่ว่างพอไหม ข้อสรุปของหน้านี้จึงอ้างอัตราส่วน ไม่ใช่ byte ดิบ และตัวตรวจก็ตรวจอัตราส่วนเช่นกัน
- ผลของ anomaly ทำซ้ำได้ครบทุกช่อง ห้ารอบให้ผลเท่ากันทุกบรรทัด ต่างจากฝั่ง WAL
- หน้านี้ไม่ได้วัดประสิทธิภาพ ไม่มีตัวเลขเวลา ไม่มี throughput และไม่มีการเทียบว่า ระดับไหนเร็วกว่าระดับไหน ทั้งหมดเป็นจำนวนนับ
ตัวเลขทุกตัวในหน้านี้มาจากสองคำสั่งใน repo ของไซต์นี้
scripts/pg-transaction-spike/up.sh # postgres ที่คุมตัวแปรแล้วnpm run verify:pg-transaction # วัดซ้ำแล้วเทียบกับ file ข้อมูลตัววัดเขียน src/data/perf/pg-transaction.ts ให้เอง หน้านี้อ่านตัวเลขจาก file นั้น
ทุกตัว แก้ตัวเลขด้วยมือไม่ได้ เพราะตัวตรวจจะวัดใหม่แล้วเทียบทีละช่อง
ตัวตรวจมี canary หนึ่งตัวที่ต้องอธิบาย มันนับ transaction id ที่ถูกใช้ระหว่างวัด และล้มทันทีถ้าไม่ได้ 1,000 พอดี เพราะรอบที่มีอย่างอื่นแทรกกลาง จะกิน transaction id เกินมา แล้วรายงานตัวเลข WAL ที่เพี้ยนออกมาเป็นข้อค้นพบ
- PostgreSQL 18 Documentation — 13.2 Transaction Isolation
(ตรวจแล้ว 2026-08-14) — ที่มาของประโยคว่า Repeatable Read คือ snapshot isolation
และของข้อกำหนดว่าระดับนี้คืน
40001เมื่อชนกัน - PostgreSQL 18 Documentation — 24.1 Routine Vacuuming
(ตรวจแล้ว 2026-08-14) — ที่มาของข้อที่ว่า
VACUUMแบบมาตรฐานคืนที่ว่างให้แถวใหม่ ไม่ได้คืนพื้นที่ให้ระบบไฟล์ - PostgreSQL 18 Documentation — 28.5 WAL Configuration
(ตรวจแล้ว 2026-08-14) — ที่มาของกลไก
full_page_writes