重复调用从三个方向来:用户连点、网络超时后的客户端重试、消息队列的重复投递。单体时代靠数据库事务就能兜住,拆成微服务后,网关、多节点、跨机房,每一跳都可能把同一个请求送两次。幂等的定义一句话:同一接口被调多次,副作用和调一次完全相同。
一个真实背景:某电商平台重构时,订单创建、支付回调接口缺幂等保障,用户在网络波动下重复下单,后台多出多条同名订单,客服投诉量翻倍。修完这个事故,团队对幂等的理解就不再是「加个唯一索引」了。
按场景选方案
支付回调这类资金接口:外部订单号加状态机,先查后写,状态只允许向前流转。别指望单一手段,唯一约束必须兜底。
消息消费端:消费前先记消息 ID(去重表或消息表),和业务主键绑定。只靠业务表唯一索引,遇到事务回滚后重新投递,完整性还是会出问题。
通用写接口:数据库唯一索引加 INSERT … ON DUPLICATE KEY UPDATE 或 INSERT IGNORE,让数据库自己判重,逻辑最短。一个细节:ON DUPLICATE KEY 命中已有行时 affected rows 返回 2,新增是 1,数据没变是 0——靠它做分支判断,先把语义搞清楚。
public Order createOrder(String orderId, OrderRequest request) {
Order order = orderMapper.selectByOrderId(orderId);
if (order != null) {
return order; // 幂等返回
}
// 注意:这里需要加分布式锁防并发插入
try {
return orderMapper.insert(new Order(orderId, request));
} catch (DuplicateKeyException e) {
return orderMapper.selectByOrderId(orderId);
}
}
全局 ID 怎么选
Snowflake 或带业务含义的单号生成器都行。UUID v4 不是不能用,是完全随机,在 InnoDB 里当主键索引会频繁页分裂,写入性能掉得厉害,分布式环境下不建议。订单创建接口的常规做法:前端生成全局唯一 ID 传给后端,插入前先查该 ID 是否存在,主键冲突就直接返回已有订单,不再重复创建。
两个实测过的坑
Redis setnx 加过期时间分成两步执行不是原子操作:setnx 成功后进程挂掉,没有过期时间的锁就成死锁。用 SET key value NX EX 一条命令替代。
锁丢失更隐蔽:直接用 setnx 做幂等判断,Redis 主从切换后锁没同步过去,重复请求就穿过了校验。用 Redisson 能缓解——看门狗机制默认给锁 30 秒租约,业务没执行完会自动续期(lockWatchdogTimeout 默认 30000 毫秒)——但资金类接口仍要加数据库唯一索引做最后防线。缓存方案永远当第一道,不是唯一一道。
另外注意:分布式环境下时钟偏差会影响时间戳去重,NTP 校时漂移会让时间窗口去重误判,关键链路别用它。
别过度设计
查询接口天然幂等,不需要加锁加表。幂等的本质是权衡:一致性要求越高,实现越复杂。非关键场景用简单的时间戳或去重表就够,资金接口才值得上状态机加唯一约束的双保险。退款是个好例子:除了接口幂等,业务层还要退款单状态机,确保每个退款单只能从待处理到已退款。
上线前怎么验证
并发重放:把同一个请求并发打 N 次,断言数据库里只有一条记录。最小验证脚本:
for i in $(seq 1 50); do
curl -s -X POST http://localhost:8080/orders \
-H 'Content-Type: application/json' \
-d '{"req_id":"R1001","sku":"A1"}' &
done
wait
# 判定:orders 表中 req_id=R1001 只允许 1 行
再补一句容易混淆的:防重复提交(前端按钮置灰)和幂等是两回事。前者是体验优化,后者是后端必须兑现的契约。写接口时先假设这个调用一定会来第二次,代码自然就写对了。