Paxa Labs
บทความทั้งหมด

25 สิงหาคม 2569 · อ่าน 1 นาที

ตัดเครดิตก่อนประมวลผล และคืนเมื่อล้มเหลว

API ที่คิดเงินตามปริมาณรักษาบัญชีให้ถูกต้องได้อย่างไร เมื่อคำขอถูกส่งซ้ำ สตรีมขาดกลางคัน หรือแคชที่ระบบพึ่งพาหยุดตอบ

API ประมวลผลที่คิดตามปริมาณมีวิธีคิดเงินสองแบบ คือคิดหลังงานสำเร็จ กับคิดก่อนเริ่มแล้วคืนให้เมื่อไม่สำเร็จ

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

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

ข้อกำหนดถูกบังคับโดยฐานข้อมูล#

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

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

หนึ่งการตัดเครดิต หนึ่งคำสั่ง
BEGIN (โดยปริยาย)
  UPDATE credit_balance   -- ล็อกยอดคงเหลือ
     SET ...              -- CHECK ห้ามติดลบ
   WHERE user_id = $1
  UPDATE api_key          -- ล็อกคีย์ ลำดับเดิม
  INSERT credit_ledger
  INSERT usage_event
COMMIT

CTE เดียวที่มีเงื่อนไขกำกับ ทำงานแบบ autocommit
คำสั่งเดียวคือทรานแซกชันของตัวมันเอง การตัดเครดิต
จึงวิ่งไปกลับฐานข้อมูลครั้งเดียว ไม่ใช่สามครั้ง

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

สตรีมที่ขาดกลางคัน#

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

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

WebSocket แบบสดคือกฎเดียวกันที่ใช้กับแต่ละช่วงการพูด แต่ละช่วงจะคิดเงินส่วนต่างระหว่างจำนวนตัวอักษรสะสมกับยอดที่การเชื่อมต่อจ่ายไปแล้ว ค่าต่ำสุดต่อคำขอจึงถูกคิดครั้งเดียวตลอดทั้งบทสนทนา ช่วงที่ล้มเหลวจะคืนส่วนต่างของมันและย้อนตัวนับสะสมกลับ ซึ่งเป็นสิ่งที่กันไม่ให้ไคลเอนต์ที่ส่งข้อความเดิมซ้ำต้องจ่ายสองครั้ง

หมายเหตุ

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

คำขอเดิม ส่งสองครั้ง#

ไคลเอนต์ที่ส่งซ้ำเมื่อหมดเวลารออาจกำลังส่งซ้ำคำขอที่สำเร็จไปแล้ว Idempotency-Key มีไว้เพื่อกรณีนั้น และการส่งผลลัพธ์ที่เก็บไว้กลับไปโดยไม่คิดเงินซ้ำคือครึ่งที่ง่าย

ครึ่งที่ยากคือการพิสูจน์ว่าคำขอที่ส่งซ้ำเป็นคำขอเดียวกัน แถวข้อมูลการใช้งานที่เก็บไว้มีลายนิ้วมือกำกับ เป็น HMAC ของฟิลด์ในคำขอ โดยใช้คีย์ API ดิบของผู้เรียกเองเป็นกุญแจ คีย์นั้นไม่เคยถูกจัดเก็บที่ใดเลย ลำพังฐานข้อมูลจึงประกอบสิ่งที่ส่งมากลับขึ้นมาไม่ได้ ลายนิ้วมือถูกคำนวณเฉพาะคำขอที่มี Idempotency-Key มาด้วย เพราะหากไม่มี การส่งซ้ำก็หาคู่ไม่ได้อยู่แล้ว และจะไม่มีใครอ่านค่านั้นกลับ

สถานการณ์คำตอบเหตุผล
สองคำขอ คีย์เดียว พร้อมกัน409 idempotency_in_flightล็อกใน Redis คำขอที่สองส่งสิ่งที่คำขอแรกยังทำไม่เสร็จไม่ได้
ส่งซ้ำคำขอที่คืนเครดิตไปแล้ว409 idempotency_refundedการตัดเครดิตที่มันอ้างถึงไม่มีผลแล้ว
ส่งซ้ำโดยเนื้อหาต่างออกไป422 idempotency_mismatchตรวจกับลายนิ้วมือ จำนวน และโมเดลที่เก็บไว้
ส่งซ้ำหลังตัดเครดิตสำเร็จ200 ไม่คิดเงินส่งรายการที่เก็บไว้กลับไปอีกครั้ง
การคืนเครดิตมาถึงระหว่างการส่งซ้ำตรวจอีกครั้งหลังประมวลผลการคืนต้องไม่ชนกับการส่งผลลัพธ์แบบไม่คิดเงิน

แถวสุดท้ายคือแถวที่ละเอียดอ่อนที่สุด การส่งซ้ำจะตรวจสถานะการคืนเครดิตก่อนประมวลผล แต่การคืนอาจมาถึงระหว่างที่การประมวลผลกำลังทำงาน ระบบจึงตรวจอีกครั้งหลังจากนั้น หากไม่มีการตรวจซ้ำ จะมีช่วงเวลาหนึ่งที่คำขอถูกส่งฟรีทับการตัดเครดิตที่ถูกย้อนกลับไปแล้ว

แคชที่หยุดตอบ#

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

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

การทำงานต่อเมื่อแคชล่มมีคมที่มองข้ามได้ง่าย ไคลเอนต์ที่ไม่มีการจำกัดเวลาของคำสั่งจะไม่แจ้งความล้มเหลวเมื่อเซิร์ฟเวอร์ค้าง มันจะค้างไปด้วย และการอ่านที่ค้างอยู่ในเส้นทางคำขอแย่กว่าการแจ้งข้อผิดพลาด ทุกคำสั่งที่ส่งไป Redis จึงทำงานภายใต้งบเวลาที่ระบุไว้ คือ 250 มิลลิวินาทีบนเส้นทางคำขอ และสองวินาทีสำหรับสัญญาณตรวจสุขภาพ เมื่องบเวลาบนเส้นทางคำขอหมด ระบบจะคืนค่าสำรองอย่างเงียบ ๆ และมีเพียงสัญญาณตรวจสุขภาพเท่านั้นที่ได้รับอนุญาตให้สรุปว่าการเชื่อมต่อตายแล้ว โดยพิสูจน์ด้วยการปิดการเชื่อมต่อที่มันเพิ่งแสดงให้เห็นว่าไม่ได้ส่งคำสั่งอยู่

แต่ละชั้นได้รับอนุญาตให้ตัดสินอะไร
Redis          เป็นเพียงคำแนะนำ ผิดได้ หายได้
  จำกัดอัตรา    ล่มแล้วปล่อยผ่าน
  สิทธิ์        ล่มแล้วปล่อยผ่าน
  แคชคีย์       ล่มแล้วไปอ่าน Postgres

Postgres       เป็นแหล่งข้อมูลจริง
  สถานะคีย์     อ่านซ้ำตอนตัดเครดิต
  เพดานใช้จ่าย   เงื่อนไข CHECK
  ยอดคงเหลือ    เงื่อนไข CHECK

ไม่มีอะไรเหนือเส้นนี้ที่ใช้เงินได้

สิ่งที่ยังเหลืออยู่#

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

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

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