SpringBoot 自定义注解实现接口幂等性实战

举报
JiHua 发表于 2026/11/05 11:52:11 2026/11/05
【摘要】 在高并发场景下,重复提交、网络重试很容易造成数据重复新增、重复扣款等问题。本文基于SpringBoot,通过自定义注解 + Redis + Token机制实现一套轻量、可复用的接口幂等方案,附带完整代码、测试流程以及生产环境避坑点,适合直接接入项目。

一、什么是接口幂等

幂等:同一个请求,执行一次 和 执行N次,最终业务结果一致,不会产生副作用。
常见触发场景:

  1. 用户连续点击提交按钮
  2. 前端页面重复刷新
  3. 网关/客户端超时自动重试
  4. 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("提交成功");
    }
}

四、测试流程

  1. GET 请求 /idempotent/token,拿到token字符串
  2. POST 请求 /idempotent/submit,请求头带上 idempotent-token: xxx
  3. 第一次请求:返回提交成功
  4. 立刻重复发送相同请求:返回 409 重复提交拦截

五、生产环境优化与坑点

  1. Redis删除必须原子操作:不要先查询再删除(并发下会击穿),直接使用DEL命令,DEL返回true代表存在且删除成功。
  2. Token有效期:根据业务设置,下单类一般30~120s,不要设置永久有效。
  3. 异常回滚:如果业务代码抛出异常,需要把Token放回Redis,否则用户重试时直接被拦截,无法再次提交。
    改造思路:在AOP捕获异常,业务报错时执行 stringRedisTemplate.opsForValue().setIfAbsent(token, "1", expire, TimeUnit.SECONDS)
  4. 集群部署:Redis是共享存储,天然支持分布式,不需要额外改造。
  5. 区分读接口:查询接口本身天然幂等,不需要加该注解。

六、拓展方案

如果是MQ消费场景,可以改成 业务唯一编号(订单号)作为Redis Key,不需要前端传Token,消费前判断key是否存在,消费成功写入key。

七、总结

这套基于自定义注解+Redis Token的幂等方案,侵入性很低,只需要在接口上加注解,统一由AOP拦截校验,是中小型项目最常用的幂等实现方式。同时要明白:幂等不是万能的,核心业务(资金、订单)建议数据库唯一索引 + 幂等Token双重保障。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。