十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java进阶必修课:接口幂等到底怎么做,才不会重复扣款、重复下单、重复发消息?

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 时执行。为什么这样更稳因为这不是“先查再改”而是数据库层直接保证只有未支付状态才能改成已支付。谁先改成功谁才有资格继续执行业务。五、场景 3MQ 重复消费怎么防止重复发消息只要你用了 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() { ListAccount 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 { ListAccount accounts accountService.listNeedSettle(); for (Account account : accounts) { settlementService.settle(account); } } finally { redisLock.unlock(settle_task); } }但要注意分布式锁解决的是“多个实例同时执行”不是“单条业务重复处理”。所以真正稳的做法还要加第二层。解决方案二业务层状态幂等例如结算表增加状态INITSETTLINGDONE处理前先做条件更新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. 幂等校验做了但副作用操作没控制比如订单状态只更新了一次但短信发了两次、库存扣了两次。这说明你只对主流程做了幂等没有对副作用链路做幂等。九、我最推荐的一种幂等思路业务主键 状态流转 数据库兜底如果你问我项目里最稳的思路是什么我会给你这套给业务找到“唯一身份”requestIdorderNopayNomessageId用状态流转保证“只处理一次”INIT - PROCESSING - SUCCESSUNPAID - PAID用数据库唯一索引或条件更新兜底防并发防重复防代码层判断失效这套方案不是最花哨但通常最稳。十、最后给一份幂等设计清单每次设计接口前先问自己这 6 个问题这类请求有没有可能重复到达同一件业务的唯一标识是什么如果请求来两次系统哪一步最危险我用的是“先查后改”还是“数据库原子控制”副作用操作有没有重复执行风险失败重试时这个接口还能保证结果一致吗
返回列表