ลองใหม่ได้ไหม ตอบด้วยจำนวนแถว ไม่ใช่ด้วยความรู้สึก
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 ท้ายคำสั่งสำคัญ · แถวที่ไม่มีคีย์มีได้หลายแถว · แถวที่มีคีย์ห้ามซ้ำ
package clinic.http
import jakarta.validation.Validimport org.springframework.web.bind.annotation.GetMappingimport org.springframework.web.bind.annotation.PostMappingimport org.springframework.web.bind.annotation.RequestBodyimport org.springframework.web.bind.annotation.RequestHeaderimport org.springframework.web.bind.annotation.RestController
@RestControllerclass 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-1 | 200 | id ใหม่ · replayed เป็น false |
| ที่สอง | Idempotency-Key: k-1 | 200 | id ตัวเดิม · replayed เป็น true |
| ที่สาม | Idempotency-Key: k-2 | 409 | ชนกับแถวที่มีอยู่ |
จำนวนแถวหลังยิงครบสามครั้ง เหลือ 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 ที่ตอบผิดคำถาม