Idempotency store: a retried charge must not double-apply

idempotency-key
Mechanism lab

Idempotency store: a retried charge must not double-apply

The starter's handle() subtracts the amount on every call and never looks at the idempotencyKey, so a network retry of the same request charges twice. The fix is an idempotency key: keep a map from key → prior Result. On the first sight of a key, apply and store; on any later sight of the same key, return the stored Result with replayed=true instead of touching the balance. The key is the unit of 'exactly once', not the network call.

résumé · SK AX — staging-table + batch-worker dedup keyed on a request key, cutting ~20 monthly reprocessing/double-apply cases to ~0.

You'll predict the failure first, then write the fix and watch your own code drive the behavior.

Understand it step by step (한국어로 이해 → 영어로 말하기)

결제 요청이 네트워크 문제로 한 번 더 가는데, 돈은 딱 한 번만 빠져야 한다.
  1. 1

    손님이 '100원 중에 20원 결제'를 보낸다. 정상이면 잔액은 80원이 된다.

    Store store = new Store();   // balance = 100
    Store.Result r = store.handle("K1", 20);  // charge 20
    // r.balance == 80
    🗣 영어로 말해

    A client sends a request to charge 20 from a balance of 100.

    checking microphone…

  2. 2

    그런데 네트워크가 불안해서 같은 요청이 또 날아간다. 손님은 한 번 누른 줄 안다.

    ⚖️ Trade-off: 인터넷은 '한 번 보낸 게 진짜 한 번 도착했는지' 모른다. 그래서 안전하게 다시 보낸다.

    // flaky network: the SAME request is resent with the SAME key
    Store.Result retry = store.handle("K1", 20);  // same key K1
    🗣 영어로 말해

    On a flaky network, the same request gets sent again.

    checking microphone…

  3. 3

    지금 코드는 올 때마다 그냥 돈을 뺀다. 그래서 두 번 빠진다 (80원이 아니라 60원).

    ⚖️ Trade-off: '요청이 오면 처리한다'만 있으면, 같은 요청 두 번 = 두 번 처리 = 이중 결제.

    // naive handle() always subtracts -> retry double-charges to 60.
    // correct: guard on the key first
    Result prior = seen.get(idempotencyKey);
    if (prior != null) return new Result(true, prior.balance);  // no second charge
    🗣 영어로 말해

    The naive code charges on every call, so a retry double-charges down to 60.

    checking microphone…

  4. 4

    고치는 법: 손님이 보낼 때 영수증 번호 같은 '키'를 같이 붙인다. 재시도해도 키는 똑같다.

    ✅ Fix: 네트워크 횟수 말고 '키'를 한 번의 기준으로 삼는다. 그래야 재시도와 진짜 새 요청을 구분한다.

    🔧 도구:Idempotency-Key 헤더클라이언트 생성 UUID

    // the key is the unit of "exactly once", not the network call
    String idempotencyKey = request.header("Idempotency-Key");  // client UUID
    Store.Result r = store.handle(idempotencyKey, amount);
    🗣 영어로 말해

    I attach a client-supplied key, and a retry carries the same key.

    checking microphone…

  5. 5

    처음 보는 키: 돈을 빼고, 그 결과를 키 옆에 저장해 둔다. 둘을 같은 거래로 묶어서.

    ⚖️ Trade-off: 돈 빼는 것과 결과 저장이 따로 놀면, 둘 사이에서 죽었을 때 또 어긋난다.

    ✅ Fix: 효과(돈)와 키 저장을 같은 트랜잭션 한 묶음으로 커밋한다. 같이 되거나 같이 안 되거나.

    🔧 도구:map: key → 저장된 결과DB 유니크 제약 (key 컬럼)단일 트랜잭션

    // first time we see this key: apply the charge AND store the result together
    balance -= amount;
    Result r = new Result(false, balance);
    seen.put(idempotencyKey, r);   // same transaction as the side effect
    return r;
    🗣 영어로 말해

    On a new key I apply the charge and store the result in the same transaction.

    checking microphone…

  6. 6

    이미 본 키가 또 오면: 돈은 안 건드리고 저장해 둔 결과를 그대로 돌려준다 (replayed=true).

    ✅ Fix: 재시도는 '이미 했음'이라고 알려주는 셈. 잔액은 그대로 80원.

    🔧 도구:replayed=true 표시

    // seen key again: replay the stored result, do NOT charge
    Result prior = seen.get(idempotencyKey);
    if (prior != null) return new Result(true, prior.balance);  // balance stays 80
    🗣 영어로 말해

    If I've seen the key before, I replay the stored result and don't charge again.

    checking microphone…

  7. 7

    진짜 새 요청(다른 키)은 당연히 정상 처리한다. 막는 건 '같은 키'뿐이다.

    // a genuinely new key (K2) is a new request -> applies normally
    Store.Result r2 = store.handle("K2", 20);  // 80 -> 60
    // r2.replayed == false
    🗣 영어로 말해

    A genuinely new key is a new request and applies normally.

    checking microphone…

  8. 8

    비용: 키마다 한 줄씩 저장하니까 공간이 든다. 그래서 며칠 지난 건 지우는 보관 기간을 둔다.

    ⚖️ Trade-off: 키 기록을 영원히 들고 있을 순 없다 (계속 쌓인다).

    ✅ Fix: 보관 기간을 정해서 오래된 키는 정리. SK AX에선 스테이징 테이블 + 배치로 키 중복 제거해서 월 20건 재처리를 거의 0으로 줄였다.

    🔧 도구:TTL / 보관 기간스테이징 테이블 + 배치 워커

    -- cost: one row per key + a retention window; purge old keys
    DELETE FROM idempotency_keys WHERE created_at < NOW() - INTERVAL '7 days';
    🗣 영어로 말해

    The cost is one row per key and a retention window, and that's worth it.

    checking microphone…

8단계 영어를 다 말하면 → 이 메커니즘 전체를 영어로 설명할 수 있게 된다.

How it evolves — toy → production

The problem

Delivery is at-least-once (client retries, queue redelivers). The same request can arrive twice. The effect (charge, create) must apply once.

  1. 1In-memory seen-settoy — lost on restart
    Set<String> seen = new HashSet<>();
    if (!seen.add(requestId)) return;   // already processed

    Use when: Single instance, non-durable. Demos only.

    Cost / limit: Lost on restart; doesn't work across instances.

  2. 2Unique constraint on the idempotency keyproduction default
    -- unique key = the mutex
    CREATE TABLE idempotency(key VARCHAR PRIMARY KEY, result JSONB, created_at TIMESTAMP);
    
    // insert the key + apply the effect in ONE transaction
    @Transactional
    Result handle(String key, Cmd cmd) {
        try {
            repo.insert(key);          // duplicate -> unique violation
            Result r = apply(cmd);
            repo.saveResult(key, r);
            return r;
        } catch (DuplicateKeyException e) {
            return repo.getResult(key); // replay the prior result, don't re-apply
        }
    }

    Use when: Any create/charge that can be retried. Most cases.

    Cost / limit: Extra row + lookup on the hot path; a key-retention/TTL policy.

  3. 3Outbox + idempotent consumerevents / cross-service
    // producer: write state + an outbox row in ONE local transaction
    // consumer: dedupe on the message id before applying (processed-ids table or upsert)
    if (!processed.add(messageId)) return;   // at-least-once -> exactly-once effect

    Use when: A write must trigger a downstream call/event you don't own.

    Cost / limit: Carries duplicates (you dedupe them); a relay + a dedup store to operate.

Know the ladder — start at the simplest that meets the need, and say what you'd reach for next and why.

Explain it in English

Say it out loud in your own words — hit these terms — then check against the gold answer.

Say it in your own words — but hit these: idempotency · idempotency key · exactly once · dedup

checking microphone…