Double-Spending: เมื่อลูกค้ามือนิ้วล็อคกดเบิ้ล แต่ระบบเสกของฟรีให้แบบไม่รู้ตัว
⏱️ ใช้เวลาอ่านประมาณ: 4 นาที 20 วินาที
ในกระเป๋าเงินดิจิทัล ลูกค้ามียอดเงินคงเหลืออยู่ 100 บาท ถ้วน
เขาเปิดแอปขึ้นมา กดสั่งซื้อไอเทมราคา 100 บาท ด้วยความที่เน็ตกระตุกชั่วขณะ เขาเลยรัวนิ้วกดปุ่ม "ยืนยันการจ่ายเงิน" ติดกัน 2 ครั้งอย่างรวดเร็วภายใน 10 มิลลิวินาที
ผลลัพธ์คือ: ระบบส่งมอบไอเทมให้ลูกค้า 2 ชิ้น... แต่พอตรวจดูยอดเงินในบัญชี ยอดเงินเหลือ 0 บาท! เงินหายไปแค่ 100 เดียว แต่ได้ของมูลค่า 200 บาทไปครอง บริษัทขาดทุนย่อยยับเพราะบั๊กสุดคลาสสิกที่เรียกว่า Race Condition
🏎️ เมื่อ Thread สองตัววิ่งแข่งกัน (Check-Then-Act)
เบื้องหลังของโค้ดตัดเงินที่เขียนกันแบบซื่อๆ มักจะมีหน้าตาแบบนี้:
val balance = walletRepository.getBalance(userId)
if (balance >= amount) {
// Step 2: ตัดเงิน
walletRepository.updateBalance(userId, balance - amount)
deliverItem(userId)
}
แพทเทิร์นนี้เรียกว่า Check-Then-Act (ตรวจก่อนแล้วค่อยทำ) ซึ่งปลอดภัย 100% ถ้ามีคนเข้าคิวทำทีละคน... แต่มันจะพังทันทีเมื่อมี Request สองตัววิ่งเข้ามาพร้อมกันในระดับมิลลิวินาที:
- Thread A: ถาม DB ว่าเงินพอไหม? DB บอก "มี 100 บาท พอจ้า!"
- Thread B: (วิ่งตามมาติดๆ ใน 2ms ถัดมา) ถาม DB เหมือนกัน DB ก็ยังบอก "มี 100 บาท พอจ้า!" (เพราะ Thread A ยังตัดเงินไม่เสร็จ)
- Thread A: ตัดเงิน 100 เหลือ 0 บาท แล้วแจกของชิ้นที่ 1
- Thread B: คิดว่าตัวเองผ่านเกณฑ์แล้ว ก็สั่งอัปเดตยอดเงินเหลือ 0 บาทซ้ำอีกรอบ แล้วแจกของชิ้นที่ 2!
🛡️ 2 ท่าล็อกคอ: Atomic Update & Idempotency Key
เพื่อไม่ให้เกิดการเสกของฟรี เราสามารถแก้ได้ทั้งในระดับ Database และระดับ API Architecture:
1. พึ่งพาพลังความจริงใจของ Database (Atomic SQL Update)
อย่าอ่านค่าขึ้นมาคำนวณบนแรมของแอปพลิเคชันเด็ดขาด! ให้สั่ง Database ตัดเงินและเช็กเงื่อนไขในคำสั่งเดียวตรงๆ:
SET balance = balance - 100
WHERE user_id = :userId AND balance >= 100;
คำสั่งนี้มีระบบ Row-level Lock ในตัว ถ้า Thread แรกตัดสำเร็จ
แถวจะถูกอัปเดต (Rows affected = 1) ส่วน Thread สองที่ตามมา
ยอดเงินจะไม่พอเงื่อนไข balance >= 100 ทันที
คำสั่งจะคืนค่า 0 แถว ทำให้เรารู้ได้เลยว่ายอดเงินไม่พอ
2. แจกบัตรคิวไม่ซ้ำ (Idempotency Key)
ฝั่ง Frontend ทุกครั้งที่จะกดยืนยันจ่ายเงิน ต้องแนบกุญแจ UUID (Idempotency-Key) ประจำรอบกดนั้นมาใน Header ด้วย
"เมื่อ Backend ได้รับ Request ที่มี Key ตัวนี้ จะบันทึกลง Redis หรือตารางชั่วคราวก่อน ถ้ามีการส่ง Request ที่มี Key เดิมซ้ำเข้ามาภายใน 1 นาที ระบบจะไม่ประมวลผลซ้ำ แต่จะคืนคำตอบเดิมกลับไปทันที"
💡 5-Min Takeaway
"ข้อมูลสถานะที่อ่านขึ้นมาในอดีต (แม้จะแค่อดีตเมื่อ 1ms ก่อน) ไม่ใช่ความจริงของปัจจุบันเสมอไป" ในระบบที่มีการทำธุรกรรมหรือตัดสต็อกสินค้า อย่าใช้ตรรกะแบบ Check-Then-Act บนแรมของแอป จงผลักภาระการการันตีความถูกต้องไปที่ระดับ Atomic Operation ของ Database หรือวางแนวป้องกันด้วย Idempotency Key ตั้งแต่หน้าประตู API