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

MVCC

isolation level ในบทเรียนก่อนหน้าทั้งหมดตั้งอยู่บนเครื่องยนต์ตัวเดียว คือ Multi-Version Concurrency Control หรือ MVCC คำมั่นของ MVCC พูดง่ายแต่ทรงพลังในทางปฏิบัติ คือ writer ไม่ block reader และ reader ไม่ block writer รายงานยาว ๆ สามารถอ่านต่อไปได้ในขณะที่การ update หลั่งไหลเข้ามา และไม่มีฝ่ายใดต้องรออีกฝ่าย

เมื่อคุณ UPDATE row หนึ่ง PostgreSQL ไม่ได้แก้ byte เดิม แต่เขียน เวอร์ชันใหม่ ของ row และทำเครื่องหมายเวอร์ชันเก่าว่าถูกแทนที่แล้ว ชั่วขณะหนึ่งทั้งสองเวอร์ชันมีอยู่บนดิสก์ transaction หนึ่ง ๆ จะเห็นเวอร์ชันใดขึ้นอยู่กับว่า transaction นั้นเริ่มเมื่อใด

แต่ละเวอร์ชันของ row พก system column ที่ซ่อนอยู่สอง column คอยบันทึกช่วงชีวิตของเวอร์ชันนั้น

  • xmin — id ของ transaction ที่สร้างเวอร์ชันนี้
  • xmax — id ของ transaction ที่ลบหรือแทนที่เวอร์ชันนี้ (เป็นศูนย์ตราบใดที่เวอร์ชันยังมีชีวิตอยู่)

transaction จะเห็นเวอร์ชันหนึ่งก็ต่อเมื่อเวอร์ชันนั้นถูกสร้างโดย transaction ที่ committed ไปก่อน snapshot ของ transaction ที่กำลังอ่าน และยังไม่ถูกแทนที่ในมุมมองของ snapshot นั้น

snapshot คือชุดของข้อมูลที่ committed แล้วซึ่ง transaction ได้รับอนุญาตให้เห็น ภายใต้ Read Committed แต่ละคำสั่งจะหยิบ snapshot ใหม่ ภายใต้ Repeatable Read และ Serializable ทั้ง transaction ใช้ snapshot เดียวร่วมกัน เพราะการอ่านถูกตอบจาก snapshot SELECT ธรรมดาจึงไม่จำเป็นต้อง lock อะไรเลยและไม่เคยรอ writer

flowchart TD
  V1["Row version 1: balance 500, xmin 100, xmax 205"]
  V2["Row version 2: balance 400, xmin 205, xmax 0"]
  V1 -->|UPDATE by txn 205 creates| V2
  S1["Snapshot A: started before txn 205 committed"]
  S2["Snapshot B: started after txn 205 committed"]
  S1 -->|reads| V1
  S2 -->|reads| V2
หนึ่ง row สองเวอร์ชัน สอง snapshot เห็นค่าต่างกัน

ในแผนภาพ transaction 205 เปลี่ยน balance จาก 500 เป็น 400 reader ที่ snapshot เกิดก่อนการ commit ยังคงเห็น 500 จากเวอร์ชัน 1 reader ที่เริ่มหลังจากนั้นเห็น 400 จากเวอร์ชัน 2 ทั้งคู่กำลังอ่านโดยไม่มี lock ในเวลาเดียวกัน และทั้งคู่ถูกต้องสำหรับจุดเวลาของตัวเอง

คุณสามารถขอ xmin และ xmax ได้โดยตรง ซึ่งทำให้กลไกนี้เป็นรูปธรรม รัน query เดียวกันก่อนและหลังการ update จาก session อื่นเพื่อดูเวอร์ชันเปลี่ยน

SELECT id, balance, xmin, xmax FROM accounts WHERE owner = 'Ada';
id | balance | xmin | xmax
----+---------+--------+------
1 | 400 | 100205 | 0
(1 row)

เวอร์ชันเก่าที่ถูกทิ้งไว้จากการ update และ delete เรียกว่า dead tuple เมื่อไม่มี snapshot ของ transaction ที่กำลังทำงานตัวใดต้องการเวอร์ชันนั้นอีกต่อไป เวอร์ชันนั้นก็เหลือเป็นแค่พื้นที่เสียเปล่า PostgreSQL ทวงคืนพื้นที่นั้นด้วย VACUUM ซึ่งรันอัตโนมัติในเบื้องหลัง (autovacuum) และยังรันด้วยมือได้ด้วย

VACUUM accounts;
-- check how many dead tuples a table is carrying:
SELECT relname, n_dead_tup FROM pg_stat_user_tables WHERE relname = 'accounts';
relname | n_dead_tup
----------+------------
accounts | 0
(1 row)

ใน pgAdmin: คลิกขวาที่ table ในแผนผัง browser แล้วเลือก Maintenance เพื่อรัน VACUUM (หรือ VACUUM ANALYZE) ผ่านกล่องโต้ตอบแทนการพิมพ์เอง

  • การอ่านธรรมดาไม่จับ row lock ภายใต้ MVCC ถ้าคุณต้องการให้การอ่านจอง row ไว้สำหรับการเขียนที่กำลังจะมา นั่นคือหน้าที่ของ SELECT ... FOR UPDATE ในบทเรียน locking
  • transaction ที่รันนานคือศัตรูของการ vacuuming ตราบใดที่ transaction ยังเปิดค้าง snapshot ของ transaction นั้นอาจยังต้องการเวอร์ชันเก่า dead tuple จึงถูกลบไม่ได้และ table จะบวม
  • VACUUM ทวงคืนพื้นที่เพื่อใช้ซ้ำภายใน table ปกติแล้วไม่ได้ลดขนาดไฟล์บนดิสก์ ส่วน VACUUM FULL ที่หนักกว่าทำได้ แต่ต้อง lock table ตลอดเวลาที่ทำงาน
  • การ update row เดิมซ้ำ ๆ บ่อย ๆ สร้างหลายเวอร์ชัน จับตา n_dead_tup ใน pg_stat_user_tables แล้วปล่อยให้ autovacuum ตามทัน หรือปรับจูน autovacuum เพิ่มสำหรับ table ที่ร้อน
ตัวเลือกBenefitCost
MVCC (reader ไม่บล็อก writer, writer ไม่บล็อก reader)concurrency สูงมาก analytics query ยาว ๆ รันพร้อมกับ write load ได้โดยไม่ต้องรอกันต้องเก็บ row เวอร์ชันเก่าไว้เป็น dead tuple ใช้พื้นที่เพิ่มและต้องมี VACUUM คอยเก็บกวาด
lock-based concurrency control (ฐานข้อมูลอื่นบางตัว)ไม่มี dead tuple สะสม ไม่ต้อง vacuumreader กับ writer บล็อกกันเอง throughput ต่ำกว่าภายใต้ workload อ่าน-เขียนผสมกัน
  • คิดว่า UPDATE เขียนทับ row เดิมแบบไม่มีผลข้างเคียง — ภายใต้ MVCC UPDATE สร้างเวอร์ชันใหม่เสมอและทิ้งเวอร์ชันเก่าไว้เป็น dead tuple ไม่ใช่การเขียนทับ byte เดิม
  • ไม่รู้ว่า transaction ที่รันนานหน่วง VACUUM ทั้งฐานข้อมูล — snapshot ของ transaction ที่ยังเปิดอยู่อาจยังต้องการเวอร์ชันเก่า ทำให้ VACUUM ลบ dead tuple ไม่ได้ ไม่ใช่แค่ table ของ transaction นั้นเอง แต่กระทบทั้งระบบ
  • ลืมว่า SELECT อาจกำลังอ่านจาก snapshot เก่า — ขณะที่ transaction อื่น commit การเปลี่ยนแปลงไปแล้ว transaction ของคุณอาจยังเห็นค่าก่อนหน้า ถ้าตรรกะแอปพลิเคชันคาดหวังค่าล่าสุดเสมอ ต้องออกแบบ isolation level ให้เหมาะสม

💡 ตัวอย่างจากของจริง

Dashboard และ analytics แบบ real-time — สามารถรัน query ที่ใช้เวลาหลายวินาทีบน table เดียวกับที่แอปพลิเคชันกำลังเขียนอยู่ตลอดเวลา ได้โดยไม่บล็อกการเขียน เพราะ MVCC ให้แต่ละฝ่ายทำงานจาก snapshot ของตัวเอง นี่เป็นเหตุผลหลักที่หลายทีมเลือก PostgreSQL สำหรับ workload อ่าน-เขียนผสมกัน

ทีม SRE ที่ tune autovacuum — บริษัทที่มี table ขนาดใหญ่และ update ถี่ ๆ (เช่น session store หรือ counter table) มักต้องปรับจูน autovacuum_vacuum_scale_factor ให้ aggressive ขึ้นเพื่อป้องกัน table bloat จาก dead tuple ที่สะสมเร็วกว่าที่ autovacuum เริ่มทำงาน

ภายใต้ MVCC เกิดอะไรขึ้นกับ row เมื่อเราสั่ง UPDATE?
ทำไม SELECT ธรรมดาถึงไม่ต้องรอ writer?
transaction ที่รันนาน ๆ สร้างปัญหาอะไรให้กับ MVCC?