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 请求头。服务器处理请求时:
- 检查数据库中是否存在该幂等键
- 如果存在,直接返回存储的响应
- 如果不存在,执行业务逻辑,然后将幂等键和响应一起存储
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 时,问自己一个问题:如果这个请求被执行十次,会发生什么?如果你的答案不是"和执行一次相同”,那么你的系统还需要幂等性设计。