文章目录
- 防重复提交组件:从"双击下了两单"说起
-
- 一、问题:重复提交从哪来
-
- [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% 的"正常用户双击",但存在几个硬伤:
- 刷新即失效:请求发出后页面刷新,按钮状态丢失,用户可以立刻再提交一次;
- 多标签页/多设备:A 标签页提交后,B 标签页的状态是独立的,照提不误;
- 前端不可信:绕过前端直接调接口,置灰逻辑形同虚设;
- 网络层重试:请求已经发出去了,前端按钮置灰管不到网关或客户端的自动重试。
所以,防重必须做在后端,而且要做到"业务代码无感知"------最好是在接口上贴一个注解就完事。
三、从单机到分布式:为什么需要 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 设计目标
动手实现前,先明确组件要满足的约束:
- 声明式:业务侧只加注解,不写任何防重代码;
- 分布式:多实例共享 Redis,天然跨节点生效;
- 可配置:防重窗口大小、提示语、是否按用户维度防重均可调;
- 统一出口:拦截后走全局异常体系,返回统一的响应结构,业务侧无需关心;
- 可扩展:用户维度的获取方式(登录方案)可替换,组件本身不绑定任何认证框架。
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。但有三个问题:
- 长度失控:请求体可能很大(长文本、大列表),拼出来可能几 KB 甚至更大,Redis key 又长又丑还浪费内存;
- 敏感信息落盘:参数里可能有手机号、身份证、业务数据,明文写进 Redis 既不安全,也不利于问题排查;
- 可读性差 :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 容器类型不能参与摘要
HttpServletRequest、HttpServletResponse、MultipartFile、BindingResult、HttpSession......这些对象要么不可序列化(直接抛异常),要么每次请求的实例都不同(序列化结果每次都不一样,会制造"假重复"------同一个请求两次进来摘要不同,防重失效)。所以实现里维护了一个"忽略清单":
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 为什么需要用户维度
如果摘要里只有"方法 + 参数",会同时产生两个问题:
- 误伤他人:用户 A 提交了订单,窗口内用户 B 提交了一模一样的订单,被当成重复拦截;
- 漏拦同人:同一个用户开了两个标签页(两个会话、两个 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;
}
两个细节值得注意:
- 类级注解要从
method.getDeclaringClass()取,而不是point.getTarget().getClass():Spring AOP 默认用 CGLIB 生成代理子类,注解没有@Inherited时子类上取不到父类注解,而getDeclaringClass()返回的是声明该方法的原始类; - 手动解析天然实现"方法级优先":先查方法注解,没有再查类注解,优先级语义一目了然,且不依赖 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();
理由有三:
- 切面不感知响应协议 :响应结构(
Result<T>、错误码、国际化文案)是业务层/框架层的事,切面直接写响应会侵入响应格式; - 复用全局异常体系 :
ServiceException由全局异常处理器统一转换为标准错误响应,业务侧无需新增任何代码; - 行为可追踪: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 完整接入示例
业务侧接入分两步:
- 引入组件依赖(Spring Boot Starter 自动装配,无需其他配置);
- 可选:注册
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 按内容防重 |
十一、总结
回顾整个设计,这个防重组件其实只做了四件事:
- 声明式接入:注解 + 切面,业务零侵入;
- 摘要定 key:五个维度哈希成定长 key,Map 规范化排序保证摘要稳定,判重逻辑完全由服务端掌控;
- SETNX 原子占位:一条 Redis 命令解决"查 + 写 + 过期"的全部竞态,value 中沉淀占位时间戳便于排查;
- SPI 留扩展:用户维度可插拔,默认降级链保证开箱即用。
而贯穿始终的设计原则是:防重是体验问题,幂等是正确性问题,两者分离、各司其职。防重组件挡得住"手滑",挡不住"重放";它让 99% 的重复提交死在门口,剩下 1% 交给独立建设的幂等机制(唯一约束、幂等表)去兜底------不在防重组件里混入幂等键逻辑,是保持两边语义完整的关键。
参考
- Redis SET 命令文档(NX / EX 原子语义)
- Spring AOP 文档(切入点指示符、绑定)
- Spring Boot Auto-configuration 文档(
AutoConfiguration.imports、@ConditionalOnMissingBean) - Spring MVC Argument Resolution(参数校验时机)