微服务架构里,网络重试、消息重复投递、用户双击提交都会让同一个请求被执行多次。如果接口不保证幂等,就会产生重复订单、重复扣款、重复入账一类问题。本文从幂等的定义讲起,给出几个可落地的接口幂等实现方案,并给出在Spring Boot环境下的实现示例与注意点。
先明确:什么样的接口算幂等
幂等是指”同一个请求无论执行一次还是多次,产生的业务结果一致”。GET天然幂等,POST天然不幂等,PUT/PATCH部分幂等。开发时先区分三类接口:查询类(天然幂等)、写入类(需要用业务标识去重)、计数类(扣减库存、扣款,需要原子操作+幂等令牌)。
方案一:唯一键约束(数据库层幂等)
最简单可靠的方式,在业务表上建唯一索引,用业务主键保证同一条记录只能插入一次:
-- 订单表:业务单号+用户ID唯一
CREATE TABLE t_order (
id BIGINT PRIMARY KEY,
order_no VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
status TINYINT,
UNIQUE KEY uk_order_no (order_no)
);
// 捕获重复插入异常
try {
orderMapper.insert(order);
} catch (DuplicateKeyException e) {
// 重复请求,返回已存在的订单
return getByOrderNo(order.getOrderNo());
}
这个方案零额外存储成本,但依赖数据库唯一索引,对”同一订单多次修改状态”这类场景不适用,只能防重复插入。
方案二:Redis幂等令牌(分布式环境下更通用)
// 前端提交前先获取令牌,随请求携带
String token = UUID.randomUUID().toString();
redis.set(token, "1", 5, TimeUnit.MINUTES);
// 处理接口时先删除令牌(原子操作)
// 能删成功 -> 第一次请求,放行
// 删除失败 -> 重复请求,直接拒绝
boolean ok = redis.eval("if redis.call('get', KEYS[1]) then return redis.call('del', KEYS[1]) else return 0 end", ...);
关键点是用Lua脚本保证”校验+删除”的原子性,避免并发下两个请求同时通过校验。令牌过期时间不能太短,要覆盖用户提交到服务端确认之间的网络延迟。这套方案的缺点是接口需要配合改造,且Redis不可用时需要降级策略。
方案三:消息消费幂等(状态机+去重表)
MQ消费场景下,同一消息可能被多个消费者消费,幂等不能靠接口层保证,要在消费端做:
// 消费前先查去重表
if (dedupMapper.exists(requestId)) {
return; // 已处理过,直接ack
}
try {
processBiz(bizData);
dedupMapper.insert(requestId); // 处理成功后落去重记录
} catch (Exception e) {
// 失败不落去重记录,允许重试
}
这里务必要先处理业务再插入去重记录;反过来的话,业务失败但去重表已插入,后续重试会被跳过。去重表建议单独建,主键用request_id,定期清理三个月前的数据。
方案对比与选用原则
| 方案 | 实现成本 | 适用范围 | 限制 |
|---|---|---|---|
| 数据库唯一约束 | 低 | 插入类接口 | 无法处理状态更新 |
| Redis令牌 | 中 | 写接口全场景 | 依赖Redis高可用 |
| 去重表 | 中 | 消息消费、异步任务 | 需要定期清理 |
选用原则:能靠业务唯一键解决的(订单号、requestId)优先用数据库约束;需要跨服务幂等的用Redis令牌;消息场景用去重表。幂等是业务语义,必须让开发者在接口文档中声明”是否幂等、幂等键是什么”,这是工程规范里比代码更重要的部分。
幂等的验证与回归:并发压测
实现完用并发工具对同一请求压测:100并发打同一个幂等键,断言最终只产生一条业务记录。注意压测要覆盖”同一key不同请求体”和”不同key相同业务”两类异常情况,这两类最容易漏。幂等改造上线前,建议把”重试按钮连点”和”消息重复投递”两个场景写进回归用例。
原创文章,作者:小编,如若转载,请注明出处:https://www.yunthe.com/wei-fu-wu-mi-deng-xing-she-ji-cong-yuan-li-dao-luo-di-jie/