支付回调接口被客户端连续点击5次,直接扣了用户5笔钱;订单提交按钮在弱网环境下被用户疯狂点击,后台生成了3个一模一样的订单;MQ消息消费端因为网络抖动重复拉取同一条消息,导致库存扣减成负数。这些场景不是教科书里的假设,而是每天都在生产环境里真实发生的生产事故。解决这些问题的核心手段只有一个:接口幂等设计。

什么是幂等,先把这个概念钉死

幂等原本是数学概念,放到接口设计里含义非常明确:同一个操作执行一次和执行多次,产生的副作用完全一样。注意关键词是“副作用”,不是“返回结果”。第一次调用返回200,第二次因为业务规则限制返回400,只要数据状态没变,这个接口就是幂等的。很多人把“返回结果相同”和“幂等”混为一谈,这是理解上的根本偏差。真正要保护的是数据库里的数据一致性,不是HTTP响应体的内容。

重复提交攻击的真实面孔

别把“攻击”两个字理解成只有黑客在搞你。绝大多数重复提交问题来自正常用户的无意操作,以及分布式系统本身的容错机制。前端按钮加loading置灰、加防抖节流,这些只能挡住一部分正常用户的重复点击,挡不住浏览器刷新重试、挡不住网络代理层的超时重发、挡不住Postman直接调接口、更挡不住上游系统因为超时触发的自动重试策略。把安全押注在前端控制上,等于把银行金库的门换成塑料锁。真正的防线必须建立在服务端。

幂等设计的核心武器:唯一令牌机制

最通用、最可靠的幂等方案是“先申请令牌,再携带令牌执行业务”。整个流程分三步:第一步,客户端在进入表单页或发起关键操作前,先向后端请求一个全局唯一的幂等令牌,服务端生成后存入Redis,同时返回给客户端。第二步,客户端提交业务请求时必须在请求头或请求体中带上这个令牌。第三步,服务端收到请求后先到Redis里删除这个令牌,删除成功说明是第一次请求,正常执行业务逻辑;删除失败说明令牌已经被用过或者不存在,直接返回“请勿重复提交”。

// 获取幂等令牌接口
@GetMapping("/idempotent/token")
public Result getToken() {
    String token = UUID.randomUUID().toString().replace("-", "");
    redisTemplate.opsForValue().set("idempotent:" + token, "1", 5, TimeUnit.MINUTES);
    return Result.success(token);
}

// 业务接口使用令牌
@PostMapping("/order/submit")
public Result submitOrder(@RequestHeader("Idempotent-Token") String token, 
                                  @RequestBody OrderDTO order) {
    Boolean deleted = redisTemplate.delete("idempotent:" + token);
    if (Boolean.FALSE.equals(deleted)) {
        return Result.fail("请勿重复提交");
    }
    // 正常执行业务逻辑
    orderService.createOrder(order);
    return Result.success("下单成功");
}

这个方案的精妙之处在于利用了Redis单线程原子删除的特性。delete操作在Redis里是原子的,并发场景下只会有一个请求删除成功,天然形成分布式锁的效果。令牌设置5分钟过期是为了防止令牌堆积占用内存,同时兼顾用户正常填单时间。有人会问,如果删除令牌成功了,但后续业务逻辑执行失败怎么办?这时候需要结合业务场景判断:如果是下单类场景,业务失败应该让用户重新获取令牌再提交;如果是支付类场景,则需要引入对账机制兜底。

数据库唯一索引:最后一道物理防线

无论上层逻辑设计得多精巧,数据库层面的唯一约束是绝对不能省略的保底手段。以订单系统为例,订单号本身就应该是全局唯一的,在订单表上对订单号字段建立唯一索引。当所有上层幂等逻辑都失效时,数据库的DuplicateKeyException就是最后一道防线。代码里必须捕获这个异常并转换为友好的业务提示,而不是直接抛500错误。

try {
    orderMapper.insert(order);
} catch (DuplicateKeyException e) {
    // 订单已存在,查询已有订单返回
    Order existing = orderMapper.selectByOrderNo(order.getOrderNo());
    return Result.success(existing);
}

这里有一个容易被忽视的细节:捕获异常后应该查询已有数据并正常返回,而不是报错。因为从业务角度看,重复提交的请求应该被视为“成功”,用户不应该因为重试而看到错误提示。这个设计理念叫“幂等返回”,是提升用户体验的关键点。

状态机幂等:利用业务状态天然去重

很多业务本身就带有状态流转,比如订单有“待支付、已支付、已取消”等状态,退款有“待审核、审核通过、已退款”等状态。这类场景可以利用状态机来天然实现幂等。核心思路是:更新数据时不直接用update set,而是带上当前状态作为条件。

// 错误写法:无条件更新,重复执行会多次扣款
UPDATE account SET balance = balance - 100 WHERE user_id = 123;

// 正确写法:带上版本号或状态条件
UPDATE order SET status = 'PAID', pay_time = NOW() 
WHERE order_no = 'ORD001' AND status = 'UNPAID';

第二条SQL的WHERE条件里带了status='UNPAID',第一次执行影响行数为1,第二次执行影响行数为0。代码里通过判断影响行数来决定后续逻辑,影响行数为0说明订单已经不是待支付状态,直接返回成功即可。这种方案不需要引入额外的令牌或Redis,完全依赖数据库自身的特性,性能最好,但适用范围受限于有明确状态流转的业务场景。

乐观锁版本号:更新的通用幂等方案

对于没有明确状态流转、但需要支持重复更新的场景,乐观锁版本号是标准解法。在数据表里增加一个version字段,每次更新时version加1,同时把当前version作为更新条件。

// 表结构
ALTER TABLE product ADD COLUMN version INT DEFAULT 0;

// 更新SQL
UPDATE product SET stock = stock - 10, version = version + 1 
WHERE id = 1001 AND version = 5;

第一次请求version=5,更新成功,version变成6。第二次请求还带着version=5来更新,WHERE条件不匹配,影响行数为0,代码检测到后直接返回。这个方案比唯一索引方案更灵活,因为唯一索引只能保证插入不重复,而乐观锁可以保证更新操作也不重复执行。代价是多一个version字段,以及并发冲突高时会有大量更新失败的请求需要处理。

分布式锁:兜底方案而非首选

很多团队一提到防重复就上分布式锁,用Redisson或者Zookeeper对业务单号加锁。这个思路本身没错,但分布式锁更适合作为并发控制的工具,而不是幂等设计的第一选择。原因有三:第一,锁有超时风险,业务执行时间超过锁的过期时间,锁自动释放,第二个请求进来还是会重复执行;第二,锁的粒度很难把握,粒度过粗影响性能,粒度过细可能漏掉并发场景;第三,引入分布式锁意味着引入额外的复杂度和故障点。令牌机制加数据库唯一索引的组合,已经能覆盖95%以上的幂等场景,分布式锁留给那5%的极端情况就够了。

MQ消费幂等:消息领域的特殊处理

消息队列的重复消费是幂等问题的重灾区。RocketMQ、Kafka等主流MQ都承诺至少投递一次,但不承诺投递且仅投递一次。消费端必须自己处理幂等。最成熟的方案是利用消息自带的MessageId或业务唯一键做去重。消费端在Redis里维护一个已消费消息ID的集合,每次消费前先检查消息ID是否已存在。

String msgId = message.getMsgId();
Boolean absent = redisTemplate.opsForValue()
    .setIfAbsent("mq:consumed:" + msgId, "1", 24, TimeUnit.HOURS);
if (Boolean.FALSE.equals(absent)) {
    // 消息已消费,直接确认
    return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
// 执行业务逻辑
processBusiness(message);
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;

注意这里的过期时间设置成了24小时,比业务令牌的5分钟长得多。因为MQ消息可能因为消费失败而延迟重试,时间跨度可能很大。如果担心Redis里堆积的消息ID太多,可以用布隆过滤器做前置判断,进一步降低存储成本。

Token存在Redis里,Redis挂了怎么办

这是面试和方案评审中必被问到的问题。坦诚地说,如果Redis挂了,令牌机制确实会失效。但工程上不需要因为这个低概率事件而过度设计。应对策略分两层:第一层,Redis本身做高可用,主从加哨兵或者直接上集群模式,把挂掉的概率降到极低;第二层,即使Redis真的挂了,还有数据库唯一索引兜底,最坏情况就是用户看到“系统繁忙”的错误提示,不会出现数据错乱。架构设计讲究的是分层防御,而不是指望某一层做到100%可靠。

前端防重和后端幂等的分工边界

最后明确一下分工。前端的按钮置灰、防抖节流、loading状态,这些属于用户体验优化,目的是减少用户无意识的重复操作,降低服务端压力。后端的令牌机制、唯一索引、状态机判断,这些属于数据安全保障,目的是在任何情况下都不产生脏数据。前端防重是锦上添花,后端幂等是底线要求。两者不是替代关系,而是协同关系。任何试图用前端逻辑替代后端幂等的设计,都是在给生产事故埋雷。

把幂等设计落到实处的团队其实不多,因为幂等问题在低并发场景下几乎不会暴露,一旦业务量上来,各种边界条件同时触发,问题就会集中爆发。现在就把这些方案落地到代码规范和CR检查清单里,比事后救火要划算得多。