ทำอย่างไรให้เงินไม่หายแม้บริการจะล่มระหว่างการโอน — รักษาความสอดคล้องของบัญชีที่กระจายตัวด้วย Saga และ Outbox

マイクロサービス 分散トランザクション Saga Outbox 金融

บทนำ

ฉันกำลังพัฒนาระบบธนาคารที่สร้างขึ้นเอง โดยเฉพาะส่วนการโอนเงินระหว่างบัญชี ภาพรวมทั้งหมดของระบบเขียนไว้ใน ระบบธนาคารที่ฉันสร้างเองกำลังทำงานอะไรอยู่จริงๆ

การโอนเงิน หากแยกให้ถึงที่สุดแล้วมีสองขั้นตอน คือ หักจากต้นทาง และ เพิ่มให้ปลายทาง นี่คือเรื่องราวว่าฉันทำให้สองขั้นตอนนี้ “ไม่ทำให้เงินหายหรือเงินเพิ่มขึ้นเอง” ได้อย่างไร ไม่ว่าระบบจะล่มระหว่างทางกี่ครั้งก็ตาม จุดนี้คือจุดที่ถ้าเขียนแบบง่ายๆ จะมีรูโหว่แน่นอน และฉันปิดรูโหว่นั้นด้วย Saga และ Outbox


ทันทีที่แยกเป็นบริการ การโอนเงินก็เสี่ยงอันตราย

ในเวอร์ชันแรก การโอนเงินเสร็จสมบูรณ์อยู่ภายในโมดูลบัญชีเดียว “การหัก” และ “การเพิ่ม” ทำใน ทรานแซกชันเดียวของฐานข้อมูลเดียวกัน ดังนั้นแม้จะล่มระหว่างทาง ทั้งสองอย่างก็จะถูกยกเลิกไปพร้อมกันเสมือนไม่เคยเกิดขึ้น ปลอดภัย

แต่ทันทีที่แยกบัญชีออกเป็นบริการอิสระ เรื่องราวก็เปลี่ยนไป “การหัก” และ “การเพิ่ม” ถูกแยกเป็นคนละบริการคนละทรานแซกชัน และหากล่มระหว่างนั้น — อาจเกิดกรณีที่หักจากต้นทางไปแล้ว แต่ไม่ถึงปลายทาง เงินหายไปในอากาศ

การโอนเงิน = หักจากต้นทาง + เพิ่มให้ปลายทาง

【ก่อนแยก:ทรานแซกชันเดียวของ DB เดียวกัน】
   หัก+เพิ่ม ─▶ สำเร็จทั้งคู่ หรือยกเลิกทั้งคู่ (ปลอดภัย)

【หลังแยก:แยกเป็นบริการ】
   บริการชำระเงิน ─▶ หักที่บัญชี (ต้นทาง) ─▶ เพิ่มที่บัญชี (ปลายทาง)
                        │                      │
                     ถ้าล่มตรงนี้… เงินหายจากต้นทาง แต่ไม่ถึงปลายทาง ×

ไม่ใช่ two-phase commit แต่เป็น Saga และการชดเชย

วิธีคลาสสิกในการรวมทรานแซกชันที่กระจายตัวเข้าด้วยกันคือ two-phase commit (2PC) ซึ่งเป็นวิธียืนยันฐานข้อมูลหลายตัวพร้อมกัน “นับสามแล้วไปพร้อมกัน” คือถามทุกคนก่อนว่า “พร้อมหรือยัง?” แล้วเมื่อทุกคนตอบว่า “OK” ถึงจะสั่ง “ยืนยัน!” พร้อมกันทั้งหมด วิธีนี้แน่นอน แต่ระหว่างนั้น ต้องล็อกทุกคนไว้รอ จึงช้าและหนัก และถ้ามีแม้แต่คนเดียวล่มหรือไม่ตอบกลับ ทั้งระบบก็จะค้าง ไม่เข้ากันกับไมโครเซอร์วิสเท่าไหร่

ฉันเลือกใช้ Saga แทน คือเลิก “นับสามแล้วไปพร้อมกัน” แล้ว ยืนยันแต่ละขั้นตอนทีละขั้นในฐานข้อมูลของตัวเองแล้วเดินหน้าต่อไป หากล้มเหลวระหว่างทาง จะ ย้อนขั้นตอนที่ทำไปแล้วกลับทีละขั้นด้วยการดำเนินการย้อนกลับ เรียกการดำเนินการย้อนกลับนี้ว่า การชดเชย (compensation) การชดเชยของ “หักจากต้นทาง” คือ “คืนกลับไปที่ต้นทาง (เพิ่มกลับเข้าไป)” — ไม่ใช่การยกเลิก แต่เป็นการปรับให้ลงตัวด้วยการดำเนินการตรงกันข้าม ดังนั้นหาก “หัก” เสร็จแล้วแต่ “เพิ่ม” ล้มเหลว การชดเชยจะหักล้าง “การหัก” นั้นทิ้ง

① หักจากต้นทาง (ทรานแซกชันภายใน) ── สำเร็จ
② เพิ่มให้ปลายทาง
     ├─ สำเร็จ ─────────▶ การโอนเสร็จสมบูรณ์
     └─ ล้มเหลว ─▶ ③ ชดเชย:คืนกลับไปที่ต้นทาง (หักล้าง ①)

Timeout ไม่ใช่ “ความล้มเหลว”

ตรงนี้มีรูโหว่ที่ถ้าเขียนแบบง่ายๆ จะพลาดแน่นอน คือ จะเริ่มการชดเชยเมื่อไหร่

ถ้า “เพิ่ม” ล้มเหลวก็ชดเชยคืนกลับ — ฟังดูถูกต้อง แต่ “ความล้มเหลว” มีสองแบบ

  • ความล้มเหลวที่ชัดเจน (ได้รับ 4xx / 5xx กลับมา): อีกฝ่ายไม่ได้ประมวลผลแน่นอน คืนกลับได้
  • Timeout (ไม่มีการตอบกลับมาเลย): นี่ไม่ใช่ความล้มเหลว แต่เป็น ผลลัพธ์ที่ไม่ทราบแน่ชัด อีกฝ่ายอาจจะสำเร็จไปแล้วก็ได้

หากตัดสินว่า timeout คือ “ความล้มเหลว” แล้วยิงการชดเชย (คืนกลับไปที่ต้นทาง) หากปรากฏว่า อีกฝ่ายสำเร็จไปแล้วจริงๆ ปลายทางก็ได้รับเงินเพิ่ม แต่ต้นทางก็ได้เงินคืนด้วย — เงินจะเพิ่มขึ้นเป็นสองเท่า พังไปในทิศทางตรงกันข้าม

try {
    accounts.deposit(toAccount, amount, idemKey);     // เพิ่มให้ปลายทาง
} catch (AccountsClientException e) {                 // 4xx/5xx = ความล้มเหลวที่ชัดเจน
    compensate(fromAccount, amount);                  // คืนกลับไปที่ต้นทาง (ชดเชยได้)
} catch (AccountsUnavailableException e) {            // timeout = ผลลัพธ์ไม่ทราบแน่ชัด
    // ห้ามคืนกลับง่ายๆ ให้ตรวจสอบผลลัพธ์จริงและระงับไว้ก่อนจนกว่าจะยืนยันได้
    markPendingForReconciliation(transferId);
}

แยก “ความล้มเหลวที่ชัดเจน” กับ “ผลลัพธ์ที่ไม่ทราบแน่ชัด” ออกจากกัน แล้วตัดสินใจว่าจะเริ่มการชดเชยหรือไม่จากตรงนั้น หากรวมสองอย่างนี้เข้าด้วยกัน จะพังได้ทั้งสองทิศทาง ทั้งเงินหายและเงินเพิ่มเป็นสองเท่า


เชื่อมการเปลี่ยนสถานะกับอีเวนต์ให้แน่นอนด้วย Outbox

ยังมีรูโหว่อีกจุดที่ระบบกระจายมักพลาด คือ จะทำอย่างไรให้ “อัปเดตยอดคงเหลือ” กับ “แจ้งบริการอื่นว่า ‘การโอนเสร็จสมบูรณ์’” เกิดขึ้นทั้งคู่โดยไม่มีอะไรตกหล่น

ถ้าทำแบบง่ายๆ คืออัปเดตยอดคงเหลือก่อน แล้วค่อยส่งอีเวนต์แยกต่างหาก แต่ถ้าล่มระหว่างสองขั้นตอนนี้ ยอดคงเหลือจะเปลี่ยนไปแล้ว แต่อีเวนต์กลับหายไป (ไม่มีการแจ้งเตือนออกไป กระบวนการถัดไปไม่ทำงาน)

ฉันจึงใช้ Outbox ซึ่งไม่ใช่ตารางท้องถิ่นที่ฉันคิดขึ้นเอง แต่เป็น แพตเทิร์นมาตรฐาน ที่ใช้กันอย่างแพร่หลายในระบบกระจาย มีชื่อเรียกอย่างเป็นทางการว่า Transactional Outbox

วิธีการคือ แทนที่จะยิงอีเวนต์ออกไปตรงๆ จะ เขียนแถวหนึ่งลงในตารางเฉพาะ (outbox) สำหรับอีเวนต์ที่ต้องการส่ง เช่น “การโอนเสร็จสมบูรณ์” ในทรานแซกชันเดียวกันกับการอัปเดตยอดคงเหลือ วิธีนี้ทำให้การอัปเดต DB กับการบันทึกอีเวนต์สำเร็จพร้อมกันหรือล้มเหลวพร้อมกัน (ผูกกันแบบ atomic) หลังจากนั้นกระบวนการแยกต่างหาก (Relay) จะอ่าน “แถวที่ยังไม่ได้ส่ง” จากตาราง outbox นั้นแล้วส่งออกไปให้แน่นอน เมื่อส่งสำเร็จก็ทำเครื่องหมายว่าส่งแล้ว แม้จะล่มระหว่างทาง แถวก็ยังอยู่ใน outbox จึงไม่มีอะไรตกหล่น

【อันตราย】อัปเดตยอดคงเหลือ →(แยกต่างหาก)ส่งอีเวนต์ … ถ้าล่มระหว่างนั้นอีเวนต์จะหายไปอย่างเดียว
【Outbox】ทรานแซกชันเดียวกัน:อัปเดตยอดคงเหลือ + เขียน "การโอนเสร็จสมบูรณ์" ลง outbox
          → กระบวนการแยกต่างหาก (Relay) อ่าน outbox แล้วส่งให้แน่นอน (ไม่มีอะไรตกหล่น)

ด้วยวิธีนี้ การโอนเงิน — แม้จะแยกเป็นหลายบริการ แม้จะล่มระหว่างทางตรงไหนก็ตาม — เงินจะไม่หาย ไม่เพิ่มขึ้น และการแจ้งเตือนก็ไม่ตกหล่น แม้จะกระจายตัว ความสอดคล้องก็ยังรักษาได้ด้วยการออกแบบ แทนที่จะผูกให้แน่นด้วย two-phase commit ฉันเดินหน้าด้วย Saga ย้อนกลับด้วยการชดเชย และส่งให้แน่นอนด้วย Outbox ไม่สับสนระหว่าง timeout กับความล้มเหลว สามอย่างนี้กลายเป็นกระดูกสันหลังของการโอนเงินแบบกระจายตัว


บทความที่เกี่ยวข้อง

ส่งข้อความได้ตามสบาย

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