ใบเสนอราคาที่ต้องไม่หมดอายุ
- เผยแพร่
- ระบบ
- Klang Ya Bhaesaj
- เวลาอ่าน
- 7 นาที
ถ้าเคยทำร้านค้าออนไลน์ปกติ ก็คงคุ้นกับรูปแบบนี้ ตะกร้า เช็คเอาท์ จ่ายเงิน สต็อกลด แต่อันนี้ไม่ใช่แบบนั้น
ร้านขายยาไม่ได้กดสั่งซื้อ แต่ส่ง ใบเสนอราคา เข้ามาแล้วรอ เจ้าหน้าที่อ่าน แก้จำนวนให้ตรงกับของที่มีบนชั้นจริง แล้วอนุมัติ ออเดอร์ถึงจะเกิด
ความต่างจุดเดียวนี้ย้ายจังหวะที่สต็อกออกจากระบบ ไม่ใช่ตอนจ่ายเงิน แต่เป็นอีกหลายชั่วโมงถัดมา คนละหน้าจอ คนละคน หลังจากมีคนแก้ตัวเลขให้ตรงกับความจริงแล้ว ปัญหาที่น่าสนใจทั้งหมดอยู่ในช่องว่างตรงนั้นแหละ
การอนุมัติต้องเป็น transaction เดียว ไม่งั้นคือบั๊ก
ตอนเจ้าหน้าที่กดอนุมัติ มีสี่อย่างที่ต้องเกิดพร้อมกัน ตรวจจำนวนที่ขอมากับสต็อกจริง ตัดสต็อก บันทึกราคาที่อนุมัติ แล้วสร้างออเดอร์
ถ้าอันไหนสำเร็จโดยอีกอันไม่สำเร็จ ร้านเสียเงินแล้วไม่มีใครรู้ไปอีกหลายสัปดาห์ อนุมัติแต่ไม่ตัดสต็อกก็ขายเกิน ตัดสต็อกแต่สร้างออเดอร์ไม่สำเร็จ สต็อกก็ค้างอยู่กับของที่ไม่มีอยู่จริง
เลยเป็น transaction เดียว และเริ่มด้วยการล็อกแถวตามลำดับตายตัว
SELECT … FROM branch_product_units
WHERE product_unit_id IN (…)
ORDER BY product_unit_id ASC
FOR UPDATE
ORDER BY ตรงนี้ไม่ได้ใส่ไว้ให้สวย ถ้าเจ้าหน้าที่สองคนอนุมัติใบที่มีสินค้าซ้ำกัน ทั้งคู่จะไปคว้าแถวเดียวกัน ถ้าไม่ล็อกตามลำดับที่แน่นอน ต่างคนต่างถือครึ่งหนึ่งของที่อีกคนต้องการ แล้วก็ deadlock พอเรียงตาม product_unit_id ทุก transaction ก็หยิบแถวในลำดับเดียวกัน อีกคนแค่รอเฉย ๆ
ข้างในล็อก ตรวจ ตัดสต็อก บันทึกตัวเลขที่อนุมัติ ตั้งสถานะ สร้างออเดอร์ การตัดสต็อกนั่นแหละคือการจอง ไม่มีคอลัมน์ reserved แยก เพราะนั่นคือแหล่งความจริงที่สองของสต็อก และแหล่งความจริงที่สองคือสิ่งที่สุดท้ายจะไม่ตรงกับอันแรก
สถานะไม่ใช่สถานะ
อันนี้คือจุดที่กัดผมเองในโค้ดตัวเอง
ใบเสนอราคาที่กลายเป็นออเดอร์ไปแล้ว สถานะยังเป็น approved_quote อยู่ ไม่ได้เปลี่ยนไป converted เลย สิ่งที่บอกจริง ๆ คือ foreign key
quotes.order_id IS NULL → ยังไม่เป็นออเดอร์
quotes.order_id IS NOT NULL → มีออเดอร์แล้ว และออเดอร์เป็นเจ้าของสต็อก
ตอนเขียนก็รู้สึกว่ามันแปลก แต่ตอนนี้คิดว่ามันถูก สถานะคือสิ่งที่เจ้าหน้าที่ตัดสินใจ ส่วน foreign key คือสิ่งที่มีอยู่จริง ถ้ายุบสองอันเข้าด้วยกัน วันที่เพิ่มสถานะที่ห้าคือวันที่ต้องไล่ตรวจทุก query ที่เคยถามว่า "อันนี้แปลงแล้วยัง"
แต่มันทิ้งของมีคมไว้ในโค้ด และผมไปเจอมันแบบเจ็บตัว
บั๊ก: สต็อกเพิ่มขึ้นเอง
ใบเสนอราคามีวันหมดอายุ ใบที่อนุมัติแล้วแต่ไม่มีใครแตะภายใน 48 ชั่วโมงควรคืนของที่จองไว้ มันเป็นแบบ lazy คือเช็คตอนอ่าน ไม่มี cron ใบที่ถูกทิ้งเลยไม่มีต้นทุนจนกว่าจะมีคนเปิด
เวอร์ชันแรกก็เขียนแบบที่นึกออกทันที
if (quote.status === "approved_quote" && quote.expiresAt < now) {
await releaseQuoteReservation(tx, quote.id, branchId);
}
ทีนี้ลองอ่านเทียบกับกฎข้างบน ใบที่แปลงแล้ว ยังเป็น approved_quote อยู่
เพราะฉะนั้น 48 ชั่วโมงหลังอนุมัติ คนแรกที่เปิดใบเสนอราคาที่อยู่หลังออเดอร์ซึ่งจัดของ แพ็ค ส่ง เก็บเงินไปหมดแล้ว จะคืนสต็อกกลับเข้าสาขาแบบเงียบ ๆ
เราจับได้ตอนรีวิว เห็นสต็อกไต่ขึ้นเอง 146 แล้วเป็น 154 โดยไม่มีรับของเข้าระหว่างนั้น ไม่มี error คิวงานก็ดูปกติ ตัวเลขมันผิดเฉย ๆ และมันจะขายเกินจากของที่คลังไม่มีต่อไปเรื่อย ๆ ลบ promotion usage ที่ออเดอร์ที่ยังมีชีวิตอยู่ชี้อยู่ แล้วยิงแจ้งเตือนของเข้าสำหรับของที่ไม่เคยกลับมา
แก้บรรทัดเดียว
if (
quote.status === "approved_quote" &&
quote.orderId === null && // ← บั๊กทั้งหมดอยู่ตรงนี้
quote.expiresAt < now
) { … }
และต้องใส่สองที่ ทั้งตอนเช็คเบา ๆ รอบแรก และเช็คซ้ำข้างใน FOR UPDATE เพราะระหว่างอ่านแถวกับล็อกแถว transaction อื่นแปลงใบไปแล้วก็ได้ การ์ดที่เช็คแค่นอกล็อกคือการ์ดที่เช็คผิดจังหวะ
เวอร์ชันทั่วไปของเรื่องนี้ เผื่อใครมีแบบนี้อยู่ ถ้าคอลัมน์นึงหมายถึง เจตนา แล้วอีกคอลัมน์หมายถึง การมีอยู่จริง กฎทุกข้อที่เขียนโดยดูแค่เจตนาคือบั๊กที่รอจังหวะเหมาะ ๆ ลอง grep ดู
ให้เจ้าหน้าที่แก้ใบได้
ฟีเจอร์ที่ทำให้โมเดลนี้คุ้มมาจากประโยคเดียวของทางร้าน สต็อกจริงไม่ตรงกับระบบ
เจ้าหน้าที่เลยแก้ใบได้ก่อนอนุมัติ เปลี่ยนจำนวน ปรับราคา เพิ่มรายการ ตัดรายการ การตัดทำด้วยการไม่ส่งรายการนั้นมา เพราะ schema บังคับ requestedQuantity >= 1 เลยไม่มี "ตั้งเป็น 0" ที่แปลได้สองแบบ
ทุกการแก้จะคิดราคาใหม่ทั้งใบและรันโปรโมชั่นใหม่หมด แก้เฉพาะบรรทัดที่เปลี่ยนมันถูกกว่า และนั่นแหละคือวิธีที่ยอดรวมเริ่มเพี้ยน อัปเดตบางส่วนหลาย ๆ ครั้งแล้วผลรวมจะเลิกตรงกับส่วนย่อย คำนวณใหม่มันน่าเบื่อ แต่มันถูกตลอด
การ์ดสองตัว
order_id IS NOT NULL→ 409 หยุดเลย แก้ใบที่แปลงแล้วจะทำให้ออเดอร์ที่ชี้จำนวนเดิมอยู่ไม่ตรงกัน ฝั่ง UI ซ่อนปุ่มด้วย แต่เซิร์ฟเวอร์ไม่เชื่อ UIexpectedUpdatedAtต้องตรง ไม่งั้น 409 เจ้าหน้าที่สองคนเปิดใบเดียวกันคือวันอังคารธรรมดาในร้าน ถ้า last-write-wins การแก้ของอีกคนหายเงียบ
มีเพดานราคาด้วย ราคาที่อนุมัติห้ามเกิน 2 เท่าของที่ขอมา กฎหยาบ ๆ ที่เล็งไปที่การพิมพ์ผิด ไม่ใช่การโกง ฿70 กลายเป็น ฿700 ควรถูกระบบจับ ไม่ใช่ให้ลูกค้าจับ
อะไรที่จะเก็บไว้
transaction เดียวครอบการอนุมัติ ลำดับล็อกที่แน่นอน คำนวณใหม่แทนแก้บางส่วน และบังคับกฎที่เซิร์ฟเวอร์ถึงแม้ UI จะบังคับแล้ว ทั้งหมดนี้พิสูจน์ตัวเองแล้ว
อันที่จะคิดหนักกว่านี้คือการแยกสถานะออกจาก foreign key มันถูก และมันยังอยู่ แต่มันทำให้ผมเสียบั๊กจริงไปตัวนึงเพราะไม่มีอะไรใน type system บอกว่า approved_quote มันกำกวม ถ้าทำใหม่ก็ใช้โมเดลเดิม แค่ทำให้ความกำกวมมองข้ามไม่ได้ อาจเป็น type ที่แคบลง หรือฟังก์ชันที่ไม่ยอมตอบคำถาม "ใบนี้หมดอายุยัง" ถ้าไม่ส่ง order id มาด้วย