Originally published athttps://somprasongd.work/blog/db/dbtx
ในการพัฒนาระบบที่เกี่ยวข้องกับข้อมูลสำคัญ เช่น การเงิน หนึ่งในสิ่งที่ขาดไม่ได้คือการใช้Database Transaction เพื่อให้มั่นใจว่าข้อมูลจะถูกจัดการอย่างถูกต้องและปลอดภัย โดยเฉพาะในสถานการณ์ที่มีหลายคำสั่ง SQL ทำงานร่วมกัน เช่น "การโอนเงิน" ที่ต้องอัปเดตยอดเงินสองบัญชีพร้อมกัน
ในบทความนี้ เราจะพาคุณเข้าใจว่าเหตุใดการใช้Transaction จึงสำคัญ ผ่านการเรียนรู้แนวคิดACID,Lock, และDeadlock ด้วยตัวอย่างจากการโอนเงินระหว่างบัญชีด้วย PostgreSQL
โครงสร้างฐานข้อมูล:accounts
และtransactions
CREATETABLEpublic.accounts(idserial4NOTNULL,"name"textNOTNULL,balancenumericNOTNULL,CONSTRAINTaccounts_pkeyPRIMARYKEY(id));CREATETABLEpublic.transactions(idserial4NOTNULL,from_account_idint4NULL,to_account_idint4NULL,amountnumericNOTNULL,created_attimestampDEFAULTnow()NULL,CONSTRAINTtransactions_pkeyPRIMARYKEY(id),CONSTRAINTtransactions_from_account_id_fkeyFOREIGNKEY(from_account_id)REFERENCESpublic.accounts(id),CONSTRAINTtransactions_to_account_id_fkeyFOREIGNKEY(to_account_id)REFERENCESpublic.accounts(id));
ปัญหา: การโอนเงินโดยไม่ใช้ Transaction
สมมุติว่าเราต้องโอนเงิน 500 บาทจากบัญชี A ไปยังบัญชี B โดยใช้คำสั่ง SQL ต่อไปน
UPDATEaccountsSETbalance=balance-500WHEREid=1;UPDATEaccountsSETbalance=balance+500WHEREid=2;INSERTINTOtransactions(from_account_id,to_account_id,amount)VALUES(1,2,500);
ถ้าเกิดเหตุขัดข้องหลังอัปเดตบัญชี A แล้ว (เช่น network error หรือ crash) แต่ยังไม่ทันเพิ่มเงินให้บัญชี B — เงิน 500 บาทจะ “หายไปจากระบบ” ซึ่งเป็นปัญหาหนักมากในระบบการเงิน
วิธีแก้: ใช้ Database Transaction
BEGIN;UPDATEaccountsSETbalance=balance-500WHEREid=1;UPDATEaccountsSETbalance=balance+500WHEREid=2;INSERTINTOtransactions(from_account_id,to_account_id,amount)VALUES(1,2,500);COMMIT;
หากคำสั่งใดล้มเหลว PostgreSQL จะไม่COMMIT
และเราสามารถROLLBACK
เพื่อยกเลิกทุกคำสั่งที่ทำไปแล้ว ทำให้ข้อมูลไม่เสียหาย
เข้าใจ ACID: หัวใจของ Transaction
ACID คือคุณสมบัติหลักของ Database Transaction ที่ทำให้ข้อมูลเชื่อถือได้ ซึ่งย่อมาจาก
- Atomicity – ทุกคำสั่งใน transaction ต้องสำเร็จทั้งหมด หรือไม่สำเร็จเลย
- Consistency – หลัง transaction สำเร็จ ฐานข้อมูลยังคงถูกต้องตามกฎ เช่น balance ≥ 0
- Isolation – แต่ละ transaction ไม่รบกวนกันระหว่างทำงานพร้อมกัน
- Durability – เมื่อ
COMMIT
แล้ว ข้อมูลจะถูกบันทึกถาวร แม้ระบบดับ
ตัวอย่าง: หากมีคนโอนเงินจากบัญชีเดียวกันสองคนพร้อมกัน PostgreSQL ต้องมั่นใจว่าไม่มีการขัดแย้ง หรือข้อมูลผิดเพี้ยน เช่นเงินติดลบ
การล็อกข้อมูล (Database Lock)
PostgreSQL จะล็อกrow-level อัตโนมัติเมื่อมีUPDATE
:
UPDATEaccountsSETbalance=balance-500WHEREid=1;
แถวของบัญชีid = 1
จะถูกล็อกไว้จนกว่า transaction จะจบ เพื่อป้องกันไม่ให้ transaction อื่นเข้ามาแก้ไขบัญชีเดียวกันพร้อมกัน
สถานการณ์:select
ก่อนupdate
แต่ในกรณีที่ใน application logic มีการดึงข้อมูล (select
) ขึ้นมาก่อน แล้วทำการแก้ไขค่า เสร็จแล้วบันทึกกลับไปในฐานข้อมูล (update
) แล้วเกิด transaction เข้ามาแก้ไขบัญชีเดียวกันพร้อมกัน เช่น
-- Transaction ABEGIN;SELECTid,"name",balanceFROMaccountsWHEREid=1;id|name|balance|--+----+-------+1|A|1000|UPDATEaccountsSETbalance=1000-500WHEREid=1;COMMIT;-- Transaction B (เกิดขึ้นพร้อมกัน)BEGIN;SELECTid,"name",balanceFROMaccountsWHEREid=1;id|name|balance|--+----+-------+1|A|1000|UPDATEaccountsSETbalance=1000-500WHEREid=1;COMMIT;
ในสถานการณ์นี้จะทำให้ค่าbalance
ผิด แทนที่จะเหลือ 0 แต่กลับเหลือ 500 แทน
วิธีแก้: ใช้SELECT FOR UPDATE
เพื่อล็อกก่อน
-- Transaction ABEGIN;SELECTid,"name",balanceFROMaccountsWHEREid=1FORUPDATE;id|name|balance|--+----+-------+1|A|1000|UPDATEaccountsSETbalance=1000-500WHEREid=1;COMMIT;-- Transaction B (เกิดขึ้นพร้อมกัน)BEGIN;SELECTid,"name",balanceFROMaccountsWHEREid=1FORUPDATE;-- จะหยุดรอจนกว่า Transaction A จะ COMMIT หรือ ROLLBACKid|name|balance|--+----+-------+1|A|500|UPDATEaccountsSETbalance=500-500WHEREid=1;COMMIT;
Deadlock: ล็อกขัดกันเอง
สถานการณ์ที่ 1:
- Transaction A: โอน A ➜ B
- Transaction B: โอน B ➜ A
-- Transaction ABEGIN;UPDATEaccountsSETbalance=balance-500WHEREid=1;UPDATEaccountsSETbalance=balance+500WHEREid=2;-- รอ เพราะ lock จาก Transaction B-- Transaction B (เกิดขึ้นพร้อมกัน)BEGIN;UPDATEaccountsSETbalance=balance-300WHEREid=2;UPDATEaccountsSETbalance=balance+300WHEREid=1;-- ERROR: deadlock detected
สถานการณ์ที่ 2:
ถ้าINSERT
ไปยังtransactions
ซึ่งมี Foreign Key อ้างถึงaccounts.id
และหากมีอีก transaction กำลังUPDATE accounts
อยู่ อาจเกิด Deadlock ได้
เช่น สมมุติให้เปลี่ยนลำดับให้บันทึกtransactions
ก่อน แล้วมาดึงข้อมูลข้อมูลaccounts
ก่อนupdate
-- Transaction ABEGIN;INSERTINTOtransactions(from_account_id,to_account_id,amount)VALUES(1,2,500);SELECTid,"name",balanceFROMaccountsWHEREid=1FORUPDATE;-- หยุดรอ เพราะ INSERT INTO transactions จาก Transaction B-- Transaction B (เกิดขึ้นพร้อมกัน)BEGIN;INSERTINTOtransactions(from_account_id,to_account_id,amount)VALUES(1,2,500);SELECTid,"name",balanceFROMaccountsWHEREid=1FORUPDATE;-- ERROR: deadlock detected
จากสถานการณ์ที่ยกตัวอย่างมา ทั้งสอง transaction จะล็อกบัญชีฝั่งของตนเองก่อน แล้วรอกันข้าม — ทำให้เกิดDeadlock PostgreSQL จะเลือก kill transaction ใด transaction หนึ่งเพื่อยกเลิก
วิธีป้องกัน Deadlock
ล็อก row ตามลำดับเสมอ
ใช้ORDER BY id
เพื่อให้ทุก transaction ล็อกบัญชีตามลำดับเดียวกัน เช่นid จากน้อยไปมาก
ล็อก row ก่อนอัปเดตด้วย
FOR NO KEY UPDATE
หากไม่ล็อก row ล่วงหน้า PostgreSQL จะพยายามล็อกเองในตอนที่INSERT
ไปยังtransactions
ซึ่งมี Foreign Key อ้างถึงaccounts.id
หากอีก transaction กำลังUPDATE accounts
อยู่ อาจเกิด Deadlock ได้FOR NO KEY UPDATE
คือระดับ lock ที่- ป้องกันการ update, delete หรือ insert ที่อ้าง foreign key
- เบากว่า
FOR UPDATE
แต่ปลอดภัยสำหรับกรณีแบบนี้ - เหมาะสำหรับ row ที่เราจะอัปเดต (แต่ไม่เปลี่ยนค่า primary key)
ควร
UPDATE accounts
ก่อน แล้วค่อยINSERT transactions
ช่วยลดความเสี่ยงในการเกิด Deadlock เพราะว่าหากคุณINSERT
ก่อน และมี Foreign Key ไปที่accounts
→ PostgreSQL จะพยายามล็อก row เหล่านั้นก่อน ถ้าคำสั่งUPDATE accounts
มาทีหลัง อาจเกิดDeadlock ได้ (โดยเฉพาะถ้าอีก transaction ล็อกกลับกัน)
ตัวอย่าง
-- Transaction ABEGIN;-- 1. ล็อกบัญชีล่วงหน้าเพื่อลดโอกาส deadlockSELECTidFROMaccountsWHEREidIN(1,2)ORDERBYidFORNOKEYUPDATE;-- 2. อัปเดตยอดเงินUPDATEaccountsSETbalance=balance-500WHEREid=1;UPDATEaccountsSETbalance=balance+500WHEREid=2;-- 3. บันทึกธุรกรรมINSERTINTOtransactions(from_account_id,to_account_id,amount)VALUES(1,2,500);COMMIT;-- Transaction B (เกิดขึ้นพร้อมกัน)BEGIN;SELECTidFROMaccountsWHEREidIN(1,2)ORDERBYidFORNOKEYUPDATE;-- รอจนกว่า Transaction A จะถูก COMMIT หรือ ROLLBACKUPDATEaccountsSETbalance=balance-300WHEREid=2;UPDATEaccountsSETbalance=balance+300WHEREid=1;INSERTINTOtransactions(from_account_id,to_account_id,amount)VALUES(2,1,300);COMMIT;
ตารางเปรียบเทียบระดับ Lock (ใน SELECT ... FOR ...)
Lock Mode | ใช้ในกรณี | ป้องกันไม่ให้ใครทำอะไรได้บ้าง |
---|---|---|
FOR UPDATE | อัปเดต row | ห้ามใคร update/delete แถวนี้ |
FOR NO KEY UPDATE | อัปเดต row (ไม่แตะ key) | ห้าม insert ที่อ้างถึง row นี้ (FK) |
FOR SHARE | อ่านอย่างเดียว | ห้ามใคร delete หรือ update แถวนี้ |
FOR KEY SHARE | อ่านพร้อมอ้าง FK | ห้ามใครเปลี่ยน primary key หรือ delete |
สรุป
การใช้Database Transaction เป็นสิ่งสำคัญอย่างยิ่งในระบบที่ต้องการความถูกต้องของข้อมูล โดยเฉพาะระบบการเงิน โดย PostgreSQL มีคุณสมบัติรองรับ ACID อย่างครบถ้วน พร้อมระบบ Lock และกลไกจัดการ Deadlock อัตโนมัติ
หากละเลยการใช้ Transaction อาจนำไปสู่ความเสียหายทางข้อมูล และความไม่ถูกต้องที่ยากจะแก้ไขภายหลัง
Top comments(0)
For further actions, you may consider blocking this person and/orreporting abuse