บทที่ 14 · Part 3 — Production & Advanced

Case Study & Checklist

สถาปัตยกรรม wallet แบบครบวงจร, ตารางแก้ปัญหา, anti-patterns และ checklist ก่อน go-live

13 บทที่ผ่านมาแยกกันอธิบายทีละเรื่อง บทนี้เอามาต่อกันเป็นระบบเดียว แล้วปิดท้ายด้วยสิ่งที่ต้องตรวจก่อนเปิดให้ลูกค้าใช้จริง

จบบทนี้คุณจะ

  • เห็นภาพรวมว่าแต่ละส่วนของระบบ wallet ใช้ pattern ไหน
  • มีตารางแก้ปัญหาที่ใช้หน้างานได้จริง
  • รู้จัก anti-pattern ที่พบบ่อยและวิธีเลี่ยง
  • มี checklist ก่อน go-live ที่ตรวจได้ทีละข้อ

สถาปัตยกรรมรวม

แยก worker เป็น 3 ชุดตามเหตุผลจากบทที่ 11: งาน orchestration เบาและเร็ว, งานคุยธนาคารต้องจำกัด rate ตามโควตา, งานกระทบยอดกินเวลานานและรันกลางดึก — ปนกันแล้วงานหนึ่งจะกิน slot ของอีกงาน

เส้นทางของ 1 ธุรกรรม

ตามรอยการเติมเงิน 500 บาทตั้งแต่ผู้ใช้กดปุ่มจนเงินเข้า

ขั้นเกิดอะไรบทที่อธิบาย
1API รับ request แล้วยิง Update-with-Start ไปที่ wallet-<id>7
2Entity workflow ของบัญชีตรวจ daily limit จาก state ในหน่วยความจำ8
3Validator ปฏิเสธทันทีถ้าเกิน limit — ไม่ถูกบันทึกลง history7
4ยอดสูงกว่าเกณฑ์ → ส่ง OTP แล้วรอ signal สูงสุด 5 นาที7
5เรียก ChargeBank บนคิว bank-io พร้อม idempotency key = transaction ID5
6ธนาคาร timeout → retry ตาม policy โดยใช้ key เดิม ไม่หักซ้ำ5
7สำเร็จ → เครดิตเข้า wallet, ล้มก่อน point of no return → compensate6
8Upsert search attribute TxnStage ทุกครั้งที่เปลี่ยนสถานะ11
9ยิง child workflow แบบ ABANDON ไปเช็ค settlement ที่ T+18
10ตี 2 Schedule ปลุกรอบกระทบยอด แตก child ต่อธนาคาร8

จุดที่ผู้เริ่มต้นมักพลาดคือขั้นที่ 6 — คิดว่า Temporal จัดการ retry ให้แล้วปลอดภัย แต่ Temporal รับประกันแค่ว่า activity จะถูกเรียกจนสำเร็จ ไม่ได้รับประกันว่าจะเรียกครั้งเดียว ความปลอดภัยเรื่องหักเงินซ้ำมาจาก idempotency key ที่เราส่งเอง

เลือก pattern ให้ถูกงาน

ส่วนของระบบpatternทำไม
เติมเงิน / ถอนเงินWorkflow ต่อ 1 ธุรกรรมอายุสั้น, pin เวอร์ชันได้, เห็นทีละรายการชัด
บัญชี walletEntity Workflow + Continue-As-Newได้ serialization ของคำสั่งฟรี ไม่ต้องล็อก DB
โอนออกธนาคารSaga + point of no returnต้อง compensate ได้ก่อนจุดที่ย้อนไม่ได้
ตรวจ settlementChild workflow แบบ ABANDON + timerอยู่ต่อหลัง parent จบ กินทรัพยากรแทบเป็นศูนย์
กระทบยอดรายวันSchedule + child ต่อธนาคารpause/backfill ได้ และธนาคารหนึ่งล้มไม่ล้มทั้งรอบ
อนุมัติโดยคนSignal (หรือ Async Activity Completion)รอได้เป็นวันโดยไม่กิน resource
ตรวจสถานะธนาคารPoll ในตัว activity หรือ retry เป็นตัวจับเวลาไม่ให้ history บวมจากการ poll
อัตราแลกเปลี่ยนLocal Activityเร็ว และทำซ้ำแล้วไม่เสียหาย

ตารางแก้ปัญหา

อาการรหัส/สัญญาณสาเหตุทำอะไร
Workflow ค้าง Running ไม่เดินTMPRL1100โค้ดใหม่ replay history เก่าไม่ตรงrollback worker ก่อน แล้วค่อยแก้ด้วย GetVersion
Workflow task fail ซ้ำTMPRL1101มีโค้ดบล็อกใน workflow (I/O, time.Sleep, mutex)ย้ายไป activity ใช้ primitive ของ Temporal
จบ workflow ไม่ได้TMPRL1102signal/update handler ยังทำงานค้างรอ workflow.AllHandlersFinished ก่อน return
ส่งข้อมูลไม่ผ่านTMPRL1103payload เกิน 2MBเก็บลง storage แล้วส่ง reference
Activity retry ไม่หยุด—error ที่ควรเป็น non-retryable ถูกจัดเป็น retryableใช้ NewNonRetryableApplicationError
ธุรกรรมช้าทั้งระบบschedule_to_start_latency สูงworker ไม่พอเพิ่ม poller หรือ scale worker
CPU worker สูงแต่งานไม่เดินsticky_cache_miss พุ่งworker restart บ่อย → replay ซ้ำลดการ restart, เพิ่ม cache size
ร้านเล็กรายการค้าง—ร้านใหญ่ครองคิวเปิด Fairness ด้วย merchant ID
ลูกค้าถูกหักเงินซ้ำ—activity ไม่ idempotentแก้ให้ส่ง idempotency key ที่ deterministic
Web UI แสดงเป็น byte—เปิด codec แล้วแต่ไม่มี codec serverตั้ง codec server ให้ UI เรียก

ลำดับการรับมือเมื่อ production มีปัญหา

  1. หยุดเลือดก่อน — rollback ให้ธุรกรรมเดินต่อได้ ยังไม่ต้องหาสาเหตุ
  2. ประเมินว่ามีเงินค้างอยู่ในสถานะกำกวมกี่รายการ
  3. ค่อยหาสาเหตุจาก history ของ execution ที่พัง

อย่าเริ่มด้วยการ terminate หรือ reset — ทั้ง 2 อย่างทำให้เสียข้อมูลสถานะ ที่ต้องใช้ตอนกระทบยอด และ reset อาจทำให้ activity รันซ้ำจนโอนเงินซ้ำ การกระทำเหล่านี้ต้องได้รับอนุมัติจากผู้มีอำนาจก่อนเสมอ

Anti-patterns

เอา business logic ที่เปลี่ยนบ่อยไว้ใน workflow

// ปัญหา: แก้ค่า fee ทีต้อง version ทีนึง
if req.AmountSatang > 10_000_00 {
	fee = req.AmountSatang * 15 / 1000
}

ย้ายไป activity แล้วเปลี่ยนกฎได้โดยไม่กระทบธุรกรรมที่ค้างอยู่ (บทที่ 10)

ใช้ workflow เป็น queue หรือ database

Workflow ไม่ได้ออกแบบมาให้เก็บ record จำนวนมาก ถ้าพบว่า state ของ workflow โตขึ้นเรื่อยๆ ตามจำนวนรายการ แสดงว่าเลือกเครื่องมือผิด

แตก child workflow เพียงเพื่อจัดระเบียบโค้ด

ฟังก์ชัน Go ธรรมดาทำได้ฟรี child workflow มีต้นทุนทั้ง event, latency และการจัดการ ID ใช้เมื่อมีเหตุผลเชิงระบบเท่านั้น (บทที่ 8)

ส่งข้อมูลก้อนใหญ่ผ่าน workflow

ทุก byte ที่ผ่าน input/output จะอยู่ใน history ตลอด retention และถูกอ่านซ้ำทุกครั้งที่ replay ส่ง reference แทน (บทที่ 11)

ตั้งแค่ ScheduleToCloseTimeout แล้วคิดว่าครบ

ถ้าไม่ตั้ง StartToCloseTimeout ด้วย activity ที่ค้างจะกิน budget รวมไปจนหมด โดยไม่มีการ retry เลย (บทที่ 5)

จับ error ทุกตัวเป็น retryable

Business rule ที่ผิด retry กี่ครั้งก็ผิดเหมือนเดิม แต่จะกิน quota ของ API ธนาคาร และหน่วงธุรกรรมจนหมดเวลา (บทที่ 6)

เชื่อว่า Temporal ทำให้ระบบ exactly-once

Temporal ให้ at-least-once สำหรับ activity ความเป็น exactly-once ต้องมาจาก idempotency ที่เราออกแบบเอง (บทที่ 5)

ใช้ Local Activity เพราะอยากให้เร็ว

มันไม่มี record ใน history จนกว่าจะเสร็จ worker ตายแล้วเริ่มใหม่หมด ห้ามใช้กับอะไรที่ทำให้เงินเคลื่อน (บทที่ 13)

Checklist ก่อน go-live

ความถูกต้องของธุรกรรม

  • ทุก activity ที่แตะเงินรับ idempotency key ที่ deterministic (ไม่ใช้ info.Attempt)
  • ทุกขั้นก่อน point of no return มี compensation และมีเทสพิสูจน์ว่าทำงาน ครั้งเดียว
  • ขั้นหลัง point of no return มีเทสยืนยันว่า ไม่ compensate
  • Error ถูกแยก retryable / non-retryable ครบทุกเส้นทาง
  • เคสที่ไม่รู้สถานะจริงของธนาคาร หยุดรอคนแทนที่จะเดา
  • จำนวนเงินเป็น integer หน่วยสตางค์ทุกที่ ไม่มี float

ความทนทาน

  • ทุก activity ตั้ง StartToCloseTimeout
  • Activity ที่รันเกิน ~30 วินาที มี heartbeat และเช็ค ctx.Err()
  • Loop ที่ไม่มีเงื่อนไขจบชัดเจน มีการเช็ค GetContinueAsNewSuggested()
  • ก่อน Continue-As-New มี AllHandlersFinished และ drain signal channel ครบ
  • Child ที่แตะเงินใช้ ParentClosePolicy เป็น REQUEST_CANCEL
  • Schedule ตั้ง TimeZoneName และ Overlap ตรงกับความหมายทางบัญชี
  • ไม่มี payload เกินขนาดไหลผ่าน workflow

การเปลี่ยนแปลงโค้ด

  • มี replay fixture อย่างน้อย 1 ไฟล์ต่อ 1 execution path สำคัญ
  • Replay test เป็นด่านบังคับใน CI ไม่ใช่เทสที่ข้ามได้
  • เลือกกลยุทธ์ versioning แล้ว และทีมเข้าใจตรงกัน
  • ขั้นตอน rollback ถูกซ้อมจริง ไม่ใช่แค่เขียนไว้ในเอกสาร

การมองเห็น

  • Metrics endpoint และ dashboard พร้อม โดยมี schedule-to-start latency เป็นตัวหลัก
  • Alert บน failure_reason="NonDeterminismError" ดังทันที
  • Log ผ่าน workflow.GetLogger ทั้งหมด
  • Search Attributes ครอบคลุมคำถามที่ ops ถามบ่อย
  • ทีม ops มี runbook และรู้ว่าเมื่อไหร่ควรส่ง signal เมื่อไหร่ควรเรียก engineer

ความปลอดภัย

  • Payload Codec เปิดใช้บนทุก environment ที่มีข้อมูลจริง
  • กุญแจมาจาก KMS มีแผน rotation และ payload เก็บ key ID ไว้
  • Codec Server มี auth, authorization และ audit log
  • Workflow ID และ Search Attributes ไม่มีข้อมูลส่วนบุคคล
  • ไม่มีข้อมูลบัตรเต็มใน input/output ของ workflow
  • แยก namespace ระหว่าง environment
  • Credential ไม่อยู่ใน repo และมี alert ก่อน certificate หมดอายุ
  • Retention ถูกตั้งตามที่ตกลงกับทีม compliance

ข้อที่ checklist ตรวจแทนไม่ได้

รายการข้างบนเป็นเรื่องเทคนิคที่ทีมพัฒนาตรวจเองได้ แต่มีอีกกลุ่มหนึ่งที่ต้อง ผ่านการอนุมัติจากผู้มีอำนาจ ก่อนขึ้น production เสมอ:

  • การประเมินความสอดคล้องกับ PDPA, PCI-DSS และข้อกำหนดของ BOT
  • ข้อตกลงกับธนาคารและ partner เรื่องโควตา, SLA และการกระทบยอด
  • แผนรับมือเหตุการณ์ผิดปกติที่กระทบลูกค้า และเกณฑ์การแจ้งเหตุ
  • สิทธิ์ของทีม ops ในการสั่งงานที่กระทบเงินของลูกค้า

อย่าถือว่า "โค้ดผ่านเทสแล้ว" เท่ากับ "พร้อมขึ้น production"

สิ่งที่ควรจำที่สุดจากคอร์สนี้

Temporal ไม่ได้ทำให้ระบบการเงินถูกต้องโดยอัตโนมัติ มันให้สิ่งเดียวคือ การรับประกันว่าโค้ดของคุณจะทำงานต่อจนจบ ไม่ว่าจะเกิดอะไรขึ้นระหว่างทาง

ส่วนที่เหลือยังเป็นงานของเรา:

  • Idempotency — Temporal เรียกซ้ำได้ เราต้องทำให้การเรียกซ้ำไม่เสียหาย
  • Compensation — Temporal จำได้ว่าทำอะไรไปแล้ว เราต้องบอกว่าจะย้อนยังไง
  • Point of no return — Temporal ไม่รู้ว่าเงินออกจากระบบไปแล้วหรือยัง เราต้องเป็นคนบอก
  • การยอมรับว่าไม่รู้ — เมื่อไม่แน่ใจสถานะจริง หยุดแล้วเรียกคน ดีกว่าเดาแล้วผิด

3 ข้อแรกคือวิศวกรรม ข้อสุดท้ายคือวิจารณญาณ — และในระบบที่จัดการเงินของคนอื่น ข้อสุดท้ายมักสำคัญที่สุด