ข้าม​ไป​ยัง​เนื้อหา

กลไก 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 แบบ​มาตรฐาน ทั้ง​สอง transaction อ่าน​เงื่อนไข​เดียวกัน แล้ว​เขียน​คนละ​แถว เงื่อนไข​ที่​แต่ละ​คน​ตรวจ​จึง​ยัง​จริง​ตอน​ที่​ตัวเอง​เขียน แต่​ผล​รวม​ผิด

ระดับสิ่ง​ที่​ทั้ง​คู่​อ่าน​เห็นเหลือ​อยู่​เวรถูก​ปฏิเสธSQLSTATE
read committed2 คน00
repeatable read2 คน00
serializable2 คน1140001

สอง​ระดับ​แรก​ให้​ผล​เหมือนกันเป๊ะ คือ​เหลือ​หมอ​อยู่​เวร 0 คน ทั้ง​ที่​ทั้ง​คู่​อ่าน​เห็น​ว่า​มี​คน​อยู่ 2 คน​ตอน​ที่​ตัดสิน​ใจ

ระดับ serializable เป็น​ระดับ​เดียว​ที่​รักษา​เงื่อนไข​ไว้​ได้ และ​วิธี​ที่​มัน​ใช้​ไม่ใช่​การ​แก้​ให้​ถูก มัน​คือ การ​ปฏิเสธ​งาน​กลับ​มา​ให้​ผู้​เรียก ด้วย​รหัส 40001

ข้อ​ที่​ต่าง​จาก​เอนจิน​ใน​คอร์ส และ​การ​อ่าน​เอกสาร​บอก​ไม่​ได้

หัวข้อ​ที่​มีชื่อ​ว่า “ข้อ​ที่​ต่าง​จาก​เอนจิน​ใน​คอร์ส และ​การ​อ่าน​เอกสาร​บอก​ไม่​ได้”

lost update คือ​จุด​ที่​คำ​ว่า snapshot isolation ใน​เอกสาร กับ​ใน code ที่​คุณ​เพิ่ง​เขียน แปล​ไม่​เหมือน​กัน

การ​ทดลอง​ที่​สอง​คือ lost update สอง​คน​อ่าน​ยอด 100 มา​เท่า​กัน คน​หนึ่ง​บวก 30 อีก​คน​บวก 50 คำ​ตอบ​ที่​ถูกต้อง​แบบ​เรียง​ลำดับ​คือ 180

ระดับยอด​สุดท้ายถูก​ปฏิเสธSQLSTATE
read committed1500
repeatable read130140001
serializable130140001

แถว​แรก​คือ 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 แถว ก่อน update40,960 B
หลัง update ทั้ง​ตาราง​หนึ่ง​ครั้ง73,728 B
หลัง vacuum73,728 B
แถว​ตาย​ที่​ค้าง​อยู่ หลัง update1,000 แถว
แถว​ตาย​ที่​ค้าง​อยู่ หลัง vacuum0 แถว

update หนึ่ง​คำ​สั่ง​ทำให้​ไฟล์​โต​ขึ้น 32,768 B เพราะ​ทุก​แถว​ถูก​เขียน​ใหม่ โดยที่​แถว​เดิม​ยัง​อยู่​ครบ

vacuum ล้าง​แถว​ตาย​จน​เหลือ 0 แต่ ขนาด​ไฟล์​ไม่​ขยับ​เลย สอง​บรรทัด​นี้​ไม่​ได้​ขัด​กัน vacuum คืน​ที่​ว่าง​ให้​แถว​ใหม่​ใช้​ต่อ ไม่​ได้​คืน​พื้นที่​ให้​ระบบ​ไฟล์

`xmin` ที่​คอร์ส EF Core สอน คือ​ตัว​เดียวกัน​นี้

คอร์ส EF Core แนะนำ UseXminAsConcurrencyToken() โดย​เรียก xmin ว่า​เป็น​ของ​ฟรี​ที่ PostgreSQL แถม​มา​ให้​ทุก​แถว

มัน​ไม่ใช่​ของ​แถม มัน​คือ transaction id ที่​สร้าง​เวอร์ชัน​นั้น ซึ่ง​เป็น​ตัว​เดียว​กับ​ที่ เอนจิน​ใช้​ตัดสิน​ว่า​ใคร​เห็น​แถว​ไหน วัด​แล้ว​มัน​เลื่อน​ที​ละ 1 ทุก​ครั้ง​ที่​แถว​ถูก update

optimistic concurrency ฝั่ง ORM จึง​ไม่​ได้​เพิ่ม​กลไก​อะไร​ใหม่​เลย มัน​แค่​หยิบ​ตัวเลข​ที่ MVCC ใช้​อยู่​แล้ว​มา​ใส่​ใน WHERE

การ​แตะ​หน้า​แรก​หลัง checkpoint แพง​กว่า​การ​แตะ​หน้า​เดิม​ซ้ำ​ร้อย​เท่า และ​นี่​คือ​จุด​ที่​สอง​กลไก​เป็น​เรื่อง​เดียวกัน

ทุก​เวอร์ชัน​ที่ MVCC สร้าง ต้อง​ลง WAL ก่อน​ถึง​จะ​ถือว่า commit ได้ คำถาม​คือ​มัน​ลง​ไป​เท่าไร

การ​วัด​คือ update แถว​เดียว​สอง​ครั้ง ครั้ง​แรก​ทำ​ทันทีหลัง checkpoint ครั้ง​ที่​สอง​ทำ​ต่อ​เลย โดย​ยัง​ไม่ checkpoint คั่น

สิ่ง​ที่​ทำลง WAL
แตะ​แถว​เดียว ครั้ง​แรก​หลัง checkpoint19,408 B
แตะ​แถว​เดิม​ซ้ำ โดย​ยัง​ไม่ checkpoint168 B

งาน​เดียวกัน​บน​แถว​เดียวกัน ต่าง​กัน​ประมาณ 116 เท่า

เหตุผล​คือ full_page_writes การ​แตะ​หน้า​หนึ่ง​เป็น​ครั้ง​แรก​หลัง checkpoint ทำให้ PostgreSQL เขียน ทั้ง​หน้า ลง WAL ไม่ใช่​แค่​ส่วน​ที่​เปลี่ยน เพื่อ​กัน​หน้าที่​เขียน​ค้าง​ตอน​ไฟ​ดับ ส่วน​การ​แตะ​ซ้ำ​ใน​รอบ​เดียวกัน​เขียน​แค่​ระเบียน​ของ​การ​เปลี่ยนแปลง

นี่​คือ​จุด​ที่​คน​ที่​รู้​ว่า “PostgreSQL เขียน WAL ระดับ record” จะ​ทาย​ผิด ประโยค​นั้น​ถูก แต่​มัน​จริง​เฉพาะ​กับ​การ​แตะ​ครั้ง​ที่​สอง​เป็นต้น​ไป

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 ของ​ไซต์​นี้

Terminal window
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