advanced-java 实战指南:分布式服务接口幂等性设计方案(以支付防重复扣款为例)
【免费下载链接】advanced-java😮 Core Interview Questions & Answers For Experienced Java(Backend) Developers | 互联网 Java 工程师进阶知识完全扫盲:涵盖高并发、分布式、高可用、微服务、海量数据处理等领域知识项目地址: https://gitcode.com/gh_mirrors/ad/advanced-java
导读
本文基于 advanced-java 仓库中 分布式服务接口的幂等性如何设计 这篇核心文档展开,聚焦于分布式系统中"同一请求被重复提交/重试时,接口仍能保证结果正确"这一生产级问题。无论你是服务提供方被同一订单的两次支付请求打中,还是订单系统因网络超时触发重试机制导致支付系统收到重复请求,幂等性设计都是必须提前规避的坑。读完本文,你将掌握幂等性的三要素,并能独立设计出"支付流水表 + 唯一键 + Redis 状态标记"这套可落地的防重复扣款方案。
一、为什么接口幂等性是分布式系统绕不开的问题
先看一个典型场景:你有一个部署在5 台机器上的服务对外提供付款接口。用户在浏览器端操作时,由于前端抖动、双击按钮等原因,一个订单不小心发起了两次支付请求,这两次请求被负载均衡算法分发到服务集群的不同机器上。结果就是:一个订单被扣了两次款。
再换一条链路:订单系统调用支付系统完成支付,结果因为网络超时走了我们前面文档提到的重试机制,支付系统收到同一个支付请求两次,且因为负载均衡落在不同机器上,重复扣款再次发生。
注意,这里的"重试机制"正是 Dubbo 服务治理中的失败重试与超时重试 所讨论的内容:consumer 调用 provider 失败或超时时,可通过
retries、timeout参数自动重试。重试本身是好设计,但如果被调方的接口不具备幂等性,重试反而会把"一次业务操作"放大成"多次副作用"。
由此可见,只要系统是分布式的,接口幂等性就是一个必须提前考虑的生产环境问题。如果不知道如何解决,做出来的分布式系统很容易埋坑。
二、什么是幂等性:先明确目标
所谓幂等性,指的是:一个接口,多次发起同一个请求,接口必须保证结果是准确的。例如:
- 不能多扣款;
- 不能多插入一条数据;
- 不能把统计值多加 1。
在 MQ 消费领域,这个概念同样适用。Kafka 消费者在"处理完消息但还没来得及提交 offset 就宕机重启"时,会重新消费部分消息(新版 Kafka 已将 offset 存储从 Zookeeper 迁移到 Kafka 内部的__consumer_offsets主题中)。如果消费者拿到一条消息就往数据库里插一条,重复消费就会导致数据插入两次。如何保证消息不被重复消费 一文中的结论是:重复消费不可怕,可怕的是没有考虑重复消费之后如何保证幂等性。一个数据重复出现多次,数据库里最终只有一条,这就是幂等。
三、保证幂等性的三要素
原文档明确指出:幂等性不是单纯的技术问题,没有通用的万能方法,必须结合业务来保证。其核心做法可以归纳为三点:
1. 每个请求必须有唯一标识
例如订单支付请求,必然包含订单 id(orderId),一个订单 id 最多支付一次。唯一标识是后续所有去重判断的前提。
2. 每次处理完请求后,必须留下"已处理"记录
常见方案是在 MySQL 中记录状态,例如支付之前先记录一条该订单的支付流水。有了这条流水,"这次支付已经处理过"这件事就有了持久化的事实依据。
3. 每次接收请求时,先判断之前是否处理过
如果某个订单已经支付、已经有支付流水,那么重复请求再来时:
- 先尝试插入支付流水;
- 由于 orderId 已有唯一键约束,重复插入会直接报错;
- 捕获到冲突后,不再执行扣款逻辑。
一句话总结:先插入支付流水成功,才允许执行实际的支付扣款。流水插入本身就是一次"互斥的抢占",谁抢到了,谁才有资格扣款。
四、可落地的防重复扣款方案:支付流水表 + 唯一键 + Redis 状态标记
原文档给出了一套非常具体的实现路径,这里展开成可直接照着做的完整方案。
第一步:支付流水表建唯一键
要求支付一个订单必须插入一条支付流水,因此对order_id字段建立唯一键:
CREATE TABLE pay_flow ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_id VARCHAR(64) NOT NULL, pay_status TINYINT NOT NULL DEFAULT 0 COMMENT '0-未支付 1-已支付', pay_amount DECIMAL(10, 2), create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_id (order_id) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;UNIQUE KEY uk_order_id (order_id)是这套方案的基石:它保证了同一订单的支付流水在数据库层面只能存在一条;- 当并发/重复请求同时尝试插入同一条流水时,数据库唯一索引会让只有一个请求插入成功,其余请求抛出主键/唯一键冲突异常;
- 具体字段(如支付状态、金额、时间)可按业务扩展,唯一键约束不能丢。
第二步:支付前先插入流水,再执行扣款
// 伪代码:支付接口核心逻辑 public PayResult pay(String orderId, BigDecimal amount) { // 1. 先尝试插入支付流水;order_id 唯一键保证同一订单只能插入成功一次 try { payFlowMapper.insert(orderId, PayStatus.UNPAID, amount); } catch (DuplicateKeyException e) { // 2. 唯一键冲突:说明该订单已经处理过,直接返回"已支付",拒绝重复扣款 return PayResult.alreadyPaid(orderId); } // 3. 只有流水插入成功,才执行实际的支付扣款 boolean ok = paymentService.deduct(orderId, amount); if (ok) { payFlowMapper.updateStatus(orderId, PayStatus.PAID); redisTemplate.set("pay:" + orderId, "payed"); } return PayResult.success(orderId); }注意:insert与deduct之间如果扣款失败,流水会停留在"未支付"状态,此时需要结合分布式事务/补偿机制处理(可参考 分布式事务 一文),这属于另一个主题,本文不再展开。
第三步:用 Redis 做高频去重缓存
由于数据库唯一键冲突要靠"报错"来感知,请求量大时每次都打到 MySQL 并不划算。原文档给出的优化是:
利用 Redis,用 orderId 作为唯一键。只有成功插入支付流水,才可以执行实际的支付扣款。
具体做法:
- 支付一个订单时,先插入支付流水,orderId 进入流水表后,再向 Redis 写入状态标记:
set order_id payed; - 下一次重复请求到来时,先查 Redis 中 order_id 对应的 value;
- 如果 value 是
payed,说明已经支付过,直接返回"已支付",不再重复支付。
这套"数据库唯一键兜底 + Redis 状态标记前置拦截"的组合,兼顾了正确性(唯一键是最终防线)与性能(Redis 挡住绝大多数重复请求)。
五、扩展:把幂等思路推广到其他场景
原文档强调"实际运作过程中要结合自己的业务来",以下场景都可以复用同一套思想:
场景一:MQ 消息消费幂等
当 Kafka/RabbitMQ/RocketMQ 因 offset 未提交等原因导致消息重复投递时,如何保证消息不被重复消费 给出的通用解法与本文如出一辙:
- 写库前先按主键查询,已存在则只做 update;
- 写 Redis 的场景天然幂等(
set操作覆盖写); - 生产者给每条消息附一个全局唯一 id(如订单 id),消费端先查 Redis 判断是否消费过,未消费则处理并写入 id,已消费则直接丢弃;
- 基于数据库唯一键约束保证重复数据不会被插入多条,重复插入只会报错,不会产生脏数据。
场景二:接口请求顺序性与幂等性的关系
幂等性解决的是"同一个请求重复执行"的问题,而 分布式服务接口请求的顺序性 解决的是"不同请求乱序执行"的问题。两者经常同时出现在生产事故分析中:比如"先插入再删除"两个请求落点不同导致删除先执行。设计上应优先让业务逻辑天然不需要严格顺序(例如把"插入+删除"合并为一个操作),能从根本上降低对幂等与顺序补偿机制的依赖。
六、小结:记住这张设计清单
回到最初的面试题——"分布式服务接口的幂等性如何设计(比如不能重复扣款)",你可以按以下结构作答:
- 先讲痛点:5 台机器部署的支付服务收到同一订单的两次支付请求,负载均衡分发到不同机器,导致重复扣款;网络超时触发 RPC 重试同样会造成重复请求;
- 再给定义:幂等性 = 多次发起同一请求,结果保持准确(不多扣款、不多插入、不多加统计值);
- 给出三要素:请求唯一标识(orderId)→ 处理记录(支付流水表)→ 接收时判重(先查再插);
- 落地细节:
order_id建唯一键,先插入支付流水成功才允许扣款;Redis 以 orderId 为键写入payed标记做前置拦截,重复请求查 Redis 命中后直接拒绝; - 补充说明:没有通用万能方案,必须结合具体业务设计,MQ 消费幂等、请求顺序性等场景可以复用同一套"唯一标识 + 处理记录 + 判重"思想。
参考与延伸阅读
- 分布式服务接口的幂等性如何设计(本文主体来源)
- 如何保证消息不被重复消费(MQ 消费幂等,同一思想的另一落地场景)
- 基于 Dubbo 的服务治理、失败重试与超时重试(网络超时触发重试、进而引出幂等需求的背景)
- 分布式服务接口请求的顺序性如何保证(与幂等性同属分布式接口设计的姊妹问题)
- 分布式事务(流水插入与扣款之间的一致性保障,可继续深入)
【免费下载链接】advanced-java😮 Core Interview Questions & Answers For Experienced Java(Backend) Developers | 互联网 Java 工程师进阶知识完全扫盲:涵盖高并发、分布式、高可用、微服务、海量数据处理等领域知识项目地址: https://gitcode.com/gh_mirrors/ad/advanced-java
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考