跳转到内容
123xiao | 无名键客

《Spring Boot 中基于 Redis 与 AOP 实现接口幂等控制的实战指南》

字数: 0 阅读时长: 1 分钟

Spring Boot 中基于 Redis 与 AOP 实现接口幂等控制的实战指南

接口幂等,说白了就是:同一个请求重复提交多次,系统最终只处理一次,结果可控

这个需求在支付、下单、退款、消息消费、表单重复提交这些场景里特别常见。很多团队一开始觉得“前端按钮置灰一下就好了”,结果一上线就会发现:网络重试、用户狂点、网关超时重放、客户端重复提交,这些情况前端根本兜不住。

这篇文章我会带你用一个比较实用、也比较容易在 Spring Boot 项目里落地的方式:Redis + AOP + 自定义注解,做一套接口幂等控制方案。重点不是“写一个能跑的 demo”,而是“写一个线上能用、出问题能排查”的版本。


背景与问题

先明确一点:幂等不等于防重复点击

它解决的是更广义的问题:

  • 用户快速点击两次提交
  • 前端因为网络抖动自动重试
  • 网关或客户端超时后重放请求
  • MQ 消费端重复消费
  • 分布式系统下多实例同时收到相同业务请求

如果没有幂等控制,常见后果包括:

  • 订单重复创建
  • 库存重复扣减
  • 支付重复处理
  • 优惠券重复发放
  • 资金类业务出现严重事故

很多人第一反应是数据库唯一索引。这个当然有用,但它更像是最后一道防线,并不总能优雅解决接口层的重复提交问题。比如:

  • 你要在业务执行前就拦截重复请求
  • 不同接口的幂等粒度不一样
  • 你希望把逻辑统一抽到切面层,而不是每个 controller/service 都手写一遍

这时候,Redis 就非常适合做一个轻量、快速、跨实例共享状态的幂等标记存储。


前置知识与环境准备

本文示例环境:

  • JDK 8+
  • Spring Boot 2.x / 3.x 都可参考
  • Redis 6+
  • Maven

核心依赖如下:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</artifactId>
        <artifactId>spring-boot-starter-aop</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
</dependencies>

配置文件示例:

spring:
  redis:
    host: 127.0.0.1
    port: 6379
    timeout: 3000ms

核心原理

我们先把思路搭起来,再看代码会更顺。

方案目标

对某个接口:

  1. 请求进来时,先生成一个幂等 Key
  2. 用 Redis 原子操作尝试写入这个 Key
  3. 如果写入成功,说明是第一次请求,放行业务逻辑
  4. 如果写入失败,说明重复请求,直接拦截
  5. Key 设置过期时间,避免永久占用

一个典型的幂等键

通常会包含这些信息:

  • 接口路径
  • 用户标识
  • 请求参数摘要
  • 业务唯一号(如果有,比如订单号)

例如:

idempotent:submitOrder:10001:9f2b1b7a8c...

为什么要用 Redis 的原子操作

幂等控制最怕“并发穿透”。比如两个相同请求几乎同时到达:

  • A 线程判断 Redis 没有 key
  • B 线程也判断 Redis 没有 key
  • 然后两个线程都继续执行业务

所以不能先 getset,而是要用 Redis 的原子语义:

SET key value NX EX 10

意思是:

  • NX:key 不存在才设置
  • EX 10:设置 10 秒过期时间

这样在并发下只有一个请求能成功。


幂等控制整体流程图

flowchart TD
    A[客户端发起请求] --> B[进入 Controller]
    B --> C[AOP 拦截带 @Idempotent 注解的方法]
    C --> D[生成幂等 Key]
    D --> E{Redis SET NX EX 是否成功}
    E -- 是 --> F[执行业务逻辑]
    F --> G[返回成功结果]
    E -- 否 --> H[拦截请求并返回重复提交提示]

方案设计:为什么是 AOP + 注解

如果每个接口都手写这样的逻辑:

if (redis 幂等校验通过) {
    // do business
}

项目一大,很快就会出现这些问题:

  • 重复代码太多
  • 不同人写法不一致
  • 接口忘记加控制
  • 后期改策略很难统一收口

所以更好的做法是:

  • @Idempotent 注解声明“这个接口需要幂等”
  • 用 AOP 在方法执行前统一处理
  • 业务代码本身保持干净

这也是 Spring Boot 项目里最常见、最舒服的落地方式。


时序图:请求如何被拦截

sequenceDiagram
    participant Client as 客户端
    participant Controller as Controller
    participant Aspect as IdempotentAspect
    participant Redis as Redis
    participant Service as BusinessService

    Client->>Controller: POST /order/submit
    Controller->>Aspect: 调用目标方法
    Aspect->>Redis: SET key value NX EX
    alt 首次请求
        Redis-->>Aspect: success
        Aspect->>Service: 执行业务
        Service-->>Aspect: 处理完成
        Aspect-->>Controller: 返回结果
        Controller-->>Client: success
    else 重复请求
        Redis-->>Aspect: fail
        Aspect-->>Controller: 抛出重复提交异常
        Controller-->>Client: 重复提交
    end

实战代码(可运行)

下面直接给一套可以落地的示例。


1. 自定义注解 @Idempotent

package com.example.demo.idempotent;

import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {

    /**
     * key 前缀
     */
    String prefix() default "idempotent";

    /**
     * 过期时间
     */
    long timeout() default 10;

    /**
     * 时间单位
     */
    TimeUnit timeUnit() default TimeUnit.SECONDS;

    /**
     * SpEL 表达式,可用于指定业务唯一键,比如 #request.orderNo
     */
    String key() default "";
}

2. 定义重复提交异常

package com.example.demo.idempotent;

public class RepeatSubmitException extends RuntimeException {

    public RepeatSubmitException(String message) {
        super(message);
    }
}

3. Redis 配置

为了便于序列化和调试,建议显式配置 StringRedisTemplateRedisTemplate。这里我们直接使用 StringRedisTemplate

package com.example.demo.config;

import org.springframework.context.annotation.Configuration;

@Configuration
public class RedisConfig {
    // 如果只用 StringRedisTemplate,这里可以不额外配置
}

4. 幂等切面实现

这里是核心。

package com.example.demo.idempotent;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.expression.EvaluationContext;
import org.springframework.expression.ExpressionParser;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.stereotype.Component;
import org.springframework.util.DigestUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

@Aspect
@Component
public class IdempotentAspect {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Resource
    private ObjectMapper objectMapper;

    private final ExpressionParser parser = new SpelExpressionParser();
    private final DefaultParameterNameDiscoverer nameDiscoverer = new DefaultParameterNameDiscoverer();

    @Around("@annotation(com.example.demo.idempotent.Idempotent)")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
        Idempotent idempotent = method.getAnnotation(Idempotent.class);

        String key = buildKey(joinPoint, method, idempotent);
        String value = UUID.randomUUID().toString();

        Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(
                key,
                value,
                idempotent.timeout(),
                idempotent.timeUnit()
        );

        if (!Boolean.TRUE.equals(success)) {
            throw new RepeatSubmitException("请求重复,请稍后再试");
        }

        return joinPoint.proceed();
    }

    private String buildKey(ProceedingJoinPoint joinPoint, Method method, Idempotent idempotent) throws Exception {
        HttpServletRequest request = getRequest();

        String prefix = idempotent.prefix();
        String uri = request != null ? request.getRequestURI() : method.getName();
        String userId = getCurrentUserId(request);

        String businessKey;
        if (StringUtils.hasText(idempotent.key())) {
            businessKey = parseSpel(method, joinPoint.getArgs(), idempotent.key());
        } else {
            String argsJson = objectMapper.writeValueAsString(joinPoint.getArgs());
            businessKey = DigestUtils.md5DigestAsHex(argsJson.getBytes(StandardCharsets.UTF_8));
        }

        return String.join(":",
                prefix,
                uri,
                userId,
                businessKey
        );
    }

    private String parseSpel(Method method, Object[] args, String spel) {
        String[] paramNames = nameDiscoverer.getParameterNames(method);
        EvaluationContext context = new StandardEvaluationContext();

        if (paramNames != null) {
            for (int i = 0; i < paramNames.length; i++) {
                context.setVariable(paramNames[i], args[i]);
            }
        }

        Object value = parser.parseExpression(spel).getValue(context);
        return Objects.toString(value, "null");
    }

    private HttpServletRequest getRequest() {
        ServletRequestAttributes attributes =
                (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        return attributes == null ? null : attributes.getRequest();
    }

    private String getCurrentUserId(HttpServletRequest request) {
        if (request == null) {
            return "anonymous";
        }
        String userId = request.getHeader("X-User-Id");
        return StringUtils.hasText(userId) ? userId : "anonymous";
    }
}

5. 全局异常处理

package com.example.demo.web;

import com.example.demo.idempotent.RepeatSubmitException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(RepeatSubmitException.class)
    public Map<String, Object> handleRepeatSubmitException(RepeatSubmitException e) {
        Map<String, Object> result = new HashMap<>();
        result.put("code", 409);
        result.put("message", e.getMessage());
        return result;
    }

    @ExceptionHandler(Exception.class)
    public Map<String, Object> handleException(Exception e) {
        Map<String, Object> result = new HashMap<>();
        result.put("code", 500);
        result.put("message", "系统异常: " + e.getMessage());
        return result;
    }
}

6. 请求对象与 Controller

package com.example.demo.order;

import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;

public class OrderRequest {

    @NotBlank
    private String orderNo;

    @NotBlank
    private String productCode;

    @Min(1)
    private Integer count;

    public String getOrderNo() {
        return orderNo;
    }

    public void setOrderNo(String orderNo) {
        this.orderNo = orderNo;
    }

    public String getProductCode() {
        return productCode;
    }

    public void setProductCode(String productCode) {
        this.productCode = productCode;
    }

    public Integer getCount() {
        return count;
    }

    public void setCount(Integer count) {
        this.count = count;
    }
}
package com.example.demo.order;

import com.example.demo.idempotent.Idempotent;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import java.util.HashMap;
import java.util.Map;

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

    @PostMapping("/submit")
    @Idempotent(prefix = "submitOrder", key = "#request.orderNo", timeout = 30)
    public Map<String, Object> submit(@RequestBody @Validated OrderRequest request) throws InterruptedException {
        // 模拟业务处理耗时
        Thread.sleep(2000);

        Map<String, Object> result = new HashMap<>();
        result.put("code", 200);
        result.put("message", "下单成功");
        result.put("orderNo", request.getOrderNo());
        return result;
    }
}

这里我故意用 #request.orderNo 作为幂等键的一部分,因为订单号本身就是业务唯一号,这比单纯对整个参数做 MD5 更稳定、更可解释。


7. 启动类

package com.example.demo;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

@SpringBootApplication
public class DemoApplication {

    @Bean
    public ObjectMapper objectMapper() {
        return new ObjectMapper();
    }

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }
}

逐步验证清单

项目启动后,可以这样验证。

第一步:发起第一次请求

curl -X POST 'http://localhost:8080/order/submit' \
  -H 'Content-Type: application/json' \
  -H 'X-User-Id: 10001' \
  -d '{
    "orderNo":"ORD202403010001",
    "productCode":"P1001",
    "count":1
  }'

正常会返回:

{
  "code": 200,
  "message": "下单成功",
  "orderNo": "ORD202403010001"
}

第二步:在短时间内重复提交相同请求

再次立即执行相同命令,会返回:

{
  "code": 409,
  "message": "请求重复,请稍后再试"
}

第三步:查看 Redis 中的 Key

redis-cli keys "submitOrder:*"

你会看到类似:

submitOrder:/order/submit:10001:ORD202403010001

再深入一点:这个方案到底拦住了什么

很多人实现完以后,会误以为“这就万无一失了”。其实不是。

这个方案主要拦住的是:

  • 短时间内重复调用同一接口
  • 同一用户同一业务键的并发请求
  • 多实例部署下的重复提交

但它并不能完全替代:

  • 数据库唯一约束
  • 业务状态机控制
  • 分布式事务
  • 消息消费幂等表

所以更准确的理解应该是:

Redis 幂等控制是接口层的一道高效前置防线,不是业务一致性的唯一保障。


状态图:幂等键的生命周期

stateDiagram-v2
    [*] --> NotExists
    NotExists --> Locked: 首次请求 SET NX EX 成功
    NotExists --> Rejected: 并发请求 SET NX EX 失败
    Locked --> Expired: TTL 到期
    Expired --> NotExists
    Rejected --> [*]

常见坑与排查

这一部分我建议你认真看,真正上线时,坑基本都在这里。

1. 用 get + set 替代 setIfAbsent

错误写法:

if (redisTemplate.opsForValue().get(key) == null) {
    redisTemplate.opsForValue().set(key, "1", 10, TimeUnit.SECONDS);
}

问题是:并发下不原子,会失效。

正确做法:必须使用原子 setIfAbsent


2. Key 设计过粗或过细

过粗

如果只用接口路径作为 Key:

idempotent:/order/submit

那所有用户都会互相影响,一个人提交,别人也被拦住。

过细

如果把时间戳、随机数也拼进去:

idempotent:/order/submit:10001:1710000000

那每次请求都不一样,等于没做幂等。

建议:Key 至少包含“接口 + 用户 + 业务唯一标识”。


3. 幂等 TTL 设太短

比如业务要跑 5 秒,但你只给 Redis key 设了 2 秒过期。
那么第一次请求还没执行完,key 已经过期,第二次请求又能进来了。

这是非常常见的坑,我当时就踩过:测试环境没问题,生产因为某个下游接口变慢,重复单就出现了。

建议:TTL 要大于业务最大处理时长,并留冗余。


4. 异常后要不要删 Key

这是一个非常关键的问题,没有标准答案,要看业务。

方案 A:不删除 Key

优点:

  • 能挡住短时间重复请求
  • 逻辑简单

缺点:

  • 如果第一次请求因为系统异常失败,后续重试也会被挡住,直到 key 过期

方案 B:异常时删除 Key

优点:

  • 失败后允许客户端重试

缺点:

  • 如果业务已经部分执行,再删 key,可能导致重复处理

所以我的建议是:

  • 资金、订单、库存类核心业务:优先依赖业务唯一号 + 数据库唯一约束,不轻易删除 key
  • 纯防重复点击类接口:可以在异常时删除 key,提升可重试性

如果你想加“异常删除 key”的逻辑,可以这么改:

@Around("@annotation(com.example.demo.idempotent.Idempotent)")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
    Method method = ((MethodSignature) joinPoint.getSignature()).getMethod();
    Idempotent idempotent = method.getAnnotation(Idempotent.class);

    String key = buildKey(joinPoint, method, idempotent);
    String value = UUID.randomUUID().toString();

    Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(
            key,
            value,
            idempotent.timeout(),
            idempotent.timeUnit()
    );

    if (!Boolean.TRUE.equals(success)) {
        throw new RepeatSubmitException("请求重复,请稍后再试");
    }

    try {
        return joinPoint.proceed();
    } catch (Throwable e) {
        stringRedisTemplate.delete(key);
        throw e;
    }
}

但请注意,这不是万能模板,要根据业务判断。


5. SpEL 表达式拿不到参数

例如你写了:

@Idempotent(key = "#request.orderNo")

但方法参数名实际不是 request,或者编译后参数名丢失,就会解析失败。

排查方向:

  • 确认方法参数名和 SpEL 一致
  • 确认是否启用了参数名保留
  • 或者直接使用整个参数摘要作为兜底方案

6. AOP 不生效

典型原因:

  • 没引入 spring-boot-starter-aop
  • 注解加在 private 方法上
  • 同类内部调用,没经过 Spring 代理
  • 切点表达式写错

排查建议:

  • 看项目启动日志里是否创建了切面 Bean
  • 在切面里打日志确认是否进入
  • 把注解先加在 Controller 的 public 方法上验证

7. Redis 序列化与 Key 可读性差

如果用了默认 RedisTemplate<Object, Object>,可能出现 key 乱码,不方便排查。

建议:幂等场景优先用 StringRedisTemplate


安全/性能最佳实践

这一部分是“从能跑到能上线”的关键。

1. 不要只靠前端防重

前端按钮置灰只是体验优化,不是安全保证。
任何核心接口都应该在服务端做幂等。


2. 核心业务一定要有业务唯一号

比如:

  • 支付单号
  • 订单号
  • 流水号
  • 请求号

如果没有业务唯一号,你只能退而求其次用“用户 + 参数摘要”,但稳定性和可解释性会差很多。


3. Redis 幂等要和数据库唯一约束配合使用

这是我最推荐的组合:

  • 接口层:Redis 快速拦截重复请求
  • 持久层:数据库唯一索引做最终兜底

例如订单表可以加唯一索引:

CREATE UNIQUE INDEX uk_order_no ON t_order(order_no);

这样即使 Redis 因为异常、过期、网络问题失效,数据库也能阻止重复落库。


4. TTL 不要无限长

TTL 太短会放过重复请求,太长会影响失败后的重试体验。

经验上:

  • 防重复点击:5~30 秒
  • 下单/支付提交:30~120 秒
  • 更长业务链路:结合业务耗时评估

5. Key 中不要直接放敏感信息

不要把手机号、身份证、银行卡号原样拼进 Redis key。
如果必须参与唯一性计算,建议做哈希摘要。


6. 给幂等失败打监控日志

建议至少记录:

  • 请求 URI
  • 用户 ID
  • 幂等 key
  • 请求参数摘要
  • 拦截时间

示例:

log.warn("repeat submit intercepted, uri={}, userId={}, key={}", uri, userId, key);

线上遇到“用户说自己没点两次”,这个日志非常有用。


7. 分清“防重复提交”和“处理结果复用”

本文方案主要是阻止重复请求进入
但有些高级场景需要的是:

  • 第一次请求正在处理中,后续相同请求不报错,而是返回“处理中”
  • 第一次处理成功后,后续相同请求直接返回第一次结果

这属于更完整的“请求幂等记录”方案,通常要在 Redis 或数据库中维护:

  • 请求状态:processing / success / fail
  • 响应结果快照

这个复杂度会高一层,适合支付、开放平台 API 这类场景。


方案边界与适用场景

适合:

  • 表单重复提交
  • 下单接口
  • 支付确认接口
  • 券发放接口
  • 有明确业务唯一号的写操作接口

不太适合直接照搬的场景:

  • 超长事务接口
  • 需要返回历史处理结果的开放 API
  • 强一致资金清算场景
  • MQ 消费端幂等(更常见是业务表/去重表方案)

如果是消息消费幂等,建议使用:

  • 消息唯一 ID
  • 消费记录表
  • 业务表唯一索引
  • 或 Redis + 持久化去重表组合

一个更稳妥的落地建议

如果你正在做生产系统,我建议按这个顺序上:

  1. 先定义业务唯一号
  2. 数据库加唯一索引
  3. 接口层加 Redis 幂等拦截
  4. 日志、监控、告警补齐
  5. 对关键链路评估异常重试策略

这套组合比单独依赖某一层稳得多。


总结

这篇文章我们实现了一套基于 Spring Boot + Redis + AOP 的接口幂等方案,核心点可以归纳为几句话:

  • 幂等的本质是:相同业务请求重复提交,只处理一次
  • Redis 的关键是:使用原子 setIfAbsent + 过期时间
  • AOP 的价值是:把通用逻辑统一收口,减少重复代码
  • Key 设计要抓住三件事:接口、用户、业务唯一标识
  • 真正上线时,Redis 幂等不是唯一保障,数据库唯一约束仍然必不可少

如果你只是想解决“用户狂点提交按钮”的问题,这套方案已经足够实用。
如果你在做支付、订单这类强业务场景,请一定记住一句话:

接口幂等是前置拦截,业务唯一约束才是最终底线。

只要把这个边界想清楚,这套方案就能用得很稳。


分享到:

上一篇
《Java Web 开发中基于 Spring Boot + JWT 的登录鉴权与权限控制实战指南》
下一篇
《Web逆向实战:从请求重放到参数还原,系统定位前端签名与加密逻辑》