2017年2月,一位开发者在Stripe官方博客下留言:他刚刚经历了一次"幽灵扣款"——用户点击支付按钮后页面超时,刷新重试,结果被扣了两次款。这不是技术故障,而是系统设计缺陷。在网络不可靠的世界里,重试是常态;如果没有幂等性设计,每一次重试都可能成为数据灾难的开始。

这个问题比想象中普遍。支付系统、订单系统、消息队列……任何涉及状态变更的系统都面临同一个挑战:当同一个请求被执行多次时,如何保证结果的一致性?

从数学到工程:一个概念的百年演进

“幂等"这个词听起来像现代计算机术语,实际上诞生于1870年。美国数学家本杰明·皮尔斯(Benjamin Peirce)在那年发表的《线性结合代数》中创造了这个词——拉丁语"idem”(相同)与"potence"(能力)的组合。他定义了一种特殊的代数元素:当元素自乘时,结果仍然是它自己。用数学语言表达:若 x · x = x,则 x 是幂等的。

这个抽象概念在计算机科学中找到了广阔的应用空间。一个操作如果满足"执行任意次数与执行一次的效果相同",就是幂等的。绝对的值函数是经典例子:abs(abs(-5)) = abs(-5) = 5。无论你对一个数取多少次绝对值,结果始终一致。

把这个概念映射到API世界:如果一个接口被调用一次或十次,系统最终状态完全相同,那这个接口就是幂等的。这个性质在网络不稳定、客户端重试、服务超时等场景下至关重要。

HTTP 协议的幂等性划分

RFC 7231(2014年6月发布的HTTP/1.1语义规范)对请求方法做了明确分类:

HTTP方法 幂等性 安全性 说明
GET 获取资源,不改变服务器状态
HEAD 获取响应头,不改变服务器状态
PUT 替换资源,多次执行结果相同
DELETE 删除资源,删除已不存在的资源仍返回相同结果
POST 创建资源,多次执行可能创建多条记录
PATCH 部分更新,依赖当前状态的操作不幂等

GET 和 HEAD 天然幂等,因为它们只是"读"。PUT 和 DELETE 也被规范定义为幂等——PUT /users/123 将 ID 为 123 的用户替换为指定数据,无论执行多少次,最终状态都是那个用户变成指定数据。DELETE 同理,删除一个资源后再次删除,资源仍然不存在。

POST 是麻烦制造者。每次 POST /orders 都可能创建一个新订单。如果用户因网络超时点击了两次提交按钮,服务器可能创建两个完全相同的订单。

幂等键:给请求一张身份证

既然 POST 不天然幂等,那就人为设计机制让它幂等。最主流的方案是幂等键

核心机制

幂等键的核心思想很简单:给每一个"逻辑请求"分配一个唯一标识符,服务器记录已处理过的标识符。当相同标识符再次出现时,直接返回之前的结果,而不是重新执行操作。

POST /payments
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json

{
  "amount": 100.00,
  "currency": "USD",
  "customer_id": "cus_abc123"
}

客户端在发送请求前生成一个 UUID(或其他足够随机的字符串),放入 Idempotency-Key 请求头。服务器处理请求时:

  1. 检查数据库中是否存在该幂等键
  2. 如果存在,直接返回存储的响应
  3. 如果不存在,执行业务逻辑,然后将幂等键和响应一起存储

Stripe 在2017年的技术博客中详细阐述了这一机制的设计理念。他们的实现有几个值得注意的细节:

存储完整响应:不仅仅是标记"已处理",而是存储完整的 HTTP 状态码和响应体。这样当客户端重试时,它收到的响应与第一次成功时完全一致——包括任何动态生成的字段(如时间戳)。

参数校验:如果同一个幂等键携带了不同的请求参数,服务器会返回 400 Bad Request。这防止了客户端错误地复用幂等键。

过期策略:幂等键不是永久存储的。Stripe 默认保留 24 小时,足够覆盖绝大多数重试场景,又不会让存储无限膨胀。

竞态条件:并发请求的陷阱

幂等键的实现看似简单,但有一个致命陷阱:竞态条件。考虑这段伪代码:

async def process_payment(idempotency_key, request):
    existing = await db.find_by_key(idempotency_key)
    if existing:
        return existing.response
    
    # 危险!多个请求可能同时到达这里
    payment = await execute_payment(request)
    await db.save(idempotency_key, payment)
    return payment

当两个携带相同幂等键的请求几乎同时到达时:

  • 请求 A 查询数据库,发现没有记录
  • 请求 B 查询数据库,发现没有记录
  • 请求 A 执行支付,存储结果
  • 请求 B 执行支付,存储结果

结果是:同一个用户的账户被扣了两次款。

解决方案一:数据库唯一约束

最可靠的方法是让数据库来保证唯一性。在 idempotency_keys 表上创建唯一索引:

CREATE TABLE idempotency_keys (
    key VARCHAR(255) PRIMARY KEY,
    user_id BIGINT NOT NULL,
    request_hash VARCHAR(64) NOT NULL,
    response_status INT,
    response_body JSONB,
    created_at TIMESTAMP DEFAULT NOW()
);

当并发插入相同 key 时,只有一个能成功,其他会抛出唯一约束异常。应用代码捕获异常后,再查询已存在的结果返回。

async def process_payment(idempotency_key, request):
    try:
        # 先插入幂等键记录(状态为 PENDING)
        await db.insert(
            idempotency_key, 
            request_hash=hash(request),
            status='PENDING'
        )
        # 执行支付
        payment = await execute_payment(request)
        # 更新结果
        await db.update(idempotency_key, payment)
        return payment
    except UniqueConstraintError:
        # 幂等键已存在,查询并返回
        existing = await db.find_by_key(idempotency_key)
        
        # 等待正在处理的请求完成
        while existing.status == 'PENDING':
            await sleep(100ms)
            existing = await db.find_by_key(idempotency_key)
        
        return existing.response

解决方案二:分布式锁

在高并发场景下,可以先用 Redis 获取分布式锁,再执行业务逻辑:

async def process_payment(idempotency_key, request):
    lock_key = f"lock:{idempotency_key}"
    
    # 尝试获取锁,30秒超时
    acquired = await redis.set(lock_key, "1", nx=True, ex=30)
    if not acquired:
        # 等待其他请求处理完成
        return await wait_for_result(idempotency_key)
    
    try:
        # 检查是否已处理
        existing = await db.find_by_key(idempotency_key)
        if existing:
            return existing.response
        
        # 执行支付
        payment = await execute_payment(request)
        await db.save(idempotency_key, payment)
        return payment
    finally:
        await redis.delete(lock_key)

缓存什么,不缓存什么

幂等键实现中有一个容易被忽视的问题:应该缓存哪些响应?

一位开发者在金融提现API的调试中遇到了诡异的问题:修复了一个数据库查询bug后,重新测试却仍然返回同样的500错误。排查了许久才发现,幂等性中间件缓存了之前的错误响应。

应该缓存的响应

  • 200-204:成功响应
  • 409 Conflict:业务冲突(如余额不足),重试也无法解决
  • 某些永久的 400 错误

不应该缓存的响应

  • 500 Internal Server Error:服务端可能正在修复
  • 429 Too Many Requests:稍后重试可能成功
  • 401/403:认证问题可能已解决
  • 大多数 400 错误:输入可能在重试时被修正

一个实用的判断原则:这个问题在稍后重试时能解决吗?如果能,就不要缓存。

超越API:分布式系统中的幂等性

幂等性不仅是API设计问题,更是分布式系统的核心挑战。

消息队列的去重困境

消息队列(如 Kafka、RabbitMQ)通常提供"至少一次"投递语义。这意味着消息可能被投递多次——网络分区、消费者崩溃、ACK丢失都可能导致重复投递。

Kafka 在 0.11 版本引入了"恰好一次"语义,其核心机制与幂等键类似:

生产者幂等性:每个生产者实例分配一个 producer_id,每条消息附带一个递增的序列号。Broker 检测到相同 producer_id 和序列号的消息时,会丢弃重复项。

事务机制:Kafka 支持跨多个分区的原子写入。生产者可以开启事务,将消息写入多个分区,要么全部成功,要么全部失败。消费者配置 isolation.level=read_committed 后,只会读取已提交的事务消息。

但 Kafka 的恰好一次语义只保证"消息系统内部"的一致性。如果消费者的业务逻辑涉及外部系统(如写入数据库、调用第三方API),仍然需要业务层实现幂等性。

TCC 分布式事务的三类陷阱

TCC(Try-Confirm-Cancel)是分布式事务的常见模式,它将一个操作拆分为三个阶段:Try 阶段预留资源,Confirm 阶段提交,Cancel 阶段回滚。TCC 面临三类幂等性问题:

空回滚:Try 请求因网络问题未到达服务提供者,但事务协调器认为 Try 失败,发起了 Cancel 请求。此时服务提供者没有任何资源需要释放,但 Cancel 方法被调用了。

悬挂:Try 请求在网络中延迟,Cancel 请求先到达并执行成功。之后 Try 请求终于到达,预留了资源——但这些资源永远无法被释放,因为 Cancel 已经执行过了。

幂等性:Confirm 或 Cancel 请求因网络问题未收到响应,事务协调器重试。服务提供者可能收到多次 Confirm 或 Cancel。

Seata 框架在 1.5.1 版本通过 tcc_fence_log 表解决了这三个问题。核心思路是:在 Try 阶段插入一条记录(状态为 TRIED),后续阶段检查记录状态决定如何处理。

CREATE TABLE tcc_fence_log (
    xid VARCHAR(128) NOT NULL,          -- 全局事务ID
    branch_id BIGINT NOT NULL,          -- 分支事务ID
    status TINYINT NOT NULL,            -- 1:TRIED, 2:COMMITTED, 3:ROLLBACKED, 4:SUSPENDED
    PRIMARY KEY (xid, branch_id)
);
  • 幂等:Confirm/Cancel 时检查状态,如果已经是 COMMITTED/ROLLBACKED,直接返回成功
  • 空回滚:Cancel 时如果找不到记录,插入一条 SUSPENDED 状态的记录(不执行业务逻辑)
  • 悬挂:Try 时如果发现记录已存在(状态为 SUSPENDED),拒绝执行

实践中的权衡

幂等性设计不是免费的午餐,需要权衡多个因素。

TTL 的选择

幂等键应该保留多久?Stripe 选择 24 小时,AWS Lambda Powertools 默认 1 小时。选择 TTL 时需要考虑:

  • 客户端最大重试周期:如果客户端配置了指数退避重试,最大间隔可能是几分钟到几十分钟
  • 跨时区问题:用户在周五发起的请求,周一客服可能需要调查,这时记录还存在会很方便
  • 存储成本:高吞吐系统,幂等键表可能快速增长

一个折中方案:对支付等关键操作保留 72 小时或更长,对一般操作保留 24 小时。

性能开销

幂等键实现增加了额外的存储查询和写入。对于高并发系统,可以考虑:

  • Redis 前置缓存:先查 Redis,命中则直接返回;未命中再查数据库
  • 异步清理:使用后台任务定期清理过期记录,而不是在请求路径上删除
  • 批量操作:对于批量导入等场景,可以在业务逻辑内部处理幂等,而不是每条记录单独处理

幂等与并发的矛盾

幂等性本质上是将"并行"强制变为"串行"。当多个相同请求同时到达时,只有一个能执行业务逻辑,其他必须等待。这对性能有影响,但这是保证一致性的必要代价。

关键是在保证正确性的前提下最小化等待时间。数据库唯一约束 + 轮询是一种方案;Redis 分布式锁 + 快速失败是另一种方案。选择取决于业务对延迟的敏感程度。

回到起点

幂等性不是一个可以事后添加的特性,而是系统设计时必须考虑的基础属性。在网络不可靠、客户端不可信、服务器会崩溃的现实世界里,幂等性是系统可靠性的基石。

它的实现并不复杂——一个唯一键、一个存储、一套正确处理并发的逻辑。但正是这些看似简单的机制,阻止了无数"幽灵扣款"、“重复订单”、“数据漂移"的发生。

当你下次设计一个会改变系统状态的 API 时,问自己一个问题:如果这个请求被执行十次,会发生什么?如果你的答案不是"和执行一次相同”,那么你的系统还需要幂等性设计。