防重复提交组件:从“双击下了两单“说起

文章目录

  • 防重复提交组件:从"双击下了两单"说起
    • 一、问题:重复提交从哪来
      • [1.1 一个常见的翻车现场](#1.1 一个常见的翻车现场)
      • [1.2 重复提交的三个来源](#1.2 重复提交的三个来源)
      • [1.3 先厘清三个概念:防重、幂等、限流](#1.3 先厘清三个概念:防重、幂等、限流)
    • 二、为什么"前端禁用按钮"不够
    • [三、从单机到分布式:为什么需要 Redis](#三、从单机到分布式:为什么需要 Redis)
      • [3.1 单机方案及其边界](#3.1 单机方案及其边界)
      • [3.2 Redis SETNX:原子地"占位"](#3.2 Redis SETNX:原子地"占位")
      • [3.3 为什么"先查后写"和"分开设过期"都不行](#3.3 为什么"先查后写"和"分开设过期"都不行)
    • [四、设计目标与 API 形态](#四、设计目标与 API 形态)
      • [4.1 设计目标](#4.1 设计目标)
      • [4.2 使用形态:一行注解](#4.2 使用形态:一行注解)
    • [五、核心设计:防重 key](#五、核心设计:防重 key)
      • [5.1 为什么不能拿参数原文当 key](#5.1 为什么不能拿参数原文当 key)
      • [5.2 摘要的内容组成:五个维度](#5.2 摘要的内容组成:五个维度)
      • [5.3 为什么是 SHA-256](#5.3 为什么是 SHA-256)
      • [5.4 参数序列化的健壮性](#5.4 参数序列化的健壮性)
      • [5.5 一个真实的坑:字段顺序(及其解法)](#5.5 一个真实的坑:字段顺序(及其解法))
    • [六、用户维度:SPI 与降级链](#六、用户维度:SPI 与降级链)
      • [6.1 为什么需要用户维度](#6.1 为什么需要用户维度)
      • [6.2 SPI:函数式接口 + 默认实现](#6.2 SPI:函数式接口 + 默认实现)
      • [6.3 降级链:用户ID → token → IP](#6.3 降级链:用户ID → token → IP)
      • [6.4 includeUser=false:逃生舱,不是典型场景](#6.4 includeUser=false:逃生舱,不是典型场景)
      • [6.5 权衡小结](#6.5 权衡小结)
    • [七、切面实现:AOP 的关键细节](#七、切面实现:AOP 的关键细节)
      • [7.1 切入点:类级 + 方法级](#7.1 切入点:类级 + 方法级)
      • [7.2 执行时机:参数校验先于切面](#7.2 执行时机:参数校验先于切面)
      • [7.3 为什么抛异常而不是直接写响应](#7.3 为什么抛异常而不是直接写响应)
      • [7.4 自调用陷阱:this 调用不走代理](#7.4 自调用陷阱:this 调用不走代理)
      • [7.5 完整的切面逻辑](#7.5 完整的切面逻辑)
    • 八、自动装配与扩展点
      • [8.1 通过 AutoConfiguration 零配置生效](#8.1 通过 AutoConfiguration 零配置生效)
      • [8.2 完整接入示例](#8.2 完整接入示例)
    • 九、全流程串联
    • 十、设计取舍与边界
      • [10.1 业务失败后,key 保留到窗口结束](#10.1 业务失败后,key 保留到窗口结束)
      • [10.2 Redis 不可用:fail-closed](#10.2 Redis 不可用:fail-closed)
      • [10.3 防重 ≠ 幂等:数据库兜底不能省](#10.3 防重 ≠ 幂等:数据库兜底不能省)
      • [10.4 窗口大小怎么定](#10.4 窗口大小怎么定)
      • [10.5 误伤风险清单](#10.5 误伤风险清单)
    • 十一、总结
    • 参考

防重复提交组件:从"双击下了两单"说起

一篇关于防重复提交从问题分析、方案演进到工程落地的完整梳理。适合后端开发同学阅读,内容由浅入深:先讲清楚"重复提交"到底是什么问题,再逐步拆解一个基于 Redis 的注解式防重组件的每个设计决策。

一、问题:重复提交从哪来

1.1 一个常见的翻车现场

想象这样一个场景:业务高峰期,用户在收银台点了"提交订单",页面卡住了。用户以为是没点上,又点了一次。两秒后页面恢复,用户发现------两笔一模一样的订单。客服查单、退款、对账,一通折腾。

这种"手滑事故"在几乎所有业务系统里都发生过:下单、转账、开票、发布公告、提交审批......凡是"写操作",都可能因为重复提交产生脏数据。防重复提交,就是要在请求到达业务逻辑之前把这种重复拦下来。

1.2 重复提交的三个来源

来源 典型场景 能否被前端"按钮置灰"挡住
用户行为 双击提交按钮、习惯性连点 大部分能挡
网络重试 移动端弱网自动重试、网关/代理超时重发、浏览器刷新重新提交 挡不住
恶意构造 脚本直接调接口、抓包重放 完全挡不住

结论很直白:前端控制只能改善体验,后端必须兜底。这也引出了本组件最核心的设计动机------在后端以最小侵入的方式拦截重复提交。

1.3 先厘清三个概念:防重、幂等、限流

很多文章把这三个词混在一起讲,但它们解决的是不同的问题:

概念 回答的问题 时间维度 典型手段
防重复提交 相同请求在短窗口内只允许执行一次 秒级窗口 按钮置灰、Redis SETNX 占位
幂等 同一个操作执行多次与执行一次效果相同 长期有效 唯一索引、幂等键 + 结果存储
限流 单位时间内允许的请求速率上限 恒定速率 令牌桶、滑动窗口

用一句话概括:防重挡"手滑",幂等挡"重放",限流挡"洪水"。本文的主角是防重复提交------它只负责"短时间窗口内不许再来一次",窗口一过,同样的请求是允许的。

二、为什么"前端禁用按钮"不够

最常见的初级方案是:点击后把按钮置灰,等接口返回再恢复。它确实能挡住 90% 的"正常用户双击",但存在几个硬伤:

  1. 刷新即失效:请求发出后页面刷新,按钮状态丢失,用户可以立刻再提交一次;
  2. 多标签页/多设备:A 标签页提交后,B 标签页的状态是独立的,照提不误;
  3. 前端不可信:绕过前端直接调接口,置灰逻辑形同虚设;
  4. 网络层重试:请求已经发出去了,前端按钮置灰管不到网关或客户端的自动重试。

所以,防重必须做在后端,而且要做到"业务代码无感知"------最好是在接口上贴一个注解就完事。

三、从单机到分布式:为什么需要 Redis

3.1 单机方案及其边界

最容易想到的后端方案:用一个本地缓存(如 ConcurrentHashMap)记录"最近处理过的请求摘要 + 时间戳",窗口内命中即拦截。

java 复制代码
// 单机版伪代码:ConcurrentHashMap + 时间窗口
private final ConcurrentHashMap<String, Long> window = new ConcurrentHashMap<>();

boolean tryAcquire(String digest) {
    long now = System.currentTimeMillis();
    Long prev = window.putIfAbsent(digest, now);
    if (prev == null) return true;            // 首次
    return now - prev > WINDOW_MS;            // 窗口已过,放行
}

它有两个致命问题:

  • 多实例部署下各自为政:请求被负载均衡打到实例 A,实例 B 完全不知道,重复提交照样放行;
  • 内存即状态:实例重启,防重记录全部丢失;还得自己写定时清理逻辑,防止 Map 无限膨胀。

在微服务/多实例部署已是常态的今天,单机方案基本可以判死刑。

3.2 Redis SETNX:原子地"占位"

分布式的自然选择是 Redis:所有实例共享一份状态。而防重的关键操作------"如果 key 不存在就写入,并设置过期时间"------恰好是 Redis 的一个原子命令:

text 复制代码
SET key value NX EX 10
  • NX:仅当 key 不存在时写入成功(Not eXists);
  • EX 10:同时设置 10 秒过期时间;
  • 原子性:整个命令在 Redis 单线程内执行,不存在竞态。

Spring Data Redis 封装了这个语义,ValueOperations.setIfAbsent(key, value, timeout, unit) 底层就是 SET ... NX PX/EX

java 复制代码
boolean firstSubmit = redisTemplate.opsForValue()
        .setIfAbsent(key, "1", interval, timeUnit);

返回值语义非常干净:true = 第一次来,放行;false = 已经占过位了,拦截

3.3 为什么"先查后写"和"分开设过期"都不行

这两个反模式值得单独说:

先查后写有竞态:

java 复制代码
// ❌ 错误示范:两步操作不原子
if (redisTemplate.hasKey(key)) {
    throw new DuplicateException();   // 拦截
}
redisTemplate.opsForValue().set(key, "1", 10, SECONDS); // 放行

并发下两个请求可能同时通过 hasKey 检查(都返回 false),然后都执行 set,重复提交穿透。

写入和过期分开设置有"永久占位"风险:

java 复制代码
// ❌ 错误示范:set 成功但 expire 失败/进程崩溃,key 永不释放
redisTemplate.opsForValue().set(key, "1");
redisTemplate.expire(key, 10, TimeUnit.SECONDS);

一旦第二步失败,这个 key 就永远留在 Redis 里,该接口被永久防重------线上事故级别的问题。

SET ... NX EX 一条命令同时解决这两个问题,这就是它成为防重基石的原因。

四、设计目标与 API 形态

4.1 设计目标

动手实现前,先明确组件要满足的约束:

  1. 声明式:业务侧只加注解,不写任何防重代码;
  2. 分布式:多实例共享 Redis,天然跨节点生效;
  3. 可配置:防重窗口大小、提示语、是否按用户维度防重均可调;
  4. 统一出口:拦截后走全局异常体系,返回统一的响应结构,业务侧无需关心;
  5. 可扩展:用户维度的获取方式(登录方案)可替换,组件本身不绑定任何认证框架。

4.2 使用形态:一行注解

java 复制代码
@RepeatSubmit(interval = 5, message = "请勿重复提交,请稍后再试")
@PostMapping("/orders")
public Result<Void> createOrder(@RequestBody @Valid OrderCreateDTO dto) {
    // 业务逻辑,无需关心防重
}

注解提供了四个属性,覆盖绝大多数场景:

属性 默认值 说明
interval 10 防重时间窗口大小
timeUnit SECONDS 时间单位
message 不允许重复提交,请稍后再试 拦截时返回给前端的提示
includeUser true 摘要是否包含用户维度(仅完全匿名接口才关闭,详见第六章)

用法简单,但简单背后是几个关键设计:防重 key 怎么生成、用户维度怎么取、切面怎么拦截。下面三章逐一拆解。

五、核心设计:防重 key

防重 key 是整个组件的心脏。它的设计质量直接决定防重的准确性 (该拦的拦得住)和安全性(不该拦的不误伤)。

5.1 为什么不能拿参数原文当 key

最直接的想法是:把请求参数拼成一个字符串当 key。但有三个问题:

  1. 长度失控:请求体可能很大(长文本、大列表),拼出来可能几 KB 甚至更大,Redis key 又长又丑还浪费内存;
  2. 敏感信息落盘:参数里可能有手机号、身份证、业务数据,明文写进 Redis 既不安全,也不利于问题排查;
  3. 可读性差 :key 里混着各种特殊字符,KEYS 巡检时根本没法看。

所以业界通用做法是:把"请求指纹"哈希成定长摘要,再拼上固定前缀。key 形如:

text 复制代码
repeat:submit:{64位十六进制摘要}

5.2 摘要的内容组成:五个维度

摘要由五个部分拼接后做 SHA-256 得到:

text 复制代码
SHA-256(方法标识 | URI | 租户ID | 用户维度 | 参数JSON)

#mermaid-svg-zZPpfJbmLqy4rFP4{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-zZPpfJbmLqy4rFP4 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-zZPpfJbmLqy4rFP4 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-zZPpfJbmLqy4rFP4 .error-icon{fill:#552222;}#mermaid-svg-zZPpfJbmLqy4rFP4 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-zZPpfJbmLqy4rFP4 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-zZPpfJbmLqy4rFP4 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-zZPpfJbmLqy4rFP4 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-zZPpfJbmLqy4rFP4 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-zZPpfJbmLqy4rFP4 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-zZPpfJbmLqy4rFP4 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-zZPpfJbmLqy4rFP4 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-zZPpfJbmLqy4rFP4 .marker.cross{stroke:#333333;}#mermaid-svg-zZPpfJbmLqy4rFP4 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-zZPpfJbmLqy4rFP4 p{margin:0;}#mermaid-svg-zZPpfJbmLqy4rFP4 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-zZPpfJbmLqy4rFP4 .cluster-label text{fill:#333;}#mermaid-svg-zZPpfJbmLqy4rFP4 .cluster-label span{color:#333;}#mermaid-svg-zZPpfJbmLqy4rFP4 .cluster-label span p{background-color:transparent;}#mermaid-svg-zZPpfJbmLqy4rFP4 .label text,#mermaid-svg-zZPpfJbmLqy4rFP4 span{fill:#333;color:#333;}#mermaid-svg-zZPpfJbmLqy4rFP4 .node rect,#mermaid-svg-zZPpfJbmLqy4rFP4 .node circle,#mermaid-svg-zZPpfJbmLqy4rFP4 .node ellipse,#mermaid-svg-zZPpfJbmLqy4rFP4 .node polygon,#mermaid-svg-zZPpfJbmLqy4rFP4 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-zZPpfJbmLqy4rFP4 .rough-node .label text,#mermaid-svg-zZPpfJbmLqy4rFP4 .node .label text,#mermaid-svg-zZPpfJbmLqy4rFP4 .image-shape .label,#mermaid-svg-zZPpfJbmLqy4rFP4 .icon-shape .label{text-anchor:middle;}#mermaid-svg-zZPpfJbmLqy4rFP4 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-zZPpfJbmLqy4rFP4 .rough-node .label,#mermaid-svg-zZPpfJbmLqy4rFP4 .node .label,#mermaid-svg-zZPpfJbmLqy4rFP4 .image-shape .label,#mermaid-svg-zZPpfJbmLqy4rFP4 .icon-shape .label{text-align:center;}#mermaid-svg-zZPpfJbmLqy4rFP4 .node.clickable{cursor:pointer;}#mermaid-svg-zZPpfJbmLqy4rFP4 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-zZPpfJbmLqy4rFP4 .arrowheadPath{fill:#333333;}#mermaid-svg-zZPpfJbmLqy4rFP4 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-zZPpfJbmLqy4rFP4 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-zZPpfJbmLqy4rFP4 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zZPpfJbmLqy4rFP4 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-zZPpfJbmLqy4rFP4 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zZPpfJbmLqy4rFP4 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-zZPpfJbmLqy4rFP4 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-zZPpfJbmLqy4rFP4 .cluster text{fill:#333;}#mermaid-svg-zZPpfJbmLqy4rFP4 .cluster span{color:#333;}#mermaid-svg-zZPpfJbmLqy4rFP4 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-zZPpfJbmLqy4rFP4 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-zZPpfJbmLqy4rFP4 rect.text{fill:none;stroke-width:0;}#mermaid-svg-zZPpfJbmLqy4rFP4 .icon-shape,#mermaid-svg-zZPpfJbmLqy4rFP4 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zZPpfJbmLqy4rFP4 .icon-shape p,#mermaid-svg-zZPpfJbmLqy4rFP4 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-zZPpfJbmLqy4rFP4 .icon-shape .label rect,#mermaid-svg-zZPpfJbmLqy4rFP4 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zZPpfJbmLqy4rFP4 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-zZPpfJbmLqy4rFP4 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-zZPpfJbmLqy4rFP4 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 方法标识

类名#方法名
SHA-256
URI

请求路径
租户ID

多租户隔离
用户维度

用户ID / token / IP
参数JSON

方法实参序列化
repeat:submit:{64位hex}

逐个说为什么需要:

维度 作用 不加会怎样
方法标识(类名#方法名) 区分不同接口 两个接口参数恰好相同会互相拦截
URI 同一方法被映射到多个路由时兜底 理论上方法标识已够,多一层保险
租户ID 多租户隔离 租户 A 提交后,租户 B 相同请求被误伤
用户维度 区分"谁"在提交 用户 A 提交后,用户 B 被误伤(详见第六章)
参数JSON 相同请求内容才算重复 不同内容的请求被误伤

注意 | 作为分隔符是有讲究的:参与摘要的每个部分都可能包含任意字符,用分隔符拼接后哈希,能避免"两个不同维度组合恰好拼出相同字符串"的歧义。

5.3 为什么是 SHA-256

  • 定长:无论原文多长,输出恒为 64 位十六进制,key 长度可控;
  • 抗碰撞:防重场景下,摘要碰撞意味着"两个不同请求被当成同一个",概率极低(SHA-256 碰撞概率约 2^-128 量级),工程上可忽略;
  • 实现零依赖MessageDigest 是 JDK 内置算法(Java 9+ 还有 HexFormat 直接转十六进制),不需要引入任何第三方库。
java 复制代码
private String sha256Hex(String content) {
    try {
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        byte[] bytes = digest.digest(content.getBytes(StandardCharsets.UTF_8));
        return HexFormat.of().formatHex(bytes);
    } catch (NoSuchAlgorithmException e) {
        // SHA-256 是 JDK 必选算法,理论上不会发生
        throw new IllegalStateException("SHA-256 算法不可用", e);
    }
}

顺带一提:有些项目用 MD5 做摘要,不是不能用(防重场景碰撞概率同样可接受),但 MD5 已被证明存在碰撞攻击,公开讨论时容易引来安全质疑,选型上 SHA-256 更稳妥,成本几乎为零

5.4 参数序列化的健壮性

参数 JSON 参与摘要,第一直觉是把 point.getArgs() 全部序列化。但真实世界的 Controller 方法参数五花八门,直接序列化会踩坑:

坑一:Servlet 容器类型不能参与摘要

HttpServletRequestHttpServletResponseMultipartFileBindingResultHttpSession......这些对象要么不可序列化(直接抛异常),要么每次请求的实例都不同(序列化结果每次都不一样,会制造"假重复"------同一个请求两次进来摘要不同,防重失效)。所以实现里维护了一个"忽略清单":

java 复制代码
private boolean isIgnoreArg(Object arg) {
    return arg instanceof ServletRequest
            || arg instanceof ServletResponse
            || arg instanceof MultipartFile
            || arg instanceof MultipartRequest
            || arg instanceof BindingResult
            || arg instanceof HttpSession
            || arg instanceof Principal
            || arg instanceof ModelAndView
            || arg instanceof InputStream
            || arg instanceof Reader;
}

坑二:序列化失败要有兜底

某些业务对象可能包含无法访问的属性(getter 抛异常等),整体序列化会失败。实现的选择是:单个参数序列化失败时,降级为类名参与摘要------宁可摘要粒度粗一点,也要保证流程可用、不误伤:

java 复制代码
try {
    sb.append(JSON.toJSONString(arg, JSONWriter.Feature.IgnoreErrorGetter));
} catch (Exception e) {
    // 序列化失败(如存在无法访问的属性)时,以类名参与摘要,保证流程可用
    sb.append(arg.getClass().getName());
}

(此处为兜底逻辑片段,完整的序列化参数------含 5.5 节的 MapSortField 规范化------见 7.5 节完整代码。)

两个坑的处理思路可以提炼成一句话:防重组件是"防守方",任何异常情况都不能让它把合法请求误伤成重复请求

5.5 一个真实的坑:字段顺序(及其解法)

这是摘要方案最容易踩的细节:JSON 序列化的字段顺序在不同对象类型上并不一致

  • DTO 对象:fastjson2 等库按字段声明顺序序列化,同一个类两次序列化结果一致,稳定
  • Map / HashSet 等无序容器:迭代顺序不保证,同一份数据两次序列化可能字段顺序不同
  • 结果就是:同一个请求(Map 参数)连续提交两次,摘要不同,防重静默失效

解法是序列化时规范化 :给序列化器开启 MapSortField------Map 按键排序(含嵌套 Map),数组/List 顺序保持不变(不影响有序语义):

java 复制代码
JSON.toJSONString(arg,
        JSONWriter.Feature.IgnoreErrorGetter,   // getter 抛异常时忽略
        JSONWriter.Feature.MapSortField);       // Map 按键排序,摘要稳定

这样同一份 Map 数据无论内部迭代顺序如何,序列化结果都一致。实测确认:嵌套 Map 也会被递归排序,而 List 的元素顺序原样保留------语义正确,零业务侵入。

六、用户维度:SPI 与降级链

6.1 为什么需要用户维度

如果摘要里只有"方法 + 参数",会同时产生两个问题:

  1. 误伤他人:用户 A 提交了订单,窗口内用户 B 提交了一模一样的订单,被当成重复拦截;
  2. 漏拦同人:同一个用户开了两个标签页(两个会话、两个 token),各自提交,如果不按用户维度防重而按 token 防重,两笔都拦不住。

所以必须有一个"用户维度"参与摘要。但这里有个组件设计的核心矛盾:基础组件不该绑定具体的认证方案(Sa-Token、Spring Security、自研 Session,各有各的取法)。解法就是 SPI。

6.2 SPI:函数式接口 + 默认实现

组件定义一个函数式接口作为扩展点:

java 复制代码
@FunctionalInterface
public interface RepeatSubmitUserProvider {
    /**
     * 获取当前登录用户的防重标识(如用户ID字符串),
     * 未登录或无法获取时返回 null
     */
    String currentUserKey();
}

默认实现返回 null(视为匿名):

java 复制代码
public class DefaultRepeatSubmitUserProvider implements RepeatSubmitUserProvider {
    @Override
    public String currentUserKey() {
        return null;
    }
}

业务侧想启用"按用户防重",只需要注册一个自己的 Bean(通过 @ConditionalOnMissingBean 覆盖默认实现,详见第八章):

java 复制代码
@Configuration
public class RepeatSubmitConfig {

    @Bean
    public RepeatSubmitUserProvider repeatSubmitUserProvider() {
        return () -> {
            Long userId = LoginContext.getUserId();  // 具体认证方案自行实现
            return userId == null ? null : userId.toString();
        };
    }
}

组件只依赖"一个返回用户标识的函数",至于用户标识从哪来------登录上下文、ThreadLocal、请求头------完全是业务侧的事。这就是依赖倒置:高层组件定义抽象,低层细节由使用者注入

6.3 降级链:用户ID → token → IP

用户维度不是总能拿到的:SPI 返回 null(未登录、或业务没实现 provider)、请求里没有凭证......实现里设计了一条降级链

text 复制代码
用户ID(SPI 提供,最理想) → Authorization token → 客户端 IP(最后兜底)

#mermaid-svg-V4syReTRts6sw2B7{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-V4syReTRts6sw2B7 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-V4syReTRts6sw2B7 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-V4syReTRts6sw2B7 .error-icon{fill:#552222;}#mermaid-svg-V4syReTRts6sw2B7 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-V4syReTRts6sw2B7 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-V4syReTRts6sw2B7 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-V4syReTRts6sw2B7 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-V4syReTRts6sw2B7 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-V4syReTRts6sw2B7 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-V4syReTRts6sw2B7 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-V4syReTRts6sw2B7 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-V4syReTRts6sw2B7 .marker.cross{stroke:#333333;}#mermaid-svg-V4syReTRts6sw2B7 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-V4syReTRts6sw2B7 p{margin:0;}#mermaid-svg-V4syReTRts6sw2B7 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-V4syReTRts6sw2B7 .cluster-label text{fill:#333;}#mermaid-svg-V4syReTRts6sw2B7 .cluster-label span{color:#333;}#mermaid-svg-V4syReTRts6sw2B7 .cluster-label span p{background-color:transparent;}#mermaid-svg-V4syReTRts6sw2B7 .label text,#mermaid-svg-V4syReTRts6sw2B7 span{fill:#333;color:#333;}#mermaid-svg-V4syReTRts6sw2B7 .node rect,#mermaid-svg-V4syReTRts6sw2B7 .node circle,#mermaid-svg-V4syReTRts6sw2B7 .node ellipse,#mermaid-svg-V4syReTRts6sw2B7 .node polygon,#mermaid-svg-V4syReTRts6sw2B7 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-V4syReTRts6sw2B7 .rough-node .label text,#mermaid-svg-V4syReTRts6sw2B7 .node .label text,#mermaid-svg-V4syReTRts6sw2B7 .image-shape .label,#mermaid-svg-V4syReTRts6sw2B7 .icon-shape .label{text-anchor:middle;}#mermaid-svg-V4syReTRts6sw2B7 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-V4syReTRts6sw2B7 .rough-node .label,#mermaid-svg-V4syReTRts6sw2B7 .node .label,#mermaid-svg-V4syReTRts6sw2B7 .image-shape .label,#mermaid-svg-V4syReTRts6sw2B7 .icon-shape .label{text-align:center;}#mermaid-svg-V4syReTRts6sw2B7 .node.clickable{cursor:pointer;}#mermaid-svg-V4syReTRts6sw2B7 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-V4syReTRts6sw2B7 .arrowheadPath{fill:#333333;}#mermaid-svg-V4syReTRts6sw2B7 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-V4syReTRts6sw2B7 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-V4syReTRts6sw2B7 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-V4syReTRts6sw2B7 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-V4syReTRts6sw2B7 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-V4syReTRts6sw2B7 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-V4syReTRts6sw2B7 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-V4syReTRts6sw2B7 .cluster text{fill:#333;}#mermaid-svg-V4syReTRts6sw2B7 .cluster span{color:#333;}#mermaid-svg-V4syReTRts6sw2B7 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-V4syReTRts6sw2B7 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-V4syReTRts6sw2B7 rect.text{fill:none;stroke-width:0;}#mermaid-svg-V4syReTRts6sw2B7 .icon-shape,#mermaid-svg-V4syReTRts6sw2B7 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-V4syReTRts6sw2B7 .icon-shape p,#mermaid-svg-V4syReTRts6sw2B7 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-V4syReTRts6sw2B7 .icon-shape .label rect,#mermaid-svg-V4syReTRts6sw2B7 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-V4syReTRts6sw2B7 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-V4syReTRts6sw2B7 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-V4syReTRts6sw2B7 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是



includeUser=true
SPI 返回用户ID?
按用户ID防重

跨设备/跨会话稳定
请求头有 token?
按 token 防重

同会话有效
按客户端 IP 防重

最粗粒度

为什么是这个顺序?看每个维度的特性:

维度 稳定性 粒度 风险
用户ID 跨设备、跨会话稳定 精确到人 拿不到(未登录/未实现)
token 随会话变化 精确到会话 同用户多会话互相拦不住
IP 长期稳定 精确到出口 NAT 下多用户共享 IP,互相误伤

降级是"宁可粒度粗,不可拦不住":拿不到用户ID,至少按 token 防重;token 也没有(匿名请求),至少按 IP 防重。

6.4 includeUser=false:逃生舱,不是典型场景

先泼一盆冷水:企业系统里"完全匿名"的写接口几乎不存在。 所谓公开表单、预约、投票、报障,上线后通常都会加登录或至少临时授权(游客 token、设备指纹、开放平台的 appKey 签名)------不然任何人都能直接调接口把服务刷崩。所以这些接口要么有登录态 (默认 includeUser=true,用户维度由 SPI 提供,这正是主流用法),要么有临时身份 (游客 token / 设备 ID 照样可以通过 RepeatSubmitUserProvider 映射成用户维度,保持默认即可)。真正"一点身份信息都拿不到"的接口是少数。

includeUser 开关的真实定位是逃生舱,不是典型场景:当接口确实设计为完全匿名(例如面向公众的匿名意见箱、无需登录的报障通道),且已经用验证码/限流挡住滥用时,关闭用户维度、仅按"方法 + 参数"判重,避免"同 IP 用户互相拦截":

java 复制代码
// 仅当业务确实存在"无任何身份"的内容型通道时才这样用
@RepeatSubmit(interval = 10, includeUser = false, message = "请勿重复提交,请稍后再试")
@PostMapping("/public/feedback")
public Result<Void> submit(@RequestBody @Valid FeedbackDTO dto) { ... }

而且就算在这种场景,防滥用的主体也必须是限流/验证码------无身份的接口可以被脚本无限调用,参数防重挡不住"每次构造不同参数"的攻击,防重只是顺手挡掉手滑双击。

关于"传参简单会不会碰撞":SHA-256 摘要碰撞概率约 2^-128,可忽略;参数空间小的接口(如参数只有一个手机号)也不会跨用户误伤------账号/手机号的唯一性决定了"相同参数"必然来自同一用户。真正需要警惕的是窗口语义的固有代价:业务失败后 key 保留到窗口结束(见 10.1 节),凡是有常态性失败重试的接口都会被窗口锁住,这类接口天然不适合窗口防重。

一句话:includeUser=false 是逃生舱不是主流;主流是 includeUser=true + SPI 用户维度(登录态或临时身份都能映射);匿名接口的滥用防护主体是限流/验证码,参数防重只是补充。

6.5 权衡小结

用户维度的设计本质是在"误伤"和"漏拦"之间选平衡点

  • 维度越精确(用户ID),误伤越少,但可能漏拦(拿不到ID时);
  • 维度越粗糙(IP),拦截越狠,但误伤越多(NAT 共享出口)。

组件把选择权交给使用者:默认降级链保证"能用",SPI 让业务按需升级到"好用"。

七、切面实现:AOP 的关键细节

7.1 切入点:类级 + 方法级

组件用 Spring AOP 实现,要求同时支持两种标注方式:

  • 类级注解 :给整个 Controller 一个"默认防重配置"------@within(RepeatSubmit) 匹配类上标注了 @RepeatSubmit 的所有方法;
  • 方法级注解 :单独覆盖某个方法的配置------@annotation(RepeatSubmit) 匹配方法上标注了 @RepeatSubmit 的方法。

|| 组合后两种标注方式都生效:类上标了默认 10 秒,某个方法想改成 3 秒,直接在方法上再标一次即可(方法级注解优先)。这给了业务侧很大的灵活性:一个类一个注解管全局,特殊方法单独覆盖。

但切入点怎么"拿到"注解实例,有一个隐藏很深的坑。

直观的写法是把注解实例直接绑定到切面参数上:

java 复制代码
// ❌ 看起来很美,实际在 Spring 6.x 上应用启动即报错
@Around("@within(repeatSubmit) || @annotation(repeatSubmit)")
public Object around(ProceedingJoinPoint point, RepeatSubmit repeatSubmit) { ... }

这个写法在应用启动时(切面被实例化、解析参数绑定的阶段)会直接抛异常:

text 复制代码
IllegalArgumentException: Found 2 candidate annotation binding variables
but only one potential argument binding slot

原因在 Spring 的实现细节里:AspectJAdviceParameterNameDiscoverer 在推断切面参数名时,并不是解析 AST,而是按空格把切入点表达式字符串分词扫描 ------@within(...)@annotation(...) 括号里的内容都会被当作"候选绑定变量"。两个候选对一个参数槽位,直接判定为歧义。更隐蔽的是,这个扫描器无法区分类型名和变量名:哪怕只在一个分支写变量名(另一个分支写类型名),同样会启动失败。笔者在 Spring Framework 6.1.5(对应 Spring Boot 3.2.x)上做了最小复现,两种写法都稳定复现启动失败。

正确的做法是:切入点上不绑定任何注解实例,在切面方法内部通过 MethodSignature 手动解析注解(方法级优先、类级兜底):

java 复制代码
@Around("@within(RepeatSubmit) || @annotation(RepeatSubmit)")
public Object around(ProceedingJoinPoint point) throws Throwable {
    RepeatSubmit repeatSubmit = resolveAnnotation(point);
    if (repeatSubmit == null) {
        return point.proceed();  // 防御:理论上不会发生
    }
    // 1. 生成防重 key
    // 2. Redis SETNX 占位
    // 3. 首次放行 / 重复拦截
}

private RepeatSubmit resolveAnnotation(ProceedingJoinPoint point) {
    Method method = ((MethodSignature) point.getSignature()).getMethod();
    RepeatSubmit annotation = method.getAnnotation(RepeatSubmit.class);
    if (annotation == null) {
        annotation = method.getDeclaringClass().getAnnotation(RepeatSubmit.class);
    }
    return annotation;
}

两个细节值得注意:

  1. 类级注解要从 method.getDeclaringClass() 取,而不是 point.getTarget().getClass() :Spring AOP 默认用 CGLIB 生成代理子类,注解没有 @Inherited 时子类上取不到父类注解,而 getDeclaringClass() 返回的是声明该方法的原始类;
  2. 手动解析天然实现"方法级优先":先查方法注解,没有再查类注解,优先级语义一目了然,且不依赖 Spring 对绑定变量的解析策略------换个 Spring 版本也不会踩雷。

7.2 执行时机:参数校验先于切面

这是一个容易被忽略、但非常关键的时序问题:@Valid 参数校验发生在切面之前

Spring MVC 的调用链是这样的:
Controller方法 AOP代理 HandlerAdapter DispatcherServlet 客户端 Controller方法 AOP代理 HandlerAdapter DispatcherServlet 客户端 #mermaid-svg-1fSrdzCvbnntvxuL{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-1fSrdzCvbnntvxuL .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-1fSrdzCvbnntvxuL .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-1fSrdzCvbnntvxuL .error-icon{fill:#552222;}#mermaid-svg-1fSrdzCvbnntvxuL .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-1fSrdzCvbnntvxuL .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-1fSrdzCvbnntvxuL .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-1fSrdzCvbnntvxuL .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-1fSrdzCvbnntvxuL .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-1fSrdzCvbnntvxuL .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-1fSrdzCvbnntvxuL .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-1fSrdzCvbnntvxuL .marker{fill:#333333;stroke:#333333;}#mermaid-svg-1fSrdzCvbnntvxuL .marker.cross{stroke:#333333;}#mermaid-svg-1fSrdzCvbnntvxuL svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-1fSrdzCvbnntvxuL p{margin:0;}#mermaid-svg-1fSrdzCvbnntvxuL .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-1fSrdzCvbnntvxuL text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-1fSrdzCvbnntvxuL .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-1fSrdzCvbnntvxuL .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-1fSrdzCvbnntvxuL .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-1fSrdzCvbnntvxuL .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-1fSrdzCvbnntvxuL #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-1fSrdzCvbnntvxuL .sequenceNumber{fill:white;}#mermaid-svg-1fSrdzCvbnntvxuL #sequencenumber{fill:#333;}#mermaid-svg-1fSrdzCvbnntvxuL #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-1fSrdzCvbnntvxuL .messageText{fill:#333;stroke:none;}#mermaid-svg-1fSrdzCvbnntvxuL .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-1fSrdzCvbnntvxuL .labelText,#mermaid-svg-1fSrdzCvbnntvxuL .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-1fSrdzCvbnntvxuL .loopText,#mermaid-svg-1fSrdzCvbnntvxuL .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-1fSrdzCvbnntvxuL .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-1fSrdzCvbnntvxuL .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-1fSrdzCvbnntvxuL .noteText,#mermaid-svg-1fSrdzCvbnntvxuL .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-1fSrdzCvbnntvxuL .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-1fSrdzCvbnntvxuL .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-1fSrdzCvbnntvxuL .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-1fSrdzCvbnntvxuL .actorPopupMenu{position:absolute;}#mermaid-svg-1fSrdzCvbnntvxuL .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-1fSrdzCvbnntvxuL .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-1fSrdzCvbnntvxuL .actor-man circle,#mermaid-svg-1fSrdzCvbnntvxuL line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-1fSrdzCvbnntvxuL :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 校验失败 → 抛 MethodArgumentNotValidException 直接进全局异常处理 POST /orders (JSON) 找到处理器并适配 参数解析 + @Valid 校验 调用代理方法 切面执行:SETNX 占位 放行,执行业务

@RequestBody @Valid 的校验发生在 HandlerAdapter 的参数解析阶段 ,而切面拦截的是 Controller 方法本身------参数解析在前,代理调用在后。

这个时序带来一个隐性收益:参数非法的请求根本不会走到切面,不会占用防重窗口。用户提交了非法数据,改完参数再提交,窗口计时从"合法请求"才开始。如果顺序反过来(切面先占位),非法请求也会把防重窗口占掉,用户修正参数后反而要等窗口过期,体验极差。

7.3 为什么抛异常而不是直接写响应

拦截发生后,实现选择抛出业务异常,而不是在切面里直接写 HTTP 响应:

java 复制代码
boolean firstSubmit = redisService.setIfAbsent(
        key, String.valueOf(System.currentTimeMillis()),   // value 存占位时间戳
        repeatSubmit.interval(), repeatSubmit.timeUnit());
if (!firstSubmit) {
    log.warn("检测到重复提交,请求被拦截,防重key: {}", key);
    throw new ServiceException(repeatSubmit.message());
}
return point.proceed();

理由有三:

  1. 切面不感知响应协议 :响应结构(Result<T>、错误码、国际化文案)是业务层/框架层的事,切面直接写响应会侵入响应格式;
  2. 复用全局异常体系ServiceException 由全局异常处理器统一转换为标准错误响应,业务侧无需新增任何代码;
  3. 行为可追踪:warn 日志带防重 key,且 SETNX 的 value 存储了占位时间戳,排查时能直接看出"这个防重窗口是什么时候被占的"。

7.4 自调用陷阱:this 调用不走代理

Spring AOP 基于代理实现,切面只对"通过代理对象发起的方法调用"生效。一个经典陷阱:

java 复制代码
@RepeatSubmit
public void submitOrder(OrderDTO dto) {
    // ...
    this.doSomething();  // ❌ this 调用绕过代理,切面不生效
}

this 指向原始对象而非代理对象,内部的 this.xxx() 调用不会经过切面。外部调用(Controller → Service 的代理引用)没问题,内部自调用会静默失效 。最稳妥的做法是:防重注解加在入口方法上(通常是 Controller 方法),而不是内部方法。

7.5 完整的切面逻辑

把上面所有点串起来,切面主体只有十几行:

java 复制代码
@Slf4j
@Aspect
@RequiredArgsConstructor
public class RepeatSubmitAspect {

    private final RedisService redisService;
    private final RepeatSubmitUserProvider userProvider;

    // 注意: 切入点不绑定注解实例(Spring 6.x 会启动报错, 详见 7.1 节),
    // 注解在方法内部手动解析
    @Around("@within(RepeatSubmit) || @annotation(RepeatSubmit)")
    public Object around(ProceedingJoinPoint point) throws Throwable {
        RepeatSubmit repeatSubmit = resolveAnnotation(point);
        if (repeatSubmit == null) {
            return point.proceed();
        }
        String key = buildKey(point, repeatSubmit);
        // value 存储占位时间戳, 便于排查防重窗口是什么时候被占的
        boolean firstSubmit = redisService.setIfAbsent(
                key, String.valueOf(System.currentTimeMillis()),
                repeatSubmit.interval(), repeatSubmit.timeUnit());
        if (!firstSubmit) {
            log.warn("检测到重复提交,请求被拦截,防重key: {}", key);
            throw new ServiceException(repeatSubmit.message());
        }
        return point.proceed();
    }

    /**
     * 解析防重注解:方法级优先,类级兜底。
     * 类级注解从 method.getDeclaringClass() 取:
     * CGLIB 代理子类不继承父类注解(@Inherited 缺失),从 target.getClass() 取会丢失
     */
    private RepeatSubmit resolveAnnotation(ProceedingJoinPoint point) {
        Method method = ((MethodSignature) point.getSignature()).getMethod();
        RepeatSubmit annotation = method.getAnnotation(RepeatSubmit.class);
        if (annotation == null) {
            annotation = method.getDeclaringClass().getAnnotation(RepeatSubmit.class);
        }
        return annotation;
    }

    /**
     * 生成防重 key:repeat:submit:{SHA-256(类#方法|URI|租户ID|用户维度|参数JSON)}
     */
    private String buildKey(ProceedingJoinPoint point, RepeatSubmit repeatSubmit) {
        MethodSignature signature = (MethodSignature) point.getSignature();
        Method method = signature.getMethod();
        String methodIdentity = method.getDeclaringClass().getName() + "#" + method.getName();

        HttpServletRequest request = RequestContext.getRequest();
        String uri = request != null ? request.getRequestURI() : "";
        String tenantId = request != null
                ? String.valueOf(RequestContext.getTenantId(false)) : "";
        String userKey = buildUserKey(repeatSubmit, request);
        String argsJson = buildArgsJson(point.getArgs());

        String raw = String.join("|", methodIdentity, uri, tenantId, userKey, argsJson);
        return KEY_PREFIX + sha256Hex(raw);
    }
    // buildUserKey / buildArgsJson / sha256Hex 见前文
}

八、自动装配与扩展点

8.1 通过 AutoConfiguration 零配置生效

组件不要求业务侧写任何 @Import,而是通过 Spring Boot 的自动装配机制注册。在 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 中声明配置类:

text 复制代码
com.example.web.configure.RepeatSubmitAutoConfiguration

配置类注册两个 Bean------切面本身,以及默认用户维度提供者:

java 复制代码
@AutoConfiguration
public class RepeatSubmitAutoConfiguration {

    /**
     * 默认用户维度提供者(匿名),业务侧注册同类型 Bean 时自动跳过
     */
    @Bean
    @ConditionalOnMissingBean(RepeatSubmitUserProvider.class)
    public RepeatSubmitUserProvider repeatSubmitUserProvider() {
        return new DefaultRepeatSubmitUserProvider();
    }

    @Bean
    public RepeatSubmitAspect repeatSubmitAspect(
            RedisService redisService, RepeatSubmitUserProvider userProvider) {
        return new RepeatSubmitAspect(redisService, userProvider);
    }
}

@ConditionalOnMissingBean 是这套设计的点睛之笔:默认实现"可用但不聪明",业务侧一注册自己的实现,默认实现自动退位。组件开箱即用,又给高级用法留了门。

8.2 完整接入示例

业务侧接入分两步:

  1. 引入组件依赖(Spring Boot Starter 自动装配,无需其他配置);
  2. 可选:注册 RepeatSubmitUserProvider 启用用户维度防重(第六章示例)。

之后在接口上贴注解即可:

java 复制代码
// 类级默认:所有写接口 10 秒防重
@RepeatSubmit
@RestController
@RequestMapping("/orders")
public class OrderController {

    // 方法级覆盖:下单窗口 5 秒,提示语更友好
    @RepeatSubmit(interval = 5, message = "订单提交过于频繁,请稍后再试")
    @PostMapping
    public Result<Long> create(@RequestBody @Valid OrderCreateDTO dto) { ... }

    // 查询接口不防重(未标注)
    @GetMapping("/{id}")
    public Result<OrderVO> detail(@PathVariable Long id) { ... }
}

九、全流程串联

把前面所有章节串起来,一次请求的完整旅程:
业务方法 Redis RepeatSubmitAspect Spring MVC 客户端 业务方法 Redis RepeatSubmitAspect Spring MVC 客户端 #mermaid-svg-OV7XvnV3wpHvm6Mp{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-OV7XvnV3wpHvm6Mp .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-OV7XvnV3wpHvm6Mp .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-OV7XvnV3wpHvm6Mp .error-icon{fill:#552222;}#mermaid-svg-OV7XvnV3wpHvm6Mp .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-OV7XvnV3wpHvm6Mp .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-OV7XvnV3wpHvm6Mp .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-OV7XvnV3wpHvm6Mp .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-OV7XvnV3wpHvm6Mp .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-OV7XvnV3wpHvm6Mp .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-OV7XvnV3wpHvm6Mp .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-OV7XvnV3wpHvm6Mp .marker{fill:#333333;stroke:#333333;}#mermaid-svg-OV7XvnV3wpHvm6Mp .marker.cross{stroke:#333333;}#mermaid-svg-OV7XvnV3wpHvm6Mp svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-OV7XvnV3wpHvm6Mp p{margin:0;}#mermaid-svg-OV7XvnV3wpHvm6Mp .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-OV7XvnV3wpHvm6Mp text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-OV7XvnV3wpHvm6Mp .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-OV7XvnV3wpHvm6Mp .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-OV7XvnV3wpHvm6Mp .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-OV7XvnV3wpHvm6Mp .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-OV7XvnV3wpHvm6Mp #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-OV7XvnV3wpHvm6Mp .sequenceNumber{fill:white;}#mermaid-svg-OV7XvnV3wpHvm6Mp #sequencenumber{fill:#333;}#mermaid-svg-OV7XvnV3wpHvm6Mp #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-OV7XvnV3wpHvm6Mp .messageText{fill:#333;stroke:none;}#mermaid-svg-OV7XvnV3wpHvm6Mp .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-OV7XvnV3wpHvm6Mp .labelText,#mermaid-svg-OV7XvnV3wpHvm6Mp .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-OV7XvnV3wpHvm6Mp .loopText,#mermaid-svg-OV7XvnV3wpHvm6Mp .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-OV7XvnV3wpHvm6Mp .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-OV7XvnV3wpHvm6Mp .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-OV7XvnV3wpHvm6Mp .noteText,#mermaid-svg-OV7XvnV3wpHvm6Mp .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-OV7XvnV3wpHvm6Mp .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-OV7XvnV3wpHvm6Mp .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-OV7XvnV3wpHvm6Mp .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-OV7XvnV3wpHvm6Mp .actorPopupMenu{position:absolute;}#mermaid-svg-OV7XvnV3wpHvm6Mp .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-OV7XvnV3wpHvm6Mp .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-OV7XvnV3wpHvm6Mp .actor-man circle,#mermaid-svg-OV7XvnV3wpHvm6Mp line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-OV7XvnV3wpHvm6Mp :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 校验失败 → 异常处理,不占用防重窗口 alt 写入成功(首次) 写入失败(重复) 提交请求 参数解析 + @Valid 校验 通过代理调用方法 生成摘要:方法|URI|租户|用户|参数JSON SHA-256 → repeat:submit:{digest} SET key 时间戳 NX EX interval true 放行,执行业务 业务结果 false 抛 ServiceException 统一错误响应(提示语)

核心流程一句话:摘要定 key,SETNX 占位,赢者放行,输者提示

十、设计取舍与边界

任何组件都是取舍的产物,这一章把防重组件的"软肋"和刻意的选择讲清楚。

10.1 业务失败后,key 保留到窗口结束

一个值得讨论的设计决策:业务执行失败后,防重 key 并不删除,而是保留到窗口自然过期

这意味着窗口内的语义是"只允许一次尝试",而不是"成功才占用"。选择这个语义的原因:

  • 防"失败风暴":接口故障时(如数据库抖动),如果失败就删 key,客户端/用户会疯狂重试,把故障接口打得更死;保留 key 等于天然的"熔断缓冲";
  • 实现简单可靠:删除 key 需要"业务成功后再删",但"业务成功"的判定时机(事务提交前/后?)很难界定,还引入额外的 Redis 操作。

代价是:用户业务失败后必须等窗口结束才能重试。所以提示语很重要------"请稍后再试"而不是"请重试"。

10.2 Redis 不可用:fail-closed

setIfAbsent 依赖 Redis,Redis 故障时调用会抛异常。实现没有捕获这个异常,即默认 fail-closed(宁可拒绝,不可放重)

这是一个刻意的安全取向:防重组件是"防守方",放重可能造成数据问题(两笔订单),拒绝最多造成一次请求失败,用户刷新重试即可。如果某些低危接口能接受"Redis 挂了就放行",可以自行捕获异常降级------但默认必须是拒绝。

10.3 防重 ≠ 幂等:数据库兜底不能省

再次强调第一章的区分,因为这是最容易产生误解的地方:

  • 防重只在窗口内生效,窗口外相同内容允许再次提交;
  • 如果业务要求"这笔单子永远只能成功一次 "(比如支付回调、退款申请),防重挡不住,必须靠幂等:数据库唯一索引、幂等表(幂等键 + 处理状态)、或分布式锁 + 状态机。

防重与幂等是分离的两个机制,本组件只做防重。 这是业界主流做法:防重组件(如 RuoYi 系列的 @RepeatSubmit)只负责"窗口内拒绝重复",不承担幂等职责;幂等由独立的机制承担------Stripe 的 Idempotency-Key(幂等键 + 结果存储)、支付宝/微信支付的商户订单号唯一约束、MQ 消费的去重表,都是与"防重注解"完全独立的实现。如果业务需要幂等,正确的做法是另外建设幂等机制(幂等表 + 结果存储,重试直接返回首次处理的结果),而不是在防重组件里混入客户端键逻辑------"窗口 + 客户端键"的组合既不完整(窗口过期后同键请求会重新执行),也把防重责任推给了不可控的调用方,两边语义都残缺。

正确的关系是叠加 :防重负责挡住 99% 的"手滑",幂等负责兜住剩下的 1% 的"重放"。上线防重组件后,该有的唯一约束一个都不能少

10.4 窗口大小怎么定

interval 默认 10 秒,实际业务要按场景调整:

  • 参考接口耗时:窗口至少要覆盖"一次完整提交"的耗时,一般取接口平均耗时的 2~3 倍;
  • 参考前端行为:前端请求超时时间、用户连点的间隔(通常 1~2 秒内);
  • 参考业务敏感度:下单、转账这类操作可以放宽到 5~10 秒,其余按业务重试节奏设定,并非越大越好。

窗口太小拦不住连点,窗口太大容易误伤"窗口内合法地连续提交相同内容"的场景(比如连扫两个相同条码入库)。

10.5 误伤风险清单

场景 表现 应对
NAT 共享 IP 同一出口的多个用户互相拦截 尽量启用用户ID维度
窗口内合法连续提交相同内容 第二次被拦截 收窄窗口 / 参数维度细化(如加序号)
未登录匿名接口 同 IP 用户互相拦截 includeUser = false 按内容防重

十一、总结

回顾整个设计,这个防重组件其实只做了四件事:

  1. 声明式接入:注解 + 切面,业务零侵入;
  2. 摘要定 key:五个维度哈希成定长 key,Map 规范化排序保证摘要稳定,判重逻辑完全由服务端掌控;
  3. SETNX 原子占位:一条 Redis 命令解决"查 + 写 + 过期"的全部竞态,value 中沉淀占位时间戳便于排查;
  4. SPI 留扩展:用户维度可插拔,默认降级链保证开箱即用。

而贯穿始终的设计原则是:防重是体验问题,幂等是正确性问题,两者分离、各司其职。防重组件挡得住"手滑",挡不住"重放";它让 99% 的重复提交死在门口,剩下 1% 交给独立建设的幂等机制(唯一约束、幂等表)去兜底------不在防重组件里混入幂等键逻辑,是保持两边语义完整的关键。

参考

相关推荐
阿里巴巴P8资深技术专家2 小时前
Redis连接工具 —— Redis Manager
redis
吠品2 小时前
Wine 在 Linux 上运行 Windows 软件完整指南
java·linux·服务器
亚历克斯神3 小时前
智能搜索系统的升级复盘——从 Elasticsearch 到混合检索的检索质量提升
java·spring·微服务
金銀銅鐵4 小时前
[Java] 一个方法最多可以有多少个入参?
java·jvm
小席是个热心肠4 小时前
Redis的自我学习
数据库·redis·学习
哭哭啼5 小时前
JAVA服务问题诊断
java·开发语言·jvm
Sayuanni%35 小时前
SpringBoot 从注解到源码:核心知识点总结
java·spring boot·后端
坚定信念,勇往无前5 小时前
Maven 私有仓库-nexus
java
Minner-Scrapy6 小时前
Scrapy 2.17 源码解析:Scheduler 调度器与磁盘/内存双队列
java·爬虫·python·scrapy·网络爬虫·twisted
晚风醉蝶6 小时前
1-11-奇偶排序-OddEvenSort
java·数据结构·算法