你的服务在处理 POST /charge 请求时中途崩溃。客户端看到超时,于是重试。结果出现了两笔扣款。客户很生气。数据库是一致的。但业务逻辑不是。

这不是边缘情况。这是分布式系统的默认行为。网络丢包。容器在请求中途被 OOM 杀掉。负载均衡器对已经到达后端的请求返回 502。如果你的 API 假设”我收到了错误,所以操作肯定没发生”,那你就是在制造 bug。

你无法逃避的 at-least-once 现实

HTTP 默认就是 at-least-once。TCP 会重传丢失的数据包。你的 HTTP 客户端会在超时时重试。你的基础设施会在 5xx 时重试。每一层都假设前一层可能失败,然后重新发送。

问题不是重试。问题是操作不具备幂等性。

GET 可以安全重试,因为读两次和读一次效果一样。但 POST /chargePOST /orders 不行。执行两次就会创建两个资源。执行零次就会丢单。在不可靠的网络上,你无法选择”exactly once”。你只能选择:带去重的”at least once”,或者数据损坏的”可能零次,可能两次”。

幂等性让”at least once”变得安全。

幂等键的实际工作原理

Stripe 推广了这个模式,但思路其实更古老。客户端生成一个唯一键(UUID),通过请求头发送:Idempotency-Key: <uuid>。服务器在执行副作用之前,先存储三元组 (key, request_body, response)。如果相同的键再次到达,服务器直接返回已存储的响应,而不重新执行操作。

核心洞察:服务器必须先存储键,再做实际工作,而不是反过来。如果你先写 charge 行,然后在存储键之前崩溃了,重试就会创建第二笔扣款。幂等键的存储和业务变更必须是原子的。

实践中,这意味着两种方案之一:

  1. 幂等存储和业务存储共享同一个数据库事务。 在同一个 BEGIN ... COMMIT 中同时插入 idempotency_keyscharges。如果提交成功,两者都存在。如果失败,两者都不存在。

  2. 幂等存储就是业务存储。 你的 charges 表有一个带 UNIQUE 约束的 client_idempotency_key 列。重试时会触发唯一性冲突失败,你直接返回已有行。

方案 2 更简单,大多数团队应该从它开始。

原子存储的完整实现示例

下面是一个最小但完整的 Python 服务器,使用 SQLite。幂等键与业务变更处于同一个事务中。如果服务器在 COMMIT 之后崩溃,重试会命中缓存。如果在 COMMIT 之前崩溃,没有任何数据被持久化,重试可以安全地重新执行。

import sqlite3
import json
import uuid

DB_PATH = "/data/inventory.db"

def init_db():
    conn = sqlite3.connect(DB_PATH)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS reservations (
            id TEXT PRIMARY KEY,
            item_id TEXT NOT NULL,
            quantity INTEGER NOT NULL,
            idempotency_key TEXT UNIQUE NOT NULL,
            created_at INTEGER
        )
    """)
    conn.execute("""
        CREATE TABLE IF NOT EXISTS idempotency_responses (
            key TEXT PRIMARY KEY,
            response_body TEXT NOT NULL,
            created_at INTEGER
        )
    """)
    conn.commit()
    return conn

def reserve_inventory(conn, idempotency_key, item_id, quantity):
    # Step 1: Check for an existing response.
    cursor = conn.execute(
        "SELECT response_body FROM idempotency_responses WHERE key = ?",
        (idempotency_key,)
    )
    row = cursor.fetchone()
    if row:
        return json.loads(row[0])

    # Step 2: Do the work inside a single transaction.
    conn.execute("BEGIN")

    # Deduct inventory atomically.
    conn.execute(
        "UPDATE inventory SET available = available - ? WHERE item_id = ? AND available >= ?",
        (quantity, item_id, quantity)
    )
    if conn.total_changes == 0:
        conn.rollback()
        return {"error": "insufficient inventory"}

    # Record the reservation.
    reservation_id = str(uuid.uuid4())
    conn.execute(
        "INSERT INTO reservations (id, item_id, quantity, idempotency_key, created_at) VALUES (?, ?, ?, ?, strftime('%s','now'))",
        (reservation_id, item_id, quantity, idempotency_key)
    )

    # Cache the response for retries.
    response = {"reservation_id": reservation_id, "status": "reserved"}
    conn.execute(
        "INSERT INTO idempotency_responses (key, response_body, created_at) VALUES (?, ?, strftime('%s','now'))",
        (idempotency_key, json.dumps(response))
    )

    conn.commit()
    return response

idempotency_responses 表就是安全网。第一次请求执行变更、提交结果、缓存响应。之后每一个携带相同键的请求都会跳过实际工作,直接返回缓存的 JSON。预约记录和缓存条目在同一个事务中写入,因此它们要么同时可见,要么同时不存在。

失效边界:副作用之外

幂等键处理来自同一客户端的重复请求。它不能处理来自不同客户端的并发重复请求。如果两个用户同时点击库存最后一件商品的”购买”,你仍然需要悲观锁或乐观并发控制。上面示例中的 available >= ? 检查是一种最基础的形式,但真实的库存系统需要更完善的机制。

更大的问题是事务之外的副作用。如果你通过 Stripe 扣款、通过 SendGrid 发邮件、再写入自己的数据库,幂等键只保护数据库部分。邮件可能发送两次。如果 Stripe 自身的幂等窗口已过期,卡也可能被扣两次。真正的安全需要每一个下游系统都参与。

这就是为什么 Stripe 在创建扣款时接受它自己的 Idempotency-Key。他们在自己的层做去重。你也应该在自己的层做同样的事。把相同的键透传给任何支持幂等的下游服务。对于不支持幂等的服务,把调用包装在本地事务中,或者接受风险。

键冲突、TTL 和其他运维陷阱

UUID4 有 122 位随机数。对于任何现实体量,冲突概率都可以忽略不计。不要用顺序整数、时间戳或请求体哈希作为键。客户端生成的 UUID 是行业标准,这是有原因的。

如果不清理旧条目,键存储会无限增长。设置 TTL:24 小时是行业标准。过期后删除旧键。如果客户端在 TTL 之后重试,就会得到重复结果。把这一点写进文档。重试窗口和 TTL 是业务契约,不是技术细节。

幂等存储的可用性至少不能低于 API 本身。如果 Redis 缓存挂了,你就无法验证键。有些团队降级为”假设是新请求”,这会在故障期间制造重复。另一些团队直接拒绝请求,这更安全,但制造了另一种故障模式。这里没有免费的午餐。

客户端同样重要

如果客户端不发送幂等键,服务器端的幂等键就毫无用处。每一次变更请求都应该在调用点生成一个键,并在超时时用同一个键重试:

import uuid
import requests

def safe_post(url, payload, max_retries=3):
    key = str(uuid.uuid4())
    for attempt in range(max_retries):
        try:
            resp = requests.post(
                url,
                json=payload,
                headers={"Idempotency-Key": key},
                timeout=10,
            )
            return resp
        except requests.Timeout:
            if attempt == max_retries - 1:
                raise
            # Retry with the SAME key. The server deduplicates.

键必须按逻辑操作只生成一次,而不是每次 HTTP 尝试都生成一次。如果你在每次重试时都生成新的 UUID,那就完全违背了初衷。键是把多次重试绑定在一起的契约。

常见问题

如果客户端是浏览器,用户刷新了页面怎么办?

页面刷新会创建新的 JavaScript 上下文。新请求会得到新的幂等键,除非你把它持久化到 localStorage 或 URL 里。对于非关键流程,大多数团队懒得处理。但对于支付,你应该处理。

GET 请求需要用幂等键吗?

不需要。GET 按 HTTP 语义已经是安全的。幂等键用于会改变状态的方法:POST、PUT、PATCH、DELETE。

我可以用请求体哈希作为幂等键吗?

只有当请求体是确定的、不包含时间戳或随机值时才可以。实践中,客户端生成的 UUID 更简单、更可靠。

幂等键应该保留多久?

比你最长的客户端重试窗口更长。如果客户端在 60 秒内重试,保留 24 小时。如果客户端可能因为批处理作业明天才重试,保留一周。

今天就让你的第一个请求具备幂等性

给每一个变更端点加上 Idempotency-Key 请求头。从 24 小时缓存开始。用 UUID4。让存储与业务事务保持原子性。第一次有容器在请求中途死掉、客户端重试时,你会庆幸自己这么做了。否则,你就得去跟财务团队解释,为什么同一个客户有十七笔一模一样的扣款。