SpringBoot 自定义注解实现接口幂等性实战
【摘要】 在高并发场景下,重复提交、网络重试很容易造成数据重复新增、重复扣款等问题。本文基于SpringBoot,通过自定义注解 + Redis + Token机制实现一套轻量、可复用的接口幂等方案,附带完整代码、测试流程以及生产环境避坑点,适合直接接入项目。
一、什么是接口幂等
幂等:同一个请求,执行一次 和 执行N次,最终业务结果一致,不会产生副作用。
常见触发场景:
- 用户连续点击提交按钮
- 前端页面重复刷新
- 网关/客户端超时自动重试
- MQ消息重复投递
常见方案对比:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 数据库唯一索引 | 简单可靠 | 只适合新增场景,无法覆盖更新、查询 |
| 数据库乐观锁 | 适合更新场景 | 版本号管理,高并发下失败率偏高 |
| 分布式Token(本文方案) | 通用性强,新增/更新都支持 | 需要依赖Redis,有少量编码 |
本文选择 Token令牌方案:前端先获取幂等Token,提交请求时携带Token,服务端校验Token,校验通过立即删除Token,重复请求直接拦截。
二、环境依赖
pom.xml
<dependencies>
<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.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
</dependencies>
application.yml Redis配置
spring:
redis:
host: 127.0.0.1
port: 6379
password:
database: 0
三、代码实现
1. 自定义幂等注解
import java.lang.annotation.*;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Idempotent {
// token在请求头中的key名称
String tokenKey() default "idempotent-token";
// 过期时间,单位秒
long expireTime() default 60;
}
2. 统一返回结果类
public class Result<T> {
private Integer code;
private String msg;
private T data;
public static <T> Result<T> success(T data) {
Result<T> r = new Result<>();
r.setCode(200);
r.setMsg("success");
r.setData(data);
return r;
}
public static <T> Result<T> fail(Integer code, String msg) {
Result<T> r = new Result<>();
r.setCode(code);
r.setMsg(msg);
return r;
}
// getter setter
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; }
}
3. AOP切面拦截逻辑
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestAttributes;
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.util.UUID;
import java.util.concurrent.TimeUnit;
@Aspect
@Component
public class IdempotentAspect {
@Resource
private StringRedisTemplate stringRedisTemplate;
@Around("@annotation(idempotent)")
public Object around(ProceedingJoinPoint point, Idempotent idempotent) throws Throwable {
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
HttpServletRequest request = ((ServletRequestAttributes) requestAttributes).getRequest();
String tokenKey = idempotent.tokenKey();
String token = request.getHeader(tokenKey);
if (token == null || token.isEmpty()) {
return Result.fail(400, "幂等Token不能为空");
}
// 原子删除:del成功代表第一次访问,失败代表已经消费过
Boolean del = stringRedisTemplate.delete(token);
if (Boolean.FALSE.equals(del)) {
return Result.fail(409, "重复提交,请求被拦截");
}
return point.proceed();
}
// 生成token接口使用
public String generateToken(long expireTime) {
String token = UUID.randomUUID().toString().replace("-", "");
stringRedisTemplate.opsForValue().set(token, "1", expireTime, TimeUnit.SECONDS);
return token;
}
}
4. Controller接口
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
@RestController
@RequestMapping("/idempotent")
public class IdempotentController {
@Resource
private IdempotentAspect idempotentAspect;
// 获取幂等Token
@GetMapping("/token")
public Result<String> getToken() {
String token = idempotentAspect.generateToken(60);
return Result.success(token);
}
// 需要幂等保护的提交接口
@PostMapping("/submit")
@Idempotent(expireTime = 60)
public Result<String> submit() {
// 这里写业务逻辑:新增订单、扣款、保存数据等
return Result.success("提交成功");
}
}
四、测试流程
- GET 请求
/idempotent/token,拿到token字符串 - POST 请求
/idempotent/submit,请求头带上idempotent-token: xxx - 第一次请求:返回提交成功
- 立刻重复发送相同请求:返回 409 重复提交拦截
五、生产环境优化与坑点
- Redis删除必须原子操作:不要先查询再删除(并发下会击穿),直接使用
DEL命令,DEL返回true代表存在且删除成功。 - Token有效期:根据业务设置,下单类一般30~120s,不要设置永久有效。
- 异常回滚:如果业务代码抛出异常,需要把Token放回Redis,否则用户重试时直接被拦截,无法再次提交。
改造思路:在AOP捕获异常,业务报错时执行stringRedisTemplate.opsForValue().setIfAbsent(token, "1", expire, TimeUnit.SECONDS) - 集群部署:Redis是共享存储,天然支持分布式,不需要额外改造。
- 区分读接口:查询接口本身天然幂等,不需要加该注解。
六、拓展方案
如果是MQ消费场景,可以改成 业务唯一编号(订单号)作为Redis Key,不需要前端传Token,消费前判断key是否存在,消费成功写入key。
七、总结
这套基于自定义注解+Redis Token的幂等方案,侵入性很低,只需要在接口上加注解,统一由AOP拦截校验,是中小型项目最常用的幂等实现方式。同时要明白:幂等不是万能的,核心业务(资金、订单)建议数据库唯一索引 + 幂等Token双重保障。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱:
cloudbbs@huaweicloud.com
- 点赞
- 收藏
- 关注作者
评论(0)