SpringBoot 接口幂等性实现(Redis+AOP 注解)解决重复提交【生产可用】
SpringBoot 接口幂等性实现(Redis+AOP 注解)解决重复提交【生产可用】
一、前言
在日常后端开发中,接口重复提交是非常高频的线上问题:用户疯狂点击按钮、网络抖动重试、网关重试、MQ 重复消费等,都会导致重复下单、重复扣款、重复新增数据等严重 Bug。
接口幂等性是解决这类问题的最优方案,也是 Java 后端面试、生产落地的核心知识点。
本文带你从零实现一套 SpringBoot + Redis + AOP 注解 的通用幂等框架,无业务侵入、适配分布式集群、可直接上线。
二、什么是幂等性
2.1 幂等概念
幂等性:一个接口无论请求一次还是多次,最终业务结果保持一致,不会产生副作用。
通俗理解:多次请求 = 一次请求。
2.2 哪些接口必须做幂等
所有写操作接口必须保证幂等:
- 新增订单、提交表单
- 支付扣款、退款接口
- 积分发放、优惠券领取
- MQ 消费业务接口
查询、列表类读接口无需幂等。
三、主流幂等方案对比(选型分析)
为方便大家业务选型,整理 4 种企业级常用方案:
| 实现方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 数据库唯一索引 | 新增场景 | 简单可靠、零成本 | 只支持新增,更新场景无效 |
| 数据库乐观锁 | 更新场景 | 无锁高并发 | 需要维护版本号,不适用于新增 |
| 分布式锁 | 高并发核心交易 | 强一致性 | 实现复杂、性能略低 |
| Token+Redis+AOP | 全场景通用 | 无侵入、通用性强、适配集群 | 依赖 Redis |
生产首选:Token+Redis+AOP 通用幂等方案,一套代码适配所有业务接口。
四、核心设计思路
- 前端进入页面 / 发起操作前,后端生成唯一幂等 Token存入 Redis;
- 前端请求业务接口时,请求头携带该 Token;
- 自定义注解标记需要幂等的接口;
- AOP 统一拦截、校验 Token;
- 校验通过立即删除 Token(一次性令牌),保证只能提交一次;
- 重复请求 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("订单提交成功");
}
}
六、完整调用流程(前后端联调)
- 前端页面加载:先调用
/api/idempotent/getToken获取唯一 Token; - 存储 Token:前端将 Token 放入请求头
idempotent-token; - 提交业务请求:点击提交,携带 Token 请求业务接口;
- 第一次请求:Token 校验成功并删除,业务正常执行;
- 重复请求: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 的删除操作是单线程原子操作,不存在并发安全问题。
九、总结
- 所有写业务接口必须做幂等,是后端工程师的基本素养;
- AOP+Redis+Token 方案通用性最强、代码最优雅;
- 注解式开发,零业务侵入、极易维护、开箱即用;
- 本文代码完整可直接部署上线,适配所有 SpringBoot 项目。
欢迎点赞、收藏、关注!持续更新 SpringBoot、微服务、分布式实战干货,带你吃透生产级技术!
更多推荐



所有评论(0)