☕ 5-Min Tech Story #17

Double-Spending: เมื่อลูกค้ามือนิ้วล็อคกดเบิ้ล แต่ระบบเสกของฟรีให้แบบไม่รู้ตัว

⏱️ ใช้เวลาอ่านประมาณ: 4 นาที 20 วินาที

ในกระเป๋าเงินดิจิทัล ลูกค้ามียอดเงินคงเหลืออยู่ 100 บาท ถ้วน

เขาเปิดแอปขึ้นมา กดสั่งซื้อไอเทมราคา 100 บาท ด้วยความที่เน็ตกระตุกชั่วขณะ เขาเลยรัวนิ้วกดปุ่ม "ยืนยันการจ่ายเงิน" ติดกัน 2 ครั้งอย่างรวดเร็วภายใน 10 มิลลิวินาที

ผลลัพธ์คือ: ระบบส่งมอบไอเทมให้ลูกค้า 2 ชิ้น... แต่พอตรวจดูยอดเงินในบัญชี ยอดเงินเหลือ 0 บาท! เงินหายไปแค่ 100 เดียว แต่ได้ของมูลค่า 200 บาทไปครอง บริษัทขาดทุนย่อยยับเพราะบั๊กสุดคลาสสิกที่เรียกว่า Race Condition

🏎️ เมื่อ Thread สองตัววิ่งแข่งกัน (Check-Then-Act)

เบื้องหลังของโค้ดตัดเงินที่เขียนกันแบบซื่อๆ มักจะมีหน้าตาแบบนี้:

// Step 1: เช็กก่อนว่ามีเงินพอไหม
val balance = walletRepository.getBalance(userId)
if (balance >= amount) {
  // Step 2: ตัดเงิน
  walletRepository.updateBalance(userId, balance - amount)
  deliverItem(userId)
}

แพทเทิร์นนี้เรียกว่า Check-Then-Act (ตรวจก่อนแล้วค่อยทำ) ซึ่งปลอดภัย 100% ถ้ามีคนเข้าคิวทำทีละคน... แต่มันจะพังทันทีเมื่อมี Request สองตัววิ่งเข้ามาพร้อมกันในระดับมิลลิวินาที:

  1. Thread A: ถาม DB ว่าเงินพอไหม? DB บอก "มี 100 บาท พอจ้า!"
  2. Thread B: (วิ่งตามมาติดๆ ใน 2ms ถัดมา) ถาม DB เหมือนกัน DB ก็ยังบอก "มี 100 บาท พอจ้า!" (เพราะ Thread A ยังตัดเงินไม่เสร็จ)
  3. Thread A: ตัดเงิน 100 เหลือ 0 บาท แล้วแจกของชิ้นที่ 1
  4. Thread B: คิดว่าตัวเองผ่านเกณฑ์แล้ว ก็สั่งอัปเดตยอดเงินเหลือ 0 บาทซ้ำอีกรอบ แล้วแจกของชิ้นที่ 2!

🛡️ 2 ท่าล็อกคอ: Atomic Update & Idempotency Key

เพื่อไม่ให้เกิดการเสกของฟรี เราสามารถแก้ได้ทั้งในระดับ Database และระดับ API Architecture:

1. พึ่งพาพลังความจริงใจของ Database (Atomic SQL Update)

อย่าอ่านค่าขึ้นมาคำนวณบนแรมของแอปพลิเคชันเด็ดขาด! ให้สั่ง Database ตัดเงินและเช็กเงื่อนไขในคำสั่งเดียวตรงๆ:

UPDATE wallets
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