分类:后端开发 · 作者:站长 · 发布于 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 次,数据库里必须只有一条业务记录。

幂等的本质是把"无法保证只发生一次"的分布式现实,收敛成"可以被重复执行但结果一致"的业务规约。先接住重复,再谈高性能。