分类:后端开发 · 作者:站长 · 发布于 2026-09-13 · 标签:Spring Boot / 幂等 / Redis / 消息队列 / 电商
重启即丢、回调重复、双击下单:一切混乱的根源都是同一件事
做电商中台最怕的不是高并发,而是同一个请求被系统执行了两遍。用户双击提交、网关超时自动重试、MQ 消费端重投、支付平台回调重复通知——任何一个环节都能让一笔订单被创建两次、库存被扣两次、余额被减两次。接口幂等不是"锦上添花",而是数据一致性的底线。
幂等的基本姿势:唯一键 + 唯一索引
最朴素的方案:给业务请求一个全局唯一的幂等键,数据库唯一索引兜底。
CREATE TABLE idempotency (
biz_type VARCHAR(32) NOT NULL, -- trade / refund / deduct
biz_key VARCHAR(64) NOT NULL, -- 客户端生成的 UUID 或 业务单据号
payload JSONB,
status SMALLINT NOT NULL DEFAULT 0, -- 0处理中 1成功 2失败
create_time TIMESTAMPTZ NOT NULL DEFAULT now(),
update_time TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (biz_type, biz_key)
);
Service 层第一步先抢占:
@Transactional
public Result createOrder(CreateOrderReq req) {
// 1. 先插入幂等记录,唯一索引保证并发下只有一个成功
try {
idemRepo.insert(req.getBizType(), req.getBizKey());
} catch (DuplicateKeyException e) {
return Result.of(idemRepo.queryResult(req.getBizType(), req.getBizKey()));
}
// 2. 真正的业务逻辑(建单、扣库存)
Order order = orderService.create(req);
idemRepo.markSuccess(req.getBizType(), req.getBizKey(), order);
return Result.success(order);
}
踩坑提醒:DuplicateKeyException 捕获要精确,Spring 里它是 DataIntegrityViolationException 的子类;如果事务里把异常吞掉还没回滚,幂等记录会留在"处理中"状态,下次请求永远卡住。所以抢占插入必须在独立事务/传播级别 REQUIRES_NEW 里完成,失败直接抛出让外层回滚。
分布式锁:Redis 的 SETNX 与过期时间
唯一索引适合"创建类"操作,但"更新类"操作(比如扣减余额)需要更强的互斥。Redis 分布式锁是常规武器:
// 加锁 + 过期,一条命令原子完成,防止死锁
String lockKey = "lock:deduct:" + userId;
Boolean locked = redis.opsForValue().setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {
try {
// 查余额 → 扣减 → 记录流水(唯一索引防重放)
} finally {
redis.delete(lockKey); // 释放锁
}
}
三个容易翻车的点:
- 锁过期时间太短:业务还没跑完锁就被释放,另一个线程进来重复扣款。分段阶梯续期(看门狗)或把过期时间设成业务最坏耗时的 3-5 倍。
- 删锁删错:A 线程的锁过期后 B 线程拿到新锁,A 结束时的 delete 把 B 的锁删了。删除前校验 value:加锁时存 UUID,删除时先 GET 比对再 DEL(用 Lua 脚本保证原子)。
- 锁内做远程调用:锁内阻塞等 Redis/MQ 响应,很容易触发上面的过期问题。锁的粒度尽量小,锁内只做内存判断 + 快速落库。
本地消息表:把"重试"变成"可追踪的状态机"
电商下单后要同步履约、发券、通知,这些跨服务操作天然会失败。本地消息表方案把"远程调用"转成"本地事务 + 定时扫描":
@Transactional
public void createOrderWithMsg(OrderReq req) {
// 1. 本地事务:建订单 + 写消息表(同库同事务,要么都成功要么都失败)
Order order = orderService.create(req);
msgRepo.insert(new Outbox(order.getId(), "order.created", order.toJson()));
// 2. 异步发送:事务提交后真正发出
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override public void afterCommit() {
mqSender.send("order.created", order.getId());
}
});
}
// 定时补偿任务:扫 30 秒内状态仍是 PENDING 的消息重新投递
@Scheduled(fixedDelay = 10_000)
public void compensate() {
List<Outbox> pending = msgRepo.findPending(30);
for (Outbox msg : pending) {
try {
mqSender.send(msg.getTopic(), msg.getBizId());
msgRepo.markSent(msg.getId());
} catch (Exception e) {
// 记录重试次数,超过 N 次转入人工队列
}
}
}
消费端必须配合幂等:MQ 至少一次投递意味着消费方要自己扛重复。规约:消费端先查本地幂等表,已处理直接 ACK。
方案对比与选型
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 唯一索引抢占 | 创建订单/退款 | 零依赖,彻底防重 | 需要独立幂等表 |
| Redis 分布式锁 | 扣减/更新类 | 低延迟、粒度细 | 锁过期/误删需小心 |
| 本地消息表 | 跨服务异步 | 本地事务保证不丢 | 多一张表 + 补偿任务 |
实战中三种组合使用:创建类用唯一索引,更新类用分布式锁 + 唯一索引双保险,跨服务用本地消息表 + 消费端幂等。
最佳实践清单(直接抄作业)
- 幂等键由客户端生成(UUID / 雪花 ID),服务端只做校验存储;请求头统一带 X-Idempotency-Key。
- 幂等表记录业务结果,重复请求直接返回上一次的成功响应,不要抛"重复提交"错误让前端困惑。
- 消息消费端永远假设会重复:先查后写、唯一索引兜底、失败进死信队列人工处理。
- 压测验证:同一请求并发 100 次,数据库里必须只有一条业务记录。
幂等的本质是把"无法保证只发生一次"的分布式现实,收敛成"可以被重复执行但结果一致"的业务规约。先接住重复,再谈高性能。