Idempotency Key กดจ่ายซ้ำไม่ตัดเงินสองครั้ง และ retry แบบ backoff + jitter
ลูกค้ากดจ่ายสองครั้งหรือ client ส่งซ้ำตอนเน็ตหลุด ต้องไม่ตัดเงินซ้ำ ใช้ idempotency key เก็บผลลัพธ์ unique constraint และ retry แบบ exponential backoff + jitter
- Client สร้าง key หนึ่งตัวต่อหนึ่งการกระทำ (เช่น UUID v4) แล้วส่ง key เดิมทุกครั้งที่ retry
- Server เก็บ key คู่กับ hash ของ request และผลลัพธ์ เจอ key ซ้ำให้ตอบผลเดิม ไม่ทำงานซ้ำ
- unique constraint ในฐานข้อมูลคือด่านสุดท้ายที่กันการทำซ้ำได้จริง ไม่ใช่การเช็กในโค้ด
- retry ด้วย exponential backoff + full jitter และ retry เฉพาะ error ที่ retry ได้ (เน็ตหลุด, 409, 429, 5xx)
ลูกค้ากด “ชำระเงิน” แล้วหน้าค้าง เลยกดอีกที หรือแอปมือถือส่ง request ไปแล้วเน็ตหลุดก่อนได้คำตอบ เลย retry เอง ถ้า server ทำงานทุก request ที่เข้ามา ลูกค้าโดนตัดเงินสองครั้ง idempotency key คือวิธีมาตรฐานที่ทำให้ “ส่งซ้ำกี่ครั้ง ผลเท่ากับส่งครั้งเดียว” บทความนี้ใช้โมเดลของ Stripe กับร่างมาตรฐาน IETF เป็นหลัก พร้อมโค้ดที่รันได้
ทดสอบบน Python 3.12, SQLite 3.49 (ไลบรารี sqlite3 ใน stdlib) ข้อเท็จจริงเรื่อง Stripe ตรวจกับเอกสาร Stripe API v1 ณ ตุลาคม 2026
ทำไม retry ธรรมดาถึงอันตราย
บทความของ Stripe ปี 2017 แบ่งจุดที่ request พังได้สามแบบ
- เชื่อมต่อไม่ติด server ไม่ได้รับเลย retry ปลอดภัย
- พังกลางทาง server ทำไปครึ่งเดียว ปลอดภัยหรือไม่ขึ้นกับว่าฐานข้อมูล rollback งานครึ่งนั้นหรือเปล่า
- ทำเสร็จแล้ว แต่คำตอบหายระหว่างทาง นี่คือตัวอันตราย client ไม่มีทางรู้ว่าสำเร็จแล้ว ถ้า retry แบบไม่มี key ก็ตัดเงินซ้ำ
จากฝั่ง client แบบ 1 กับแบบ 3 หน้าตาเหมือนกันทุกอย่าง คือ timeout เหมือนกัน เลยต้องให้ server เป็นคนจำว่าเคยทำ request นี้แล้วหรือยัง
idempotency key ทำงานยังไง
Client สร้าง key ที่ไม่ซ้ำ หนึ่งตัวต่อ หนึ่งการกระทำ (ไม่ใช่ต่อหนึ่ง request) แล้วส่งใน header Idempotency-Key ทุกครั้งที่ retry ใช้ key เดิม
POST /v1/payments HTTP/1.1
Host: api.example.com
Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"
Content-Type: application/json
{"order_id": "A-1001", "amount_satang": 129900}
Server ทำสามอย่าง
- จอง key ถ้ายังไม่เคยเห็น บันทึกว่า “กำลังทำ”
- ทำงานจริงครั้งเดียว แล้วบันทึก status code กับ body ของคำตอบคู่กับ key
- เจอ key ซ้ำ ตอบผลที่เก็บไว้ ไม่ทำงานซ้ำ
Stripe API v1 ทำแบบนี้ทุกประการ เก็บ status code กับ body ของ request แรกหลังจาก endpoint เริ่มทำงานแล้ว รวมถึงคำตอบ 500 ด้วย key ยาวได้ถึง 255 ตัว ลบได้เมื่ออายุครบ 24 ชั่วโมงขึ้นไป และถ้า key เดิมมากับ parameter ที่ต่างจากเดิม จะตอบ error แทนการทำงาน ส่วน request ที่ validate ไม่ผ่าน หรือชนกับ request ที่ใช้ key เดียวกันซึ่งยังทำอยู่ จะไม่ถูกเก็บผล retry ได้
ร่างมาตรฐาน IETF บอกอะไรไว้
IETF มีร่าง draft-ietf-httpapi-idempotency-key-header ฉบับล่าสุดคือ -07 (ตุลาคม 2025) และ หมดอายุไปแล้ว ยังไม่เป็น RFC แต่เนื้อหาเป็นแนวทางที่ดีสำหรับกำหนด status code
| สถานการณ์ | status ตามร่าง IETF |
|---|---|
| endpoint บังคับใช้ key แต่ไม่ส่งมา | 400 Bad Request |
| key เดิม แต่ body ต่างจากเดิม | 422 Unprocessable Content |
| key เดิม แต่ request แรกยังทำไม่เสร็จ | 409 Conflict |
ร่างนี้กำหนดค่า header เป็น Structured Field แบบ String (เลยมีเครื่องหมายคำพูดครอบ) แนะนำให้ใช้ UUID หรือค่าสุ่มที่คล้ายกัน และให้ server ประกาศนโยบายวันหมดอายุของ key ไว้ในเอกสาร Stripe ส่งค่าแบบไม่มีเครื่องหมายคำพูด ถ้าเขียน API เอง ให้รับได้ทั้งสองแบบ
key ผูกกับการกระทำ ไม่ใช่กับ request สร้างครั้งเดียวตอนผู้ใช้เริ่มกดจ่าย แล้วใช้ตัวเดิมทุกครั้งที่ส่งซ้ำ
ตัวอย่าง: ตาราง key + unique constraint
โค้ดนี้รันได้ด้วย Python stdlib ล้วน ใช้ SQLite แทน Postgres ให้ลองง่าย (ON CONFLICT ... DO NOTHING กับ RETURNING ใช้ได้ทั้งสองตัว SQLite ต้องเวอร์ชัน 3.35 ขึ้นไป) จำนวนเงินเก็บเป็นสตางค์แบบจำนวนเต็มตามเหตุผลในบทความเรื่องเงินกับ float
import hashlib, json, sqlite3, time
con = sqlite3.connect("app.db", isolation_level=None)
con.executescript("""
CREATE TABLE IF NOT EXISTS idempotency_keys (
key TEXT PRIMARY KEY,
request_hash TEXT NOT NULL,
status TEXT NOT NULL, -- 'started' | 'done'
response_code INTEGER,
response_body TEXT,
created_at INTEGER NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS charges (
id INTEGER PRIMARY KEY,
idempotency_key TEXT NOT NULL UNIQUE, -- ด่านที่สอง
order_id TEXT NOT NULL,
amount_satang INTEGER NOT NULL
) STRICT;
""")
def pay(key: str, body: dict) -> tuple[int, dict]:
h = hashlib.sha256(json.dumps(body, sort_keys=True).encode()).hexdigest()
# 1) จอง key
con.execute("BEGIN IMMEDIATE")
claimed = con.execute(
"INSERT INTO idempotency_keys(key, request_hash, status, created_at) "
"VALUES (?,?, 'started', ?) ON CONFLICT(key) DO NOTHING RETURNING key",
(key, h, int(time.time()))).fetchone()
if not claimed:
row = con.execute("SELECT request_hash, status, response_code, response_body "
"FROM idempotency_keys WHERE key=?", (key,)).fetchone()
con.execute("COMMIT")
if row[0] != h:
return 422, {"error": "key reused with a different body"}
if row[1] == "started":
return 409, {"error": "request still in progress"}
return row[2], json.loads(row[3]) # ตอบผลเดิม
con.execute("COMMIT")
# 2) ทำงานจริงครั้งเดียว 3) เก็บผล
con.execute("BEGIN IMMEDIATE")
cur = con.execute("INSERT INTO charges(idempotency_key, order_id, amount_satang) "
"VALUES (?,?,?)", (key, body["order_id"], body["amount_satang"]))
resp = {"charge_id": cur.lastrowid, "status": "paid"}
con.execute("UPDATE idempotency_keys SET status='done', response_code=201, "
"response_body=? WHERE key=?", (json.dumps(resp), key))
con.execute("COMMIT")
return 201, resp
เรียก pay() ด้วย key เดิมสามครั้ง ครั้งที่สามเปลี่ยนยอดเงิน ผลที่ได้จริง
(201, {'charge_id': 1, 'status': 'paid'})
(201, {'charge_id': 1, 'status': 'paid'}) <- ตอบผลเดิม ไม่ได้ตัดใหม่
(422, {'error': 'key reused with a different body'})
1 charge(s)
จุดที่ทำให้ถูกจริงคือ PRIMARY KEY ของตาราง key กับ UNIQUE ของ charges สอง request ที่เข้ามาพร้อมกันแล้ว “SELECT ดูก่อนว่ามี key ไหม” จะเห็นว่าไม่มีทั้งคู่และทำงานทั้งคู่ แต่ INSERT ที่ชน unique constraint จะผ่านได้แค่ตัวเดียวเสมอ ไม่ว่าจะมีกี่ process (SQLite บน production อธิบายเหตุผลของ BEGIN IMMEDIATE ไว้)
ถ้าขั้นที่ 2 เรียก payment gateway ภายนอก อย่าเรียกระหว่างถือ transaction ไว้ ให้ commit สถานะ started ก่อนเหมือนในโค้ด แล้วส่ง key ของเราต่อไปให้ gateway ด้วย (Stripe รับ Idempotency-Key เช่นกัน) ถ้า process ตายกลางทาง แถว started ที่ค้างจะตอบ 409 ไปเรื่อย ๆ ต้องมีงานกวาดที่ไปถาม gateway ว่าจริง ๆ แล้วตัดเงินไปหรือยัง แล้วปิดแถวนั้นให้ถูก
Retry ฝั่ง client: exponential backoff + jitter
มี key แล้ว client retry ได้ปลอดภัย แต่ต้อง retry ให้ถูกวิธี ไม่อย่างนั้นทุก client จะรุมยิงซ้ำพร้อมกันตอน server เพิ่งฟื้น (thundering herd) บทความ AWS Architecture Blog เปรียบเทียบแล้วสรุปว่า backoff ที่ไม่มี jitter แย่ที่สุด ส่วนแบบ Full Jitter ลดงานของ server ได้มากพอ ๆ กับแบบ Decorrelated และสูตรสั้นที่สุด
import random, time, uuid
import urllib.request, urllib.error
RETRYABLE = {409, 429, 500, 502, 503, 504}
def backoff(attempt: int, base: float = 0.5, cap: float = 8.0) -> float:
return random.uniform(0, min(cap, base * 2 ** attempt)) # full jitter
def post_payment(url: str, data: bytes, max_retries: int = 5):
key = str(uuid.uuid4()) # สร้างครั้งเดียว ใช้ทุกรอบ
for attempt in range(max_retries + 1):
req = urllib.request.Request(url, data=data, method="POST", headers={
"Content-Type": "application/json", "Idempotency-Key": key})
try:
with urllib.request.urlopen(req, timeout=10) as r:
return r.status, r.read()
except urllib.error.HTTPError as e:
if e.code not in RETRYABLE or attempt == max_retries:
raise
except urllib.error.URLError: # เน็ตหลุด / timeout ตอนเชื่อมต่อ
if attempt == max_retries:
raise
time.sleep(backoff(attempt))
ลองกับ server ทดสอบในเครื่องที่ตอบ 503 สองครั้งแล้วค่อย 201 โค้ดนี้ส่งไปสามรอบ ทุกรอบใช้ key เดียวกัน
ตัวอย่างคำนวณ: รอนานแค่ไหน
ตั้ง base = 0.5 วินาที cap = 8 วินาที retry สูงสุด 5 ครั้ง เพดานของแต่ละรอบคือ min(8, 0.5 × 2^n)
| retry ครั้งที่ | n | เพดาน | รอจริง (สุ่มสม่ำเสมอ) | เฉลี่ย |
|---|---|---|---|---|
| 1 | 0 | 0.5 × 1 = 0.5 s | 0 – 0.5 s | 0.25 s |
| 2 | 1 | 0.5 × 2 = 1 s | 0 – 1 s | 0.5 s |
| 3 | 2 | 0.5 × 4 = 2 s | 0 – 2 s | 1 s |
| 4 | 3 | 0.5 × 8 = 4 s | 0 – 4 s | 2 s |
| 5 | 4 | 0.5 × 16 = 8 s (ชน cap พอดี) | 0 – 8 s | 4 s |
- รอรวมสูงสุด 0.5 + 1 + 2 + 4 + 8 = 15.5 วินาที เฉลี่ยครึ่งหนึ่งคือ 7.75 วินาที (ไม่นับเวลาของ request เอง)
- ถ้ามี 1,000 client พังพร้อมกัน แบบไม่มี jitter ทั้ง 1,000 ยิงซ้ำที่วินาที 0.5 พอดี แบบ full jitter รอบแรกกระจายทั่ว 0–500 ms เฉลี่ย 1,000 ÷ 500 = 2 request ต่อ ms แทนที่จะเป็นก้อนเดียว 1,000 ตัว
ทั้งหมดนี้ต้องอยู่ในช่วงที่ server ยังจำ key อยู่ Stripe บอกให้ retry ด้วย key เดิมภายใน 24 ชั่วโมงแรก ถ้าเลยไปแล้วยังไม่รู้ผล ให้หยุด แล้วไปตรวจสถานะจริงก่อน
ข้อผิดพลาดที่เจอบ่อย
- สร้าง key ใหม่ทุกรอบ retry แบบนี้ไม่ต่างจากไม่มี key ต้องสร้างตอนเริ่มการกระทำแล้วเก็บไว้ ถ้า retry ข้าม process หรือหลัง restart ต้องบันทึก key ไว้ด้วย
- เช็กด้วย SELECT แล้วค่อย INSERT แพ้ race condition เมื่อสอง request มาพร้อมกัน ต้องพึ่ง unique constraint
- retry ทุก 4xx 400 กับ 422 ส่งซ้ำกี่รอบก็ผิดเหมือนเดิม retry เฉพาะเน็ตหลุด 409 429 และ 5xx
- ได้ 500 แล้วเปลี่ยน key ใหม่ Stripe ให้ถือว่า 500 คือ “ไม่รู้ผล” อาจตัดเงินไปแล้ว ห้าม retry ด้วย key ใหม่จนกว่าจะตรวจผลจริง (ดึง object มาดู หรือรอ webhook ซึ่งต้องรับให้ปลอดภัย) และเพราะ Stripe v1 เก็บผล 500 ไว้ด้วย retry ด้วย key เดิมก็จะได้ 500 เดิมกลับมา
- ใช้ข้อมูลส่วนตัวเป็น key เช่นอีเมลหรือเบอร์โทร Stripe เตือนไว้ตรง ๆ ว่าอย่าทำ
- ลบ key เร็วกว่าช่วง retry key ที่ลบแล้วถูกมองเป็น request ใหม่
unique constraint ในฐานข้อมูลคือสิ่งที่กันเงินซ้ำจริง โค้ดที่ “เช็กก่อน” เป็นแค่ทางลัดให้ตอบเร็ว
แหล่งอ้างอิง
- Idempotent requests — Stripe API Reference
- Advanced error handling (idempotency, retries, 500) — Stripe Docs
- The Idempotency-Key HTTP Header Field, draft -07 — IETF
- INSERT … ON CONFLICT — PostgreSQL 18 Docs
- Designing robust and predictable APIs with idempotency — Stripe Blog
- Exponential Backoff And Jitter — AWS Architecture Blog
คำถามที่พบบ่อย
ใช้ order id เป็น idempotency key ได้ไหม
ได้ถ้าหนึ่ง order จ่ายได้ครั้งเดียว Stripe เองแนะนำทั้งสองแบบ คือ UUID v4 หรือ id ของสิ่งที่ผู้ใช้ผูกไว้ เช่นตะกร้า แต่ถ้า order เดียวจ่ายได้หลายงวด ต้องใช้ key ที่แยกตามงวด ไม่อย่างนั้นงวดที่สองจะได้ผลของงวดแรกกลับมา
เก็บ key ไว้นานแค่ไหน
นานกว่าช่วงที่ client อาจ retry Stripe API v1 เก็บอย่างน้อย 24 ชั่วโมงแล้วค่อยลบได้ ถ้าเก็บสั้นกว่าช่วง retry ของ client key ที่ถูกลบไปแล้วจะทำงานใหม่ได้อีกครั้ง
GET ต้องใช้ idempotency key ไหม
ไม่ต้อง GET, PUT, DELETE เป็น idempotent ตามนิยามของ HTTP อยู่แล้ว key ใช้กับ POST หรือ PATCH ที่สร้างผลข้างเคียง เช่นตัดเงิน ส่งอีเมล สร้าง order
คำสั่งและค่าตั้งในบทความทดสอบบนระบบตัวอย่าง ลองบนเครื่องทดสอบก่อนใช้กับระบบจริงเสมอ