API Idempotency Design Patterns: Idempotency Keys, Distributed Deduplication, and Payment-Safe APIs

后端开发

Summary

  • Idempotency is the "seat belt" of distributed systems: network retries, duplicate messages, and double-clicks can all cause multiple API invocations
  • Idempotency Keys are the standard approach for HTTP API idempotency — adopted by Stripe, Alipay, and WeChat Pay
  • Three layers of idempotency defense: client idempotency key → server dedup table → database unique constraint
  • Payment, order creation, and inventory deduction are the top 3 idempotency-critical APIs — a must-know interview topic
  • This article provides a complete solution from theory to Go implementation, with Redis + PostgreSQL dual-safety architecture

Table of Contents


What Is Idempotency and Why It Is Mandatory

One-Line Definition

Executing the same request any number of times produces the same result as executing it once, with no extra side effects.

Mathematical expression: f(f(x)) = f(x)

Why Distributed Systems Always Get Duplicate Requests

Source Typical Scenario Frequency
User double-click Pay button, submit order Daily occurrence
Network timeout retry Client auto-retry, gateway retry Production norm
Message queue redelivery Kafka at-least-once, RabbitMQ redelivery By design
Load balancer retry Nginx/Envoy upstream timeout retry Frequent if misconfigured
Microservice chain retry Downstream timeout, upstream resends More common with longer chains

Real Cost of Missing Idempotency

In 2025, a second-hand trading platform had a "duplicate charge" incident: after a user clicked pay, network jitter caused the client to auto-retry 3 times. The server had no idempotency check, charging the same order 3 times — over 2 million CNY involved, with regulatory investigation.

Idempotency is not an optimization — it is an entry requirement for payment/transaction systems.


Four Levels of Idempotency

Level 0: Naturally Idempotent (HTTP GET/PUT/DELETE)

POST and PATCH are NOT idempotent by default — each call may create a new resource or produce new side effects.

Level 1: Business-Layer Idempotency

Check if the order already exists and return the previous result. Simple but vulnerable to concurrent race conditions.

Level 2: Idempotency Key + Dedup Table

Client sends a unique idempotency key; server records whether this key has been processed.

Level 3: Database Unique Constraint (Final Defense)

CREATE UNIQUE INDEX idx_orders_idempotency_key
ON orders (idempotency_key)
WHERE idempotency_key IS NOT NULL;

Production recommendation: Level 2 + Level 3 combined.


Idempotency Key Design Standards

Rule Description Example
Client-generated Caller generates UUID before request 550e8400-e29b-41d4-a716-446655440000
Uniqueness Same business operation uses same key Must reuse original key on retry
Scope Usually bound to user_id + operation type Different users can use same key format
TTL 24-72 hours Reusable after expiry (rare)
Transport HTTP Header Idempotency-Key: <uuid>

Stripe Idempotency Key Standard

Stripe logic: check key → not exists: execute and store result (201) → exists with same params: return cached result (200) → exists with different params: return 409 Conflict.

Idempotency Key vs Request ID vs Trace ID

Field Purpose Lifecycle Used for Dedup
Idempotency-Key Business idempotency 24-72h Yes
X-Request-ID Request tracing Single request No
Trace-ID Distributed tracing Single call chain No

Three-Layer Idempotency Defense Architecture

Layer 1: Redis SETNX Fast Intercept

func (s *IdempotencyService) TryAcquire(ctx context.Context, key string, ttl time.Duration) (bool, error) {
    redisKey := fmt.Sprintf("idem:%s", key)
    acquired, err := s.redis.SetNX(ctx, redisKey, "processing", ttl).Result()
    if err != nil {
        return false, fmt.Errorf("redis setnx: %w", err)
    }
    return acquired, nil
}

Layer 2: Idempotency Records Table

CREATE TABLE idempotency_records (
    id              BIGSERIAL PRIMARY KEY,
    idempotency_key VARCHAR(64) NOT NULL,
    request_hash    VARCHAR(64) NOT NULL,
    response_status INT,
    response_body   JSONB,
    status          VARCHAR(20) NOT NULL DEFAULT 'processing',
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    expires_at      TIMESTAMPTZ NOT NULL,
    CONSTRAINT uq_idempotency_key UNIQUE (idempotency_key)
);

Layer 3: Business Table Unique Constraint

Even if Layers 1 and 2 fail (Redis down, app bug), the database unique constraint is the last line of defense.


Go Production Idempotency Middleware

func (m *IdempotencyMiddleware) Handle() gin.HandlerFunc {
    return func(c *gin.Context) {
        key := c.GetHeader("Idempotency-Key")
        if key == "" {
            c.Next()
            return
        }

        ctx := c.Request.Context()
        cached, err := m.getCachedResponse(ctx, key)
        if err == nil && cached != nil {
            c.JSON(cached.Status, cached.Body)
            c.Abort()
            return
        }

        acquired, err := m.redis.SetNX(ctx, "idem:"+key, "1", m.ttl).Result()
        if !acquired {
            for i := 0; i < 30; i++ {
                time.Sleep(100 * time.Millisecond)
                cached, err = m.getCachedResponse(ctx, key)
                if cached != nil {
                    c.JSON(cached.Status, cached.Body)
                    c.Abort()
                    return
                }
            }
            c.JSON(409, gin.H{"error": "duplicate request in progress"})
            c.Abort()
            return
        }

        blw := &bodyLogWriter{body: bytes.NewBufferString(""), ResponseWriter: c.Writer}
        c.Writer = blw
        c.Next()
        m.saveResponse(ctx, key, c.Writer.Status(), blw.body.String())
    }
}

The SETNX + poll wait pattern (100ms × 30 = 3 seconds) is the standard at Stripe, Alipay, and other major platforms.


Payment Idempotency in Practice

Payment Flow with Idempotency

  1. Client generates Idempotency-Key (UUID)
  2. POST /api/payments with header
  3. Idempotency check: Redis SETNX → dedup table → order status check
  4. Business processing in DB transaction
  5. Cache response to dedup table + Redis
  6. Return result

Three Special Considerations

  1. Payment channel idempotency: pass merchant order number (out_trade_no) to WeChat/Alipay
  2. State machine: pending → processing → success/failed — retries on success always return success
  3. Reconciliation fallback: daily reconciliation catches inconsistencies idempotency cannot prevent

Interview FAQs and Pitfall Guide

Q1: GET is idempotent, why not POST?

GET is read-only. POST is write — may create new resources. Idempotency keys make POST idempotent.

Q2: Does idempotency work if Redis is down?

Layers 2 and 3 still work. Redis is a performance layer, not the only guarantee.

Q3: Can distributed locks implement idempotency?

Possible but not optimal. Locks solve mutual exclusion; idempotency keys + dedup tables are simpler and more reliable.

Q4: Relationship between idempotency and transactions?

Idempotency ensures multiple executions = one execution. Transactions ensure atomicity. Payment needs both.

Pitfall Checklist

Pitfall Symptom Fix
Server-generated key Different key on retry Client must generate and reuse
Only cache success Failed retry returns old error Cache both success and failure
No TTL Unlimited data growth TTL 24-72h + cleanup job
No lock on concurrency Two requests pass check SETNX + poll wait
Ignore payment channel idempotency Local ok but channel charged twice Globally unique merchant order ID

Inventory Deduction Idempotency

Beyond payments, inventory deduction is the second most common idempotency scenario. Unlike payments (create new records), inventory updates existing rows — race conditions are subtler.

Use idempotency key + conditional update:

result, err := tx.ExecContext(ctx, `
    UPDATE inventory SET quantity = quantity - $1, version = version + 1
    WHERE product_id = $2 AND quantity >= $1
`, req.Quantity, req.ProductID)

WHERE quantity >= $1 is the database-level atomic guard against overselling.


Distributed Environment Challenges

  • Multi-instance: SETNX + poll wait pattern (Stripe standard)
  • Redis failover: Redis is acceleration only; DB unique constraint is final defense
  • Clock skew: Use NOW() + INTERVAL from database, not local clocks

Idempotency with Message Queues

Kafka producer idempotence (enable.idempotence=true) only covers single-producer-to-single-partition retries. Consumers still need business-level dedup via processed_events table or business unique keys.


Performance Optimization

Approach P99 Latency Suitable QPS
DB unique constraint only 15-30ms < 1,000
Redis SETNX + DB 3-8ms < 10,000
Redis + Bloom Filter + DB 1-3ms < 50,000

Enterprise Case Study

A mid-size payment platform (2M daily transactions) eliminated duplicate charges after implementing three-phase rollout: Idempotency-Key headers → shared middleware → partitioned idempotency table with 72h TTL. Result: zero duplicate charge incidents over 8 months, P99 check latency 4.2ms.


Advanced Interview Questions

Q7: Return 201 or 200? First creation returns 201; idempotent replay returns 200 with cached result.

Q8: Global or per-user unique key? Prefer UNIQUE(user_id, idempotency_key).

Q9: GraphQL mutations? Same Idempotency-Key HTTP header at gateway level.


Hands-On: 30-Minute Idempotent Payment API

curl -X POST http://localhost:8080/api/payments \
  -H "Idempotency-Key: test-key-001" \
  -d '{"order_id":"ORD-001","amount":9900}'
# Repeat same request — should return identical result, no double charge

Gateway plugins (Envoy, Kong) for first-line dedup; OpenTelemetry integration for idempotency hit rate monitoring; AI Agent Function Calling requires API-level idempotency as last defense.


Summary and Further Reading

Remember the core formula: Client idempotency key + Redis SETNX + Database unique constraint = Production idempotency.

Related reading:

References:

Try these browser-local tools — no sign-up required →

#API幂等性#分布式幂等#Idempotency Key#支付接口设计#面试高频#2026