Animated Architecture Patterns

Compensating Transaction

ย้อนขั้นที่ทำเสร็จไปแล้ว ตอนที่งานหลายขั้นพังกลางทาง

หรือที่เรียกกันว่า Compensating action, Compensation

sold this trip held on sale Booking workflow trip T-1042 trip T-1043 trip T-1044 ● worker running ✕ worker crashed ↻ new worker reads log planbook flight → book hotel → book car undocancel hotel → cancel flight (newest first) planhold seat → hold room → hold car → pay + ticket hardest to undo never reached durable step log undo with 1 2 3 book flight↩︎ cancel flight ✓ FL-381 ✓ FL-412 ↩︎ cancelled · $40 fee hold seat✓ held↩︎ just expires book hotel↩︎ cancel hotel ✓ H-5521 ✓ H-5530 ↩︎ cancelled    attempt 1 of 3    attempt 2 of 3    attempt 3 of 3 → manual queue hold room✓ held↩︎ just expires book car hold car ✕ none leftnothing to undo lapsed by itself lapsed by itself each entry is saved before the next step runs ✕ no transaction spans the three systems: nothing to roll back saved before each next step, so a new worker knows what is left each retry reuses one key: cancelling twice = one cancellation each hold expires on its own unless the workflow confirms it a failure just means not confirming: the holds lapse by themselves Flights airline's own system seats seat off sale back on sale held · 15 min FL-381 booked FL-412 booked FL-430 held · 15 min FL-381 cancelled · fee $40 FL-412 cancelled · fee $40 FL-430 hold lapsed no response Hotel hotel's own system rooms room off sale back on sale held · 15 min H-5521 booked H-5530 booked H-5540 held · 15 min H-5521 cancelled H-5540 hold lapsed Car rental rental firm's own system cars 0 left no car available Itinerary T-1042 T-1043 T-1044 BOOKING… CAR FAILED CANCELLING… CANCELLED NEEDS ATTENTION HOLDING… NOT BOOKED flight ✓ · hotel ✓ · car ✕ refunded, less a $40 fee a person will finish it no charge, nothing to undo Manual intervention queue for a person empty on-call alerted T-1043 · cancel hotel H-5530 3 attempts, all timed out key cxl-T1043-hotel

ขั้นที่ 1 · Rollback ไม่ได้ จองทริปหนึ่งครั้งคือจองเที่ยวบิน แล้วก็โรงแรม แล้วก็รถ แต่ละอย่างอยู่กับผู้ให้บริการคนละเจ้า และ commit ในระบบของเจ้านั้นเอง เที่ยวบินกับโรงแรมยืนยันแล้ว แต่จองรถไม่ได้เพราะไม่เหลือสักคัน ไม่มี transaction ไหนคลุมทั้งสามเจ้า เลยไม่มีอะไรให้ rollback: สองรายการแรกจองไปจริง ๆ แล้ว ที่นั่งกับห้องพักก็ปิดขายให้คนอื่นไปแล้ว

ขั้นที่ 2 · ย้อนด้วยการ compensate Workflow รัน compensating action ให้ทุกขั้นที่ทำเสร็จแล้ว เริ่มจากขั้นล่าสุดก่อน: ยกเลิกโรงแรม แล้วค่อยยกเลิกเที่ยวบิน compensation คือ business operation ใหม่ ไม่ใช่การกู้ข้อมูลเก่ากลับมา: ที่นั่งกลับไปเปิดขาย สายการบินเก็บค่ายกเลิก $40 และการจองเดิมก็ยังอยู่ในประวัติ ส่วน workflow จดแต่ละขั้นลง durable log ไว้ก่อนเริ่มขั้นถัดไป พอ worker crash กลางทาง worker ตัวใหม่ก็แค่อ่าน log แล้วรู้ว่าเหลือแค่เที่ยวบินที่ต้องย้อน

ขั้นที่ 3 · Compensation ก็พังได้ ทริปถัดมา การยกเลิกโรงแรมเจอ timeout ตัว workflow เลย retry ด้วย idempotency key เดิม ที่ทำแบบนี้ได้อย่างปลอดภัยก็เพราะการยกเลิกเป็น idempotent เท่านั้น: ยกเลิกสองรอบก็ยังเป็นการยกเลิกครั้งเดียว หลังครั้งที่สาม workflow ก็ส่งเรื่องเข้า manual-intervention queue พร้อมข้อมูลที่คนต้องใช้ปิดงานให้จบ แล้วแจ้ง on-call จากนั้นก็ยังยกเลิกเที่ยวบินต่อ และติดสถานะทริปไว้ว่า needs attention ไม่ปล่อยให้ยกเลิกค้างไว้ครึ่ง ๆ กลาง ๆ โดยไม่มีใครรู้

ขั้นที่ 4 · ออกแบบให้ย้อนได้แบบไม่แพง ออกแบบให้การย้อนไม่แพง หรือไม่ต้องย้อนเลย กันที่นั่งกับห้องพักไว้ 15 นาที แล้วค่อยเก็บเงินกับออกตั๋วเป็นอย่างสุดท้าย ตอนที่กันได้ครบทุกอย่างแล้ว: ในตัวอย่างนี้จองรถไม่ได้ ยังไม่ได้ยืนยันอะไรเลย ที่กันไว้ก็หมดเวลาไปเอง ไม่มีอะไรต้อง compensate แต่ผลบางอย่างก็ย้อนคืนไม่ได้เลย (อีเมลถูกอ่านไปแล้ว เงินจ่ายออกไปแล้ว) compensation ของมันเลยเป็นการแก้ไขแทน เช่น ขอโทษหรือคืนเงิน

ปัญหา

การจองทริปหนึ่งครั้งไปแตะสามบริษัท: สายการบิน โรงแรม และบริษัทรถเช่า แต่ละเจ้า commit การจองในระบบของตัวเอง และไม่มีเจ้าไหนยอมเข้าร่วม transaction ที่เราคุมอยู่ พอการจองตัวที่สามล้มเหลว สองตัวแรกก็เกิดขึ้นจริงไปแล้ว: ที่นั่งกับห้องพักปิดขายให้คนอื่นแล้ว และเงินก็อาจโดนตัดไปแล้วด้วย ไม่มี transaction ไหนให้ rollback

แม้จะอยู่ใน database เดียวกัน step ที่ commit ไปเมื่อหลายนาทีก่อนก็ใส่คืนกลับง่าย ๆ ไม่ได้: ระหว่างนั้นอาจมีงานอื่นอ่านข้อมูลนั้นแล้วต่อยอดไปแล้ว การเขียนค่าเก่ากลับลงไปจะทับการเปลี่ยนแปลงพวกนั้น การย้อนเลยต้องเป็น business operation ที่ตั้งใจทำ

ทำงานยัง⁠ไง

compensating transaction ย้อน step ที่ทำเสร็จแล้วด้วย operation ใหม่ที่กลับผลทางธุรกิจของมัน: การยกเลิกสำหรับการจอง การคืนเงินสำหรับการเรียกเก็บเงิน รายการแก้ไขสำหรับการลงบัญชี paper เรื่อง saga ของ Garcia-Molina กับ Salem ในปี 1987 วางกรอบไว้แบบนี้ตั้งแต่แรก: compensation ย้อนผลของ step ในเชิงความหมาย โดยไม่ได้สัญญาว่าจะใส่ข้อมูลกลับไปให้เหมือนเดิมเป๊ะ เพราะแบบนี้ compensation เลยขึ้นกับแต่ละแอปพลิเคชัน การยกเลิกอาจมีค่าธรรมเนียม การคืนเงินอาจคืนแค่บางส่วน และ record เดิมก็ยังอยู่ในประวัติข้าง ๆ record ที่กลับผลของมัน

  • บันทึกความคืบหน้าให้ durable ก่อนรัน step ถัดไป ให้จดไว้ว่า step ไหนเสร็จแล้วบ้าง และอะไรใช้ย้อนแต่ละ step ถ้า worker crash ตัวที่มาแทนก็อ่าน record นั้นแล้วรู้ว่าเหลืออะไรต้องทำ workflow engine เก็บ record นี้ไว้ให้: durable execution ของ Temporal ทำให้ compensation รันต่อไปได้แม้ worker ล้ม, Azure Durable Functions เก็บประวัติของแต่ละ orchestration ไว้ใน store แบบ append-only ที่เป็น event-sourced แล้ว replay มันหลัง restart และ workflow แบบ Standard ของ AWS Step Functions ก็เก็บ execution state ไว้ระหว่าง step (ส่วน workflow แบบ Express ไม่เก็บ)
  • เลือกลำดับ การย้อน step ล่าสุดก่อนเป็นค่า default ที่ใช้กันทั่วไป และ saga helper ของ Temporal ก็รัน compensation ย้อนลำดับกับที่ register ไว้ guidance ของ Microsoft เสริมว่าลำดับไม่ต้องย้อนกลับเป๊ะก็ได้: ย้อนอะไรที่อ่อนไหวกับความไม่สอดคล้องที่สุดก่อน และรัน step ย้อนที่ไม่ได้พึ่งกันแบบ parallel
  • คาดไว้เลยว่า compensation ก็ล้มได้ การเรียก cancel ก็ timeout ได้เหมือนการเรียกอื่น ๆ ให้ retry มัน (ดู Retry with Backoff & Jitter) แต่จะปลอดภัยก็ต่อเมื่อ compensation เป็น idempotent: ส่ง idempotency key เดิมไปทุกครั้งที่ลอง ยกเลิกสองรอบก็ยังเหลือการยกเลิกครั้งเดียว (ดู Idempotent Consumer)
  • ส่งต่อสิ่งที่ทำให้จบไม่ได้ พอ retry หมดแล้ว ให้ส่ง alert และเอาเคสเข้า queue ให้คนจัดการ พร้อมข้อมูลทริป เลขอ้างอิงการจอง สิ่งที่ย้อนไปแล้ว และ error ล่าสุด operation ที่ย้อนไปครึ่งเดียวโดยไม่มีใครรู้แย่กว่าตัวที่รออยู่ใน queue มาก

ใช้ตอนไหนดี

  • operation ครอบคลุมหลาย service, data store หรือ API ของ third party ที่ใช้ transaction แบบ atomic ร่วมกันไม่ได้ และ step ที่ทำเสร็จแล้วต้องย้อนเมื่อ step ทีหลังล้มเหลว saga ทุกตัวต้องมีสิ่งนี้: ดู Saga (Orchestration) และ Saga (Choreography) มันยังเป็นราคาที่ต้องจ่ายของ Database per Service ด้วย: พอแต่ละ service เป็นเจ้าของข้อมูลของตัวเอง ก็ไม่มี transaction เดียวที่คลุม business operation ที่ข้ามหลาย service ได้
  • การย้อนต้องใช้ business rule (ค่าธรรมเนียม การคืนเงินบางส่วน ข้อความถึงลูกค้า) ไม่ใช่แค่กู้ข้อมูลเก่ากลับมา
  • workflow ที่รันนาน ๆ ที่ถือ lock ไว้ตลอดทั้งช่วงไม่ได้

อย่าใช้ถ้าทุกอย่างอยู่ใน database เดียว: local transaction ให้ rollback จริงมาฟรี ๆ ถ้าความล้มเหลวเป็นแบบชั่วคราวและ step จะสำเร็จในที่สุด ให้ใช้ retry ธรรมดาดีกว่า และถ้าธุรกิจทนความไม่สอดคล้องชั่วคราวไม่ได้ ก็ให้ใช้ atomic transaction ดีกว่า ก่อน compensate ให้ถามด้วยว่าเดินหน้าต่อจะดีกว่าไหม: ตัวอย่างทริปใน guidance ของ Azure เองเสนอโรงแรมอื่นให้ลูกค้าแทนการยกเลิกเที่ยวบิน และให้ลูกค้าเป็นคนเลือก

ได้อะไร เสียอะไร

  • คนอื่นเห็น state ระหว่างทาง จนกว่า compensation จะรัน ที่นั่งก็เต็มสำหรับผู้เดินทางคนอื่น และยอดเงินก็ค้างอยู่บนบัตร saga ไม่มี isolation ทีมต่าง ๆ เลยเพิ่มมาตรการรับมือ: semantic lock ที่ mark ว่า record กำลังอยู่ระหว่างทำ (ตัวอย่าง saga ของ Richardson สร้าง order ไว้ใน state PENDING), pessimistic view ที่จัดลำดับ saga ใหม่ให้การ update ไปตกอยู่ใน step ที่ retry ได้ตอนท้าย, การอ่านค่าใหม่อีกรอบก่อนเขียนทับ และการ update ที่สลับลำดับกันได้ หนังสือ Microservices Patterns ของ Chris Richardson อธิบายเรื่องพวกนี้ไว้ละเอียด
  • การย้อนแทบไม่เคยสมบูรณ์ ค่าธรรมเนียมยังอยู่ อีเมลถูกอ่านไปแล้ว เงินจ่ายออกไปแล้ว พัสดุส่งไปแล้ว สำหรับพวกนี้ compensation คือการแก้ไข: คำขอโทษ การคืนเงิน หรือใบลดหนี้
  • Logic เป็นสองเท่า ทุก step ที่ย้อนได้ต้องมีตัวย้อนที่มีคนเขียน review และคอยดูแลให้ตรงกับโค้ดขาไปเสมอ แถมต้องเก็บข้อมูลไว้พอให้รันมันได้
  • Compensation รันไม่บ่อย เลยพังโดยไม่มีใครเห็น ให้ตั้งใจทดสอบมัน: ทำให้แต่ละ step ล้มทีละตัวใน integration test แล้วตรวจว่าเหลืออะไรค้างอยู่ และ inject fault ใน test environment (ดู Chaos Engineering)
  • ต้องมี observability เชื่อมแต่ละ operation กับ compensation ของมันตั้งแต่ต้นจนจบ alert เมื่อ compensation ล้มเหลว และมี dashboard ของ instance ที่ค้างอยู่หรือรอคนมาจัดการ

ข้อควรรู้ตอนลงมือทำ

  • เอา step ที่ย้อนยากที่สุดไว้ท้ายสุด Chris Richardson แบ่ง step ของ saga เป็น step แบบ compensatable ที่ย้อนได้, pivot ที่เป็นจุดที่ย้อนกลับไม่ได้แล้ว และ step แบบ retriable ที่อยู่หลังจากนั้น โดย step แบบนี้ทำผิด business rule ไม่ได้ และแค่ retry ไปจนกว่าจะสำเร็จ (guidance เรื่อง saga ของ Microsoft เรียกว่า compensable, pivot และ retryable) ในทริปนี้ การกันที่ไว้เป็นแบบ compensatable การเก็บเงินและออกตั๋วคือ pivot ส่วนการส่งอีเมลยืนยันเป็นแบบ retriable
  • กันไว้ก่อน แล้วค่อยยืนยัน การกันที่ที่หมดเวลาไปเองเปลี่ยนความล้มเหลวให้กลายเป็นการไม่ทำอะไรเลย Microsoft แนะนำ lock ระยะสั้นที่มี timeout บนแต่ละ resource ยึดไว้ก่อนทำงานจริงและยืนยันให้เสร็จก่อนมันหมดเวลา Pat Helland อธิบาย operation แบบ tentative ที่จบด้วยการยืนยันหรือการยกเลิกอย่างใดอย่างหนึ่งเสมอ ส่วนแบบ Try-Cancel/Confirm (TCC) ของ Guy Pardon กับ Cesare Pautasso สำหรับ REST API ให้ผู้ร่วมแต่ละรายยกเลิกเองถ้าไม่ได้ยินอะไรเลยก่อนถึง timeout ของตัวเอง
  • เก็บสิ่งที่การย้อนต้องใช้ไว้กับ step: เลขอ้างอิงการจอง จำนวนเงิน และ idempotency key ส่วน Temporal ให้ register compensation ได้ก่อน step ของมันจะรัน ทำให้ step ที่ตายไปครึ่งทางก็ยังมี compensation คุ้มครองอยู่ แต่แบบนี้ compensation ก็ต้องรับมือกับ step ที่ไม่เคยเกิดขึ้นได้ด้วย
  • ตั้ง retry policy ให้ compensation ใน AWS Step Functions ตัว block Retry (ค่า default คือ retry สามครั้ง ครั้งแรกหลังหนึ่งวินาที แล้ว interval ก็เพิ่มเป็นสองเท่าทุกครั้ง) จะรันก่อนที่ Catch จะส่ง error ไปที่ state ถัดไป เช่น compensation หรือการ escalate ส่วน Temporal แนะนำว่าอย่าตั้ง timeout ระดับ workflow เพื่อให้ compensation retry ต่อไปจนกว่าจะสำเร็จ ขณะที่ตัวอย่างของ Azure จำกัดจำนวน retry แล้วย้าย message ไป dead-letter queue พร้อม alert ไม่ว่าจะแบบไหน ต้องมีคนรู้เรื่อง
  • Ledger กับ event store ไม่เคยลบ งานบัญชีแก้การลงบัญชีที่ผิดด้วยรายการแก้ไข และระบบที่เป็น event-sourced ก็ append compensating event (เช่น ReservationCanceled) แทนการลบตัวเดิม: ดู Event Sourcing
  • ส่งคำสั่งย้อนให้ไว้ใจได้ ถ้า compensation publish message ให้เขียนผ่าน transactional outbox การย้อนจะได้ไม่หายไประหว่าง database กับ broker