浅谈接口幂等性、MQ消费幂等性
- 一、什么是幂等性?
-
- [1. 举个例子](#1. 举个例子)
- [2. 基本概念了解](#2. 基本概念了解)
-
- [2.1 服务是什么?](#2.1 服务是什么?)
- [2.2 服务调用者、服务提供者](#2.2 服务调用者、服务提供者)
- [2.3 消息](#2.3 消息)
- [3. 幂等性是什么?](#3. 幂等性是什么?)
-
- 3.1【幂等性】的定义
- [3.2 服务调用者 为什么会发起多次相同的请求(重复请求)?](#3.2 服务调用者 为什么会发起多次相同的请求(重复请求)?)
- [3.3 重复请求带来的问题及解决方案](#3.3 重复请求带来的问题及解决方案)
- [二、Spring Boot接口幂等性实现](#二、Spring Boot接口幂等性实现)
-
- [方案:Token + Redis(最推荐!最常用!)](#方案:Token + Redis(最推荐!最常用!))
-
- [1. 添加依赖(pom.xml)](#1. 添加依赖(pom.xml))
- [2. 配置Redis(application.yml)](#2. 配置Redis(application.yml))
- [3. 创建API接口(OrderController.java)](#3. 创建API接口(OrderController.java))
- [4. 前端调用流程](#4. 前端调用流程)
- 三、下游系统接口幂等性实现
-
- [方案:请求ID + 数据库(最通用方案)](#方案:请求ID + 数据库(最通用方案))
-
- [1. 创建幂等辅助表](#1. 创建幂等辅助表)
- [2. 服务端代码](#2. 服务端代码)
- 四、MQ消费者幂等性处理
-
- [1. 为什么MQ需要幂等性?](#1. 为什么MQ需要幂等性?)
- [2. MQ消费者幂等性处理方案](#2. MQ消费者幂等性处理方案)
-
- [方案:唯一ID + Redis(推荐)](#方案:唯一ID + Redis(推荐))
- 五、实战建议
-
- [1. MQ消费者幂等性](#1. MQ消费者幂等性)
- [2. 重要避坑点](#2. 重要避坑点)
- 六、总结
一、什么是幂等性?
1. 举个例子
在了解幂等性之前,先举个例子:用微信发红包。发送红包的正常流程是:①在微信聊天页面的下方点击红包按钮;②输入金额;③点击"塞钱进红包"按钮(假设不用输入支付密码);恰巧此刻网络卡了,然后我们又重新点击了一次"塞钱进红包"(也就是将 第③步 操作了两次),我们原本只是想发200块钱的红包,结果由于点了两次"塞钱进红包"导致发出去400块钱,这就是 → 非幂等(备注:实际是不会发生这样的情况)。

2. 基本概念了解
在说幂等性之前,先来了解几个概念:服务、服务提供者、服务消费者、消息生产者、消息消费者。
2.1 服务是什么?
最常见的服务就是我们通常说的 API接口,对Spring架构来说指的就是Controller层。不同的API接口会提供不同的功能(服务),比如,下单接口提供下单服务、支付接口提供支付服务、扣减库存接口提供扣减商品库存服务。
2.2 服务调用者、服务提供者
我们说既然有服务,必然就会有服务提供者(谁提供服务谁就是服务提供者)和 服务消费者,否则一个服务就没有存在的必要。
我们常见的 调用 场景有:
- ① 前端页面 调用 后端API接口。前端页面就是服务调用者,后端API接口就是服务提供者。Spring架构中API接口指的就是Controller层。
- ② 上下游系统之间的调用。比如说,我们去银行营业厅办理 银行卡开通业务,营业厅里面柜员(玻璃后面的那个人)操作的系统叫 "柜面系统",柜员通过 柜面系统 给用户办卡,柜面系统会把数据(比如,办卡人的信息)传送给一个叫 "核心系统" 的系统。
- 上下游系统的定位是由 "
业务流程触发" 和 "数据流向" 决定的。 - 上下游系统在开发之前,首先由彼此的需求人员、技术人员(通常就是架构师[技术架构师 + 业务架构师])一起开个会确定都有哪些业务(技术人员的职责是确定在具体的项目周期内是否能够实现),然后由彼此的技术人员沟通 来确定最终的【接口文档,常见的就是Excel文档】。
- 上下游系统的定位是由 "
- ③ 分布式架构(spring boot + spring cloud)中各个微服务之间的调用(通过feign的方式进行调用),服务消费者 调用 服务提供者提供的API接口。
2.3 消息
在分布式架构(spring boot + spring cloud)中,接口之间通过feign来调用也可以修改为通过mq来解耦。此时,服务调用者就变成了消息的生产者,消息生产者 生产消息发送给mq,mq将消息投递给消息消费者。
💡思考:假设微服务A通过feign来调用微服务B,而此时恰巧发生了网络抖动导致 微服务A在超时时间内 没有收到微服务B的响应,再假设微服务A没有开启Ribbon(客户端软负载均衡器),即没有开启重试机制。问 :微服务A该如何处理?分析 :要明白一个问题,微服务A在【超时时间内】没有收到微服务B的响应,超时 ≠ 失败 ,并不意味着微服务B的业务逻辑执行失败,只是响应没有及时返回。这就【属于】分布式事务中的一致性问题。所以,我们的问题变成了如何实现 分布式事务 ? 方案:① 本地事务表 + mq 实现分布式事务(最终一致性);② mq事务消息(不是所有mq都支持事务消息);③ seata分布式事务框架。具体的放到讲分布式的时候再说,这里就不展开了。
3. 幂等性是什么?
3.1【幂等性】的定义
- 在分布式系统或者网络通信中,【客户端】【重复调用】同一个接口,得到的结果是一致的,而且不会因为多次调用而产生副作用。也就是说,
不管【服务调用者】发起 多少次【相同】的请求,【服务提供者】只会执行一次【实际】的操作,而且 多次【相同】调用的结果 和 单次调用的结果 是完全一样的。 - 看完这个定义,我们先想几个
问题:- ① 服务调用者 为什么会发起多次相同的请求?也就是说,服务调用者在什么样的情况会发起多次相同的请求?
- ② 多次相同的请求 对服务提供者 会带来什么问题 以及 服务提供者是如何保证多次相同请求的执行结果是一样的?
3.2 服务调用者 为什么会发起多次相同的请求(重复请求)?
- 先要明白
【多次相同请求】的意思就是【一模一样】的请求,这个理解很重要,尤其是对于后面的实现方案。 - 最最常见的【重复请求】情况就是:
【重试机制】,也就是说,重试机制 导致 重复请求 。但是,重试机制 不是 导致重复请求的 唯一原因。常见的重试场景有如下几种 :- ① 用户重复点击提交按钮:前端页面如果没有防止重复提交的功能,就会导致同一个请求发送多次到后端服务。
- ② 接口超时重试 :在分布式架构中(spring boot + spring cloud架构),微服务之间通过feign的方式调用。当 服务调用者 在超时时间内 没有收到 服务提供者 的响应时,调用方【可能 】就会进行重试。
- 备注:① Feign可以配置超时时间,默认有超时设置(例如,connectTimeout默认连接超时 和 readTimeout读取超时);② Ribbon(客户端负载均衡器,Spring Cloud Ribbon 是对 Netflix开源Ribbon的封装)默认不开启,当开启负载均衡的时候才会进行重试。在实际项目中,通常也只会配置对GET请求进行重试(OkToRetryOnAllOperations),毕竟重试会增加下游服务压力(增加服务提供者的压力),而且这已经涉及到了分布式事务。
- ++接口超时重试 除了 分布式架构内部微服务之间的调用,还有 上下游系统的调用++。
- ③ 消息队列重复消费:消费者消费消息后,没有及时提交,导致消息被重复消费。
- 导致重试的原因又有什么 ?
- 超时(最常见):客户端等待响应时间 > 配置的超时阈值。
- 网络故障(非超时):网络断连、路由异常,客户端无法收到任何响应。比如,网关故障导致请求完全丢失。
- 关键结论:
重试的直接原因是「客户端感知请求失败」,而失败可能是 超时、网络故障 等。
- 导致超时的原因有什么 ?
- 网络抖动(网络延迟波动):网络传输延迟波动(如100ms→500ms),未在超时阈值内返回响应。
- 网络拥塞:网络带宽不足,数据包排队(如高流量时段)。
- 服务调用者超时配置过短:服务调用者设置的超时时间 < 服务端实际处理时间。
- 服务端处理过慢:服务端CPU/内存过载、数据库锁、慢SQL 导致 "处理时间 > 超时阈值"。
- 服务端GC停顿:服务端JVM Full GC暂停用户线程(STW,stop the world 暂停所有用户线程,只有垃圾收集线程在运行),未及时响应。
3.3 重复请求带来的问题及解决方案
试想一下,对于转账业务,如果不停地重复请求,那就可能会导致重复扣款。下单也是如此。
💡 说人话:幂等性就是防止系统"多收钱"、"多扣款"、"多发券"、"多生成订单"的保险栓!
Q:对于这种问题该如何处理?
A:幂等性。在服务提供者这里对API接口实现幂等性。
需要幂等性的常见场景 也就是 常见的重试场景。下面,我们主要探讨三种重试场景:① 用户重复点击提交按钮;② 上下游系统之间的重试;③ 消息队列重复消费。
二、Spring Boot接口幂等性实现
用户在前端页面重复点击提交按钮,提交按钮调用的就是后端API接口。
方案:Token + Redis(最推荐!最常用!)
-
适用场景:用户表单提交类接口(比如注册、开户、下单、支付等)。
-
为什么推荐 ?
- 实现简单,代码量少
- 性能好,Redis查询快
- 适合高并发场景
- 有完整流程,不易出错
-
详细流程
用户 → 点击"下单"按钮 → 申请令牌 → 提交订单 + 令牌 → 服务端校验 → 执行业务 → 令牌失效
1. 添加依赖(pom.xml)
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
2. 配置Redis(application.yml)
yaml
spring:
redis:
host: 127.0.0.1
port: 6379
password:
timeout: 5000
3. 创建API接口(OrderController.java)
java
@RestController
@RequestMapping("/api")
public class OrderController {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String TOKEN_PREFIX = "idempotent:token:";
private static final long EXPIRE_SECONDS = 60; // 令牌有效期60秒
// 1. 获取幂等令牌(前端调用此接口获取token)
@GetMapping("/token")
public ResponseEntity<String> getToken() {
// 生成并存储token
String token = UUID.randomUUID().toString();
String key = TOKEN_PREFIX + token;
// 令牌有效期60秒
redisTemplate.opsForValue().set(key, "1", EXPIRE_SECONDS, TimeUnit.SECONDS);
return ResponseEntity.ok(token);
}
// 2. 提交订单(幂等)
@PostMapping("/order")
public ResponseEntity<String> createOrder(
@RequestBody OrderRequest request,
@RequestHeader("Idempotent-Token") String token) {
// 2.1 查令牌不能为空
if (token == null || token.isEmpty()) {
return ResponseEntity.badRequest().body("缺少幂等令牌");
}
// 2.2 使用Lua脚本保证原子性(先get再del)
String script = "if redis.call('get', KEYS[1]) then " +
"return redis.call('del', KEYS[1]) " +
"else return 0 end";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(TOKEN_PREFIX + token),
null
);
if (result == 0) {
return ResponseEntity.badRequest().body("请勿重复提交或令牌已过期");
}
// 2.3 执行业务逻辑(这里模拟下单)
Order order = new Order();
order.setId(UUID.randomUUID().toString());
order.setUserId(request.getUserId());
order.setAmount(request.getAmount());
// 保存订单到数据库(这里省略了实际保存逻辑)
// orderService.createOrder(order);
return ResponseEntity.ok("下单成功,订单ID: " + order.getId());
}
}
4. 前端调用流程
也可以用postman测试工具测试。
javascript
// 1. 先获取token令牌
fetch('/api/token')
.then(res => res.text())
.then(token => {
// 2. 提交订单时带上令牌
fetch('/api/order', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotent-Token': token
},
body: JSON.stringify({
userId: 'user123',
amount: 1000
})
})
});
三、下游系统接口幂等性实现
我们说代码都要留痕,为什么要留痕?是为了排查问题,为了找出是谁的责任。尤其是 当下游系统 还是一个核心系统时,留痕就更有必要了。比如说,银行系统中的 "大核心系统"(通常指的就是一类户账户系统)、"小核心系统"(二三类户),基金行业中的TA(transfer agent,登记过户)系统(其他的 销售系统、资金核算系统、估值系统都要跟这个系统交互)。
方案:请求ID + 数据库(最通用方案)
适用场景:任何需要幂等性的接口。该方案也适合【Spring Boot接口幂等性实现】。
1. 创建幂等辅助表
sql
-- 创建幂等辅助表(idempotent_call)
CREATE TABLE `idempotent_call` (
`id` varchar(50) NOT NULL,
`req_sys_id` varchar(3) NOT NULL COMMENT '请求系统编号',
`request_id` varchar(128) NOT NULL COMMENT '请求ID唯一',
`status` smallint DEFAULT 0 COMMENT '业务状态:0-处理中,1-处理成功,-1-处理失败',
`request_json` json COMMENT '请求的json',
`response_json` json COMMENT '返回的json',
`version` int DEFAULT 0 COMMENT '版本号用于乐观锁',
PRIMARY KEY (`id`),
UNIQUE KEY `uq_request_id` (`request_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- req_sys_id(请求系统编号):这个编号是下游系统给调用方分配的。
-- 比如,一家基金公司会对接很多家代销商,那么基金公司就会给这些代销商分配一个编号,
-- 这样在排查问题的时候就很容易知道是哪家代销商给的数据。
2. 服务端代码
- 下面的代码只是简单展示。在实际的项目中,会将 "对幂等辅助表的操作" 与 "业务逻辑" 分开操作,最少是在3个事务中进行处理(幂等辅助表插入、业务逻辑、幂等辅助表修改)。
- 用postman测试即可。要明白
重试 ≠ 并发,所以用测试工具就可以了,每次调用都发送【相同】的数据就行。 - request_id :正常情况下,也就是不发生重试的时候,每次发送的都是唯一的。
重试就意味着一模一样的请求数据,重试时每次发送的 request_id 也是一模一样的。
java
@Transactional // 事务管理
public String createOrder(OrderRequest request) {
String reqSysId = request.getReqSysId(); // 客户端编号(请求系统编号)
String requestId = request.getRequestId(); // 客户端生成的唯一请求ID
// 查询是否已处理
IdempotentCall record = idempotentCallMapper.selectByReq(reqSysId,requestId);
if (record == null) {
try {
// 首次请求
record = new DempotentCall();
record.setId(UUID.randomUUID().toString());
record.setReqSysId(reqSysId);
record.setRequestId(requestId);
record.setStatus(0); // 处理中
record.setRequestJson(JSON.toJSONString(request));
idempotentCallMapper.insert(record);
// 执行业务逻辑(插入订单信息)
Order order = new Order();
order.setId(UUID.randomUUID().toString());
order.setUserId(request.getUserId());
order.setAmount(request.getAmount());
orderMapper.insert(order);
// 更新记录
record.setStatus(1); // 处理成功
record.setResponseJson(JSON.toJSONString(order));
idempotentCallMapper.updateByPrimaryKey(record);
return "下单成功,订单ID: " + order.getId();
} catch(Exception e) {
if (record != null) {
// 更新记录
record.setStatus(-1); // 处理失败
record.setResponseJson(JSON.toJSONString(order));
idempotentCallMapper.updateByPrimaryKey(record);
} else {
throw e;
}
}
} else {
if (record.getStatus() == 0) {
// 处理中,返回"处理中"提示
return "订单正在处理中,请稍后再试";
} else {
// 已处理(成功/失败),直接返回
return record.getResponseJson();
}
}
}
四、MQ消费者幂等性处理
1. 为什么MQ需要幂等性?
MQ(消息队列)在分布式系统中很常见,但 网络问题可能导致 同一条消息被消费多次。
常见原因:
- 网络波动:消费者处理完消息,但ACK没传回MQ。
- 系统故障:消费者处理完消息,但系统崩溃了。
- MQ重试机制:MQ自动重试未确认的消息。
后果:
- 重复下单 → 用户多扣钱
- 重复发券 → 优惠券发多了
2. MQ消费者幂等性处理方案
方案:唯一ID + Redis(推荐)
核心思想:用业务唯一标识(如订单ID)作为去重依据。
java
// 幂等性检查通用工具
@Component
public class MqIdempotentHandler {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String KEY_PREFIX = "idempotent:mq:";
/**
* 检查幂等性(原子操作)
* @param uniqueId 唯一ID(如订单ID)
* @return true=未处理过,false=已处理
*/
public boolean checkIdempotent(String uniqueId) {
String key = KEY_PREFIX + uniqueId;
/*
* 需要用lua来保证原子性。不能把get操作和setex操作分两步来执行,如果分两步的话,
* 并发调用该方法时,可能会有多个线程同时去调用get方法,获取到的结果都是一样的,
* 这肯定不是我们想要的结果。
*
* 先get看是否存在,不存在则设置(带过期时间,避免永久占用内存)并返回1(表示未处理);
* 存在说明已经处理过了,返回0(表示已处理)。
*/
String script =
"if redis.call('get', KEYS[1]) == false then " +
" redis.call('setex', KEYS[1], 86400, 1) " +
" return 1 " +
"else " +
" return 0 " +
"end";
// 执行Lua脚本,返回1表示未处理,0表示已处理
return (Long) redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(key)
) == 1;
}
}
java
// 使用示例
@Component
public class OrderConsumer {
@Autowired
private OrderMapper orderMapper;
@Autowired
private MqIdempotentHandler idempotentHandler;
@RabbitListener(queues = "order-create-queue")
public void processOrder(OrderMessage message) {
// 1. 用唯一ID(可以是订单ID)检查幂等性
String uniqueId = message.getOrderId();
if (!this.idempotentHandler.checkIdempotent(uniqueId)) {
System.out.println("消息已处理,跳过: " + uniqueId);
return;
}
// 2. 执行业务逻辑(下单)
try {
Order order = new Order();
order.setId(message.getOrderId());
order.setUserId(message.getUserId());
order.setAmount(message.getAmount());
orderMapper.insert(order);
System.out.println("订单创建成功: " + message.getOrderId());
} catch (Exception e) {
// 处理失败,可以记录日志或重试
System.out.println("订单创建失败: " + message.getOrderId() + ", " + e.getMessage());
throw e;
}
}
}
五、实战建议
1. MQ消费者幂等性
- 所有MQ消费 都
必须实现幂等性。 - 唯一ID可以是业务主键(如订单ID、用户ID等),不要用MQ的Message ID作为幂等键,MQ的Message ID 是由 消息队列 MQ 系统自动生成,和业务无关。
- 设置Redis过期时间,避免内存泄漏。
2. 重要避坑点
- 不要在业务代码中直接处理幂等。
- Redis的键名要加前缀:避免和其他业务冲突。
- 设置Redis过期时间:避免内存泄漏。
六、总结
| 问题 | 解决方案 | 推荐度 |
|---|---|---|
| Spring Boot接口幂等性 | Token + Redis | ⭐⭐⭐⭐⭐ |
| MQ消费者幂等性 | 唯一ID + Redis | ⭐⭐⭐⭐⭐ |
| 留痕场景幂等性 | 请求ID + 数据库 | ⭐⭐ |
记住一句话 :"接口幂等性是资金安全的底线,MQ幂等性是系统稳定的保障"。
😊