《一行注解解决重复提交:Spring Boot 接口幂等实战》

发布于:2025-07-20 ⋅ 阅读:(16) ⋅ 点赞:(0)

《一行注解解决重复提交:Spring Boot 接口幂等实战》

一、问题背景

高并发或前端重复点击时,「支付、下单、抢券」类接口极易产生重复数据或资损。传统做法在业务代码里加锁、校验、状态机,既繁琐又容易遗漏。
本文给出“一个注解 + 30 行 AOP”的通用方案,支持:

  • 任意维度幂等键(用户+订单号、手机号+活动 ID …)
  • 本地 / 分布式锁一键切换
  • 业务零侵入,RT < 1 ms
二、最终效果
@PostMapping("/pay")
@NoRepeatSubmit(keySpEL = "#userId + ':' + #order.id", ttl = 10)
public ApiResp<Void> pay(@RequestBody Order order) {
    return ApiResp.success(payService.pay(order));
}

第二次点击直接返回 "请勿重复提交",10 秒内同一 key 拒绝再次进入业务逻辑。

三、实现步骤
  1. 定义注解
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoRepeatSubmit {
    /** 幂等键 SpEL;空串时使用默认规则 */
    String keySpEL() default "";
    /** 锁存活时间(秒) */
    int ttl() default 5;
    /** key 前缀 */
    String prefix() default "repeat:";
}
  1. AOP 切面(核心 30 行)
@Aspect
@Component
@RequiredArgsConstructor
public class NoRepeatSubmitAspect {

    private final RedissonClient redisson;              // 可选:分布式锁
    private final Cache localCache = Caffeine.newBuilder()
                                             .expireAfterWrite(Duration.ofSeconds(10))
                                             .build();
    private final SpelExpressionParser parser = new SpelExpressionParser();
    private final DefaultParameterNameDiscoverer nameDiscoverer = new DefaultParameterNameDiscoverer();

    @Around("@annotation(submit)")
    public Object around(ProceedingJoinPoint jp, NoRepeatSubmit submit) throws Throwable {
        String key = buildKey(jp, submit);
        RLock lock = redisson.getLock(key);
        boolean locked = lock.tryLock(0, submit.ttl(), TimeUnit.SECONDS);
        if (!locked) throw new BizException("请勿重复提交");

        try {
            return jp.proceed();
        } finally {
            if (lock.isHeldByCurrentThread()) lock.unlock();
        }
    }

    private String buildKey(ProceedingJoinPoint jp, NoRepeatSubmit submit) {
        Method method = ((MethodSignature) jp.getSignature()).getMethod();
        EvaluationContext ctx = new StandardEvaluationContext();
        // 注入常用变量
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        Object[] args = jp.getArgs();
        String[] names = nameDiscoverer.getParameterNames(method);
        for (int i = 0; i < names.length; i++) ctx.setVariable(names[i], args[i]);
        ctx.setVariable("request", request);
        ctx.setVariable("userId", StpUtil.getLoginIdAsString());
        ctx.setVariable("methodName", method.toGenericString());
        ctx.setVariable("argsMD5", DigestUtils.md5DigestAsHex(JSON.toJSONBytes(args)));

        String spEL = StringUtils.hasText(submit.keySpEL()) ? submit.keySpEL()
                : "#userId + ':' + #methodName + ':' + #argsMD5";
        return submit.prefix() + parser.parseExpression(spEL).getValue(ctx, String.class);
    }
}
  1. 依赖坐标(最新正式版)
<!-- 分布式锁 -->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.32.0</version>
</dependency>
<!-- 本地缓存(可选) -->
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>3.1.8</version>
</dependency>
四、常见 SpEL 示例
场景 keySpEL 写法
用户+订单号 "#userId + ':' + #order.id"
手机号+活动 "#request.getParameter('mobile') + ':' + #actId"
默认规则 留空即可(用户+方法+参数 MD5)
五、 这个注解具体是如何防止重复请求的?
1 一次请求的完整链路
  1. 浏览器/前端调用接口
  2. Spring 拦截器 → AOP 切面 NoRepeatSubmitAspect
  3. 切面 计算幂等键 key
    • 默认:repeat:{userId}:{方法签名}:{参数MD5}
    • 自定义:repeat:{自定义SpEL值}
  4. 尝试拿锁
    • 分布式:Redisson RLock.tryLock(0, ttl, SECONDS)
    • 本地:Caffeine cache.getIfPresent(key)
  5. 拿锁成功 → pjp.proceed() → 执行业务 → 返回正常结果
  6. 拿锁失败 → 抛 BizException("请勿重复提交")业务方法根本不会被执行
2 锁的粒度与隔离级别
维度 说明
锁名称 repeat:{业务唯一key},不同业务/参数/用户天然隔离
锁类型 RLock(Redisson 分布式)或本地 Cache(单机)
锁超时 注解 @NoRepeatSubmit(ttl = 5) 指定,5 秒后自动过期
锁竞争 无阻塞,tryLock(0, …) 立即返回失败,避免排队
3 为什么能防重复请求?
  1. 幂等键唯一
    同一用户、同一接口、同一参数 → 同一 key → 同一锁。

  2. 锁生命周期短
    只保护 本次请求窗口,防止“连点”或网络重发,不会长期占用。

  3. 无侵入
    业务方法看不到任何锁代码,异常在切面层就返回,不会走到 Service/DAO

  4. 可横向扩展
    Redisson 锁基于 Redis,集群部署时多台应用共享同一把分布式锁,水平扩容也能防重

4 时序图(文字版)
前端          切面(锁)              业务方法
───► 请求1 ──► 拿锁成功 ──► 执行Service
   ├─► 请求2 ──► 拿锁失败 ──► 直接返回错误
   └─► 5s后锁自动过期
5 什么时候会失效?

幂等键计算错误:SpEL 写成了常量,导致不同请求同一 key;
ttl 设置过长:业务正常需要 8 s,锁 5 s 提前释放,可能产生并发;
Redis 故障:分布式锁降级为本地锁,多实例场景下可能出现“漏网之鱼”。

六、结语

一行 @NoRepeatSubmit,让 Spring Boot 接口自带“防抖”能力。
“幂等键即锁名,切面即守门员,锁成功进门办事,锁失败直接拒客。”

把复杂留给自己,把简单留给业务方 —— 这才是优雅编码。


网站公告

今日签到

点亮在社区的每一天
去签到