เข้าใจ PostgreSQL Isolation Level เพื่อป้องกัน Race Condition ใน Production Backend
ทำความเข้าใจระดับ Isolation Level ของ PostgreSQL ตั้งแต่ Read Committed, Repeatable Read ไปจนถึง Serializable พร้อมแนวทางเลือกใช้และป้องกันข้อมูลผิดพลาดเมื่อหลาย transaction ทำงานพร้อมกัน

PostgreSQL Isolation Level คือระดับที่กำหนดว่า transaction หนึ่งจะมองเห็นการเปลี่ยนแปลงจาก transaction อื่นได้มากน้อยเพียงใด กลไกนี้มีผลโดยตรงต่อความถูกต้องของข้อมูล โดยเฉพาะระบบ backend ที่มีผู้ใช้หลายคนอ่านหรือแก้ไขข้อมูลพร้อมกัน
โค้ดที่ทำงานถูกต้องเมื่อทดสอบทีละ request อาจให้ผลลัพธ์ผิดทันทีเมื่อขึ้น production เพราะ transaction หลายชุดสามารถอ่านข้อมูลชุดเดียวกันและตัดสินใจจากสถานะเดียวกันได้ การเข้าใจ Isolation Level จึงไม่ใช่เรื่องเฉพาะของผู้ดูแลฐานข้อมูล แต่เป็นพื้นฐานสำคัญของการออกแบบระบบที่เกี่ยวข้องกับเงิน สต็อก การจอง และสิทธิ์ของผู้ใช้
ทำไมระบบ Backend ต้องสนใจ Isolation Level
ลองนึกถึงระบบที่เหลือห้องว่างเพียงหนึ่งห้อง หากผู้ใช้สองคนส่งคำขอจองเข้ามาในเวลาใกล้กัน ทั้งสอง request อาจอ่านพบว่าห้องยังว่างและบันทึกการจองสำเร็จพร้อมกันได้ ปัญหาแบบเดียวกันเกิดขึ้นได้กับการตัดสต็อก การโอนเงิน หรือการใช้คูปองที่ควรใช้ได้เพียงครั้งเดียว
เหตุการณ์เหล่านี้เรียกว่า race condition ซึ่งเกิดจากหลายกระบวนการแข่งกันอ่านและเปลี่ยนแปลงข้อมูล แม้โค้ดแต่ละบรรทัดจะดูสมเหตุสมผล แต่ผลลัพธ์สุดท้ายอาจผิดเพราะแต่ละ transaction ตัดสินใจจากข้อมูลที่ไม่สะท้อนสิ่งที่ transaction อื่นกำลังทำอยู่
ตัวอย่างงานที่ต้องระวังเป็นพิเศษ ได้แก่
- การจองสินค้าหรือห้องที่เหลือเป็นรายการสุดท้าย
- การลดจำนวนสินค้าเมื่อมีคำสั่งซื้อหลายรายการพร้อมกัน
- การหักและเพิ่มยอดเงินระหว่างบัญชี
- การใช้คูปองหรือสิทธิ์ที่จำกัดจำนวนครั้ง
- การจัดคิวหรือเลือกงานชิ้นถัดไปให้ worker หลายตัว
Isolation Level หลักใน PostgreSQL
ระดับที่พบบ่อยใน PostgreSQL มีสามระดับ ได้แก่ Read Committed, Repeatable Read และ Serializable แต่ละระดับแลกเปลี่ยนระหว่างความยืดหยุ่น ประสิทธิภาพ และการป้องกันความผิดปกติของข้อมูล ไม่มีระดับใดที่เหมาะกับทุก transaction จึงควรเลือกตามลักษณะของกฎธุรกิจในแต่ละจุด
Read Committed: ค่าเริ่มต้นสำหรับงานทั่วไป
Read Committed เป็นค่าเริ่มต้นของ PostgreSQL โดยแต่ละคำสั่งจะเห็นเฉพาะข้อมูลที่ commit แล้ว ณ เวลาที่คำสั่งนั้นเริ่มทำงาน ระดับนี้มีประสิทธิภาพดี ใช้งานง่าย และเหมาะกับงานทั่วไป เช่น อ่านโปรไฟล์ แสดงรายการคำสั่งซื้อ หรือแก้ไขข้อมูลที่ไม่มีเงื่อนไขซับซ้อน
อย่างไรก็ตาม คำสั่งสองคำสั่งภายใน transaction เดียวกันอาจเห็นข้อมูลไม่เหมือนกันได้ หาก transaction อื่นเปลี่ยนข้อมูลและ commit ระหว่างสองคำสั่งนั้น ตัวอย่างเช่น ครั้งแรกอ่านสต็อกได้ 10 ชิ้น แต่เมื่ออ่านอีกครั้งอาจเหลือ 9 ชิ้น เพราะมี transaction อื่นตัดสต็อกสำเร็จไปแล้ว พฤติกรรมนี้เรียกว่า non-repeatable read และถือเป็นเรื่องปกติของ Read Committed
Read Committed จึงต้องใช้อย่างระมัดระวังกับรูปแบบ “อ่านก่อน แล้วค่อยตัดสินใจเขียน” เพราะหลาย transaction อาจอ่านค่าเริ่มต้นเดียวกันก่อนจะพยายามแก้ไขข้อมูล หากกฎธุรกิจขึ้นอยู่กับค่าที่อ่าน ควรเสริมด้วย constraint, atomic update หรือ row lock แทนการเชื่อผลจากการอ่านเพียงอย่างเดียว
Repeatable Read: ใช้ Snapshot เดิมตลอด Transaction
Repeatable Read ทำให้ transaction อ่านข้อมูลจาก snapshot เดียวกันตลอดอายุของ transaction เมื่ออ่าน record เดิมหลายครั้งจึงยังเห็นค่าเดิม แม้ transaction อื่นจะเปลี่ยนและ commit ข้อมูลไปแล้วก็ตาม วิธีนี้เหมาะกับรายงาน การคำนวณแบบ batch หรือกระบวนการที่อ่านหลายตารางและต้องการให้ข้อมูลทั้งหมดสะท้อนช่วงเวลาเดียวกัน
ใน PostgreSQL ระดับ Repeatable Read ยังป้องกันไม่ให้ผลการค้นหาใน snapshot เดิมมีแถวที่เพิ่งถูกเพิ่มเข้ามาปรากฏในคำสั่งถัดไปด้วย อย่างไรก็ตาม snapshot ที่นิ่งไม่ได้แปลว่าทุกการเขียนจะสำเร็จ หากข้อมูลที่ transaction ต้องการแก้ไขถูกเปลี่ยนโดย transaction อื่น PostgreSQL อาจยกเลิก transaction เพื่อป้องกันผลลัพธ์ที่ขัดแย้งกัน
ดังนั้นระบบที่ใช้ Repeatable Read ต้องเตรียมรับข้อผิดพลาดจากความขัดแย้งและสามารถเริ่ม transaction ใหม่ได้ตั้งแต่ต้น การ retry เฉพาะคำสั่งสุดท้ายมักไม่เพียงพอ เพราะข้อมูลที่ใช้ตัดสินใจก่อนหน้านั้นมาจาก snapshot ของ transaction เก่า
Phantom Read คืออะไร
Phantom read เกิดเมื่อคำสั่งค้นหาด้วยช่วงหรือเงื่อนไขเดียวกันให้จำนวนแถวต่างกันในการอ่านครั้งถัดไป ตัวอย่างเช่น ครั้งแรกค้นหาห้องว่างพบหนึ่งห้อง แต่ก่อนค้นหารอบที่สองมี transaction อื่นเพิ่มรายการจอง ทำให้ผลลัพธ์ของเงื่อนไขเดิมเปลี่ยนไป
ปัญหานี้สำคัญกับระบบจอง คิว สินค้าคงคลัง และกฎความเป็นเอกลักษณ์ที่ไม่สามารถอธิบายด้วย unique index แบบง่ายได้ แม้ PostgreSQL Repeatable Read จะรักษา snapshot เดิมและไม่แสดงแถวใหม่ใน transaction นั้น แต่การตัดสินใจเขียนจาก snapshot ยังอาจขัดแย้งกับ transaction อื่น จึงต้องใช้ constraint, lock หรือระดับ Serializable ให้เหมาะกับกฎธุรกิจ
Serializable: ความถูกต้องสูงสุดพร้อมภาระการ Retry
Serializable เป็น Isolation Level ที่เข้มที่สุด โดย PostgreSQL พยายามทำให้ผลลัพธ์เสมือนว่า transaction ทั้งหมดถูกรันทีละชุดตามลำดับ แม้ในความเป็นจริงระบบจะยังประมวลผลหลาย transaction พร้อมกัน ระดับนี้ช่วยป้องกัน anomaly ที่เกิดจากการอ่านและเขียนข้อมูลข้าม transaction ได้ครอบคลุมที่สุด
Serializable ไม่ใช่โหมดวิเศษที่เปิดแล้วทุก transaction จะสำเร็จ เมื่อ PostgreSQL ตรวจพบว่าการทำงานพร้อมกันไม่สามารถให้ผลลัพธ์ที่เทียบเท่ากับการรันทีละ transaction ได้ ระบบจะยกเลิก transaction หนึ่งด้วย serialization failure แอปพลิเคชันจึงต้องจับข้อผิดพลาดและ retry transaction ทั้งชุดด้วยข้อมูล snapshot ใหม่
ระดับนี้เหมาะกับกฎธุรกิจที่ยอมให้ข้อมูลผิดไม่ได้ เช่น การเคลื่อนย้ายเงิน การจำกัดสิทธิ์ที่มีมูลค่า หรือเงื่อนไขซับซ้อนซึ่งพิจารณาข้อมูลหลายแถว อย่างไรก็ดี transaction ควรสั้นและมี retry strategy ที่จำกัดจำนวนครั้ง เพื่อไม่ให้ความขัดแย้งจำนวนมากกลายเป็นปัญหาด้านประสิทธิภาพ
ใช้ Constraint, Lock และ Atomic Update ร่วมกัน
Isolation Level ไม่ควรเป็นกลไกป้องกันข้อมูลเพียงชั้นเดียว PostgreSQL มี constraint หลายชนิดที่ช่วยบังคับกฎให้ถูกต้องแม้แอปพลิเคชันมีบั๊ก เช่น unique index, foreign key และ check constraint หากกฎสามารถกำหนดในฐานข้อมูลได้ ก็ควรให้ฐานข้อมูลเป็นด่านสุดท้ายในการปฏิเสธข้อมูลที่ไม่ถูกต้อง
หากหลาย transaction ต้องแก้ไขแถวเดียวกัน สามารถใช้ SELECT ... FOR UPDATE เพื่อล็อกแถวนั้นก่อนอ่านและตัดสินใจเขียน ตัวอย่างต่อไปนี้ทำให้ transaction อื่นที่ต้องการล็อกสินค้าเดียวกันต้องรอจนกว่า transaction ปัจจุบันจะจบ
BEGIN;
SELECT stock
FROM products
WHERE id = 42
FOR UPDATE;
UPDATE products
SET stock = stock - 1
WHERE id = 42;
COMMIT;
อีกแนวทางหนึ่งคือรวมเงื่อนไขตรวจสอบและการแก้ไขไว้ในคำสั่ง atomic update เดียว วิธีนี้ช่วยลดช่วงเวลาระหว่างการอ่านกับการเขียน และสามารถตรวจจากแถวที่คืนมาว่าการตัดสต็อกสำเร็จหรือไม่
UPDATE products
SET stock = stock - 1
WHERE id = 42
AND stock > 0
RETURNING id, stock;
ถ้าคำสั่งไม่คืนแถว แปลว่าสต็อกไม่เพียงพอหรือไม่พบสินค้า แอปพลิเคชันจึงตอบกลับอย่างเหมาะสมได้โดยไม่ต้องอ่านค่าแล้วค่อยส่งคำสั่งแก้ไขแยกกัน
แนวทางสำหรับ Node.js, Prisma และ pg
ไม่ว่าจะใช้ Node.js ผ่าน Prisma, pg หรือไลบรารีอื่น การตั้ง Isolation Level เป็นเพียงส่วนหนึ่งของการออกแบบ transaction สิ่งสำคัญคือ transaction ต้องครอบคลุมเฉพาะงานฐานข้อมูลที่จำเป็นและจบให้เร็วที่สุด เพราะ transaction ที่เปิดค้างไว้นานจะถือ snapshot หรือ lock นานขึ้น และเพิ่มโอกาสชนกับ request อื่น
แนวทางที่ควรปฏิบัติมีดังนี้
- ใช้ Read Committed เป็นค่าเริ่มต้นสำหรับงานทั่วไป
- ระวัง flow ที่อ่านข้อมูลก่อนนำผลไปตัดสินใจเขียน
- ใช้ unique index, foreign key และ check constraint บังคับกฎในฐานข้อมูล
- ใช้ atomic update หรือ
SELECT ... FOR UPDATEเมื่อหลาย request อาจแก้แถวเดียวกัน - ใช้ Repeatable Read เมื่อต้องอ่านข้อมูลหลายครั้งจาก snapshot เดียวกัน
- ใช้ Serializable เมื่อกฎธุรกิจห้ามเกิดความคลาดเคลื่อนและระบบรองรับการ retry ได้
- retry transaction ทั้งชุดเมื่อพบ serialization failure หรือข้อผิดพลาดชั่วคราวที่รองรับการ retry
ไม่ควรเรียก external API ภายใน transaction เช่น เปิด transaction แล้วรอ payment gateway หรือบริการภายนอกตอบกลับ การรอเครือข่ายทำให้ transaction ยาวโดยไม่จำเป็นและอาจถือ lock ขวาง request อื่น ควรแยกขั้นตอนภายนอกออกจาก transaction และออกแบบสถานะหรือกระบวนการชดเชยให้รองรับกรณีที่แต่ละขั้นตอนสำเร็จไม่พร้อมกัน
วิธีจำ Isolation Level ทั้งสามระดับ
แนวคิดของแต่ละระดับสามารถสรุปให้จำง่ายได้ดังนี้
- Read Committed: แต่ละ statement เห็นข้อมูลล่าสุดที่ commit แล้วเมื่อ statement เริ่มทำงาน
- Repeatable Read: transaction อ่านจาก snapshot เดิมตลอด จึงเห็นข้อมูลที่สอดคล้องกันระหว่างหลาย query
- Serializable: ฐานข้อมูลบังคับให้ผลลัพธ์เทียบเท่าการรัน transaction ทีละชุด และแอปต้อง retry เมื่อเกิดความขัดแย้ง
สรุป
Isolation Level คือเข็มขัดนิรภัยของ production backend เพราะช่วยควบคุมสิ่งที่ transaction มองเห็นเมื่อมีงานหลายชุดเกิดขึ้นพร้อมกัน การเลือกระดับที่อ่อนเกินไปอาจทำให้ข้อมูลผิด ส่วนการเลือกระดับที่เข้มเกินความจำเป็นอาจเพิ่มความขัดแย้ง ภาระการ retry และต้นทุนในการประมวลผล
แนวทางที่เหมาะสมคือเริ่มจาก Read Committed ร่วมกับ constraint ที่รัดกุมและ atomic update แล้วเพิ่ม row lock, Repeatable Read หรือ Serializable เฉพาะจุดตามความเสี่ยงของกฎธุรกิจ สำหรับข้อมูลอย่างเงินจริง สต็อกจริง และสิทธิ์ที่สูญเสียไม่ได้ ควรออกแบบทั้ง Isolation Level และ retry strategy ตั้งแต่ต้นแทนที่จะรอแก้หลังเกิดข้อมูลผิดพลาดใน production