浅谈接口幂等性、MQ消费幂等性

浅谈接口幂等性、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. 重要避坑点

  1. 不要在业务代码中直接处理幂等
  2. Redis的键名要加前缀:避免和其他业务冲突。
  3. 设置Redis过期时间:避免内存泄漏。

六、总结

问题 解决方案 推荐度
Spring Boot接口幂等性 Token + Redis ⭐⭐⭐⭐⭐
MQ消费者幂等性 唯一ID + Redis ⭐⭐⭐⭐⭐
留痕场景幂等性 请求ID + 数据库 ⭐⭐

记住一句话"接口幂等性是资金安全的底线,MQ幂等性是系统稳定的保障"


😊

相关推荐
Wang's Blog8 小时前
RabbitMQ: 高并发外卖系统的微服务架构设计与工程实现
分布式·微服务·rabbitmq
墨香幽梦客12 小时前
合规视角的数据安全与隐私:HIPAA等法规的架构内生化实践
java·分布式·微服务
znhy605812 小时前
分布计算系统
网络·分布式
狮恒12 小时前
OpenHarmony Flutter 分布式设备发现与连接:无感组网与设备协同管理方案
分布式·flutter·wpf·openharmony
Wang's Blog14 小时前
RabbitMQ: 消息交换机制的核心原理与实践指南之基于 AMQP 协议的系统设计与工程实现
分布式·rabbitmq
狮恒14 小时前
OpenHarmony Flutter 分布式音视频:跨设备流传输与实时协同交互方案
分布式·flutter·wpf·openharmony
狮恒15 小时前
OpenHarmony Flutter 分布式安全与隐私保护:跨设备可信交互与数据防泄漏方案
分布式·flutter·wpf·openharmony
ha_lydms16 小时前
Spark函数
大数据·分布式·spark
狮恒17 小时前
OpenHarmony Flutter 分布式任务调度:跨设备资源协同与负载均衡方案
分布式·flutter·wpf·openharmony