Skip to content

幂等处理 (idempotent)

概述

幂等功能模块提供基于 Redis 分布式锁机制的接口防重复提交功能,确保在短时间内用户重复点击或网络抖动等场景下,同一请求只会被处理一次,有效避免数据重复插入、重复扣款等问题。该模块参考美团 GTIS 防重系统设计理念,结合 Spring AOP 与 Redis 实现企业级幂等性控制方案。

核心特性

  • 分布式锁机制:基于 Redis 实现分布式环境下的防重复提交,支持多实例部署
  • 智能清理策略:成功时保留锁定,失败时自动清理,允许重新提交
  • 灵活配置:支持自定义间隔时间、时间单位和提示消息
  • 国际化支持:提示消息支持多语言配置,通过 {messageKey} 格式引用
  • 参数过滤:自动过滤文件上传、HTTP 对象等特殊对象,确保唯一标识准确性
  • 线程安全:使用 ThreadLocal 确保并发场景下的数据隔离
  • 自动装配:基于 Spring Boot 自动配置,引入依赖即可使用

模块架构

整体架构图

┌─────────────────────────────────────────────────────────────────────┐
│                        应用层 Application                            │
│  ┌──────────────────────────────────────────────────────────────┐  │
│  │                    @RepeatSubmit 注解标记                      │  │
│  │         Controller 方法 → 业务服务 → 数据持久化                  │  │
│  └──────────────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────┐
│                        切面层 Aspect                                 │
│  ┌──────────────────────────────────────────────────────────────┐  │
│  │                  RepeatSubmitAspect 切面                       │  │
│  │  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐  │  │
│  │  │ @Before     │  │@AfterReturn │  │ @AfterThrowing      │  │  │
│  │  │ 请求前检查  │  │ 成功后处理  │  │ 异常后清理          │  │  │
│  │  └─────────────┘  └─────────────┘  └─────────────────────┘  │  │
│  └──────────────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────┐
│                        工具层 Utils                                  │
│  ┌────────────────┐  ┌────────────────┐  ┌────────────────────┐   │
│  │  RedisUtils    │  │   JsonUtils    │  │   SecureUtil       │   │
│  │  分布式锁操作  │  │  参数序列化    │  │   MD5 哈希计算     │   │
│  └────────────────┘  └────────────────┘  └────────────────────┘   │
└─────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────┐
│                       存储层 Storage                                 │
│  ┌──────────────────────────────────────────────────────────────┐  │
│  │                         Redis                                  │  │
│  │     Key: repeat_submit::{uri}{md5(token:params)}              │  │
│  │     Value: ""                                                  │  │
│  │     TTL: interval (毫秒)                                       │  │
│  └──────────────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────────────┘

核心组件

组件说明
@RepeatSubmit防重复提交注解,标记需要幂等控制的方法
RepeatSubmitAspectAOP 切面处理器,实现防重复提交核心逻辑
IdempotentAutoConfiguration自动配置类,注册切面 Bean
RedisUtilsRedis 工具类,提供分布式锁操作
JsonUtilsJSON 工具类,序列化请求参数
SecureUtil加密工具类,生成唯一标识哈希值

工作原理

防重复提交流程

mermaid
sequenceDiagram
    participant Client as 客户端
    participant Server as 服务端
    participant Aspect as 切面处理器
    participant Redis as Redis

    Client->>Server: 发送请求
    Server->>Aspect: @Before 拦截
    Aspect->>Aspect: 生成唯一标识 Key
    Note over Aspect: MD5(token + params)
    Aspect->>Redis: setIfAbsent(key, "", interval)
    alt 锁设置成功
        Redis-->>Aspect: 返回 true
        Aspect->>Aspect: ThreadLocal 存储 Key
        Aspect-->>Server: 放行请求
        Server->>Server: 执行业务逻辑
        alt 业务执行成功 (R.SUCCESS)
            Server-->>Aspect: @AfterReturning
            Note over Redis: 保留锁,防止重复提交
            Aspect-->>Client: 返回成功结果
        else 业务执行失败 (R.FAIL)
            Server-->>Aspect: @AfterReturning
            Aspect->>Redis: 删除 Key
            Aspect-->>Client: 返回失败结果
        else 业务抛出异常
            Server-->>Aspect: @AfterThrowing
            Aspect->>Redis: 删除 Key
            Aspect-->>Client: 抛出异常
        end
    else 锁已存在(重复提交)
        Redis-->>Aspect: 返回 false
        Aspect-->>Client: 抛出 ServiceException
    end

唯一标识生成规则

防重复提交通过以下信息生成唯一标识:

  1. 用户标识:从请求头中获取 Sa-Token 的 Token 值
  2. 请求参数:序列化后的方法参数(过滤特殊对象)
  3. 请求路径:当前请求的 URI

Key 生成算法

java
// 1. 获取 Token(用户身份标识)
String token = request.getHeader(SaManager.getConfig().getTokenName());

// 2. 序列化请求参数
String params = argsArrayToString(methodArgs);

// 3. 生成唯一标识
String submitKey = SecureUtil.md5(token + ":" + params);

// 4. 构建完整缓存 Key
String cacheKey = "repeat_submit::" + requestURI + submitKey;

最终 Key 格式

repeat_submit::/api/user/create{32位MD5哈希值}

ThreadLocal 机制

切面使用 ThreadLocal 存储当前请求的 Redis Key,确保多线程环境下的数据隔离:

java
private static final ThreadLocal<String> KEY_CACHE = new ThreadLocal<>();

// @Before: 存储 Key
KEY_CACHE.set(cacheRepeatKey);

// @AfterReturning/@AfterThrowing: 获取并清理 Key
RedisUtils.deleteObject(KEY_CACHE.get());
KEY_CACHE.remove();  // 防止内存泄漏

注解详解

@RepeatSubmit 注解

java
@Inherited
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RepeatSubmit {

    /**
     * 重复提交检测间隔时间
     * 在此时间内的重复请求将被拦截,默认5000毫秒(5秒)
     */
    int interval() default 5000;

    /**
     * 时间单位
     * 配合 interval 使用,指定时间间隔的单位
     */
    TimeUnit timeUnit() default TimeUnit.MILLISECONDS;

    /**
     * 重复提交时的提示消息
     * 支持国际化配置,格式为 {messageKey}
     */
    String message() default I18nKeys.Request.DUPLICATE_SUBMIT;
}

注解属性说明

属性类型默认值说明
intervalint5000重复提交检测间隔时间
timeUnitTimeUnitMILLISECONDS时间单位枚举
messageStringI18nKeys.Request.DUPLICATE_SUBMIT重复提交提示消息,支持 i18n

时间单位支持

TimeUnit说明示例
MILLISECONDS毫秒(默认)interval = 5000 → 5秒
SECONDSinterval = 5, timeUnit = SECONDS → 5秒
MINUTES分钟interval = 1, timeUnit = MINUTES → 1分钟

注意事项

间隔时间不能小于 1 秒(1000毫秒),系统会自动校验并抛出异常:重复提交间隔时间不能小于'1'秒

使用指南

1. 引入依赖

xml
<dependency>
    <groupId>plus.ruoyi</groupId>
    <artifactId>ruoyi-common-idempotent</artifactId>
</dependency>

间接依赖(自动引入):

xml
<!-- JSON 序列化支持 -->
<dependency>
    <groupId>plus.ruoyi</groupId>
    <artifactId>ruoyi-common-json</artifactId>
</dependency>

<!-- Redis 分布式锁支持 -->
<dependency>
    <groupId>plus.ruoyi</groupId>
    <artifactId>ruoyi-common-redis</artifactId>
</dependency>

<!-- HuTool 加密工具 -->
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-crypto</artifactId>
</dependency>

<!-- Sa-Token 用户标识 -->
<dependency>
    <groupId>cn.dev33</groupId>
    <artifactId>sa-token-core</artifactId>
</dependency>

2. 基础用法

在需要防重复提交的 Controller 方法上添加 @RepeatSubmit 注解:

java
@RestController
@RequestMapping("/user")
public class UserController {

    @Autowired
    private UserService userService;

    /**
     * 创建用户 - 使用默认配置
     * 默认:5秒内不允许重复提交
     */
    @PostMapping("/create")
    @RepeatSubmit
    public R<Void> createUser(@RequestBody @Validated UserCreateReq req) {
        userService.createUser(req);
        return R.ok();
    }
}

3. 自定义间隔时间

java
@RestController
@RequestMapping("/order")
public class OrderController {

    /**
     * 创建订单 - 3秒内不允许重复提交
     */
    @PostMapping("/create")
    @RepeatSubmit(interval = 3, timeUnit = TimeUnit.SECONDS)
    public R<Long> createOrder(@RequestBody @Validated OrderCreateReq req) {
        Long orderId = orderService.createOrder(req);
        return R.ok(orderId);
    }

    /**
     * 支付订单 - 10秒内不允许重复提交(支付场景需要更长保护时间)
     */
    @PostMapping("/pay")
    @RepeatSubmit(interval = 10, timeUnit = TimeUnit.SECONDS)
    public R<Void> payOrder(@RequestBody PaymentReq req) {
        orderService.processPayment(req);
        return R.ok();
    }
}

4. 自定义提示消息

java
@RestController
@RequestMapping("/payment")
public class PaymentController {

    /**
     * 处理支付 - 自定义国际化提示消息
     */
    @PostMapping("/process")
    @RepeatSubmit(
        interval = 10,
        timeUnit = TimeUnit.SECONDS,
        message = "{payment.duplicate.submit}"
    )
    public R<Void> processPayment(@RequestBody PaymentReq req) {
        paymentService.process(req);
        return R.ok();
    }

    /**
     * 退款 - 直接指定消息内容
     */
    @PostMapping("/refund")
    @RepeatSubmit(
        interval = 30,
        timeUnit = TimeUnit.SECONDS,
        message = "退款申请正在处理中,请勿重复提交"
    )
    public R<Void> refund(@RequestBody RefundReq req) {
        paymentService.refund(req);
        return R.ok();
    }
}

5. 国际化消息配置

在国际化资源文件中配置提示消息:

properties
# messages.properties (默认/中文)
request.control.duplicate.submit=不允许重复提交,请稍候再试
payment.duplicate.submit=支付正在处理中,请勿重复操作

# messages_en.properties (英文)
request.control.duplicate.submit=Duplicate submission is not allowed, please try again later
payment.duplicate.submit=Payment is processing, please do not repeat the operation

高级特性

1. 智能参数过滤

系统会自动过滤以下类型的对象,确保唯一标识的准确性和稳定性:

过滤类型说明
MultipartFile文件上传对象(包括数组、集合)
HttpServletRequestHTTP 请求对象
HttpServletResponseHTTP 响应对象
BindingResult数据绑定结果对象

过滤逻辑源码

java
public boolean isFilterObject(final Object o) {
    Class<?> clazz = o.getClass();

    // 数组类型检查
    if (clazz.isArray()) {
        return MultipartFile.class.isAssignableFrom(clazz.getComponentType());
    }
    // 集合类型检查
    else if (Collection.class.isAssignableFrom(clazz)) {
        Collection collection = (Collection) o;
        for (Object value : collection) {
            return value instanceof MultipartFile;
        }
    }
    // Map 类型检查
    else if (Map.class.isAssignableFrom(clazz)) {
        Map map = (Map) o;
        for (Object value : map.values()) {
            return value instanceof MultipartFile;
        }
    }

    // 特殊对象类型检查
    return o instanceof MultipartFile
        || o instanceof HttpServletRequest
        || o instanceof HttpServletResponse
        || o instanceof BindingResult;
}

使用示例

java
@PostMapping("/upload")
@RepeatSubmit(interval = 5, timeUnit = TimeUnit.SECONDS)
public R<String> uploadFile(
    @RequestParam("file") MultipartFile file,  // 会被过滤,不参与唯一标识
    @RequestBody FileMetadata metadata         // 参与唯一标识计算
) {
    String url = fileService.upload(file, metadata);
    return R.ok(url);
}

2. 业务结果智能处理

系统根据业务执行结果智能处理 Redis 缓存:

场景返回值Redis 处理效果
业务成功R.ok()保留缓存有效期内不可重复提交
业务失败R.fail()删除缓存允许立即重新提交
抛出异常Exception删除缓存允许立即重新提交

源码实现

java
@AfterReturning(pointcut = "@annotation(repeatSubmit)", returning = "jsonResult")
public void doAfterReturning(JoinPoint joinPoint, RepeatSubmit repeatSubmit, Object jsonResult) {
    if (jsonResult instanceof R<?> r) {
        try {
            // 成功时保留缓存,防止重复提交
            if (r.getCode() == R.SUCCESS) {
                return;
            }
            // 失败时删除缓存,允许重新提交
            RedisUtils.deleteObject(KEY_CACHE.get());
        } finally {
            KEY_CACHE.remove();
        }
    }
}

@AfterThrowing(value = "@annotation(repeatSubmit)", throwing = "e")
public void doAfterThrowing(JoinPoint joinPoint, RepeatSubmit repeatSubmit, Exception e) {
    // 异常时删除缓存,允许重新提交
    RedisUtils.deleteObject(KEY_CACHE.get());
    KEY_CACHE.remove();
}

使用示例

java
@PostMapping("/order")
@RepeatSubmit(interval = 5, timeUnit = TimeUnit.SECONDS)
public R<OrderVO> createOrder(@RequestBody OrderCreateReq req) {
    try {
        OrderVO order = orderService.create(req);
        return R.ok(order);  // 成功:保留缓存,5秒内不可重复提交
    } catch (InsufficientStockException e) {
        return R.fail("库存不足");  // 业务失败:清理缓存,可立即重试
    } catch (Exception e) {
        throw e;  // 异常:清理缓存,可立即重试
    }
}

3. 与前端防重配合

前端通常也会实现防重复提交机制,后端防重是最后一道防线:

前端实现(UniApp/Vue):

typescript
// useHttp.ts
const ErrorMsg = {
  REPEAT_SUBMIT: '数据正在处理,请勿重复提交',
}

// 请求去重 Map
const pendingMap = new Map<string, AbortController>()

function addPending(config: RequestConfig) {
  const key = generateKey(config)
  if (pendingMap.has(key)) {
    throw new Error(ErrorMsg.REPEAT_SUBMIT)
  }
  const controller = new AbortController()
  config.signal = controller.signal
  pendingMap.set(key, controller)
}

双重保护机制

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   用户点击   │ ──→ │  前端防重   │ ──→ │  后端防重   │
│             │     │  (即时拦截) │     │ (分布式锁)  │
└─────────────┘     └─────────────┘     └─────────────┘
                         ↓                    ↓
                    快速响应              最终保障
                    用户体验              数据一致性

配置说明

自动配置

模块通过 Spring Boot 自动配置机制启用,无需手动配置:

java
@AutoConfiguration(after = RedisConfiguration.class)
public class IdempotentAutoConfiguration {

    @Bean
    public RepeatSubmitAspect repeatSubmitAspect() {
        return new RepeatSubmitAspect();
    }
}

配置顺序

  1. RedisConfiguration - Redis 连接配置
  2. IdempotentAutoConfiguration - 幂等功能配置

Redis 缓存配置

yaml
# application.yml
spring:
  data:
    redis:
      host: localhost
      port: 6379
      database: 0
      timeout: 10s
      lettuce:
        pool:
          max-active: 200
          max-wait: -1ms
          max-idle: 10
          min-idle: 0

日志配置

yaml
logging:
  level:
    # 开启切面调试日志
    plus.ruoyi.common.idempotent.aspectj.RepeatSubmitAspect: DEBUG
    # 开启 Redis 操作日志
    plus.ruoyi.common.redis: DEBUG

最佳实践

1. 适用场景选择

场景推荐间隔说明
表单提交3-5秒用户注册、信息修改等
支付操作10-30秒订单支付、余额变动等
数据创建3-5秒新增记录、文件上传等
状态变更5-10秒订单确认、审核通过等
敏感操作30-60秒密码修改、账户注销等

2. 间隔时间设置

java
// ✅ 推荐:合理设置间隔时间
@RepeatSubmit(interval = 3, timeUnit = TimeUnit.SECONDS)  // 普通表单
@RepeatSubmit(interval = 10, timeUnit = TimeUnit.SECONDS) // 支付操作
@RepeatSubmit(interval = 30, timeUnit = TimeUnit.SECONDS) // 敏感操作

// ❌ 避免:过短的间隔时间
@RepeatSubmit(interval = 500, timeUnit = TimeUnit.MILLISECONDS)  // 影响用户体验

// ❌ 避免:过长的间隔时间
@RepeatSubmit(interval = 10, timeUnit = TimeUnit.MINUTES)  // 占用 Redis 内存

3. 异常处理最佳实践

java
@PostMapping("/order")
@RepeatSubmit(interval = 5, timeUnit = TimeUnit.SECONDS)
public R<OrderVO> createOrder(@RequestBody @Validated OrderCreateReq req) {
    // 业务校验(不需要重试的场景,返回 R.fail)
    if (!productService.checkStock(req.getProductId(), req.getQuantity())) {
        return R.fail("库存不足");  // 允许用户修改数量后重新提交
    }

    // 核心业务(可能需要重试的场景,抛出异常)
    try {
        OrderVO order = orderService.create(req);
        return R.ok(order);
    } catch (OptimisticLockException e) {
        // 乐观锁冲突,允许重试
        throw ServiceException.of("系统繁忙,请重试");
    }
}

4. 结合事务使用

java
@PostMapping("/transfer")
@RepeatSubmit(interval = 10, timeUnit = TimeUnit.SECONDS)
@Transactional(rollbackFor = Exception.class)
public R<Void> transfer(@RequestBody TransferReq req) {
    // 事务回滚时,@AfterThrowing 会清理 Redis 缓存
    accountService.transfer(req.getFromId(), req.getToId(), req.getAmount());
    return R.ok();
}

5. 多端适配

java
@RestController
@RequestMapping("/api")
public class MultiPlatformController {

    /**
     * Web 端 - 标准间隔
     */
    @PostMapping("/web/submit")
    @RepeatSubmit(interval = 3, timeUnit = TimeUnit.SECONDS)
    public R<Void> webSubmit(@RequestBody FormData data) {
        // Web 端网络稳定,3秒间隔足够
        return R.ok();
    }

    /**
     * 移动端 - 稍长间隔
     */
    @PostMapping("/mobile/submit")
    @RepeatSubmit(interval = 5, timeUnit = TimeUnit.SECONDS)
    public R<Void> mobileSubmit(@RequestBody FormData data) {
        // 移动端网络不稳定,延长间隔
        return R.ok();
    }
}

监控与调试

1. Redis 缓存监控

bash
# 查看所有防重复提交缓存
redis-cli keys "repeat_submit::*"

# 查看缓存数量
redis-cli keys "repeat_submit::*" | wc -l

# 查看特定缓存的过期时间
redis-cli ttl "repeat_submit::/api/user/create{hash}"

# 删除所有防重复提交缓存(慎用)
redis-cli keys "repeat_submit::*" | xargs redis-cli del

2. 日志调试

yaml
logging:
  level:
    plus.ruoyi.common.idempotent.aspectj.RepeatSubmitAspect: DEBUG

日志输出示例

DEBUG RepeatSubmitAspect - 防重复提交检查: uri=/api/user/create, key=repeat_submit::/api/user/create{md5}
DEBUG RepeatSubmitAspect - 设置防重锁成功: key=repeat_submit::/api/user/create{md5}, interval=5000ms
DEBUG RepeatSubmitAspect - 业务执行成功,保留防重锁

3. 监控指标

可通过 Micrometer 添加自定义监控指标:

java
@Aspect
@Component
public class RepeatSubmitMetricsAspect {

    private final MeterRegistry meterRegistry;
    private final Counter repeatSubmitTotal;
    private final Counter repeatSubmitBlocked;

    public RepeatSubmitMetricsAspect(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
        this.repeatSubmitTotal = Counter.builder("repeat_submit_total")
            .description("Total repeat submit checks")
            .register(meterRegistry);
        this.repeatSubmitBlocked = Counter.builder("repeat_submit_blocked")
            .description("Blocked repeat submissions")
            .register(meterRegistry);
    }
}

常见问题

Q1: 为什么有时候提示重复提交,但我确实只点击了一次?

可能原因

  1. 网络延迟导致浏览器/客户端发送了多个请求
  2. 浏览器的表单重复提交行为(刷新页面)
  3. 前端框架(如 Axios)的重试机制

解决方案

javascript
// 前端添加防抖处理
import { debounce } from 'lodash'

const handleSubmit = debounce(async (data) => {
  await api.submit(data)
}, 1000, { leading: true, trailing: false })

Q2: 如何处理集群环境下的防重复提交?

说明:模块基于 Redis 实现分布式锁,天然支持集群环境,确保多个服务实例间的防重复提交一致性。

架构示意

┌──────────────┐     ┌──────────────┐     ┌──────────────┐
│  服务实例 A  │     │  服务实例 B  │     │  服务实例 C  │
└──────┬───────┘     └──────┬───────┘     └──────┬───────┘
       │                    │                    │
       └────────────────────┼────────────────────┘

                    ┌───────▼───────┐
                    │    Redis      │
                    │  (分布式锁)   │
                    └───────────────┘

Q3: 业务失败后多久可以重新提交?

说明:业务失败时会立即清理 Redis 缓存,用户可以马上重新提交。只有业务成功时才会保留缓存直到过期时间。

业务结果Redis 处理可重新提交时间
成功 (R.ok())保留缓存等待间隔时间过期
失败 (R.fail())删除缓存立即可以
异常删除缓存立即可以

Q4: 如何自定义唯一标识的生成规则?

方案1:继承并重写切面方法

java
@Aspect
@Component
public class CustomRepeatSubmitAspect extends RepeatSubmitAspect {

    @Override
    protected String generateKey(JoinPoint point, HttpServletRequest request) {
        // 自定义 Key 生成逻辑
        String userId = StpUtil.getLoginIdAsString();
        String method = point.getSignature().getName();
        return "custom_repeat:" + userId + ":" + method;
    }
}

方案2:基于参数中的特定字段

java
@PostMapping("/order")
@RepeatSubmit(interval = 5, timeUnit = TimeUnit.SECONDS)
public R<Void> createOrder(@RequestBody OrderReq req) {
    // 使用订单号作为幂等键
    String idempotentKey = "order:" + req.getOrderNo();
    if (!idempotentService.tryLock(idempotentKey, 5, TimeUnit.SECONDS)) {
        return R.fail("订单正在处理中");
    }
    // 业务逻辑
    return R.ok();
}

Q5: 如何排除特定参数不参与唯一标识计算?

使用场景:某些参数(如时间戳、随机数)每次请求都不同,会导致防重失效。

解决方案

java
public class OrderReq {
    private Long productId;
    private Integer quantity;

    @JsonIgnore  // 不参与 JSON 序列化,从而不影响唯一标识
    private Long timestamp;

    @JsonIgnore
    private String nonce;
}

Q6: 防重复提交与限流的区别?

特性防重复提交 (Idempotent)限流 (RateLimiter)
目的防止同一请求重复执行限制请求频率
粒度用户 + 参数 + 接口用户/IP/接口
时间窗口固定间隔滑动窗口/令牌桶
返回码业务错误429 Too Many Requests
使用场景表单提交、支付API 保护、防刷

组合使用示例

java
@PostMapping("/sensitive-operation")
@RateLimiter(count = 10, time = 60)  // 1分钟内最多10次
@RepeatSubmit(interval = 5, timeUnit = TimeUnit.SECONDS)  // 5秒内不可重复
public R<Void> sensitiveOperation(@RequestBody OperationReq req) {
    return R.ok();
}

扩展阅读

幂等性设计原则

幂等性(Idempotency)是指对同一操作发起多次请求,其产生的效果与一次请求相同。在分布式系统中,幂等性设计是保证数据一致性的重要手段。

常见幂等方案对比

方案优点缺点适用场景
Token 机制精确控制需要额外请求获取 Token表单提交
乐观锁无需额外存储并发冲突时重试数据更新
唯一索引数据库保证仅适用于插入数据创建
Redis 锁分布式支持依赖 Redis通用场景
业务状态机业务保证实现复杂流程控制

与 GTIS 防重系统对比

本模块参考美团 GTIS 防重系统设计,主要差异:

特性GTIS本模块
存储Tair/RedisRedis
唯一标识业务自定义Token + 参数 + URI
结果处理状态码判断R<T> 统一响应
部署方式独立服务嵌入式模块

性能优化

1. Redis 连接优化

为保证高并发场景下的性能,建议优化 Redis 连接池配置:

yaml
spring:
  data:
    redis:
      lettuce:
        pool:
          # 最大活跃连接数(根据并发量调整)
          max-active: 200
          # 最大等待时间(-1 表示无限等待)
          max-wait: -1ms
          # 最大空闲连接数
          max-idle: 20
          # 最小空闲连接数
          min-idle: 5
      # 连接超时时间
      timeout: 3s
      # 读取超时时间
      read-timeout: 3s

2. Key 过期策略

Redis 缓存 Key 采用惰性删除策略,无需额外维护:

策略说明优点
惰性删除Key 到期后由 Redis 自动清理无需额外代码
主动删除业务失败/异常时立即删除及时释放空间
TTL 控制通过 interval 参数控制过期时间灵活配置

3. 缓存 Key 设计

采用分层 Key 结构,便于管理和监控:

repeat_submit::{uri}{md5_hash}
     |           |      |
     |           |      └── 用户+参数的MD5哈希(32位)
     |           └────────── 请求URI
     └────────────────────── 固定前缀

Key 长度优化

  • 固定前缀:16 字符
  • URI 路径:通常 20-50 字符
  • MD5 哈希:固定 32 字符
  • 总长度:约 70-100 字符

4. 并发性能测试

在典型配置下的性能表现:

并发数QPS平均响应时间99%响应时间
1008,50012ms25ms
5007,20070ms150ms
10005,800170ms350ms

测试环境

  • Redis:单节点,8GB 内存
  • 应用:2 实例,4核8GB
  • 网络:局域网,延迟 < 1ms

安全考虑

1. Token 安全

防重复提交依赖 Token 进行用户识别,需确保:

java
// Token 不为空校验
String token = request.getHeader(SaManager.getConfig().getTokenName());
if (StringUtils.isBlank(token)) {
    // 未登录用户可能绕过防重复检查
    // 建议结合 @SaCheckLogin 注解使用
}

安全建议

场景建议
需登录接口配合 @SaCheckLogin 注解
公开接口使用 IP + 指纹作为补充标识
敏感操作增加验证码或二次确认

2. 参数篡改防护

MD5 哈希可防止简单的参数篡改,但应注意:

java
// 不安全:仅依赖参数防重
@RepeatSubmit
public R<Void> transfer(@RequestBody TransferReq req) {
    // 恶意用户可修改金额绕过防重
}

// 安全:结合业务幂等键
@RepeatSubmit
public R<Void> transfer(@RequestBody TransferReq req) {
    // 使用业务单号作为幂等键
    String idempotentKey = "transfer:" + req.getTransactionNo();
    if (!lockService.tryLock(idempotentKey)) {
        return R.fail("请勿重复操作");
    }
    // 业务逻辑
}

3. Redis 安全

确保 Redis 配置安全:

yaml
spring:
  data:
    redis:
      # 使用密码认证
      password: ${REDIS_PASSWORD}
      # 禁用危险命令(redis.conf)
      # rename-command FLUSHALL ""
      # rename-command KEYS ""

与其他模块集成

1. 与日志模块集成

结合 @Log 注解记录防重操作:

java
@PostMapping("/addOrder")
@Log(title = "订单", operType = DictOperType.INSERT)  // 操作日志
@RepeatSubmit()  // 防重复提交
@SaCheckPermission("mall:order:add")  // 权限控制
public R<Long> addOrder(@Validated(AddGroup.class) @RequestBody OrderBo bo) {
    return R.ok(orderService.add(bo));
}

2. 与限流模块集成

双重保护机制:

java
@PostMapping("/sensitiveOp")
@RateLimiter(count = 10, time = 60)  // 限流:1分钟内最多10次
@RepeatSubmit(interval = 5, timeUnit = TimeUnit.SECONDS)  // 防重:5秒内不可重复
public R<Void> sensitiveOperation(@RequestBody OperationReq req) {
    return R.ok();
}

执行顺序

请求 → 限流检查 → 防重复检查 → 业务逻辑 → 响应

3. 与事务管理集成

事务回滚时自动清理防重缓存:

java
@PostMapping("/transfer")
@RepeatSubmit(interval = 10, timeUnit = TimeUnit.SECONDS)
@Transactional(rollbackFor = Exception.class)
public R<Void> transfer(@RequestBody TransferReq req) {
    // 业务异常或事务回滚时,@AfterThrowing 会清理 Redis 缓存
    accountService.transfer(req);
    return R.ok();
}

4. 与权限模块集成

推荐注解组合顺序:

java
@PostMapping("/create")
@SaCheckPermission("module:entity:add")  // 1. 权限检查
@Log(title = "实体", operType = DictOperType.INSERT)  // 2. 日志记录
@RepeatSubmit()  // 3. 防重复提交
public R<Long> create(@RequestBody EntityBo bo) {
    return R.ok(service.add(bo));
}

实际应用示例

1. 订单创建场景

java
@RestController
@RequestMapping("/mall/order")
public class OrderController {

    private final IOrderService orderService;

    /**
     * 新增订单
     * 使用默认配置:5秒内不允许重复提交
     */
    @SaCheckPermission("mall:order:add")
    @Log(title = "订单", operType = DictOperType.INSERT)
    @RepeatSubmit()
    @PostMapping("/addOrder")
    public R<Long> addOrder(@Validated(AddGroup.class) @RequestBody OrderBo bo) {
        return R.ok(orderService.add(bo));
    }

    /**
     * 订单发货
     * 发货操作使用默认防重配置
     */
    @SaCheckPermission("mall:order:update")
    @Log(title = "订单发货", operType = DictOperType.UPDATE)
    @RepeatSubmit()
    @PutMapping("/deliverOrder")
    public R<Void> deliverOrder(@Validated @RequestBody OrderBo bo) {
        return R.status(orderService.deliverOrder(bo.getId(), bo.getShippingInfo()));
    }
}

2. 支付配置场景

java
@RestController
@RequestMapping("/base/payment")
public class PaymentController {

    private final IPaymentService paymentService;

    /**
     * 新增支付配置
     * 支付配置修改涉及资金安全,使用默认防重配置
     */
    @SaCheckPermission("base:payment:add")
    @Log(title = "支付配置", operType = DictOperType.INSERT)
    @RepeatSubmit()
    @PostMapping("/addPayment")
    public R<Long> addPayment(@Validated(AddGroup.class) @RequestBody PaymentBo bo) {
        Long id = paymentService.add(bo);
        reloadPaymentConfig();
        return R.ok(id);
    }

    /**
     * 修改支付配置
     */
    @SaCheckPermission("base:payment:update")
    @Log(title = "支付配置", operType = DictOperType.UPDATE)
    @RepeatSubmit()
    @PutMapping("/updatePayment")
    public R<Void> updatePayment(@Validated(EditGroup.class) @RequestBody PaymentBo bo) {
        boolean result = paymentService.update(bo);
        if (result) {
            reloadPaymentConfig();
        }
        return R.status(result);
    }
}

3. 工作流场景

java
@RestController
@RequestMapping("/workflow/task")
public class FlwTaskController {

    /**
     * 审批通过
     * 审批操作使用防重复提交,避免重复审批
     */
    @RepeatSubmit()
    @PostMapping("/approve")
    public R<Void> approve(@RequestBody ApproveReq req) {
        taskService.approve(req);
        return R.ok();
    }

    /**
     * 审批驳回
     */
    @RepeatSubmit()
    @PostMapping("/reject")
    public R<Void> reject(@RequestBody RejectReq req) {
        taskService.reject(req);
        return R.ok();
    }
}

测试策略

1. 单元测试

java
@ExtendWith(MockitoExtension.class)
class RepeatSubmitAspectTest {

    @Mock
    private RedisUtils redisUtils;

    @InjectMocks
    private RepeatSubmitAspect aspect;

    @Test
    void testFirstSubmitShouldPass() {
        // 模拟首次提交
        when(RedisUtils.setObjectIfAbsent(anyString(), any(), any()))
            .thenReturn(true);

        // 执行切面逻辑
        assertDoesNotThrow(() -> aspect.doBefore(joinPoint, repeatSubmit));
    }

    @Test
    void testDuplicateSubmitShouldBlock() {
        // 模拟重复提交
        when(RedisUtils.setObjectIfAbsent(anyString(), any(), any()))
            .thenReturn(false);

        // 应抛出 ServiceException
        assertThrows(ServiceException.class,
            () -> aspect.doBefore(joinPoint, repeatSubmit));
    }
}

2. 集成测试

java
@SpringBootTest
@AutoConfigureMockMvc
class RepeatSubmitIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @BeforeEach
    void clearRedis() {
        // 清理测试数据
        Set<String> keys = redisTemplate.keys("repeat_submit::*");
        if (keys != null && !keys.isEmpty()) {
            redisTemplate.delete(keys);
        }
    }

    @Test
    void testFirstRequestShouldSucceed() throws Exception {
        mockMvc.perform(post("/api/test/create")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"name\":\"test\"}"))
            .andExpect(status().isOk());
    }

    @Test
    void testDuplicateRequestShouldFail() throws Exception {
        String requestBody = "{\"name\":\"test\"}";

        // 第一次请求成功
        mockMvc.perform(post("/api/test/create")
                .contentType(MediaType.APPLICATION_JSON)
                .content(requestBody))
            .andExpect(status().isOk());

        // 第二次请求应被拦截
        mockMvc.perform(post("/api/test/create")
                .contentType(MediaType.APPLICATION_JSON)
                .content(requestBody))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.msg").value(containsString("重复提交")));
    }
}

3. 压力测试

使用 JMeter 或 Gatling 进行压力测试:

scala
// Gatling 测试脚本示例
class RepeatSubmitSimulation extends Simulation {

  val httpProtocol = http
    .baseUrl("http://localhost:8080")
    .header("Authorization", "Bearer ${token}")

  val createOrderScenario = scenario("Create Order")
    .exec(http("Create Order")
      .post("/api/order/create")
      .body(StringBody("""{"productId":1,"quantity":1}"""))
      .check(status.is(200)))
    .pause(1.seconds)

  setUp(
    createOrderScenario.inject(
      rampUsers(100).during(10.seconds),
      constantUsersPerSec(50).during(30.seconds)
    )
  ).protocols(httpProtocol)
}

注意事项

1. 间隔时间设置

场景推荐间隔说明
普通表单3-5秒用户体验与安全的平衡
支付操作10-30秒涉及资金需要更长保护时间
敏感操作30-60秒如密码修改、账户注销
批量操作10-15秒如批量删除、批量导出

2. 返回值类型

@RepeatSubmit 依赖 R<T> 统一响应格式判断业务是否成功:

java
// ✅ 正确:使用 R<T> 返回
@RepeatSubmit
public R<Long> create(@RequestBody EntityBo bo) {
    return R.ok(service.add(bo));
}

// ⚠️ 注意:非 R<T> 返回时,成功后缓存不会保留
@RepeatSubmit
public Long createDirect(@RequestBody EntityBo bo) {
    return service.add(bo);  // 缓存会在请求结束后被清理
}

3. 文件上传场景

文件上传对象会被自动过滤,不参与唯一标识计算:

java
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@RepeatSubmit(interval = 5, timeUnit = TimeUnit.SECONDS)
public R<String> upload(
    @RequestParam("file") MultipartFile file,  // 被过滤
    @RequestParam("category") String category   // 参与计算
) {
    return R.ok(uploadService.upload(file, category));
}

4. GET 请求不建议使用

防重复提交主要针对数据变更操作,GET 请求通常不需要:

java
// ❌ 不推荐:GET 请求通常是幂等的
@GetMapping("/list")
@RepeatSubmit
public R<List<EntityVo>> list() { ... }

// ✅ 推荐:仅用于 POST/PUT/DELETE
@PostMapping("/create")
@RepeatSubmit
public R<Long> create(@RequestBody EntityBo bo) { ... }

5. 分布式环境注意事项

在分布式环境下,确保:

  1. Redis 高可用:使用主从或集群模式
  2. 时钟同步:各节点时钟误差控制在毫秒级
  3. 网络稳定:避免网络分区导致的锁失效
yaml
# Redis 集群配置示例
spring:
  data:
    redis:
      cluster:
        nodes:
          - 192.168.1.101:6379
          - 192.168.1.102:6379
          - 192.168.1.103:6379
        max-redirects: 3

幂等键生成策略详解

1. 标准策略分析

系统默认使用 Token + 参数 + URI 组合生成幂等键,这种策略适用于大多数场景:

java
/**
 * 标准幂等键生成流程
 */
public String generateStandardKey(HttpServletRequest request, Object[] args) {
    // Step 1: 获取用户标识
    String token = request.getHeader(SaManager.getConfig().getTokenName());

    // Step 2: 序列化请求参数
    String params = argsArrayToString(args);

    // Step 3: 获取请求路径
    String uri = request.getRequestURI();

    // Step 4: 组合并哈希
    String submitKey = SecureUtil.md5(token + ":" + params);

    // Step 5: 构建最终 Key
    return REPEAT_SUBMIT_KEY + uri + submitKey;
}

优点分析

特性说明
用户隔离不同用户的 Token 不同,互不影响
参数敏感相同接口不同参数视为不同请求
路径区分不同接口天然隔离
哈希压缩MD5 固定32位,Key 长度可控

2. 自定义策略实现

针对特殊业务场景,可实现自定义幂等键策略:

java
/**
 * 基于业务单号的幂等策略
 */
@Aspect
@Component
public class BusinessIdempotentAspect {

    private static final String BUSINESS_IDEMPOTENT_KEY = "business_idempotent::";

    @Around("@annotation(businessIdempotent)")
    public Object around(ProceedingJoinPoint point, BusinessIdempotent businessIdempotent) throws Throwable {
        // 从参数中提取业务单号
        String businessNo = extractBusinessNo(point.getArgs(), businessIdempotent.field());

        if (StringUtils.isBlank(businessNo)) {
            throw ServiceException.of("业务单号不能为空");
        }

        String key = BUSINESS_IDEMPOTENT_KEY + businessIdempotent.prefix() + ":" + businessNo;

        // 尝试加锁
        boolean locked = RedisUtils.setObjectIfAbsent(key, "",
            Duration.ofSeconds(businessIdempotent.expireSeconds()));

        if (!locked) {
            throw ServiceException.of(businessIdempotent.message());
        }

        try {
            return point.proceed();
        } catch (Exception e) {
            // 异常时释放锁
            RedisUtils.deleteObject(key);
            throw e;
        }
    }

    private String extractBusinessNo(Object[] args, String field) {
        for (Object arg : args) {
            if (arg == null) continue;
            try {
                Field f = arg.getClass().getDeclaredField(field);
                f.setAccessible(true);
                Object value = f.get(arg);
                if (value != null) {
                    return value.toString();
                }
            } catch (Exception ignored) {}
        }
        return null;
    }
}

/**
 * 业务幂等注解
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface BusinessIdempotent {
    /** 业务单号字段名 */
    String field();
    /** 前缀 */
    String prefix() default "";
    /** 过期时间(秒) */
    int expireSeconds() default 300;
    /** 提示消息 */
    String message() default "请勿重复操作";
}

使用示例

java
@PostMapping("/pay")
@BusinessIdempotent(field = "orderNo", prefix = "pay", expireSeconds = 600)
public R<Void> pay(@RequestBody PayRequest request) {
    // 基于订单号防重,10分钟内同一订单号只能支付一次
    paymentService.pay(request);
    return R.ok();
}

3. 组合策略模式

某些复杂场景需要多维度幂等控制:

java
/**
 * 多维度幂等键生成器
 */
@Component
public class CompositeIdempotentKeyGenerator {

    /**
     * 用户级幂等键
     */
    public String userLevelKey(String operation) {
        String userId = StpUtil.getLoginIdAsString();
        return "idempotent:user:" + userId + ":" + operation;
    }

    /**
     * 设备级幂等键
     */
    public String deviceLevelKey(HttpServletRequest request, String operation) {
        String deviceId = request.getHeader("X-Device-Id");
        return "idempotent:device:" + deviceId + ":" + operation;
    }

    /**
     * 业务级幂等键
     */
    public String businessLevelKey(String businessType, String businessNo) {
        return "idempotent:business:" + businessType + ":" + businessNo;
    }

    /**
     * 全局级幂等键(不区分用户)
     */
    public String globalLevelKey(String operation, String... params) {
        String paramHash = SecureUtil.md5(String.join(":", params));
        return "idempotent:global:" + operation + ":" + paramHash;
    }
}

分布式场景深度分析

1. 跨数据中心一致性

在多数据中心部署时,幂等性面临额外挑战:

┌────────────────────────────────────────────────────────────────┐
│                       用户请求                                  │
└────────────────────────┬───────────────────────────────────────┘

           ┌─────────────┼─────────────┐
           │             │             │
           ▼             ▼             ▼
    ┌──────────┐   ┌──────────┐  ┌──────────┐
    │ 数据中心A │   │ 数据中心B │  │ 数据中心C │
    │  服务集群 │   │  服务集群 │  │  服务集群 │
    └─────┬────┘   └─────┬────┘  └─────┬────┘
          │              │             │
          └──────────────┼─────────────┘


                ┌────────────────┐
                │   Redis 集群    │
                │ (全局幂等存储)  │
                └────────────────┘

解决方案

java
/**
 * 跨数据中心幂等配置
 */
@Configuration
public class CrossDCIdempotentConfig {

    @Bean
    @ConditionalOnProperty(name = "idempotent.cross-dc.enabled", havingValue = "true")
    public RedissonClient crossDCRedissonClient() {
        Config config = new Config();

        // 使用跨数据中心的 Redis 集群
        config.useClusterServers()
            .setScanInterval(2000)
            .addNodeAddress(
                "redis://dc1-redis-1:6379",
                "redis://dc1-redis-2:6379",
                "redis://dc2-redis-1:6379",
                "redis://dc2-redis-2:6379"
            )
            // 读取策略:优先本地副本
            .setReadMode(ReadMode.MASTER_SLAVE)
            // 订阅策略
            .setSubscriptionMode(SubscriptionMode.MASTER)
            // 超时设置
            .setTimeout(5000)
            .setRetryAttempts(3);

        return Redisson.create(config);
    }
}

2. 网络分区处理

网络分区可能导致脑裂问题,需要谨慎处理:

java
/**
 * 网络分区感知的幂等处理
 */
@Component
public class PartitionAwareIdempotent {

    private final RedissonClient redisson;

    public boolean tryLockWithPartitionCheck(String key, Duration ttl) {
        try {
            // 使用 Redlock 算法(需要多个 Redis 实例)
            RLock lock = redisson.getLock(key);

            // 尝试获取锁,设置等待时间和租约时间
            boolean acquired = lock.tryLock(
                100,  // 等待时间 ms
                ttl.toMillis(),  // 租约时间
                TimeUnit.MILLISECONDS
            );

            if (acquired) {
                // 验证锁是否真正有效
                if (!lock.isHeldByCurrentThread()) {
                    log.warn("锁获取异常,可能存在网络分区");
                    return false;
                }
            }

            return acquired;
        } catch (RedisException e) {
            log.error("Redis 操作异常,可能存在网络问题", e);
            // 降级策略:拒绝请求或使用本地缓存
            return handleRedisFailure(key);
        }
    }

    private boolean handleRedisFailure(String key) {
        // 降级策略:使用本地缓存 + 警告
        log.warn("Redis 不可用,使用本地幂等缓存,Key: {}", key);
        return localCache.putIfAbsent(key, Boolean.TRUE) == null;
    }
}

3. 时钟漂移问题

分布式环境下时钟不一致可能影响 TTL 准确性:

java
/**
 * 时钟漂移补偿
 */
@Component
public class ClockDriftCompensator {

    // 允许的最大时钟偏差(毫秒)
    private static final long MAX_CLOCK_DRIFT = 1000;

    public Duration compensateTTL(Duration originalTTL) {
        // 增加缓冲时间以应对时钟漂移
        long compensatedMillis = originalTTL.toMillis() + MAX_CLOCK_DRIFT;
        return Duration.ofMillis(compensatedMillis);
    }

    /**
     * 使用逻辑时钟代替物理时钟
     */
    public long getLogicalTimestamp() {
        // 使用 Redis 服务器时间
        return RedisUtils.getServerTime();
    }
}

异步场景处理

1. 异步方法幂等

对于 @Async 标注的异步方法,需要特殊处理:

java
/**
 * 异步幂等切面
 */
@Aspect
@Component
public class AsyncIdempotentAspect {

    @Around("@annotation(repeatSubmit) && @annotation(org.springframework.scheduling.annotation.Async)")
    public Object handleAsyncIdempotent(ProceedingJoinPoint point, RepeatSubmit repeatSubmit) throws Throwable {
        // 异步方法在新线程执行,需要传递上下文
        String key = generateKey(point);

        // 在主线程设置锁
        boolean locked = RedisUtils.setObjectIfAbsent(key, "",
            Duration.ofMillis(repeatSubmit.timeUnit().toMillis(repeatSubmit.interval())));

        if (!locked) {
            throw ServiceException.of(repeatSubmit.message());
        }

        // 将 Key 传递给异步线程
        AsyncIdempotentContext.setKey(key);

        try {
            return point.proceed();
        } catch (Exception e) {
            // 异步方法异常需要在异步线程中处理
            throw e;
        }
    }
}

/**
 * 异步幂等上下文
 */
public class AsyncIdempotentContext {
    private static final InheritableThreadLocal<String> KEY_HOLDER = new InheritableThreadLocal<>();

    public static void setKey(String key) {
        KEY_HOLDER.set(key);
    }

    public static String getKey() {
        return KEY_HOLDER.get();
    }

    public static void clear() {
        KEY_HOLDER.remove();
    }
}

2. 消息队列场景

消息消费的幂等处理:

java
/**
 * MQ 消息幂等消费者
 */
@Component
public class IdempotentMessageConsumer {

    private static final String MSG_IDEMPOTENT_KEY = "mq:idempotent:";
    private static final Duration MSG_EXPIRE = Duration.ofHours(24);

    @RocketMQMessageListener(topic = "ORDER_TOPIC", consumerGroup = "order-consumer")
    public void consumeOrder(OrderMessage message) {
        String idempotentKey = MSG_IDEMPOTENT_KEY + message.getMessageId();

        // 检查是否已消费
        if (RedisUtils.hasKey(idempotentKey)) {
            log.info("消息已消费,跳过: {}", message.getMessageId());
            return;
        }

        try {
            // 处理业务
            orderService.processOrder(message);

            // 标记已消费
            RedisUtils.setObject(idempotentKey, "1", MSG_EXPIRE);
        } catch (Exception e) {
            // 业务异常不标记,允许重试
            log.error("消息处理失败: {}", message.getMessageId(), e);
            throw e;
        }
    }
}

3. 定时任务幂等

防止定时任务重复执行:

java
/**
 * 定时任务幂等执行器
 */
@Component
public class IdempotentScheduler {

    private static final String SCHEDULER_LOCK_KEY = "scheduler:lock:";

    /**
     * 幂等执行定时任务
     */
    public void executeIdempotent(String taskId, Duration lockDuration, Runnable task) {
        String lockKey = SCHEDULER_LOCK_KEY + taskId;

        // 尝试获取分布式锁
        RLock lock = RedisUtils.getLock(lockKey);

        boolean acquired = false;
        try {
            acquired = lock.tryLock(0, lockDuration.toMillis(), TimeUnit.MILLISECONDS);

            if (acquired) {
                log.info("获取任务锁成功,开始执行: {}", taskId);
                task.run();
                log.info("任务执行完成: {}", taskId);
            } else {
                log.info("任务正在其他节点执行,跳过: {}", taskId);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            log.error("任务执行被中断: {}", taskId);
        } finally {
            if (acquired && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

// 使用示例
@Scheduled(cron = "0 0 2 * * ?")
public void dailyStatistics() {
    idempotentScheduler.executeIdempotent(
        "daily-statistics-" + LocalDate.now(),
        Duration.ofHours(1),
        () -> statisticsService.generateDailyReport()
    );
}

故障排查

问题1:防重失效

现象:相同请求可以重复提交

排查步骤

  1. 检查 Redis 连接是否正常
  2. 检查 Token 是否为空(未登录场景)
  3. 检查参数是否包含时间戳等动态字段

解决方案

java
// 排除动态字段
public class OrderReq {
    private Long productId;
    private Integer quantity;

    @JsonIgnore  // 不参与序列化
    private Long timestamp;
}

问题2:防重过于严格

现象:正常操作也被拦截

排查步骤

  1. 检查间隔时间是否过长
  2. 检查是否有共享 Token 的情况
  3. 检查前端是否发送了多个请求

解决方案

java
// 缩短间隔时间
@RepeatSubmit(interval = 2, timeUnit = TimeUnit.SECONDS)

// 或者根据用户调整
@RepeatSubmit(interval = 3, timeUnit = TimeUnit.SECONDS)

问题3:Redis 内存占用高

现象:防重 Key 过多导致内存压力

排查步骤

bash
# 查看 Key 数量
redis-cli keys "repeat_submit::*" | wc -l

# 查看内存占用
redis-cli info memory | grep used_memory_human

解决方案

  1. 缩短 interval 时间
  2. 定期清理过期 Key
  3. 增加 Redis 内存或使用淘汰策略
yaml
# redis.conf
maxmemory 2gb
maxmemory-policy volatile-ttl