用户点了一次”支付”,结果扣了两笔钱。这类事故几乎总能追溯到同一个原因:某个环节没做幂等。另一面是,团队知道要做幂等,于是给每个接口都加了一张去重表,最后系统又慢又复杂,边角场景上依然会漏。
幂等不是”加个锁”或”加张表”,而是一套需要分层的防护体系。
幂等的严格定义
数学上的定义:对同一个操作的多次执行与一次执行产生的效果相同。
注意定义里说的是”效果”,不是”返回值”。这一点常被误读,导致一些不必要的复杂设计。比如下单接口,第一次请求返回:
{ "code": 0, "data": { "order_id": "SO202610210001", "status": "created" } }
重复请求返回:
{ "code": 0, "data": { "order_id": "SO202610210001", "status": "created" } }
两次返回一致当然最好,但幂等并不强制要求这一点。你可以让重复请求返回 { "code": 4090, "message": "重复请求" },只要”订单只创建了一次”这个副作用成立,它就是幂等的。反过来说,如果两次都返回成功却生成了两个订单,那么无论返回值多一致,它都不是幂等的。
幂等的作用域也要写清楚。 “同一笔订单只支付一次”是业务级幂等,”同一个幂等键只处理一次”是传输级幂等,多数事故出在把后者当成了前者。
重复请求从哪里来
防护体系要先搞清楚敌人是谁。
| 来源 | 典型场景 | 特征 | 是否可避免 |
|---|---|---|---|
| 客户端重试 | 超时后重发、SDK 自动重试 | 请求内容完全相同 | 否,超时不可判定 |
| 网络中间件重传 | 网关重试、负载均衡重试、TCP 重传 | 服务端可能收到两次完整请求 | 否 |
| 消息队列重复投递 | 消费者处理成功但 ACK 丢失 | 同一条消息被多次消费 | 否,至少一次语义决定 |
| 用户重复点击 | 双击按钮、刷新提交页、回退再提交 | 内容相同但携带不同的请求 ID | 部分可避免 |
第三条最容易被忽略。主流消息队列普遍提供”至少一次”投递语义:消费者处理完了,提交 offset 时网络抖动失败,消息会被重新投递。这不是缺陷,而是”处理”和”确认”两个动作无法原子化的必然结果。
第四条则说明了一件关键的事:靠请求 ID 做幂等,挡不住用户重复点击。
幂等键的设计
幂等键的选择是整个设计里最容易出错的一步。
请求唯一性 vs 业务唯一性。 一个常见的做法是让客户端生成一个 UUID 作为 request_id,服务端用它做去重:
POST /api/orders
Idempotency-Key: 8f14e45f-ceea-467a-9982-9b1c2f3d4e5a
这能挡住客户端重试和网关重传——因为这两者会复用同一个 request_id。但用户双击按钮时,前端通常会重新生成一个 UUID,服务端收到两个不同的键,去重失效,生成两笔订单。
所以幂等键应该由业务要素构成,而不是由一次网络请求构成:
idempotency_key = hash(user_id + sku_id + cart_snapshot_hash + 时间窗口)
或者更实用的做法:加一个”幂等窗口”。同一个用户对同一个商品,在 5 分钟内只允许创建一笔订单,键就是 (user_id, sku_id, floor(now/5min))。时间窗口是个务实的妥协——它牺牲了”用户真的想在 5 分钟内买两次”的极端场景,换来了实现的简洁和防护的确定性。
哪些要素必须进键: 操作主体(用户/租户)、操作对象(商品/账户)、操作类型、业务时间窗口。哪些不该进: 随机值、纳秒时间戳、进程 ID、机器名——任何会让”同一个意图”产生不同键的东西。
三种实现方式
去重表 + 唯一约束
最通用的方案,适合”创建类”操作。
CREATE TABLE idempotency_record (
idempotency_key VARCHAR(128) NOT NULL,
biz_type VARCHAR(32) NOT NULL,
status TINYINT NOT NULL DEFAULT 0, -- 0处理中 1成功 2失败
result_snapshot JSON,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (biz_type, idempotency_key)
) ENGINE=InnoDB;
流程是:先插入一条 status=0 的记录,插入成功才执行业务,执行完更新为 status=1 并写入结果快照。插入失败(唯一键冲突)说明有人在处理或者已经处理过:
public Result createOrder(CreateOrderCmd cmd) {
String key = buildIdempotencyKey(cmd);
try {
idempotencyMapper.insertProcessing("order.create", key);
} catch (DuplicateKeyException e) {
IdempotencyRecord exist = idempotencyMapper.get("order.create", key);
if (exist.getStatus() == SUCCESS) {
return Result.duplicate(exist.getResultSnapshot()); // 返回首次结果
}
if (exist.getStatus() == PROCESSING) {
return Result.reject("处理中,请稍后重试");
}
// 失败态:允许重试,抢占这条记录
int n = idempotencyMapper.casStatus(key, FAILED, PROCESSING);
if (n == 0) return Result.reject("处理中,请稍后重试");
}
try {
Order order = doCreate(cmd);
idempotencyMapper.markSuccess("order.create", key, snapshotOf(order));
return Result.ok(order);
} catch (Exception ex) {
idempotencyMapper.markFailed("order.create", key);
throw ex;
}
}
result_snapshot 让重复请求返回首次结果而非一句”重复请求”,用户刷新页面后依然能看到订单号。
状态机前置校验
适合”更新类”操作,尤其是已经有状态流转的领域模型。
-- 只有 pending 状态的订单才能被支付,天然幂等
UPDATE orders
SET status = 'paid', paid_at = NOW(), pay_txn_id = ?
WHERE order_id = ?
AND status = 'pending';
-- 影响行数 0:要么已支付,要么订单不存在,都拒绝
关键在 WHERE status = 'pending' 这个条件。第二次执行时状态已经是 paid,影响行数为 0,直接拒绝。数据库的行锁保证了并发下只有一个事务能改成功。
如果改完了还需要区分”已支付”和”不存在”,再查一次即可,此时查到的状态是可信的终态。
Token 预领取
适合跨页面、跨步骤的操作,典型是提交表单。
1. 进入表单页 -> GET /api/form-token -> 服务端生成 token 存 Redis(TTL 10min),返回给前端
2. 提交表单 -> POST /api/submit 携带 token
3. 服务端 -> DEL token,删除成功才继续;删除失败说明已提交过
-- Redis 原子删除,返回 1 表示首次提交
local n = redis.call('DEL', KEYS[1])
return n
用 DEL 而不是 GET 再 DEL,是因为后者在并发下两个请求可能都读到 token 存在。删除本身是原子的检查加获取。
并发下的竞态
这是最经典的坑,值得单独拆开:
// 错误示范:先查后插
if (idempotencyMapper.exists(key)) {
return Result.duplicate();
}
idempotencyMapper.insert(key); // 两个请求同时走到这里,都插入成功
doBusiness();
SELECT 和 INSERT 之间存在时间窗口,两个并发请求都会通过检查。在默认的 READ COMMITTED 隔离级别下,这两个事务看不到对方的未提交数据,检查形同虚设。
唯一约束是唯一可靠的答案。 让数据库用唯一索引做仲裁,应用层捕获冲突异常。这也解释了为什么去重策略必须落在有唯一约束的存储上——用 Redis 做去重要用 SET NX,用数据库做去重要用 UNIQUE INDEX,本质都是在依赖某个系统提供的原子性。
另一个容易漏的竞态:业务执行成功、更新去重表状态失败。下一次请求会看到 PROCESSING 并拒绝,而业务其实已完成。缓解手段是给 PROCESSING 记录加超时,超时后允许重新抢占并做业务层校验(比如再查一次订单是否存在)。
幂等键的过期与清理
去重表会无限增长。清理策略有几种:
-- 按业务保留期清理,比 TTL 更可控
DELETE FROM idempotency_record
WHERE created_at < DATE_SUB(NOW(), INTERVAL 30 DAY)
LIMIT 5000;
- 保留多久取决于重试窗口。 支付类通常保留到对账周期结束;普通创建接口几小时到几天。
- 用分区表按天删除比
DELETE高效得多,直接DROP PARTITION,不留碎片。 - 不要把去重键和业务主键混在一张表里。 去重记录是技术设施,生命周期和业务数据不同步,混在一起会让归档变得极其别扭。
哪些天然幂等,哪些不可能
设计接口时,先判断它的数学性质,能省掉大量无用功。
天然幂等的: 赋值(SET name = 'x')、删除(DELETE WHERE id = 1,第二次影响行数为 0)、按主键更新、幂等的查询、状态设置为终态。
不可能幂等的: 自增(UPDATE t SET count = count + 1)、累加余额、追加日志、INSERT 无唯一约束的记录、发送通知类副作用。
对后者,要么改造语义,要么显式去重。改造语义的一个典型例子:把”余额增加 100 元”改成”记账流水 T123 已入账”,后者可以通过流水号的唯一约束实现幂等。这是把非幂等操作转成幂等操作的通用手法——引入一个稳定的事实标识,让”做了没有”变成可判定的。
一个务实的分层
从外到内,防护可以分成三层,每一层挡住不同的重复来源:
- 网关层:基于
Idempotency-Key头做短窗口(例如 60 秒)去重,挡掉网络重传。 - 应用层:业务幂等键加去重表或状态机校验,挡掉客户端重试和消息重复投递。
- 数据层:唯一约束兜底,保证即使前两层被绕过,数据也不会错。
三层里只有数据层是必须的,另外两层是性能和体验优化。想清楚这一点,就能避免在网关层堆砌复杂的去重逻辑,也能在资源有限时知道该先做哪一层。
评论(0)
还没有评论,来抢沙发。
请 登录 后参与评论。