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