3 นาที

Postgres transactions สำหรับเวิร์กโฟลว์หลายขั้นตอน: รูปแบบปฏิบัติ

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

Postgres transactions สำหรับเวิร์กโฟลว์หลายขั้นตอน: รูปแบบปฏิบัติ

ทำไมการอัปเดตหลายขั้นตอนมักไม่สอดคล้องกัน

ฟีเจอร์จริง ๆ มักไม่ใช่การอัปเดตฐานข้อมูลทีเดียวจบรอบเดียว แต่เป็นห่วงโซ่สั้น ๆ: แทรกแถว, อัปเดตยอดคงเหลือ, เปลี่ยนสถานะ, เขียนบันทึกตรวจสอบ, อาจคิวงานหลังบ้าน บางครั้งจะเกิดการเขียนบางส่วนเมื่อมีเพียงไม่กี่ขั้นตอนที่ถูกบันทึกลงฐานข้อมูลเท่านั้น

ปัญหานี้ปรากฏเมื่อมีบางอย่างขัดจังหวะห่วงโซ่: เซิร์ฟเวอร์ 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 โดยไม่ใช้ศัพท์เทคนิค

Test failure cases safely
Experiment with transactions and rollbacks confidently with snapshots while you iterate.

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 การร้องขอครั้งที่สองจะคืนคำสั่งซื้อที่สร้างแล้วและข้ามการจองที่สองแทนที่จะทำงานซ้ำ

ใช้ฐานข้อมูลบังคับกฎ แทนการพึ่งเฉพาะโค้ด

Keep full control
Get source code you can review, customize, and audit for transaction safety.

ธุรกรรมช่วยจับกลุ่มเวิร์กโฟลว์หลายขั้นตอน แต่ไม่ทำให้ข้อมูลถูกต้องโดยอัตโนมัติ วิธีที่แข็งแรงคือทำให้สถานะ "ผิด" เป็นเรื่องยากหรือเป็นไปไม่ได้ในฐานข้อมูล แม้โค้ดผิดพลาดก็ยังถูกป้องกันได้บางส่วน

เริ่มจากราวบันไดความปลอดภัยพื้นฐาน 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 ที่คงสภาพถูกต้องเมื่อเกิดความล้มเหลว

Apply this to checkout
Create an order flow that stays correct under timeouts, retries, and concurrency.

การ 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 โดยเฉพาะหากต้องรอการเรียกผ่านเครือข่าย

Related posts