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 นั้น
แต่ละ transaction อ่านจาก 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
ในแผนภาพ transaction 205 เปลี่ยน balance จาก 500 เป็น 400 reader ที่ snapshot เกิดก่อนการ commit ยังคงเห็น 500 จากเวอร์ชัน 1 reader ที่เริ่มหลังจากนั้นเห็น 400 จากเวอร์ชัน 2 ทั้งคู่กำลังอ่านโดยไม่มี lock ในเวลาเดียวกัน และทั้งคู่ถูกต้องสำหรับจุดเวลาของตัวเอง
ดู column ที่ซ่อนอยู่
หัวข้อที่มีชื่อว่า “ดู column ที่ซ่อนอยู่”คุณสามารถขอ xmin และ xmax ได้โดยตรง ซึ่งทำให้กลไกนี้เป็นรูปธรรม รัน query เดียวกันก่อนและหลังการ update จาก session อื่นเพื่อดูเวอร์ชันเปลี่ยน
SELECT id, balance, xmin, xmax FROM accounts WHERE owner = 'Ada';const res = await pool.query( 'SELECT id, balance, xmin, xmax FROM accounts WHERE owner = $1', ['Ada'],);console.log(res.rows[0]);cur.execute("SELECT id, balance, xmin, xmax FROM accounts WHERE owner = %s", ("Ada",))row = cur.fetchone()var id, xmin, xmax int64var balance float64conn.QueryRow(ctx, "SELECT id, balance, xmin, xmax FROM accounts WHERE owner = $1", "Ada"). Scan(&id, &balance, &xmin, &xmax)let row = sqlx::query("SELECT id, balance, xmin, xmax FROM accounts WHERE owner = $1") .bind("Ada") .fetch_one(&pool) .await?; id | balance | xmin | xmax----+---------+--------+------ 1 | 400 | 100205 | 0(1 row)dead tuple และ VACUUM
หัวข้อที่มีชื่อว่า “dead tuple และ VACUUM”เวอร์ชันเก่าที่ถูกทิ้งไว้จากการ 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';await pool.query('VACUUM accounts'); // run outside a transaction blockconn.autocommit = True # VACUUM cannot run inside a transactioncur.execute("VACUUM accounts")_, err := conn.Exec(ctx, "VACUUM accounts") // must not be inside a transactionsqlx::query("VACUUM accounts").execute(&pool).await?; // not inside a transaction 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 ที่ร้อน
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | Benefit | Cost |
|---|---|---|
| MVCC (reader ไม่บล็อก writer, writer ไม่บล็อก reader) | concurrency สูงมาก analytics query ยาว ๆ รันพร้อมกับ write load ได้โดยไม่ต้องรอกัน | ต้องเก็บ row เวอร์ชันเก่าไว้เป็น dead tuple ใช้พื้นที่เพิ่มและต้องมี VACUUM คอยเก็บกวาด |
| lock-based concurrency control (ฐานข้อมูลอื่นบางตัว) | ไม่มี dead tuple สะสม ไม่ต้อง vacuum | reader กับ writer บล็อกกันเอง throughput ต่ำกว่าภายใต้ workload อ่าน-เขียนผสมกัน |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”- คิดว่า
UPDATEเขียนทับ row เดิมแบบไม่มีผลข้างเคียง — ภายใต้ MVCCUPDATEสร้างเวอร์ชันใหม่เสมอและทิ้งเวอร์ชันเก่าไว้เป็น 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 เริ่มทำงาน