บทความ › การเขียนโปรแกรม

Idempotency Key กดจ่ายซ้ำไม่ตัดเงินสองครั้ง และ retry แบบ backoff + jitter

ลูกค้ากดจ่ายสองครั้งหรือ client ส่งซ้ำตอนเน็ตหลุด ต้องไม่ตัดเงินซ้ำ ใช้ idempotency key เก็บผลลัพธ์ unique constraint และ retry แบบ exponential backoff + jitter

กลาง อ่าน 15 นาที 22 ตุลาคม 2569

อ่านจบแล้วจะรู้
  • 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 พังได้สามแบบ

  1. เชื่อมต่อไม่ติด server ไม่ได้รับเลย retry ปลอดภัย
  2. พังกลางทาง server ทำไปครึ่งเดียว ปลอดภัยหรือไม่ขึ้นกับว่าฐานข้อมูล rollback งานครึ่งนั้นหรือเปล่า
  3. ทำเสร็จแล้ว แต่คำตอบหายระหว่างทาง นี่คือตัวอันตราย 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 ทำสามอย่าง

  1. จอง key ถ้ายังไม่เคยเห็น บันทึกว่า “กำลังทำ”
  2. ทำงานจริงครั้งเดียว แล้วบันทึก status code กับ body ของคำตอบคู่กับ key
  3. เจอ key ซ้ำ ตอบผลที่เก็บไว้ ไม่ทำงานซ้ำ

Stripe API v1 ทำแบบนี้ทุกประการ เก็บ status code กับ body ของ request แรกหลังจาก endpoint เริ่มทำงานแล้ว รวมถึงคำตอบ 500 ด้วย key ยาวได้ถึง 255 ตัว ลบได้เมื่ออายุครบ 24 ชั่วโมงขึ้นไป และถ้า key เดิมมากับ parameter ที่ต่างจากเดิม จะตอบ error แทนการทำงาน ส่วน request ที่ validate ไม่ผ่าน หรือชนกับ request ที่ใช้ key เดียวกันซึ่งยังทำอยู่ จะไม่ถูกเก็บผล retry ได้

ส่ง key + bodyclient สร้าง key ครั้งเดียวต่อการกระทำจอง key (UNIQUE)คนมาทีหลังชนกำแพงทำงานครั้งเดียวตัดเงิน ส่งของเก็บ status + bodyคู่กับ keykey ซ้ำ = ตอบผลเดิมไม่ทำงานซ้ำ
ส่งซ้ำกี่ครั้ง ผลเท่ากับครั้งเดียว เพราะ server จำ key

ร่างมาตรฐาน 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เพดานรอจริง (สุ่มสม่ำเสมอ)เฉลี่ย
100.5 × 1 = 0.5 s0 – 0.5 s0.25 s
210.5 × 2 = 1 s0 – 1 s0.5 s
320.5 × 4 = 2 s0 – 2 s1 s
430.5 × 8 = 4 s0 – 4 s2 s
540.5 × 16 = 8 s (ชน cap พอดี)0 – 8 s4 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 ชั่วโมงแรก ถ้าเลยไปแล้วยังไม่รู้ผล ให้หยุด แล้วไปตรวจสถานะจริงก่อน

retry 10.5 วิretry 21 วิretry 32 วิretry 44 วิretry 58 วิรอจริง = สุ่มระหว่าง 0 ถึงเพดาน (full jitter)
เพดานรอโตเท่าตัว สุ่มจริงระหว่าง 0 ถึงเพดาน

ข้อผิดพลาดที่เจอบ่อย

  • สร้าง 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 ในฐานข้อมูลคือสิ่งที่กันเงินซ้ำจริง โค้ดที่ “เช็กก่อน” เป็นแค่ทางลัดให้ตอบเร็ว

แหล่งอ้างอิง

คำถามที่พบบ่อย

ใช้ 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

คำสั่งและค่าตั้งในบทความทดสอบบนระบบตัวอย่าง ลองบนเครื่องทดสอบก่อนใช้กับระบบจริงเสมอ

อ่านต่อ

#โค้ดกลาง

Background job และ queue: ย้ายงานช้าออกจาก request พร้อม retry ที่ไม่ส่งซ้ำ

ส่งอีเมล ย่อรูป เรียก AI ไม่ควรทำใน request ทำ job queue ด้วย Postgres SKIP LOCKED พร้อม retry แบบ backoff, visibility timeout, dead-letter และทำให้ job รันซ้ำได้ปลอดภัย

อ่าน 15 นาที
#โค้ดกลาง

SQLite บน production ปี 2026: ตั้ง WAL สำรองด้วย Litestream และเมื่อไรต้องย้าย

เว็บเล็กถึงกลางบนเซิร์ฟเวอร์เครื่องเดียว ใช้ SQLite แทน Postgres ได้เมื่อไร pragma ที่ต้องตั้ง เขียนทีละคนทำไมถึงพอ สำรองยังไง และจุดที่มันพัง พร้อมตัวเลขจริง

อ่าน 12 นาที
#โค้ดกลาง

Go ใน Docker: GOMAXPROCS ตาม CPU limit (Go 1.25) และ Green Tea GC (Go 1.26)

Go 1.25 ตั้ง GOMAXPROCS ตาม CPU limit ของ container เอง Go 1.26 เปิด Green Tea GC เป็นค่าเริ่มต้น สิ่งที่ต้องเช็กใน Dockerfile และ go.mod พร้อมผลวัดจริง

อ่าน 9 นาที