Spring Cloud 微服务架构下幂等性的 业务场景、解决的核心问题、完整实现方案及可运行代码

Spring Cloud 微服务架构下幂等性的 业务场景、解决的核心问题、完整实现方案及可运行代码

核心是希望掌握在分布式环境中如何避免重复操作导致的数据异常。

一、先搞懂:幂等性在Spring Cloud中能解决什么问题?

幂等性定义:同一请求(或操作)多次执行,最终效果完全一致,不会产生重复数据、数据不一致或资源重复消耗

在 Spring Cloud 微服务(分布式、跨服务调用、网络不稳定、异步通信)场景下,核心解决以下问题:

  1. 重复提交:用户快速点击按钮(如下单、支付)导致重复请求
  2. 服务重试:Feign 调用超时重试、网关重试导致重复处理
  3. 消息重复消费:MQ(RocketMQ/Kafka)消息重试、消息重投导致重复处理
  4. 支付回调重试:第三方支付平台(微信/支付宝)回调接口因网络超时重试
  5. 分布式事务补偿: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",库存不变

总结

  1. 核心场景:Spring Cloud 中幂等性主要应用于「重复提交、服务重试、消息重复消费、支付回调」等分布式场景。

  2. 核心方案:通过「Redis+唯一ID」拦截重复请求、「数据库唯一约束」防止重复插入、「乐观锁+状态机」控制数据更新,三层防护确保幂等。

  3. 关键实现

    1. 用自定义注解+拦截器统一处理幂等校验,减少重复代码;
    2. 结合Redis实现分布式幂等,适配微服务跨节点部署;
    3. 数据库约束和乐观锁作为最终数据防护,避免极端情况下的异常。

这套方案可直接应用于实际开发,根据具体业务场景调整过期时间、幂等键规则即可。

相关推荐
PieroPC1 小时前
飞牛Nas-通过Docker的Compose 安装WordPress
后端
shengjk11 小时前
当10万天分区来袭:一个让StarRocks崩溃、Kudu拒绝、HDFS微笑的架构故事
后端
一 乐1 小时前
鲜花销售|基于springboot+vue的鲜花销售系统设计与实现(源码+数据库+文档)
java·数据库·vue.js·spring boot·后端·spring
T.O.P_KING1 小时前
Common Go Mistakes(IV 字符串)
开发语言·后端·golang
盒马盒马2 小时前
Rust:Trait 标签 & 常见特征
开发语言·后端·rust
韩立学长2 小时前
基于Springboot儿童福利院规划管理系统o292y1v8(程序、源码、数据库、调试部署方案及开发环境)系统界面展示及获取方式置于文档末尾,可供参考。
数据库·spring boot·后端
y1y1z2 小时前
Spring国际化
java·后端·spring
weixin_307779132 小时前
Jenkins ASM API 插件:详解与应用指南
java·运维·开发语言·后端·jenkins
程序员爱钓鱼2 小时前
Node.js 与前端 JavaScript 的区别:不仅仅是“运行环境不同”
后端·node.js