我知道很多人看到“Spring Boot接口防抖”“防重复提交”“接口幂等性”这几个词,第一反应是:这不就是加个 Redis 锁嘛,有什么好写的。但我在实际项目里把这套东西完整做过一轮之后,发现坑远比想象中多:请求体读不到、参数签名不一致、异常被吞、并发压测下误杀率居高不下。这篇文章把我从需求分析、方案选型到代码落地、压测收尾的完整过程捋一遍,重点讲清楚每一步为什么这么做,以及哪些地方最容易翻车。
1. 先把对手看明白:防抖、防重和幂等到底在解决哪些问题
1.1 一次生产事故让我重新审视重复请求
那次事故的场景很简单:前端表单页在用户点击“提交订单”后,按钮没有做状态锁,用户网络有点卡,手快连点了两下。两条一模一样的请求几乎同时到达服务端,接口里也没有做任何保护,于是数据库里插入了两条完全相同的订单记录。后面财务对账的时候发现了这条重复订单,我花了一个下午加一个晚上去补数据、写脚本清理、给用户道歉。排查日志更扎心:两条请求的时间差只有 32 毫秒,日志里长得一模一样,唯一不同的是请求的 traceId。
这件事之后我认真想了一个问题:我们天天说“接口防抖”“防重复提交”“接口幂等”,它们真的是同一个东西吗?实际处理的时候又该怎么区分?
1.2 三类需求不能混为一谈
我把日常开发里遇到的需求归纳成三类:
| 需求名称 | 典型触发场景 | 核心目标 | 处理维度 |
|---|---|---|---|
| 防抖 | 用户连续点击按钮、双击提交 | 短时间内自动忽略重复触发 | 时间窗口 + 请求指纹 |
| 防重复提交 | 同一条请求在短时间或系统异常后再次进入服务端 | 同一业务请求只会被处理一次 | 请求唯一标识 + 服务端原子拦截 |
| 接口幂等 | 网络重试、消息队列重投、第三方回调重复通知 | 无论执行多少次,结果和状态都保持一致 | 业务语义 + 存储层约束 + 状态流转控制 |
简单说,防抖是“不让多余请求进来”,防重复提交是“重复请求进来也拦得住”,幂等则是“就算请求真的被重复执行了,系统也不会出问题”。
这三者的关系是层层递进的。防重和防抖负责把可见的重复请求挡在门外,幂等性负责在更底层的机制层面兜底。我在后面的设计里是两套都做了:入口处用 Redis 做防重复提交拦截,业务数据层面再用唯一约束和状态字段保证幂等。很多团队只做了前者就觉得万事大吉,结果遇到 MQ 重投或者第三方回调重试,照样出现重复数据。
1.3 重复请求是怎么产生的
搞清楚对手是谁,还要知道它是怎么来的,否则方案容易设计过头或者漏掉场景。结合我的经历,重复请求主要来源有四个:
- 用户交互产生的重复:手抖双击、按钮未置灰、移动端弱网导致前端重发。这类请求通常间隔极短,也就是人所说的“毫秒级尖峰”;
- 网关或代理层重试:Nginx、Spring Cloud Gateway 这些组件在超时后可能自动重发请求;
- MQ 消息重投:RocketMQ 默认至少一次语义,消费者处理完还没来得及提交 offset 就挂了,几分钟后消息会重新投递;
- 第三方回调重试:支付结果通知、短信状态回执,回调方往往有自己的重试策略,短则几秒,长则数天。
不同来源对“重复”的定义不一样。比如支付回调,回调方重试时可能请求体完全一致,也可能携带不同的回调标识。如果是消息重投,业务请求的幂等键往往需要自己从消息体里提取。所以我在做设计时不会只锚定“一个接口 + 固定参数”这种简单模型,而是要求业务方能够显式提供幂等键,或者系统能从参数中提取一个稳定唯一字段。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 防重复提交的方案选型:为什么最终落在 Redis 原子操作上
2.1 单体内存锁的局限
最早我在小项目里是用 ConcurrentHashMap 加锁来做防重的,实现非常简单,但只限于单机。现在稍微像样点的服务基本都多实例部署,用户第一次请求打到 A 实例,第二次请求打到 B 实例,内存锁就完全失效了。也有人说可以用数据库唯一索引做,但这会把“拦截重复请求”的动作变成“数据库发现冲突”,依赖报错来做业务判断,体验和语义都不太对,而且高并发下数据库压力很大。
Redis 的优势在于三点:跨实例共享状态、单线程执行命令天然具备原子性、SETNX 配合过期时间可以很优雅地处理“一定时间内只允许一次成功”。我选型时基本没有太多犹豫,直接用 Redis。
2.2 锁、布隆过滤器还是请求指纹
定下来用 Redis,还有个前置问题需要想清楚:Redis 里面到底存什么?怎么判断两个请求是不是重复的?
我见过有团队直接用“用户 ID + 接口路径”做 key,这样用户 5 秒内只能调用一次接口,不管请求参数是什么。这种做法对并发很粗暴,会把同一用户并发调用两个不同参数的合法请求也误杀。例如用户同时提交两个不同地址的订单,理论上两个请求都该成功,直接按用户维度限流就不合理了。
我的做法是按“请求指纹”判断,也就是把接口路径、关键参数组合成一个唯一标识。只有路径和参数完全一致,或者命中了业务方显式提供的幂等键,才会被判定为重复请求。第一步先做参数签名:
- 从请求中取参数,包括 GET 的 query string 和 POST 的 form/body;
- 对参数进行排序、拼接,过滤掉无意义的时间戳字段;
- 对拼好的字符串做 SHA-256,得到 64 位十六进制指纹;
- 最终 Redis key 结构固定为
no_repeat:{userId}:{method}:{fingerprint}。
这么做的好处是通用性高,每个接口可以直接复用,不要求业务方额外做改动。坏处是如果两个请求参数中存在随机数,比如每次前端都生成一个 uuid 放在请求头里,那指纹就完全对不上了。所以到后面我在设计里做了一层抽象:优先使用业务方指定的幂等字段,拿不到再从参数中生成指纹,比较灵活。
2.3 选择 SETNX 还是 Lua 脚本
Redis 防重最常见的命令是 SET key value NX EX timeout。这个命令表示:只有当 key 不存在的时候才写入,并且设置过期时间,写入成功就说明当前
