Postgres transactions สำหรับเวิร์กโฟลว์หลายขั้นตอน: รูปแบบปฏิบัติ
เรียนรู้การใช้ Postgres transactions กับเวิร์กโฟลว์หลายขั้นตอน: วิธีรวมการอัปเดตอย่างปลอดภัย ป้องกันการเขียนบางส่วน จัดการการ retry และรักษาความสอดคล้องของข้อมูล

ทำไมการอัปเดตหลายขั้นตอนมักไม่สอดคล้องกัน
ฟีเจอร์จริง ๆ มักไม่ใช่การอัปเดตฐานข้อมูลทีเดียวจบรอบเดียว แต่เป็นห่วงโซ่สั้น ๆ: แทรกแถว, อัปเดตยอดคงเหลือ, เปลี่ยนสถานะ, เขียนบันทึกตรวจสอบ, อาจคิวงานหลังบ้าน บางครั้งจะเกิดการเขียนบางส่วนเมื่อมีเพียงไม่กี่ขั้นตอนที่ถูกบันทึกลงฐานข้อมูลเท่านั้น
ปัญหานี้ปรากฏเมื่อมีบางอย่างขัดจังหวะห่วงโซ่: เซิร์ฟเวอร์ error, การเชื่อมต่อกับ Postgres หมดเวลา, การล้มเหลวหลังจากขั้นตอนที่ 2, หรือการ retry ที่รันขั้นตอนแรกซ้ำ คำสั่งแต่ละอันต่างเป็นปกติเมื่อดูแยกกัน แต่เวิร์กโฟลว์พังเมื่อมันหยุดกลางทาง
คุณมักจะเห็นสัญญาณได้เร็ว:
- มีแถวอยู่ แต่แถวที่เกี่ยวข้องกลับหายไป (สร้างคำสั่งซื้อ แต่ไม่มีรายการสินค้า)
- เงินถูกย้าย แต่สถานะไม่เปลี่ยน (จ่ายแล้วแต่ยังถูกมาร์กว่าไม่ได้จ่าย)
- สองระเบียนที่ควรเป็นหนึ่ง (สมัครซ้ำหลัง retry)
- ธงที่ไม่สอดคล้องกัน (ผู้ใช้เป็น "active" แต่ไม่มีแพลน)
- สถานะที่ปรากฏเฉพาะตอนมีภาระสูงหรือเกิดความล้มเหลว
ตัวอย่างชัดเจน: การอัปเกรดแพลนอาจอัปเดตแพลนของลูกค้า บันทึกการชำระเงิน และเพิ่มเครดิต หากแอปพังหลังจากบันทึกการชำระเงินแต่ก่อนเพิ่มเครดิต ฝ่ายซัพพอร์ตจะเห็นว่าในตารางหนึ่งเป็น "paid" แต่ในอีกตารางหนึ่งเป็น "no credits" ถาลูกค้าทำการ retry อาจบันทึกการชำระเงินซ้ำได้
เป้าหมายชัดเจน: ทำให้เวิร์กโฟลว์เป็นสวิตช์เดียว คือทุกขั้นตอนต้องสำเร็จทั้งหมด หรือไม่มีอันไหนเลย เพื่อไม่ให้เก็บงานที่ทำค้างไว้แบบครึ่งกลาง
ธุรกรรม (Transactions) แบบเข้าใจง่าย
ธุรกรรมคือวิธีของฐานข้อมูลในการบอกว่า: ให้ปฏิบัติต่อชุดคำสั่งเหล่านี้เป็นหน่วยงานเดียว การเปลี่ยนแปลงทั้งหมดจะเกิดขึ้นพร้อมกัน หรือไม่มีอะไรเกิดขึ้นเลย นี่สำคัญเมื่อต้องทำงานมากกว่าหนึ่งการอัปเดต เช่น สร้างแถว อัปเดตยอดคงเหลือ และเขียนบันทึกตรวจสอบ
คิดถึงการย้ายเงินระหว่างบัญชี คุณต้องหักจากบัญชี A และเพิ่มให้บัญชี B หากแอปพังหลังจากทำขั้นตอนแรก คุณไม่ต้องการให้ระบบจำได้เพียงการหักเงินเท่านั้น
Commit กับ rollback
เมื่อคุณ commit คุณบอก Postgres ว่าเก็บทุกอย่างที่ทำในธุรกรรมนี้ การเปลี่ยนแปลงทั้งหมดจะถาวรและเห็นได้โดย session อื่น
เมื่อคุณ rollback คุณบอก Postgres ให้ลืมทุกอย่างที่ทำในธุรกรรมนี้ Postgres จะยกเลิกการเขียนจากธุรกรรมนั้นเหมือนมันไม่เคยเกิดขึ้น
สิ่งที่ Postgres รับประกัน (และสิ่งที่ไม่ได้รับประกัน)
ภายในธุรกรรม Postgres รับประกันว่าคุณจะไม่เผยผลลัพธ์ครึ่งกลางให้ session อื่นก่อน commit หากเกิดความล้มเหลวและ rollback ฐานข้อมูลจะล้างการเขียนจากธุรกรรมที่ล้มเหลวนั้น
ธุรกรรมไม่สามารถแก้ปัญหาการออกแบบเวิร์กโฟลว์ที่ผิดได้ ถ้าคุณหักจำนวนผิด ใช้ user ID ผิด หรือข้ามการตรวจสอบที่จำเป็น Postgres จะ commit ผลลัพธ์ที่ผิดนั้นอย่างซื่อสัตย์ ธุรกรรมยังไม่ป้องกันความขัดแย้งเชิงธุรกิจทั้งหมด (เช่น ขายของเกินสต็อก) หากไม่ได้จับคู่กับข้อจำกัด ล็อก หรือตั้งระดับการแยก (isolation) ที่เหมาะสม
เวิร์กโฟลว์ที่ควรนำมารวมกัน
ทุกครั้งที่คุณอัปเดตมากกว่าตารางเดียว (หรือมากกว่าหนึ่งแถว) เพื่อทำให้การกระทำในโลกจริงเสร็จสิ้น นั่นคือผู้สมัครสำหรับการใช้ธุรกรรม หลักการเดียวกัน: ทุกอย่างต้องเสร็จ หรือไม่มีอะไรเลย
กระบวนการสั่งซื้อเป็นกรณีคลาสสิก อาจสร้างแถว order, จองสต็อก, เก็บเงิน แล้วมาร์ก order ว่า paid หากการชำระเงินสำเร็จแต่การอัปเดตสถานะล้มเหลว คุณจะมีเงินที่ถูกเก็บแต่คำสั่งซื้อยังดูเหมือนไม่ถูกจ่าย หากสร้างแถว order แต่ไม่ได้จองสต็อก คุณอาจขายสินค้าที่ไม่มีจริง
การลงทะเบียนผู้ใช้ก็แตกหักได้แบบเดียวกัน การสร้างผู้ใช้ แทรกโปรไฟล์ กำหนดบทบาท และบันทึกว่าจะส่งอีเมลต้อนรับเป็นหนึ่งการกระทำเชิงตรรกะ หากไม่ได้รวมกัน คุณอาจจบด้วยผู้ใช้ที่ล็อกอินได้แต่ไม่มีสิทธิ์ หรือโปรไฟล์อยู่แต่ไม่มีผู้ใช้
งานหลังบ้านมักต้องการพฤติกรรม "บันทึก + เปลี่ยนสถานะ" ที่เข้มงวด การอนุมัติคำขอ เขียนบันทึกตรวจสอบ และอัปเดตยอดคงเหลือควรสำเร็จพร้อมกัน ถ้ายอดคงเหลือเปลี่ยนแต่ไม่มีบันทึกตรวจสอบ คุณจะเสียหลักฐานว่าใครทำอะไรและเพราะเหตุใด
งาน background ได้ประโยชน์เช่นกัน โดยเฉพาะเมื่อประมวลผลงานที่มีหลายขั้นตอน: จองงานเพื่อป้องกันไม่ให้ worker สองตัวทำซ้ำ, ใช้การเปลี่ยนแปลงธุรกิจ, บันทึกผลลัพธ์สำหรับรายงานและ retry, แล้วมาร์กงานว่าเสร็จหรือไม่สำเร็จพร้อมเหตุผล หากขั้นตอนเหล่านี้แยกจากกัน การ retry และ concurrency จะสร้างความยุ่งเหยิง
ออกแบบเวิร์กโฟลว์ก่อนเขียน SQL
ฟีเจอร์หลายขั้นตอนพังเมื่อคุณจัดการมันเป็นชุดการอัปเดตแยกจากกัน ก่อนเปิด client ฐานข้อมูล ให้เขียนเวิร์กโฟลว์เป็นเรื่องสั้นที่มีเส้นชัยเดียวชัดเจน: อะไรนับเป็น "เสร็จ" สำหรับผู้ใช้
เริ่มจากเขียนรายการขั้นตอนด้วยภาษาง่าย ๆ แล้วกำหนดเงื่อนไขสำเร็จเดียว เช่น: "คำสั่งซื้อถูกสร้าง สต็อกถูกจอง และผู้ใช้เห็นหมายเลขยืนยันคำสั่งซื้อ" อะไรก็ตามที่สั้นกว่านั้นถือว่าไม่สำเร็จ แม้บางตารางจะถูกอัปเดตก็ตาม
จากนั้นแยกงานในฐานข้อมูลออกจากงานภายนอกอย่างชัดเจน งานฐานข้อมูลคืองานที่คุณสามารถปกป้องด้วยธุรกรรม งานภายนอกเช่นการชำระเงิน ส่งอีเมล หรือเรียก API ของบุคคลที่สาม มักล้มเหลวในรูปแบบช้าและไม่สามารถย้อนกลับได้ง่าย
แนวทางง่าย ๆ ในการวางแผน: แยกขั้นตอนเป็น (1) ต้องเป็นทั้งหมดหรือไม่มีเลย, (2) สามารถเกิดหลัง commit ได้
ตัดสินใจว่าสิ่งใดควรอยู่ในธุรกรรม
ภายในธุรกรรม ให้เก็บเฉพาะขั้นตอนที่ต้องคงที่ด้วยกัน:
- สร้างหรืออัปเดตแถวหลัก (order, invoice, ยอดบัญชี)
- จองทรัพยากรร่วม (สต็อก, ที่นั่ง, โควต้า)
- บันทึกอีเวนต์ทนทาน "จะทำอะไรต่อไป" (เช่น ตาราง outbox)
- บังคับกฎด้วยข้อจำกัด (unique keys, foreign keys)
ย้าย side effects ออกไปข้างนอก เช่น commit คำสั่งซื้อก่อน แล้วส่งอีเมลยืนยันจาก worker ที่อ่านตาราง outbox
เขียนความคาดหวังการ rollback ต่อแต่ละขั้นตอน
สำหรับแต่ละขั้นตอน เขียนว่าอะไรควรเกิดขึ้นถ้าขั้นตอนถัดไปล้มเหลว "Rollback" อาจหมายถึง rollback ของฐานข้อมูล หรือการทำ action ชดเชย
ตัวอย่าง: ถ้าการชำระเงินสำเร็จแต่การจองสต็อกล้มเหลว ให้ตัดสินใจก่อนว่าจะคืนเงินทันที หรือตั้งสถานะเป็น "ชำระแล้ว รอสินค้า" แล้วจัดการแบบอะซิงโครนัส
ทีละขั้นตอน: ห่อเวิร์กโฟลว์ในธุรกรรม
ธุรกรรมบอก Postgres ว่าให้ปฏิบัติต่อขั้นตอนเหล่านี้เป็นหน่วยเดียว ทุกขั้นต้องเกิดขึ้นหรือไม่มีเลย นี่เป็นวิธีที่เรียบง่ายที่สุดเพื่อป้องกันการเขียนบางส่วน
โฟลว์พื้นฐาน
ใช้การเชื่อมต่อฐานข้อมูลเดียว (session เดียว) ตั้งแต่เริ่มจนจบ หากคุณกระจายขั้นตอนไปยังการเชื่อมต่อหลายตัว Postgres จะไม่สามารถรับประกันผลแบบทั้งหมดหรือไม่มีเลยได้
ลำดับนั้นตรงไปตรงมา: begin, รันการอ่านและเขียนที่ต้องการ, commit ถ้าทุกอย่างสำเร็จ มิฉะนั้น rollback และคืนค่า error ที่ชัดเจน
นี่คือ ตัวอย่าง SQL ขั้นพื้นฐาน:
BEGIN;
-- reads that inform your decision
SELECT balance FROM accounts WHERE id = 42 FOR UPDATE;
-- writes that must stay together
UPDATE accounts SET balance = balance - 50 WHERE id = 42;
INSERT INTO ledger(account_id, amount, note) VALUES (42, -50, 'Purchase');
COMMIT;
-- on error (in code), run:
-- ROLLBACK;
ทำให้มันสั้น (และง่ายต่อการดีบัก)
ธุรกรรมจะถือล็อกขณะที่รัน ยิ่งเปิดไว้นานเท่าไรก็จะยิ่งบล็อกงานอื่นและมีโอกาสชนกับ timeouts หรือ deadlocks สูงขึ้น ทำเฉพาะสิ่งจำเป็นภายในธุรกรรม และย้ายงานช้า ๆ (ส่งอีเมล เรียกผู้ให้บริการชำระเงิน สร้าง PDF) ออกไปข้างนอก
เมื่อมีความล้มเหลว ให้ล็อกข้อมูลที่เพียงพอเพื่อทำซ้ำปัญหาได้โดยไม่รั่วข้อมูลสำคัญ: ชื่อเวิร์กโฟลว์, order_id หรือ user_id, พารามิเตอร์สำคัญ (amount, currency), และรหัสข้อผิดพลาดของ Postgres หลีกเลี่ยงการล็อก payload เต็ม ๆ ข้อมูลบัตร หรือรายละเอียดส่วนบุคคล
พื้นฐานการทำงานพร้อมกัน: ล็อกและ isolation โดยไม่ใช้ศัพท์เทคนิค
Concurrency คือสองสิ่งที่เกิดขึ้นพร้อมกัน ลองนึกถึงลูกค้าสองคนพยายามซื้อบัตรคอนเสิร์ตใบสุดท้าย ทั้งสองจอแสดง "เหลือ 1" ทั้งคู่กดจ่าย แล้วแอปของคุณต้องตัดสินว่าใครได้บัตร
หากไม่ป้องกัน คำขอทั้งสองสามารถอ่านค่าเก่าเดียวกันและเขียนอัปเดตได้ นั่นคือสาเหตุที่คุณอาจมีสต็อกติดลบ จองซ้ำ หรือการชำระเงินที่ไม่มีคำสั่งซื้อ
ล็อกแถวเป็นแนวป้องกันง่าย ๆ คุณล็อกแถวที่กำลังจะเปลี่ยน ตรวจสอบ แล้วอัปเดต Transaction อื่นที่แตะแถวเดียวกันต้องรอจนกว่าคุณจะ commit หรือ rollback ซึ่งป้องกันการอัปเดตซ้ำ
รูปแบบทั่วไป: เริ่มธุรกรรม, เลือกแถว inventory ด้วย FOR UPDATE, ตรวจสอบว่ายังมีสต็อก, ลดจำนวน, แล้วแทรกคำสั่งซื้อ นั่นคือการ "ปิดประตู" ขณะคุณทำขั้นตอนสำคัญให้เสร็จ
ระดับ isolation ควบคุมว่าคุณยอมให้ความผิดปกติจากธุรกรรมพร้อมกันมากน้อยแค่ไหน ผลประโยชน์มักเป็นความปลอดภัยกับความเร็ว:
- Read Committed (ค่าเริ่มต้น): เร็ว แต่คุณอาจเห็นการเปลี่ยนแปลงที่คนอื่น commit ระหว่างคำสั่ง
- Repeatable Read: ธุรกรรมเห็น snapshot คงที่ เหมาะสำหรับการอ่านที่สอดคล้อง อาจต้อง retry บ่อยขึ้น
- Serializable: ปลอดภัยที่สุด Postgres อาจยกเลิกธุรกรรมหนึ่งเพื่อรักษาผลลัพธ์เหมือนรันทีละตัว
เก็บล็อกให้สั้น หากธุรกรรมเปิดขณะคุณเรียก API ภายนอกหรือรอการกระทำจากผู้ใช้ คุณจะสร้างการรอและ timeouts ยาว ๆ ควรมีเส้นทางล้มเหลวชัดเจน: ตั้ง lock timeout จับ error แล้วคืนค่า "กรุณาลองใหม่" แทนปล่อยให้คำขอค้าง
ถ้าต้องทำงานนอกฐานข้อมูล (เช่น charge card) ให้แยกเวิร์กโฟลว์: จองเร็ว ๆ, commit, แล้วทำส่วนช้า จากนั้นสรุปด้วยธุรกรรมสั้นอีกครั้ง
Retry โดยไม่สร้างรายการซ้ำ
การ retry เป็นเรื่องปกติในแอปที่ใช้ Postgres คำขออาจล้มเหลวแม้โค้ดถูกต้อง: deadlocks, statement timeouts, การหลุดของเครือข่าย, หรือ serialization errors หากคุณรัน handler เดิมซ้ำ คุณเสี่ยงสร้างคำสั่งซื้อซ้ำ เก็บเงินสองครั้ง หรือแทรกรายการ "event" ซ้ำ
วิธีแก้คือ idempotency: การดำเนินการควรปลอดภัยเมารันสองครั้งด้วย input เดียวกัน ฐานข้อมูลต้องจำได้ว่า "นี่คือคำขอเดียวกัน" และตอบอย่างสม่ำเสมอ
รูปแบบปฏิบัติได้คือแนบ idempotency key (บ่อยครั้งเป็น request_id ที่ลูกค้าสร้าง) กับทุกเวิร์กโฟลว์หลายขั้นตอน และเก็บไว้บนระเบียนหลัก แล้วเพิ่ม unique constraint บนคีย์นั้น
ตัวอย่าง: ใน checkout สร้าง request_id เมื่อผู้ใช้กด Pay แล้วแทรก order พร้อม request_id หาก retry ครั้งที่สองพยายามแทรกอีกครั้ง จะชนกับ unique constraint แล้วคุณคืนคำสั่งซื้อที่มีอยู่แทนการสร้างใหม่
สิ่งที่ควรทำ:
- ใช้ unique constraint บน (request_id) หรือ (user_id, request_id) เพื่อป้องกันซ้ำ
- เมื่อชน constraint ให้ดึงแถวที่มีอยู่แล้วและคืนผลลัพธ์เดิม
- ให้ side effects ใช้นโยบายเดียวกัน: หนึ่ง payment intent ต่อคำสั่งซื้อ หนึ่ง event "order confirmed" ต่อคำสั่งซื้อ
- บันทึก request_id เพื่อฝ่ายซัพพอร์ตตามรอยได้
เก็บ loop การ retry ไว้นอกธุรกรรม แต่ละครั้งควรเริ่มธุรกรรมใหม่และรันหน่วยงานงานทั้งหมดจากจุดเริ่มต้น การ retry ภายในธุรกรรมที่ล้มเหลวไม่ช่วยเพราะ Postgres จะมาร์กธุรกรรมนั้นเป็น aborted
ตัวอย่างเล็ก ๆ: แอปของคุณพยายามสร้างคำสั่งซื้อและจองสต็อก แต่หมดเวลาเพียงหลัง COMMIT ลูกค้าทำ retry หากมี idempotency key การร้องขอครั้งที่สองจะคืนคำสั่งซื้อที่สร้างแล้วและข้ามการจองที่สองแทนที่จะทำงานซ้ำ
ใช้ฐานข้อมูลบังคับกฎ แทนการพึ่งเฉพาะโค้ด
ธุรกรรมช่วยจับกลุ่มเวิร์กโฟลว์หลายขั้นตอน แต่ไม่ทำให้ข้อมูลถูกต้องโดยอัตโนมัติ วิธีที่แข็งแรงคือทำให้สถานะ "ผิด" เป็นเรื่องยากหรือเป็นไปไม่ได้ในฐานข้อมูล แม้โค้ดผิดพลาดก็ยังถูกป้องกันได้บางส่วน
เริ่มจากราวบันไดความปลอดภัยพื้นฐาน Foreign keys ทำให้แน่ใจว่าการอ้างอิงมีจริง (order line ต้องไม่ชี้ไปที่ order ที่หายไป) NOT NULL หยุดแถวที่กรอกไม่ครบ CHECK constraints จับค่าที่ไม่สมเหตุสมผล (เช่น quantity > 0, total_cents >= 0) กฎเหล่านี้ทำงานทุกครั้งที่เขียน ไม่ว่าบริการหรือสคริปต์ใดจะเข้าถึงฐานข้อมูล
สำหรับเวิร์กโฟลว์ยาว ๆ ให้จำลองการเปลี่ยนสถานะอย่างชัดเจน แทนที่จะใช้ boolean หลายตัว ให้ใช้คอลัมน์ status เดียว (pending, paid, shipped, canceled) และอนุญาตเฉพาะการเปลี่ยนสถานะที่ถูกต้อง คุณสามารถบังคับด้วย constraint หรือ trigger เพื่อให้ฐานข้อมูลปฏิเสธการกระโดดสถานะที่ผิด เช่น shipped -> pending
ความเป็นเอกลักษณ์ (uniqueness) เป็นอีกรูปลักษณ์หนึ่งของความถูกต้อง เพิ่ม unique constraints ในที่ที่การซ้ำจะทำลายเวิร์กโฟลว์: order_number, invoice_number, หรือตัว idempotency_key ของ retry แล้วเมื่อแอปรันซ้ำ Postgres จะบล็อกการแทรกที่สองและคุณสามารถตอบว่า "already processed" แทนการสร้างคำสั่งซื้อใหม่
เมื่อคุณต้องการความสามารถในการตรวจสอบ (traceability) ให้จัดเก็บอย่างชัดเจน ตาราง audit (หรือ history) ที่บันทึกว่าใครเปลี่ยนอะไร เวลาใด จะเปลี่ยน "การอัปเดตปริศนา" ให้เป็นข้อเท็จจริงที่สามารถถามได้ในเหตุการณ์
ความผิดพลาดทั่วไปที่ทำให้เกิดการเขียนบางส่วน
การเขียนบางส่วนส่วนใหญ่ไม่ได้เกิดจาก "SQL ผิด" แต่เกิดจากการตัดสินใจในเวิร์กโฟลว์ที่ทำให้สะดวกที่จะ commit ครึ่งเรื่อง
กับดักที่มักพบในแอปจริง
- ทำงานภายนอกที่ช้าในขณะเปิดธุรกรรม. เรียกผู้ให้บริการชำระเงิน ส่งอีเมล หรืออัปโหลดไฟล์ในธุรกรรมทำให้ล็อกถูกถือไว้นาน ถ้า API ช้า หรือ timeout ผู้ใช้คนอื่นจะคิวรอหลังธุรกรรมของคุณ
- อ่านนอกธุรกรรม แล้วเขียนทีหลัง. ตัวอย่าง: ดึงยอดคงเหลือผู้ใช้ แสดงบนหน้าจอ แล้วค่อยหักทีหลัง ค่าอาจถูกเปลี่ยนโดย session อื่นในระหว่างนั้น
- จับ error แต่ยัง commit บางอย่าง. รูปแบบที่พบบ่อยคือ "ลองขั้นตอน 1, ลองขั้นตอน 2, ล็อก error, คืนค่าความสำเร็จ" หากโค้ดไปถึง COMMIT หลังจากเกิดความล้มเหลว คุณทำให้ฐานข้อมูลไม่สอดคล้องตามตั้งใจ
- อัปเดตตารางต่าง ๆ ตามลำดับที่ต่างกันในเส้นทางโค้ดต่างกัน. ถ้าคำขอหนึ่งอัปเดต
accountsก่อนordersแต่คำขออื่นทำสลับกัน คุณเพิ่มโอกาสเกิด deadlock ภายใต้ภาระงานสูง - เก็บธุรกรรมไว้นานเกินไป. ธุรกรรมยาวบล็อกการเขียน ชะลอการทำความสะอาด vacuum และสร้าง timeouts ที่สับสน
ตัวอย่างชัดเจน: ใน checkout คุณจองสต็อก สร้างคำสั่งซื้อ แล้วเรียกเก็บเงิน หาก charge card อยู่ภายในธุรกรรม คุณอาจถือล็อกสต็อกขณะรอเครือข่าย ถ้า charge สำเร็จแต่ธุรกรรมต่อมาถูก rollback คุณชาร์จลูกค้าโดยไม่มีคำสั่งซื้อ
รูปแบบที่ปลอดภัยกว่า: ให้ธุรกรรมมุ่งที่ state ของฐานข้อมูลเท่านั้น (จองสต็อก สร้างคำสั่งซื้อ บันทึก payment pending), commit, แล้วเรียก API ภายนอก แล้วเขียนผลลัพธ์ในธุรกรรมสั้น ๆ อีกครั้ง หลายทีมทำแบบนี้ด้วยสถานะ pending ง่าย ๆ และงาน background
เช็คลิสต์ด่วนสำหรับการทำงานแบบทั้งหมดหรือไม่มีเลย
เมื่อเวิร์กโฟลว์มีหลายขั้นตอน (insert, update, charge, send) เป้าหมายคือ: ทุกอย่างถูกบันทึก หรือไม่มีอะไรเลย
ขอบเขตธุรกรรม
เก็บการเขียนฐานข้อมูลที่จำเป็นทั้งหมดไว้ในธุรกรรมเดียว หากขั้นตอนใดล้มเหลว ให้ rollback และทิ้งข้อมูลเหมือนก่อนเริ่ม
กำหนดเงื่อนไขสำเร็จอย่างชัดเจน เช่น: "คำสั่งซื้อถูกสร้าง สต็อกถูกจอง และสถานะการชำระเงินถูกบันทึก" สิ่งใดที่ไม่ถึงจะถือว่าเป็นเส้นทางล้มเหลวและต้อง abort ธุรกรรม
- การเขียนที่จำเป็นทั้งหมดเกิดภายใน
BEGIN ... COMMITบล็อกเดียว - มีสถานะ "เสร็จ" ชัดเจนในฐานข้อมูล (ไม่ใช่แค่ในหน่วยความจำแอป)
- ทุกข้อผิดพลาดนำไปสู่
ROLLBACKและผู้เรียกได้รับผลลัพธ์ล้มเหลวที่ชัดเจน
ราวนิรภัย (เพื่อให้ retry ปลอดภัย)
สมมติว่าคำขอเดียวกันอาจถูกรันซ้ำ ฐานข้อมูลควรช่วยบังคับกฎ "ทำครั้งเดียว"
- รองรับการกระทำที่ควรทำครั้งเดียวด้วย unique constraints (หนึ่งบันทึกการชำระเงินต่อคำสั่งซื้อ, หรือการจองหนึ่งรายการต่อคำสั่งซื้อ)
- ทำให้การ retry ปลอดภัยและทำซ้ำได้ (input เดิมให้ผลลัพธ์สุดท้ายเดิม ไม่ใช่ซ้ำ)
ทำให้ธุรกรรมสั้น
ทำเฉพาะงานขั้นต่ำภายในธุรกรรม และหลีกเลี่ยงการรอเครือข่ายขณะถือล็อก
- ทำให้ธุรกรรมสั้น และตั้ง timeout เพื่อไม่ให้ค้าง
- ทำงานช้า (เช่น เรียกผู้ให้บริการชำระเงิน) นอกธุรกรรม แล้วบันทึกผลในธุรกรรมสั้นใหม่
สังเกตความล้มเหลว
ถ้าคุณมองไม่เห็นว่ามันพังตรงไหน คุณจะเดาต่อไปเรื่อย ๆ
- บันทึกขั้นตอนเวิร์กโฟลว์และ request id ทุกครั้งที่เกิดความล้มเหลว
- ติดตามอัตราการ rollback และ lock timeouts เพื่อจับความเสี่ยงของการเขียนบางส่วนตั้งแต่เนิ่น ๆ
ตัวอย่าง: โฟลว์ checkout ที่คงสภาพถูกต้องเมื่อเกิดความล้มเหลว
การ checkout มีหลายขั้นตอนที่ควรย้ายพร้อมกัน: สร้างคำสั่งซื้อ จองสต็อก บันทึกความพยายามชำระเงิน แล้วมาร์กสถานะคำสั่งซื้อ
สมมติผู้ใช้กด Buy สำหรับ 1 ชิ้น
โฟลว์ที่ปลอดภัย (งานฐานข้อมูลเป็นหน่วยเดียว)
ภายในธุรกรรมเดียว ทำเฉพาะการเปลี่ยนแปลงฐานข้อมูล:
- แทรกแถว
ordersด้วยสถานะpending_payment - จองสต็อก (เช่น ลด
inventory.availableหรือสร้างแถวreservations) - แทรกแถว
payment_intentsพร้อมidempotency_keyที่ลูกค้าส่งมา (unique) - แทรกแถว
outboxเช่น "order_created"
หากคำสั่งใดล้มเหลว (สินค้าหมด ข้อจำกัดผิด สคริปต์พัง) Postgres จะ rollback ธุรกรรมทั้งหมด คุณจะไม่จบด้วยคำสั่งซื้อแต่ไม่มีการจอง หรือการจองโดยไม่มีคำสั่งซื้อ
ถ้าการชำระเงินล้มเหลวกึ่งกลางล่ะ?
ผู้ให้บริการชำระเงินอยู่นอกฐานข้อมูล ให้ถือเป็นขั้นตอนแยกกัน
หากการเรียก API ล้มเหลวก่อน commit ยกเลิกธุรกรรมแล้วไม่มีอะไรถูกเขียน หากการเรียกล้มเหลวหลัง commit ให้รันธุรกรรมใหม่เพื่อมาร์กความพยายามชำระเงินว่า failed, ปล่อยการจอง, และตั้งสถานะคำสั่งซื้อเป็น canceled
Retry โดยไม่สร้างคำสั่งซื้อที่สอง
ให้ไคลเอนต์ส่ง idempotency_key ต่อการพยายาม checkout บังคับด้วย unique index บน payment_intents(idempotency_key) (หรือบน orders หากต้องการ) เมื่อ retry โค้ดจะดูก่อนว่ามีแถวอยู่แล้วแล้วดำเนินการต่อแทนการแทรกใหม่
อีเมลและการแจ้งเตือน
อย่าส่งอีเมลภายในธุรกรรม เขียนบันทึก outbox ในธุรกรรมเดียวกัน แล้วให้ worker หลัง commit เป็นคนส่งอีเมล วิธีนี้คุณจะไม่ส่งอีเมลสำหรับคำสั่งซื้อที่ถูก rollback
ขั้นตอนต่อไป: นำไปใช้กับเวิร์กโฟลว์หนึ่งอันในสัปดาห์นี้
เลือกเวิร์กโฟลว์ที่แตะมากกว่าหนึ่งตาราง: สมัคร + คิวอีเมลต้อนรับ, checkout + สต็อก, invoice + ledger, หรือสร้างโปรเจกต์ + การตั้งค่าเริ่มต้น
เขียนขั้นตอนก่อน แล้วเขียนกฎที่ต้องเป็นจริงเสมอ (invariants) ตัวอย่าง: "คำสั่งซื้อจะถูกจ่ายและจองทั้งหมด หรือไม่จ่ายและไม่จองเลย ห้ามครึ่งจอง" แปลงกฎเหล่านั้นเป็นหน่วยทั้งหมดหรือไม่มีเลย
แผนง่าย ๆ:
- ระบุ SQL operations ที่แน่นอนตามลำดับ (reads, inserts, updates, deletes).
- เพิ่มข้อจำกัดในฐานข้อมูลที่ขาด (unique keys, foreign keys, check constraints).
- เพิ่ม idempotency key สำหรับคำขอเพื่อให้ retry ไม่สร้างซ้ำ
- ห่อขั้นตอนในธุรกรรมเดียวและกำหนดจุดสำเร็จให้ชัดเจน (commit ก็ต่อเมื่อผ่านการตรวจสอบทุกอย่าง)
- ตัดสินใจว่าการ retry ที่ปลอดภัยเป็นอย่างไร (idempotency key เดิม ผลลัพธ์เดิม)
จากนั้นทดสอบกรณีไม่สวยโดยเจตนา จำลองการล้มหลังขั้นตอนที่ 2, หมดเวลาก่อน commit, และการกดส่งซ้ำจาก UI เป้าหมายคือผลลัพธ์ที่น่าเบื่อ: ไม่มีแถวโดดเดี่ยว ไม่มีการเก็บเงินซ้ำ ไม่มีสถานะค้างตลอดไป
ถ้าคุณกำลังทำโปรโตไทป์ การร่างเวิร์กโฟลว์ในเครื่องมือวางแผนก่อนจะช่วยให้คุณ iterate boundary ของธุรกรรมและข้อจำกัดได้สะดวกขึ้น ตัวอย่างเช่น Koder.ai (koder.ai) มี Planning Mode และรองรับ snapshots กับ rollback ซึ่งอาจเป็นประโยชน์ขณะคุณปรับจูนขอบเขตธุรกรรมและข้อจำกัด
ทำสิ่งนี้กับเวิร์กโฟลว์หนึ่งอันในสัปดาห์นี้ อันที่สองจะเร็วขึ้นมาก
คำถามที่พบบ่อย
ควรใช้ทรานแซกชันของ Postgres เมื่อใด?
ใช้ทรานแซกชันเมื่อการทำงานหนึ่งครั้งของผู้ใช้เปลี่ยนหลายแถวหรือตารางที่ต้องสอดคล้องกัน ตัวอย่างทั่วไป ได้แก่ การชำระเงิน การโอนเงินระหว่างบัญชี การสมัครใช้งานพร้อมกำหนดบทบาท และการจองสินค้าในสต็อก
COMMIT ทำอะไรใน Postgres?
COMMIT จะทำให้การเปลี่ยนแปลงทั้งหมดในทรานแซกชันถาวรและมองเห็นได้จากเซสชันฐานข้อมูลอื่น ควร COMMIT หลังจากการตรวจสอบและการเขียนข้อมูลที่จำเป็นทั้งหมดสำเร็จแล้วเท่านั้น
จะเกิดอะไรขึ้นเมื่อใช้ ROLLBACK?
ROLLBACK จะยกเลิกทรานแซกชันและลบการเปลี่ยนแปลงที่ยังไม่ได้บันทึก โค้ดของคุณควร ROLLBACK เมื่อขั้นตอนที่จำเป็นขั้นตอนใดล้มเหลว แล้วส่งคืนข้อผิดพลาดที่ผู้เรียกจัดการได้
คิวรีทั้งหมดในทรานแซกชันต้องใช้การเชื่อมต่อเดียวกันหรือไม่?
ให้การอ่านและเขียนข้อมูลของเวิร์กโฟลว์นั้นใช้การเชื่อมต่อฐานข้อมูลเดียวกันตั้งแต่ BEGIN จนถึง COMMIT หรือ ROLLBACK การเชื่อมต่อที่แยกกันจะสร้างทรานแซกชันแยกกัน
ควรเรียก payment API หรือ email API ภายในทรานแซกชันหรือไม่?
ไม่ ทรานแซกชันช่วยให้การเปลี่ยนแปลงในฐานข้อมูลอยู่ด้วยกัน แต่ไม่สามารถย้อนกลับการเรียกเก็บเงินจากบัตร อีเมล หรือการเรียก third-party API ได้ ให้ COMMIT สถานะในฐานข้อมูลก่อน บันทึกเหตุการณ์ outbox หรือสถานะรอดำเนินการ แล้วจึงทำงานภายนอกแยกต่างหาก
จะป้องกันไม่ให้ผู้ใช้สองคนซื้อสินค้าชิ้นสุดท้ายพร้อมกันได้อย่างไร?
ล็อกแถวก่อนตรวจสอบและอัปเดตค่าที่ใช้ร่วมกัน เช่น จำนวนสินค้าในสต็อก ตัวอย่างเช่น เลือกแถวสต็อกด้วย FOR UPDATE ตรวจสอบจำนวนสินค้า ลดจำนวนลง และสร้างคำสั่งซื้อก่อน COMMIT
การลองใหม่จะหลีกเลี่ยงคำสั่งซื้อซ้ำได้อย่างไร?
กำหนด idempotency key ที่คงที่ให้แต่ละคำขอ และบังคับใช้ด้วย unique constraint เมื่อการลองใหม่ชนกับ constraint นี้ ให้โหลดและส่งคืนผลลัพธ์เดิมแทนการแทรกคำสั่งซื้อหรือบันทึกการชำระเงินอีกครั้ง
เหตุใดการลองใหม่จึงต้องเริ่มทรานแซกชันใหม่?
ให้ลองใหม่ตั้งแต่ต้นในทรานแซกชันใหม่ หลังคำสั่งเกิดข้อผิดพลาด Postgres จะทำเครื่องหมายทรานแซกชันปัจจุบันว่า aborted จึงไม่สามารถทำงานต่อได้อย่างปลอดภัยจนกว่าจะ ROLLBACK
ข้อจำกัดของฐานข้อมูลใดช่วยป้องกันข้อมูลไม่สอดคล้องกัน?
ใช้ foreign key สำหรับการอ้างอิงที่ถูกต้อง ใช้ NOT NULL สำหรับฟิลด์ที่จำเป็น ใช้ CHECK constraint สำหรับค่าที่ถูกต้อง และใช้ unique constraint สำหรับระเบียนที่ต้องมีได้เพียงครั้งเดียว กฎเหล่านี้ช่วยปกป้องข้อมูลแม้โค้ดแอปพลิเคชันมีบั๊ก
เหตุใดทรานแซกชันจึงควรมีระยะเวลาสั้น?
ให้ทรานแซกชันมุ่งเน้นงานฐานข้อมูลที่รวดเร็ว ทรานแซกชันที่ยาวนานจะถือครองล็อก เพิ่มเวลารอ และอาจทำให้เกิด deadlock หรือ timeout โดยเฉพาะหากต้องรอการเรียกผ่านเครือข่าย