ข้าม​ไป​ยัง​เนื้อหา

ลอง​ใหม่​ได้​ไหม ตอบ​ด้วย​จำนวน​แถว ไม่ใช่​ด้วย​ความ​รู้สึก

409 ที่​เรา​เพิ่ง​สร้าง​ขึ้น​บอก​ผู้​เรียก​ว่า ลอง​ใหม่​แบบ​เดิม​ไม่​ช่วย

แต่​ยัง​มี​กรณี​ที่​ผู้​เรียก​ต้อง​ลอง​ใหม่​จริงๆ · คือ​ตอน​ที่​เขา​ไม่​เคย​ได้​รับคำ​ตอบ​เลย

ผู้​เรียก​ส่ง​คำขอ​ออก​ไป แล้ว​สาย​ขาด​ก่อน​คำ​ตอบ​จะ​กลับ​มา

จาก​มุม​ของ​ผู้​เรียก สอง​สถานการณ์​ข้าง​ล่าง​นี้​แยก​กัน​ไม่​ออก​เลย

  • คำขอ​ไป​ไม่​ถึง​เรา · ไม่มี​อะไร​เกิด​ขึ้น · ต้อง​ส่ง​ใหม่
  • คำขอ​ถึง​เรา​แล้ว บันทึก​สำเร็จ​แล้ว · คำ​ตอบ​หาย​ระหว่าง​ทาง · ส่ง​ใหม่​แล้ว​จะ​ซ้ำ

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

ยิง​คำ​ขอ​เดิม​เป๊ะๆ เข้าไป​เป็น​ครั้ง​ที่​สอง

ครั้ง​แรก​ได้ 200 และ​บันทึก​หนึ่ง​แถว · ครั้ง​ที่​สอง​ชน​กับ​แถว​ที่​ตัว​เอง​เพิ่ง​สร้าง แล้ว​ได้ 409 ตาม​ที่​บท​ที่ 4 ทำ​ไว้

409 ครั้ง​นี้​ถูก​ต้อง​ตาม​ตัว​อักษร แต่​มัน​ตอบ​ผิด​คำถาม · ผู้​เรียก​อ่าน​แล้ว​เข้าใจ​ว่า​มี​คน​อื่น​จอง​ไป​แล้ว ทั้ง​ที่​คน​ที่​จอง​ไป​คือ​ตัว​เขา​เอง

ขอบ​ของ​เรา​แยก “คุณ​ทำ​ไป​แล้ว” ออก​จาก “คน​อื่น​ทำ​ไป​แล้ว” ไม่​ได้

ระบบ​ของ​เรา​ไม่มี​ทาง​รู้​ว่า​สอง​คำขอ​เป็น​ตัว​เดียวกัน ถ้า​ผู้​เรียก​ไม่​บอก

Idempotency-KeyIdempotency-Keyหัวข้อ​ความ​ที่​ผู้​เรียก​ใช้​บอก​ว่า​คำขอ​นี้​เป็น​ตัว​เดิม​กับ​ที่​เคย​ส่ง · เป็น​สิ่ง​เดียว​ที่​แยก 'คำขอ​เดิม​ที่​ส่ง​ซ้ำ' ออก​จาก 'คำขอ​ใหม่​ที่​บังเอิญ​เหมือน​กัน' ได้ คือ​หัวข้อ​ความ​ที่​ผู้​เรียก​ตั้ง​ค่า​เอง แล้ว​ส่ง​ค่า​เดิม​มา​กับ ทุก​ครั้ง​ที่​ส่ง​คำขอ​เดียวกัน​ซ้ำ

เก็บ​มัน​ไว้​ใน​แถว​เดียว​กับ​ข้อมูล แล้ว​บังคับ​ให้​ไม่​ซ้ำ

บังคับ​ให้​คีย์​ไม่​ซ้ำ
CREATE UNIQUE INDEX http_appointment_idem ON http_appointment_row (idempotency_key)
WHERE idempotency_key IS NOT NULL;

WHERE ท้าย​คำ​สั่ง​สำคัญ · แถว​ที่​ไม่มี​คีย์​มี​ได้​หลาย​แถว · แถว​ที่​มี​คีย์​ห้าม​ซ้ำ

app/src/main/kotlin/clinic/http/BookingController.kt
package clinic.http
import jakarta.validation.Valid
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestHeader
import org.springframework.web.bind.annotation.RestController
@RestController
class BookingController(private val rows: AppointmentRows) {
@PostMapping("/bookings")
fun book(
@RequestHeader(name = "Idempotency-Key", required = false) key: String?,
@Valid @RequestBody req: BookingRequest,
): Map<String, Any> {
if (key != null) {
val seen = rows.findByIdempotencyKey(key)
if (seen != null) return mapOf("id" to seen.id, "replayed" to true)
}
val saved = rows.saveAndFlush(
AppointmentRow(req.doctorId, req.startMinute, req.endMinute, key),
)
return mapOf("id" to saved.id, "replayed" to false)
}
@GetMapping("/boom")
fun boom(): Nothing = throw IllegalStateException("อะไรบางอย่างพังข้างใน")
}

สอง​ครั้ง​แรก​ใช้​คีย์​เดียวกัน ครั้ง​ที่​สาม​เปลี่ยน​คีย์​แต่​เนื้อหา​เห​มือ​น​เดิม​เป๊ะ

ครั้งหัวข้อ​ความรหัสผล​ที่​ได้
ที่​หนึ่งIdempotency-Key: k-1200id ใหม่ · replayed เป็น false
ที่​สองIdempotency-Key: k-1200id ตัว​เดิม · replayed เป็น true
ที่​สามIdempotency-Key: k-2409ชน​กับ​แถว​ที่​มี​อยู่

จำนวน​แถว​หลัง​ยิง​ครบ​สาม​ครั้ง เหลือ 1 แถว

replayreplayการ​ตอบ​คำขอ​ที่​ซ้ำ​ด้วย​ผล​ของ​ครั้ง​แรก แทนที่​จะ​ทำงาน​ใหม่ · ผู้​เรียก​ได้ `id` ตัว​เดิม และ​จำนวน​แถว​ไม่​ขยับ คือ​สิ่ง​ที่​เกิด​ขึ้น​ใน​ครั้ง​ที่​สอง · เรา​ไม่​ได้​ทำงาน​ใหม่ เรา​ตอบ​ด้วย​ผล​ของ​ครั้ง​แรก และ​บอก​ผู้​เรียก​ตรงๆ ว่า​นี่​คือ​ของ​เก่า

🎯 คีย์​เดิม​กับ​คีย์​ใหม่ ต้อง​ได้​คำ​ตอบ​คนละ​แบบ

สอง​แถว​ล่าง​ของ​ตาราง​มี body เหมือน​กัน​ทุก​ตัว​อักษร · สิ่ง​เดียว​ที่​ต่าง​กัน​คือ​คีย์

  • คีย์​เดิม แปล​ว่า “นี่​คือ​คำขอ​เดิม​ของ​ฉัน​ที่​ส่ง​ซ้ำ” → ตอบ​ผล​เดิม
  • คีย์​ใหม่ แปล​ว่า “นี่​คือ​คำขอ​ใหม่​ที่​บังเอิญ​เหมือน​กัน” → ชน จึง​ตอบ 409

ค่า​ตั้ง​ต้น​แยก​สอง​อย่าง​นี้​ไม่​ได้​เลย เพราะ​ไม่มี​อะไร​ใน​คำขอ​ที่​บอก​ความ​ต่าง

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

ประโยค​ที่​ว่า “ปลาย​ทาง​นี้​ปลอดภัย​เวลา​ส่ง​ซ้ำ” ตรวจ​ไม่​ได้ · ประโยค​ที่​ว่า “ส่ง​คำขอ​เดิม​สาม​ครั้ง​แล้ว​เหลือ​หนึ่ง​แถว” ตรวจ​ได้​ทันที

ทุก​ครั้ง​ที่​มี​คน​บอก​ว่า​ปลาย​ทาง​ไหน idempotent ให้​ถาม​กลับ​สอง​ข้อ · นับ​ด้วย​อะไร และ​เคย​ยิง​ซ้ำ​จริง​แล้ว​หรือ​ยัง

⚠️ ทาง​ที่​บท​นี้​ใช้ เป็นการ​ค้นหา ไม่ใช่​การ​จอง

findByIdempotencyKey แล้ว​ค่อย​บันทึก มี​ช่อง​ว่าง​อยู่​ระหว่าง​สอง​ขั้น​ตอน​นั้น

ถ้า​คำขอ​ที่​มี​คีย์​เดียวกัน​สอง​ใบ​เข้า​มา​พร้อม​กัน​จริงๆ ทั้ง​คู่​จะ​ค้น​ไม่​เจอ แล้ว​บันทึก​ทั้ง​คู่ · ตัว​ที่มา​ทีหลัง​จะ​ชน unique index แล้ว​ได้ 409 แทนที่​จะ​ได้​ผล​เดิม

index ตัว​นั้น​คือ​สิ่ง​ที่​กัน​ข้อมูล​ซ้ำ​ได้​จริง ส่วน​การ​ค้น​ก่อน​เป็น​แค่​ทาง​ลัด​ของ​กรณี​ปกติ · ทาง​ที่​สมบูรณ์​กว่า​นี้​ต้อง​บันทึก​คำ​ตอบ​ของ​ครั้ง​แรก​ไว้​ด้วย แล้ว​ตอบ​จาก​ที่​เก็บ​นั้น ซึ่ง​เป็น​เรื่อง​ที่​กว้าง​กว่า​คอร์ส​นี้

หลัก​เดียว​กับ​บท​ที่ 6 ของ​คอร์ส​ก่อน · สิ่ง​ที่​บังคับ​ได้​จริง​คือ​ฐาน​ข้อมูล ไม่ใช่​ลำดับ​คำ​สั่ง​ใน​โปรแกรม

สาม​ข้อ · ทุก​ข้อ​วัด​ด้วย​รหัส​กับ​จำนวน​แถว

  • ส่ง​ซ้ำ​โดย​ไม่มี​คีย์​ได้ 409 ซึ่ง​ถูก​ตาม​ตัว​อักษร แต่​ตอบ​ผิด​คำถาม
  • คีย์​เดิม​ได้ 200 พร้อม id ตัว​เดิม และ​ผู้​เรียก​รู้​ว่า​เป็น​ของ​เก่า​จาก​คีย์ replayed
  • คีย์​ใหม่​กับ​เนื้อหา​เดิม​ได้ 409 และ​หลัง​ยิง​ครบ​สาม​ครั้ง เหลือ​หนึ่ง​แถว

บท​ที่ 6 จะ​ย้าย​ออก​จาก​คำขอ ไป​ที่​คำถาม​ว่า​เรา​เห็น​อะไร​อยู่​บ้าง จาก​ข้าง​นอก​ระบบ

🔬 ตัวเลข​ทุก​ตัว​ใน​บท​นี้​มา​จาก​ไหน
  • ยิง​สาม​ครั้ง​เรียง​กัน​ใน​การ​รัน​เดียว แล้ว​นับ​แถว​ด้วย​คำ​สั่ง​ที่​ฐาน​ข้อมูล​ตัว​จริง
  • id ที่​เท่า​กัน​ของ​สอง​ครั้ง​แรก เทียบ​จาก​ค่าที่​อยู่​ใน body ไม่​ได้​เทียบ​จาก​ใน​โปรแกรม
  • Spring Boot 4.1.1 · Kotlin 2.4.10 · PostgreSQL 18
  • รัน​ซ้ำ​เอง​ได้​ด้วย npm run verify:clinic-http
  • PostgreSQL — Partial Indexes (เข้า​ถึง 2026-09-02) — CREATE UNIQUE INDEX ... WHERE
  • Stripe — Idempotent Requests (เข้า​ถึง 2026-09-02) — ที่มา​ของ​หัวข้อ​ความ​ชื่อ​นี้​ใน​ทาง​ปฏิบัติ

เช็กความเข้าใจ — บทที่ 5

ข้อ 1 / 3

ทำไมคำขอเดิมที่ส่งซ้ำโดยไม่มีคีย์ ถึงได้ 409 ที่ตอบผิดคำถาม