Spring Cloud 微服务架构下幂等性的 业务场景、解决的核心问题、完整实现方案及可运行代码
核心是希望掌握在分布式环境中如何避免重复操作导致的数据异常。
一、先搞懂:幂等性在Spring Cloud中能解决什么问题?
幂等性定义:同一请求(或操作)多次执行,最终效果完全一致,不会产生重复数据、数据不一致或资源重复消耗。
在 Spring Cloud 微服务(分布式、跨服务调用、网络不稳定、异步通信)场景下,核心解决以下问题:
- 重复提交:用户快速点击按钮(如下单、支付)导致重复请求
- 服务重试:Feign 调用超时重试、网关重试导致重复处理
- 消息重复消费:MQ(RocketMQ/Kafka)消息重试、消息重投导致重复处理
- 支付回调重试:第三方支付平台(微信/支付宝)回调接口因网络超时重试
- 分布式事务补偿:TCC/SAGA 模式下的重试操作导致的数据重复修改
二、Spring Cloud 中幂等性的典型业务场景
场景1:用户下单(高频重复提交+消息重复消费)
- 业务描述:用户在电商平台点击「下单」按钮,因网络延迟快速点击了2次,导致订单服务收到2次下单请求;同时订单服务向库存服务发送扣减库存的 MQ 消息,因 MQ 重试机制,库存服务收到2次相同消息。
- 潜在问题:重复创建订单、重复扣减库存(超卖/少卖)、重复锁定优惠券。
场景2:支付回调通知(API重试)
- 业务描述:用户支付成功后,微信支付平台向商户的支付回调接口发送通知,但因网络超时,支付平台重试3次回调请求。
- 潜在问题:重复更新订单状态(已支付→已支付重复执行)、重复给用户账户入账。
场景3:商品库存扣减(服务调用重试)
- 业务描述:订单服务通过 Feign 调用库存服务扣减库存,因库存服务响应超时,Feign 触发重试机制,发起多次扣减请求。
- 潜在问题:库存重复扣减(导致超卖)。
三、Spring Cloud 幂等性完整解决方案
核心思路
结合「前端防重 + 后端幂等校验 + 数据层约束」三层防护,选择适合场景的幂等方案:
| 方案类型 | 适用场景 | 核心原理 | 技术选型 |
|---|---|---|---|
| 唯一请求ID + Redis | 重复提交、API重试 | 首次请求存储ID,重复请求直接拦截 | Redis + 自定义注解+拦截器 |
| 数据库唯一约束 | 订单创建、用户注册 | 唯一索引/联合索引防止重复数据插入 | MySQL 唯一索引 |
| 乐观锁(版本号) | 库存扣减、订单状态更新 | 基于版本号控制,仅允许状态变更一次 | MySQL 版本字段 + MyBatis |
| 消息ID + Redis | MQ消息重复消费 | 消费前校验消息ID是否已处理 | Redis + MQ 消费监听 |
| 状态机控制 | 订单状态流转(待支付→已支付) | 仅允许从指定状态流转到目标状态 | 业务代码中状态判断 |
技术栈选型(Spring Cloud Alibaba 生态)
- 基础框架:Spring Boot 2.7.x + Spring Cloud Alibaba 2021.0.4.0
- 服务注册发现:Nacos
- 缓存(幂等校验):Redis
- 消息队列:RocketMQ(处理异步通信+重复消费)
- 持久层:MyBatis-Plus + MySQL 8.0
- 接口文档:Swagger(可选,方便测试)
四、完整代码实现(聚焦场景1+场景2)
1. 项目整体结构
bash
idempotent-demo/
├── idempotent-common/ # 公共模块(注解、工具类)
├── idempotent-order/ # 订单服务(场景1:下单+场景2:支付回调)
├── idempotent-inventory/ # 库存服务(场景1:库存扣减)
└── pom.xml # 父工程依赖
2. 父工程依赖(pom.xml)
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.15</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>idempotent-demo</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>pom</packaging>
<modules>
<module>idempotent-common</module>
<module>idempotent-order</module>
<module>idempotent-inventory</module>
</modules>
<properties>
<spring-cloud-alibaba.version>2021.0.4.0</spring-cloud-alibaba.version>
<java.version>1.8</java.version>
</properties>
<!-- 依赖管理 -->
<dependencyManagement>
<dependencies>
<!-- Spring Cloud Alibaba 依赖 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>${spring-cloud-alibaba.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Spring Boot 核心依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- 工具类 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- 接口文档 -->
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-boot-starter</artifactId>
<version>3.0.0</version>
</dependency>
</dependencies>
</project>
3. 公共模块(idempotent-common)
3.1 幂等注解(@Idempotent)
java
package com.example.idempotent.common.annotation;
import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;
/**
* 幂等注解:标记需要幂等校验的接口
*/
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {
/**
* Redis 过期时间(默认30秒)
*/
long expire() default 30;
/**
* 时间单位(默认秒)
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
/**
* 重复请求提示信息
*/
String message() default "操作过于频繁,请稍后再试!";
}
3.2 Redis 工具类(RedisUtil)
typescript
package com.example.idempotent.common.util;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
/**
* Redis 工具类(简化幂等校验的存储/查询)
*/
@Component
public class RedisUtil {
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 存储幂等键(如果不存在则存储,返回true;已存在返回false)
*/
public boolean setIfAbsent(String key, String value, long expire, TimeUnit timeUnit) {
Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(key, value, expire, timeUnit);
return Boolean.TRUE.equals(success);
}
/**
* 删除幂等键
*/
public void delete(String key) {
stringRedisTemplate.delete(key);
}
/**
* 查询幂等键是否存在
*/
public boolean hasKey(String key) {
Boolean exists = stringRedisTemplate.hasKey(key);
return Boolean.TRUE.equals(exists);
}
}
3.3 全局异常处理(GlobalExceptionHandler)
scala
package com.example.idempotent.common.exception;
import lombok.Data;
/**
* 幂等异常(重复请求时抛出)
*/
@Data
public class IdempotentException extends RuntimeException {
private int code = 409; // 冲突状态码
private String message;
public IdempotentException(String message) {
super(message);
this.message = message;
}
}
java
package com.example.idempotent.common.exception;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.HashMap;
import java.util.Map;
/**
* 全局异常处理器
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 处理幂等异常
*/
@ExceptionHandler(IdempotentException.class)
@ResponseStatus(HttpStatus.CONFLICT)
public Map<String, Object> handleIdempotentException(IdempotentException e) {
Map<String, Object> result = new HashMap<>();
result.put("code", e.getCode());
result.put("message", e.getMessage());
return result;
}
}
4. 订单服务(idempotent-order)
4.1 配置文件(application.yml)
yaml
server:
port: 8081
spring:
application:
name: idempotent-order
# Redis 配置
redis:
host: localhost
port: 6379
password: 123456
lettuce:
pool:
max-active: 8
# 数据库配置
datasource:
url: jdbc:mysql://localhost:3306/idempotent_db?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
# RocketMQ 配置
rocketmq:
name-server: localhost:9876
producer:
group: order-producer-group
# Nacos 服务注册
spring.cloud.nacos.discovery:
server-addr: localhost:8848
# MyBatis-Plus 配置
mybatis-plus:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.example.idempotent.order.entity
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
4.2 数据库表设计(订单表)
sql
CREATE DATABASE IF NOT EXISTS idempotent_db;
USE idempotent_db;
-- 订单表(唯一索引防重复创建,版本号控制乐观锁)
CREATE TABLE `t_order` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`order_no` varchar(64) NOT NULL COMMENT '订单号(唯一)',
`user_id` bigint NOT NULL COMMENT '用户ID',
`product_id` bigint NOT NULL COMMENT '商品ID',
`amount` decimal(10,2) NOT NULL COMMENT '订单金额',
`status` tinyint NOT NULL COMMENT '订单状态:0-待支付,1-已支付,2-已取消',
`version` int NOT NULL DEFAULT 0 COMMENT '版本号(乐观锁)',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_no` (`order_no`) COMMENT '订单号唯一约束'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';
4.3 实体类(Order)
kotlin
package com.example.idempotent.order.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@TableName("t_order")
public class Order {
@TableId(type = IdType.AUTO)
private Long id;
private String orderNo; // 订单号(唯一)
private Long userId;
private Long productId;
private BigDecimal amount;
private Integer status; // 0-待支付,1-已支付,2-已取消
private Integer version; // 乐观锁版本号
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
4.4 幂等拦截器(IdempotentInterceptor)
java
package com.example.idempotent.order.interceptor;
import com.example.idempotent.common.annotation.Idempotent;
import com.example.idempotent.common.exception.IdempotentException;
import com.example.idempotent.common.util.RedisUtil;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;
/**
* 幂等拦截器:拦截带有@Idempotent注解的接口,进行幂等校验
*/
@Component
public class IdempotentInterceptor implements HandlerInterceptor {
@Resource
private RedisUtil redisUtil;
// 幂等请求头名称(前端需传递唯一请求ID)
private static final String IDEMPOTENT_REQUEST_ID = "Idempotent-Request-Id";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 1. 判断是否是目标方法(Controller接口方法)
if (!(handler instanceof HandlerMethod handlerMethod)) {
return true;
}
Method method = handlerMethod.getMethod();
Idempotent idempotent = method.getAnnotation(Idempotent.class);
if (idempotent == null) {
return true; // 无幂等注解,直接放行
}
// 2. 获取前端传递的唯一请求ID
String requestId = request.getHeader(IDEMPOTENT_REQUEST_ID);
if (requestId == null || requestId.trim().isEmpty()) {
throw new IdempotentException("缺少幂等请求ID,请重试!");
}
// 3. 构建Redis键(用户ID+请求ID,避免不同用户冲突)
String userId = request.getHeader("User-Id"); // 实际场景从Token中解析
String redisKey = String.format("idempotent:%s:%s:%s", userId, request.getRequestURI(), requestId);
// 4. Redis校验:不存在则存储,存在则抛出异常
boolean success = redisUtil.setIfAbsent(
redisKey,
"1",
idempotent.expire(),
idempotent.timeUnit()
);
if (!success) {
throw new IdempotentException(idempotent.message());
}
return true;
}
}
4.5 拦截器配置(WebConfig)
kotlin
package com.example.idempotent.order.config;
import com.example.idempotent.order.interceptor.IdempotentInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import javax.annotation.Resource;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Resource
private IdempotentInterceptor idempotentInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 注册幂等拦截器,拦截所有接口(可通过excludePathPatterns排除不需要的接口)
registry.addInterceptor(idempotentInterceptor)
.addPathPatterns("/**")
.excludePathPatterns("/swagger-ui/**", "/v3/api-docs/**");
}
}
4.6 Mapper + Service(订单服务核心逻辑)
less
package com.example.idempotent.order.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.idempotent.order.entity.Order;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface OrderMapper extends BaseMapper<Order> {
// 乐观锁更新订单状态
int updateOrderStatus(@Param("orderNo") String orderNo, @Param("oldStatus") Integer oldStatus, @Param("newStatus") Integer newStatus, @Param("version") Integer version);
}
xml
<!-- resources/mapper/OrderMapper.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.idempotent.order.mapper.OrderMapper">
<!-- 乐观锁更新订单状态:仅当版本号匹配且状态为oldStatus时更新 -->
<update id="updateOrderStatus">
UPDATE t_order
SET status = #{newStatus}, version = version + 1, update_time = NOW()
WHERE order_no = #{orderNo} AND status = #{oldStatus} AND version = #{version}
</update>
</mapper>
java
package com.example.idempotent.order.service;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.idempotent.common.util.RedisUtil;
import com.example.idempotent.order.entity.Order;
import com.example.idempotent.order.mapper.OrderMapper;
import com.example.idempotent.order.feign.InventoryFeignClient;
import com.example.idempotent.order.mq.OrderMqProducer;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.util.UUID;
@Service
public class OrderService extends ServiceImpl<OrderMapper, Order> {
@Resource
private OrderMapper orderMapper;
@Resource
private InventoryFeignClient inventoryFeignClient; // Feign调用库存服务
@Resource
private OrderMqProducer orderMqProducer; // MQ生产者
@Resource
private RedisUtil redisUtil;
/**
* 下单接口(幂等处理:Redis+唯一订单号)
*/
@Transactional(rollbackFor = Exception.class)
public Order createOrder(Long userId, Long productId, BigDecimal amount) {
// 1. 生成唯一订单号(防止数据库重复插入)
String orderNo = UUID.randomUUID().toString().replace("-", "").substring(0, 32);
// 2. 创建订单(数据库唯一索引order_no防止重复)
Order order = new Order();
order.setOrderNo(orderNo);
order.setUserId(userId);
order.setProductId(productId);
order.setAmount(amount);
order.setStatus(0); // 0-待支付
orderMapper.insert(order);
// 3. 异步扣减库存(发送MQ消息,库存服务消费时做幂等处理)
orderMqProducer.sendDeductInventoryMsg(orderNo, productId, 1);
return order;
}
/**
* 支付回调接口(幂等处理:状态机+乐观锁)
*/
@Transactional(rollbackFor = Exception.class)
public boolean handlePayCallback(String orderNo, Integer version) {
// 1. 状态机控制:仅允许从「待支付(0)」更新为「已支付(1)」
int rows = orderMapper.updateOrderStatus(orderNo, 0, 1, version);
if (rows == 0) {
// 更新失败:可能是重复回调,或状态已变更
return false;
}
// 2. 后续业务:如给用户入账、发送通知等
return true;
}
}
4.7 MQ生产者(OrderMqProducer)
typescript
package com.example.idempotent.order.mq;
import org.apache.rocketmq.client.producer.SendResult;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
/**
* 订单MQ生产者:发送库存扣减消息
*/
@Component
public class OrderMqProducer {
@Resource
private RocketMQTemplate rocketMQTemplate;
// 库存扣减消息主题
private static final String TOPIC_DEDUCT_INVENTORY = "topic-deduct-inventory";
/**
* 发送库存扣减消息(消息ID作为幂等标识)
*/
public SendResult sendDeductInventoryMsg(String orderNo, Long productId, Integer num) {
// 构建消息内容(包含订单号、商品ID、扣减数量)
DeductInventoryMsg msg = new DeductInventoryMsg();
msg.setOrderNo(orderNo);
msg.setProductId(productId);
msg.setNum(num);
// 发送消息(RocketMQ自动生成唯一消息ID,消费时用该ID做幂等)
return rocketMQTemplate.syncSend(TOPIC_DEDUCT_INVENTORY, msg);
}
// 消息实体
public static class DeductInventoryMsg {
private String orderNo;
private Long productId;
private Integer num;
// getter/setter
public String getOrderNo() { return orderNo; }
public void setOrderNo(String orderNo) { this.orderNo = orderNo; }
public Long getProductId() { return productId; }
public void setProductId(Long productId) { this.productId = productId; }
public Integer getNum() { return num; }
public void setNum(Integer num) { this.num = num; }
}
}
4.8 Controller(对外接口)
less
package com.example.idempotent.order.controller;
import com.example.idempotent.common.annotation.Idempotent;
import com.example.idempotent.order.entity.Order;
import com.example.idempotent.order.service.OrderService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.math.BigDecimal;
@RestController
@RequestMapping("/order")
@Api(tags = "订单接口(幂等性示例)")
public class OrderController {
@Resource
private OrderService orderService;
/**
* 下单接口(幂等注解:防止重复提交)
* 前端需传递请求头:Idempotent-Request-Id(唯一ID)、User-Id(用户ID)
*/
@PostMapping("/create")
@ApiOperation("创建订单")
@Idempotent(expire = 60, message = "下单请求过于频繁,请1分钟后重试!")
public Order createOrder(
@RequestHeader("User-Id") Long userId,
@RequestParam Long productId,
@RequestParam BigDecimal amount) {
return orderService.createOrder(userId, productId, amount);
}
/**
* 支付回调接口(幂等处理:状态机+乐观锁)
* 支付平台回调时传递订单号和版本号
*/
@PostMapping("/pay/callback")
@ApiOperation("支付回调")
@Idempotent(expire = 180, message = "回调请求已处理,请不要重复提交!")
public String payCallback(
@RequestParam String orderNo,
@RequestParam Integer version) {
boolean success = orderService.handlePayCallback(orderNo, version);
return success ? "回调处理成功" : "回调处理失败(重复请求或状态已变更)";
}
}
5. 库存服务(idempotent-inventory)
5.1 配置文件(application.yml)
yaml
server:
port: 8082
spring:
application:
name: idempotent-inventory
redis:
host: localhost
port: 6379
password: 123456
datasource:
url: jdbc:mysql://localhost:3306/idempotent_db?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
rocketmq:
name-server: localhost:9876
consumer:
group: inventory-consumer-group
spring.cloud.nacos.discovery:
server-addr: localhost:8848
mybatis-plus:
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.example.idempotent.inventory.entity
configuration:
map-underscore-to-camel-case: true
5.2 数据库表设计(库存表)
sql
-- 库存表(乐观锁防止重复扣减)
CREATE TABLE `t_inventory` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`product_id` bigint NOT NULL COMMENT '商品ID',
`stock` int NOT NULL COMMENT '库存数量',
`version` int NOT NULL DEFAULT 0 COMMENT '版本号(乐观锁)',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_product_id` (`product_id`) COMMENT '商品ID唯一'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='库存表';
-- 初始化库存数据
INSERT INTO `t_inventory` (`product_id`, `stock`) VALUES (1001, 100);
5.3 MQ消费者(处理消息重复消费)
typescript
package com.example.idempotent.inventory.mq;
import com.example.idempotent.common.util.RedisUtil;
import com.example.idempotent.inventory.service.InventoryService;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
/**
* 库存消费者:处理库存扣减消息(幂等处理:消息ID+Redis)
*/
@Component
@RocketMQMessageListener(topic = "topic-deduct-inventory", consumerGroup = "inventory-consumer-group")
public class InventoryConsumer implements RocketMQListener<OrderMqProducer.DeductInventoryMsg> {
@Resource
private InventoryService inventoryService;
@Resource
private RedisUtil redisUtil;
@Override
public void onMessage(OrderMqProducer.DeductInventoryMsg msg) {
// 1. 获取RocketMQ消息ID(通过ThreadLocal获取,需自定义消息转换器)
String msgId = MqMsgIdContextHolder.getMsgId();
if (msgId == null) {
throw new RuntimeException("获取消息ID失败,无法进行幂等校验");
}
// 2. 构建Redis幂等键(消息ID唯一)
String redisKey = String.format("mq:idempotent:%s", msgId);
// 3. 幂等校验:已处理过的消息直接返回
if (redisUtil.hasKey(redisKey)) {
System.out.println("消息已处理,跳过重复消费:" + msgId);
return;
}
try {
// 4. 扣减库存(乐观锁保证幂等)
boolean success = inventoryService.deductStock(msg.getProductId(), msg.getNum());
if (success) {
// 5. 库存扣减成功,标记消息已处理(Redis过期时间1小时)
redisUtil.setIfAbsent(redisKey, "processed", 3600, java.util.concurrent.TimeUnit.SECONDS);
} else {
throw new RuntimeException("库存扣减失败:库存不足或重复扣减");
}
} catch (Exception e) {
// 6. 处理失败:可根据业务需要重试或告警
System.err.println("库存扣减异常:" + e.getMessage());
throw e;
}
}
// 消息ID上下文持有类(通过自定义消息转换器设置)
public static class MqMsgIdContextHolder {
private static final ThreadLocal<String> MSG_ID_HOLDER = new ThreadLocal<>();
public static void setMsgId(String msgId) {
MSG_ID_HOLDER.set(msgId);
}
public static String getMsgId() {
return MSG_ID_HOLDER.get();
}
public static void clear() {
MSG_ID_HOLDER.remove();
}
}
}
5.4 自定义RocketMQ消息转换器(获取消息ID)
kotlin
package com.example.idempotent.inventory.config;
import com.example.idempotent.inventory.mq.InventoryConsumer;
import org.apache.rocketmq.common.message.MessageExt;
import org.apache.rocketmq.spring.support.DefaultRocketMQMessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.Message;
import org.springframework.messaging.converter.MessageConverter;
@Configuration
public class RocketMQConfig {
/**
* 自定义消息转换器:获取RocketMQ消息ID并存入上下文
*/
@Bean
public MessageConverter rocketMQMessageConverter() {
return new DefaultRocketMQMessageConverter() {
@Override
public Object fromMessage(Message<?> message, Class<?> targetClass) {
// 获取RocketMQ原始消息
MessageExt messageExt = (MessageExt) message.getHeaders().get("rocketmq_messageExt");
if (messageExt != null) {
// 将消息ID存入上下文
InventoryConsumer.MqMsgIdContextHolder.setMsgId(messageExt.getMsgId());
}
try {
return super.fromMessage(message, targetClass);
} finally {
// 清除上下文,避免内存泄漏
InventoryConsumer.MqMsgIdContextHolder.clear();
}
}
};
}
}
5.5 库存Service(乐观锁扣减)
scala
package com.example.idempotent.inventory.service;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.idempotent.inventory.entity.Inventory;
import com.example.idempotent.inventory.mapper.InventoryMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
@Service
public class InventoryService extends ServiceImpl<InventoryMapper, Inventory> {
@Resource
private InventoryMapper inventoryMapper;
/**
* 扣减库存(乐观锁保证幂等)
*/
@Transactional(rollbackFor = Exception.class)
public boolean deductStock(Long productId, Integer num) {
// 乐观锁扣减:仅当版本号匹配时扣减库存
int rows = inventoryMapper.deductStock(productId, num);
return rows > 0;
}
}
五、测试验证
1. 环境准备
- 启动 Nacos(端口8848)
- 启动 Redis(端口6379)
- 启动 RocketMQ(NameServer端口9876,Broker端口10911)
- 启动 MySQL,执行上述SQL创建数据库和表
- 启动订单服务(8081)和库存服务(8082)
2. 测试场景1:重复下单(Postman)
-
请求地址:
http://localhost:8081/order/create -
请求头:
Idempotent-Request-Id: test-123456(同一ID重复提交)User-Id: 1001
-
请求参数:
productId=1001&amount=99.9 -
测试结果:
- 第一次请求:成功创建订单,库存扣减1(库存变为99)
- 第二次请求:返回409冲突,提示"下单请求过于频繁,请1分钟后重试!",订单表无重复数据,库存不变
3. 测试场景2:支付回调重试
-
请求地址:
http://localhost:8081/order/pay/callback -
请求头:
Idempotent-Request-Id: pay-789 -
请求参数:
orderNo=xxx(第一次下单返回的orderNo)&version=0 -
测试结果:
- 第一次请求:订单状态从0→1,返回"回调处理成功"
- 第二次请求:返回"回调处理失败(重复请求或状态已变更)",订单状态不变
4. 测试场景3:消息重复消费
- 手动在RocketMQ控制台重发"topic-deduct-inventory"主题的消息
- 观察库存服务日志:输出"消息已处理,跳过重复消费:xxx",库存不变
总结
-
核心场景:Spring Cloud 中幂等性主要应用于「重复提交、服务重试、消息重复消费、支付回调」等分布式场景。
-
核心方案:通过「Redis+唯一ID」拦截重复请求、「数据库唯一约束」防止重复插入、「乐观锁+状态机」控制数据更新,三层防护确保幂等。
-
关键实现:
- 用自定义注解+拦截器统一处理幂等校验,减少重复代码;
- 结合Redis实现分布式幂等,适配微服务跨节点部署;
- 数据库约束和乐观锁作为最终数据防护,避免极端情况下的异常。
这套方案可直接应用于实际开发,根据具体业务场景调整过期时间、幂等键规则即可。