Java进阶必修课:接口幂等到底怎么做,才不会重复扣款、重复下单、重复发消息?
Java 开发第一次接触幂等,都是从一句定义开始:“多次执行和一次执行,对系统结果的影响相同。”
这句话当然没错,但在项目里它其实没什么指导意义。
因为真正让人头疼的不是定义,而是这些具体问题:
- 用户连续点了两次“提交订单”,为什么生成了两笔订单?
- 支付平台回调通知了两次,为什么系统扣了两次库存?
- MQ 消息重复投递,为什么短信发了两遍?
- 定时任务重跑了一次,为什么数据又结算了一遍?
你会发现,很多线上事故并不是“请求失败了”,而是:
请求成功了两次。
这篇文章就不讲空泛理论了,我们直接讲最常见的 4 类幂等场景,以及对应的落地方案。
一、先理解什么是幂等
你可以把幂等理解成一句人话:
同一件事,不管请求来几次,系统都只能认一次。
比如:
- 同一个订单,只能创建一次
- 同一笔支付,只能处理一次
- 同一条消息,只能消费一次
- 同一个任务,只能结算一次
幂等的目标:防止系统在网络重试、消息重复、回调重复、人工重放时,把同一件业务做了两遍。
二、最容易出事的 4 个幂等场景
真实项目里,幂等问题通常集中在这几类:
- 前端重复提交
- 第三方回调重复通知
- MQ 消息重复消费
- 定时任务或批处理重复执行
这四种场景虽然都叫幂等,但做法并不完全一样。
如果你试图拿一种方案打天下,最后大概率会踩坑。
三、场景 1:前端重复提交,怎么防止重复下单?
这是最常见的场景。
用户网络卡了一下,没看到结果,就又点了一次“提交订单”。
或者用户手速快,连续点了两下。
如果后端没有保护,很容易生成两笔订单。
错误写法
@PostMapping("/order/create")
public Long createOrder(@RequestBody CreateOrderRequest request) {
Order order = new Order();
order.setUserId(request.getUserId());
order.setProductId(request.getProductId());
order.setAmount(request.getAmount());
orderService.save(order);
return order.getId();
}
这段代码的问题不是业务逻辑错了,而是:
它默认每次请求都是一笔新业务。
但现实里,这两次请求可能其实是同一笔业务。
解决方案一:前端传唯一请求号,后端做幂等控制
比如前端提交订单前,先生成一个 requestId:
{
"requestId": "f4f1df0f-8b98-4d12-9d64-123456789abc",
"userId": 1001,
"productId": 2001,
"amount": 99.00
}
后端先查这个 requestId 是否已经处理过。
@PostMapping("/order/create")
public Long createOrder(@RequestBody CreateOrderRequest request) {
Order existing = orderService.getByRequestId(request.getRequestId());
if (existing != null) {
return existing.getId();
}
Order order = new Order();
order.setRequestId(request.getRequestId());
order.setUserId(request.getUserId());
order.setProductId(request.getProductId());
order.setAmount(request.getAmount());
orderService.save(order);
return order.getId();
}
数据库给 request_id 加唯一索引:
ALTER TABLE orders ADD UNIQUE INDEX uk_request_id (request_id);
为什么要加唯一索引?
因为只靠“先查再插”是不够的。
两个请求几乎同时进来时,都可能查不到,然后都去插入。
所以真正的做法是:
- 业务代码里先判断
- 数据库层再用唯一索引兜底
这样即使并发下两个请求同时进来,也只有一个能插成功。
四、场景 2:支付回调重复通知,怎么防止重复扣款?
支付场景是幂等最经典的案例。
因为第三方支付平台的回调机制通常都不是“只通知一次”,而是:
只要你没正确响应,它就会不断重试。
所以你一定要默认:
支付回调可能来 2 次、3 次、5 次,甚至更多。
错误写法
@PostMapping("/pay/callback")
public String callback(@RequestBody PayCallbackRequest request) {
Order order = orderService.getByOrderNo(request.getOrderNo());
order.setStatus("PAID");
orderService.update(order);
stockService.reduce(order.getProductId(), order.getQuantity());
messageService.sendPaySuccessMessage(order);
return "success";
}
这段代码的问题非常严重。
如果回调重复两次,就可能出现:
- 状态重复更新
- 库存重复扣减
- 消息重复发送
正确做法:基于业务状态做幂等
支付回调最稳妥的做法,是先判断订单状态。
@PostMapping("/pay/callback")
@Transactional
public String callback(@RequestBody PayCallbackRequest request) {
Order order = orderService.getByOrderNo(request.getOrderNo());
if (order == null) {
return "fail";
}
if ("PAID".equals(order.getStatus())) {
return "success";
}
order.setStatus("PAID");
order.setPayTime(request.getPayTime());
orderService.update(order);
stockService.reduce(order.getProductId(), order.getQuantity());
messageService.sendPaySuccessMessage(order);
return "success";
}
这样就够了吗?
还不完全够。
如果两个支付回调同时进来,都读到订单状态还是 UNPAID,那仍然可能并发更新成功两次。
所以支付回调这类高风险场景,推荐再加一层:
解决方案:状态更新加条件
UPDATE orders
SET status = 'PAID', pay_time = NOW()
WHERE order_no = ? AND status = 'UNPAID';
Java 里判断更新行数:
int updated = orderMapper.updatePaid(orderNo);
if (updated == 0) {
return "success";
}
后面的扣库存、发消息只在 updated == 1 时执行。
为什么这样更稳?
因为这不是“先查再改”,而是:
数据库层直接保证只有未支付状态才能改成已支付。
谁先改成功,谁才有资格继续执行业务。
五、场景 3:MQ 重复消费,怎么防止重复发消息?
只要你用了 MQ,就要默认一件事:
消息可能重复。
很多消息中间件都只能尽力保证“至少投递一次”,而不是“绝对只投递一次”。
比如发短信:
错误写法
public void onMessage(OrderMessage message) {
smsService.send(message.getPhone(), "您的订单已支付成功");
}
如果这条消息被重复投递两次,短信就会发两遍。
解决方案:消费前先检查消息唯一标识
每条消息必须带一个唯一 messageId 或业务唯一号,比如 orderNo。
public void onMessage(OrderMessage message) {
if (consumeRecordService.isConsumed(message.getMessageId())) {
return;
}
smsService.send(message.getPhone(), "您的订单已支付成功");
consumeRecordService.markConsumed(message.getMessageId());
}
数据库建表:
CREATE TABLE mq_consume_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
message_id VARCHAR(64) NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_message_id (message_id)
);
更稳一点的写法
不要先查再插,直接插入唯一记录,利用唯一索引判断是否重复:
public void onMessage(OrderMessage message) {
boolean locked = consumeRecordService.tryMarkConsumed(message.getMessageId());
if (!locked) {
return;
}
smsService.send(message.getPhone(), "您的订单已支付成功");
}
tryMarkConsumed 底层逻辑就是:
- 插入成功:说明第一次消费
- 唯一索引冲突:说明已经消费过
为什么推荐这样做?
因为它天然抗并发。
相比“先查后插”,这种方案在重复消息同时到达时更安全。
六、场景 4:定时任务重复执行,怎么防止重复结算?
很多系统里都有结算任务、统计任务、补偿任务。
问题在于:
- 任务可能重跑
- 多实例部署可能同时执行
- 人工补跑时也可能重复执行
如果没有幂等控制,就可能造成重复结算。
错误写法
@Scheduled(cron = "0 0 1 * * ?")
public void settle() {
List<Account> accounts = accountService.listNeedSettle();
for (Account account : accounts) {
settlementService.settle(account);
}
}
如果服务部署了 2 台,两台机器同时跑这个定时任务,就可能一批数据被结算两遍。
解决方案一:分布式锁防止任务并发执行
比如用 Redis 锁:
public void settle() {
boolean locked = redisLock.tryLock("settle_task", 60);
if (!locked) {
return;
}
try {
List<Account> accounts = accountService.listNeedSettle();
for (Account account : accounts) {
settlementService.settle(account);
}
} finally {
redisLock.unlock("settle_task");
}
}
但要注意:
分布式锁解决的是“多个实例同时执行”,不是“单条业务重复处理”。
所以真正稳的做法还要加第二层。
解决方案二:业务层状态幂等
例如结算表增加状态:
- INIT
- SETTLING
- DONE
处理前先做条件更新:
UPDATE settlement
SET status = 'SETTLING'
WHERE id = ? AND status = 'INIT';
只有抢到处理权的线程,才能继续执行结算逻辑。
结算完成后再改成:
UPDATE settlement
SET status = 'DONE'
WHERE id = ? AND status = 'SETTLING';
为什么要两层?
因为:
- 分布式锁控制“任务级”并发
- 状态流转控制“数据级”幂等
两层一起用,才更稳。
七、幂等常见方案,到底该怎么选?
很多人学完幂等,会记住很多关键词:
- token
- 唯一索引
- 状态机
- 分布式锁
- 去重表
但真正项目里最重要的问题是:
不同场景到底选哪个?
我给你一个最实用的对应关系。
1. 防止前端重复提交
优先选:
- 请求唯一号
- 唯一索引
- Redis 短期去重
适合:
- 下单
- 提交申请
- 创建记录
2. 支付回调、状态变更类接口
优先选:
- 业务状态判断
- 条件更新
- 乐观锁 / 状态流转控制
适合:
- 支付成功
- 发货成功
- 审批完成
- 状态推进类业务
3. MQ 消费幂等
优先选:
- 消息唯一 ID
- 消费记录表
- 唯一索引去重
适合:
- 发短信
- 发通知
- 发优惠券
- 异步业务事件处理
4. 定时任务、批处理任务
优先选:
- 分布式锁
- 数据状态控制
- 任务执行记录
适合:
- 每日结算
- 对账
- 补偿任务
- 批量修复任务
八、幂等最容易踩的 4 个坑
1. 只在代码里 if 判断,不做数据库兜底
if (!exists) { insert(); }
这个在并发下非常脆弱。
2. 误以为加了分布式锁就万事大吉
锁只能解决“同时执行”,不一定能解决“重复执行”。
3. 把“防重复提交”和“业务幂等”混为一谈
前端按钮置灰可以减少重复请求,
但它从来不能替代后端幂等。
4. 幂等校验做了,但副作用操作没控制
比如订单状态只更新了一次,但短信发了两次、库存扣了两次。
这说明你只对主流程做了幂等,没有对副作用链路做幂等。
九、我最推荐的一种幂等思路:业务主键 + 状态流转 + 数据库兜底
如果你问我项目里最稳的思路是什么,我会给你这套:
-
给业务找到“唯一身份”
- requestId
- orderNo
- payNo
- messageId
-
用状态流转保证“只处理一次”
- INIT -> PROCESSING -> SUCCESS
- UNPAID -> PAID
-
用数据库唯一索引或条件更新兜底
- 防并发
- 防重复
- 防代码层判断失效
这套方案不是最花哨,但通常最稳。
十、最后给一份幂等设计清单
每次设计接口前,先问自己这 6 个问题:
- 这类请求有没有可能重复到达?
- 同一件业务的唯一标识是什么?
- 如果请求来两次,系统哪一步最危险?
- 我用的是“先查后改”,还是“数据库原子控制”?
- 副作用操作有没有重复执行风险?
- 失败重试时,这个接口还能保证结果一致吗?
更多推荐



所有评论(0)