SpringBoot 接口幂等性实现(Redis+AOP 注解)解决重复提交【生产可用】

一、前言

在日常后端开发中,接口重复提交是非常高频的线上问题:用户疯狂点击按钮、网络抖动重试、网关重试、MQ 重复消费等,都会导致重复下单、重复扣款、重复新增数据等严重 Bug。

接口幂等性是解决这类问题的最优方案,也是 Java 后端面试、生产落地的核心知识点

本文带你从零实现一套 SpringBoot + Redis + AOP 注解通用幂等框架无业务侵入、适配分布式集群、可直接上线

二、什么是幂等性

2.1 幂等概念

幂等性一个接口无论请求一次还是多次,最终业务结果保持一致,不会产生副作用

通俗理解:多次请求 = 一次请求

2.2 哪些接口必须做幂等

所有写操作接口必须保证幂等:

  • 新增订单、提交表单
  • 支付扣款、退款接口
  • 积分发放、优惠券领取
  • MQ 消费业务接口

查询、列表类读接口无需幂等。

三、主流幂等方案对比(选型分析)

为方便大家业务选型,整理 4 种企业级常用方案:

实现方案 适用场景 优点 缺点
数据库唯一索引 新增场景 简单可靠、零成本 只支持新增,更新场景无效
数据库乐观锁 更新场景 无锁高并发 需要维护版本号,不适用于新增
分布式锁 高并发核心交易 强一致性 实现复杂、性能略低
Token+Redis+AOP 全场景通用 无侵入、通用性强、适配集群 依赖 Redis

生产首选:Token+Redis+AOP 通用幂等方案,一套代码适配所有业务接口。

四、核心设计思路

  1. 前端进入页面 / 发起操作前,后端生成唯一幂等 Token存入 Redis;
  2. 前端请求业务接口时,请求头携带该 Token;
  3. 自定义注解标记需要幂等的接口;
  4. AOP 统一拦截、校验 Token;
  5. 校验通过立即删除 Token(一次性令牌),保证只能提交一次;
  6. 重复请求 Token 不存在,直接拦截,不执行业务逻辑。

核心精髓:Token 一次性有效,用完即废,彻底防重

五、代码实战(生产完整源码)

5.1 引入核心依赖

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

<!-- AOP切面 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

5.2 自定义幂等注解(无侵入标记)

通过注解方式,按需开启接口幂等,不污染业务代码:

import java.lang.annotation.*;

/**
 * 接口幂等注解,标记接口开启防重提交
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {

    /**
     * 重复提交提示信息
     */
    String message() default "操作频繁,请勿重复提交";
}

5.3 幂等 Token 工具类(生成 + 校验)

封装 Redis 操作,统一管理幂等令牌:

import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import javax.annotation.Resource;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

@Component
public class IdempotentUtil {

    @Resource
    private RedisTemplate<String, Object> redisTemplate;

    /**
     * 生成唯一幂等Token
     */
    public String generateToken() {
        // 去除横杠,保证token纯净
        String token = UUID.randomUUID().toString().replace("-", "");
        // 缓存10分钟,适配绝大多数表单提交场景
        String key = "idempotent:token:" + token;
        redisTemplate.opsForValue().set(key, System.currentTimeMillis(), 10, TimeUnit.MINUTES);
        return token;
    }

    /**
     * 校验Token:存在则删除,返回true;不存在返回false
     */
    public boolean validToken(String token) {
        if (!StringUtils.hasText(token)) {
            return false;
        }
        String key = "idempotent:token:" + token;
        // 原子操作:判断存在 + 删除
        if (Boolean.TRUE.equals(redisTemplate.hasKey(key))) {
            redisTemplate.delete(key);
            return true;
        }
        return false;
    }
}

5.4 AOP 切面统一拦截(核心)

通过环绕通知,统一拦截所有带@Idempotent注解的接口:

import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;

@Aspect
@Component
public class IdempotentAspect {

    @Resource
    private IdempotentUtil idempotentUtil;

    private static final String TOKEN_HEADER = "idempotent-token";

    @Around("@annotation(idempotent)")
    public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable {
        // 获取HttpServletRequest
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();

        // 获取请求头中的幂等token
        String token = request.getHeader(TOKEN_HEADER);

        // 校验token
        if (!idempotentUtil.validToken(token)) {
            // 重复提交,直接返回提示
            return Result.fail(idempotent.message());
        }
        // 校验通过,执行业务方法
        return joinPoint.proceed();
    }
}

5.5 统一返回结果类

项目通用返回体,可直接替换你项目的:

public class Result<T> {

    private Integer code;
    private String msg;
    private T data;

    public static <T> Result<T> fail(String msg) {
        Result<T> result = new Result<>();
        result.setCode(500);
        result.setMsg(msg);
        return result;
    }

    public static <T> Result<T> success(T data) {
        Result<T> result = new Result<>();
        result.setCode(200);
        result.setMsg("操作成功");
        result.setData(data);
        return result;
    }

    public Integer getCode() {
        return code;
    }

    public void setCode(Integer code) {
        this.code = code;
    }

    public String getMsg() {
        return msg;
    }

    public void setMsg(String msg) {
        this.msg = msg;
    }

    public T getData() {
        return data;
    }

    public void setData(T data) {
        this.data = data;
    }
}

5.6 编写测试接口

5.6.1 获取幂等 Token 接口
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;

@RestController
@RequestMapping("/api/idempotent")
public class IdempotentController {

    @Resource
    private IdempotentUtil idempotentUtil;

    @GetMapping("/getToken")
    public Result<String> getToken() {
        return Result.success(idempotentUtil.generateToken());
    }
}
5.6.2 业务幂等接口(只需加注解)

零侵入,一行注解开启幂等

import org.springframework.web.bind.annotation.PostMapping;

public class OrderController {

    @PostMapping("/submit/order")
    @Idempotent(message = "订单提交中,请勿重复操作")
    public Result<String> submitOrder() {
        // 模拟下单业务逻辑
        return Result.success("订单提交成功");
    }
}

六、完整调用流程(前后端联调)

  1. 前端页面加载:先调用 /api/idempotent/getToken 获取唯一 Token;
  2. 存储 Token:前端将 Token 放入请求头 idempotent-token
  3. 提交业务请求:点击提交,携带 Token 请求业务接口;
  4. 第一次请求:Token 校验成功并删除,业务正常执行;
  5. 重复请求:Token 已删除,校验失败,直接拦截重复提交。

七、生产环境优化点

7.1 支持分布式集群

基于 Redis 实现,天然支持多服务器集群部署,无单点问题。

7.2 动态过期时间(可扩展)

可在注解中新增 expireTime 参数,适配不同业务:

  • 秒杀场景:1 分钟过期
  • 普通表单:10 分钟过期

7.3 幂等 + 限流 双重防护

生产环境建议配合 Redis 限流 使用,既能防重复提交,又能防恶意刷接口。

7.4 适配 MQ 消费幂等

修改 Token 存储 Key,结合消息唯一 ID,可实现 MQ 消费防重。

八、常见问题答疑

Q1:为什么不使用 Redis 预减,而是查询 + 删除?

A:一次性令牌核心就是用完即删,保证全局唯一单次可用,杜绝并发重复提交。

Q2:高并发下是否存在线程安全问题?

A:Redis 的删除操作是单线程原子操作,不存在并发安全问题。

九、总结

  1. 所有写业务接口必须做幂等,是后端工程师的基本素养;
  2. AOP+Redis+Token 方案通用性最强、代码最优雅;
  3. 注解式开发,零业务侵入、极易维护、开箱即用
  4. 本文代码完整可直接部署上线,适配所有 SpringBoot 项目。

欢迎点赞、收藏、关注!持续更新 SpringBoot、微服务、分布式实战干货,带你吃透生产级技术!

更多推荐