前阵子线上出了个让我印象很深的故障:某个订单接口在高峰期被连续提交了两次,结果数据库里多出一条重复订单,还连带着把库存给超卖了。排查到最后发现,前端按钮确实做了 loading 防重,但请求在弱网环境超时后,小程序框架层自动重试了一次,这次重试完全绕过了页面限制,直接打到后端。从那天起我就意识到,接口重复提交这件事,不能只靠前端,后端自己必须有一套防重机制。
这篇文章把我实际落地的基于 Redis 的防重复提交方案完整整理出来,从场景分析、方案选型、核心原理,到 Spring Boot 里的代码实现、实测踩坑,再到从"防重"走向"幂等"的进阶思路。适合正在做交易、支付、库存、表单提交类后端接口的同学参考。我会把关键命令、代码和设计取舍都讲清楚,你可以直接照着抄去用。
1. 接口重复提交的真实场景与代价
1.1 按钮禁用本身就不够用
很多人以为接口重复提交只是"用户手快点多了一下",前端把按钮禁用就万事大吉。但我在实际项目里遇到的情况要复杂得多,至少有这么几类:
- 用户双击、连点,特别是移动端页面卡顿、点击事件延迟时,一次点击会被识别成多次。
- 网络超时后,前端框架或网关层自动重试。像上面提到的框架级重试,前端根本控制不了。
- 客户端和服务端之间用了消息队列做削峰时,消息重复投递导致同一个业务被重复执行。
- 负载均衡或注册中心在节点切换、服务重启时,把未完成的请求重新分发一次。
按钮禁用能挡住"正常的用户连点",但挡不住后面的所有情况。真正要靠的还是后端能做"这次请求是不是重复的"判断。
1.2 重复数据会带来哪些连锁反应
重复提交最直观的代价是脏数据,但脏数据的连锁反应往往更严重:
- 订单系统里出现两条相同订单,用户实际只下单一次,却收到两次扣款通知。
- 库存系统里同一件商品被扣两次,实际库存小于记录库存,盘点对不上账。
- 营销活动里同一个用户对同一个活动领取了两次权益,预算超发。
- 积分、优惠券、抽奖这类接口,更是一次重复提交直接造成真金白银的损失。
这类问题在测试环境很难发现,因为测试数据量小、并发低,但一旦线上遇到,都是要拉会议复盘的事故级别问题。所以我现在做接口设计时,防重几乎是默认要求,而不是可选项。
1.3 先分清防重、幂等与限流
在动手写代码前,我建议先把三个词理清楚,因为它们经常被混在一起:
- 防重:在请求入口做拦截,同一窗口期内同一个请求只放行一次。核心是"阻止第二次进来"。
- 幂等:业务层面允许请求重复执行,但结果保持一致。核心是"即使进来很多次,也只生效一次"。
- 限流:在单位时间内限制请求总量,保护后端不过载。核心是"控制流量水位"。
三者的目标和实现方式都不同。防重通常用短时间窗口 + 同一标识判断,幂等要依赖业务唯一键和存储层约束,限流多用计数器或令牌桶。后面我会讲到,一套成熟的方案里往往是三者配合,而不是只做其中一个。
2. 防重方案选型:为什么最终选了Redis
2.1 四个方案的横向对比
防重这件事有很多技术路线,我在不同项目里都试过,这里直接给出一份我自己整理的对比表。
| 方案 | 实现思路 | 优点 | 缺点 |
|---|---|---|---|
| 前端按钮禁用 | 提交后置灰、loading | 最简单、体验直接 | 拦不住非浏览器端和框架重试 |
| 本地锁(synchronized / ReentrantLock) | 单机内存锁 | 零依赖、性能极高 | 集群多实例时各自为政,没有集中协调 |
| 数据库唯一约束 | 业务唯一键建唯一索引 | 最可靠,天然幂等 | 侵入性强、需要调整表结构、高并发下压力大 |
| Redis SETNX | 分布式占位,带过期时间 | 原子、性能好、支持集群 | 依赖Redis可用性,Redis挂掉时防重失效 |
从表里能看出来,前端按钮是辅助,本地锁在分布式环境下不成立,数据库唯一约束适合做最终兜底,但作为唯一的防重手段太重了。Redis方案在性能和分布式协调之间找到了比较合适的平衡点。
2.2 Redis方案的优势与边界
Redis 能成为防重的默认选择,核心原因有三个:
- SETNX 命令天然自带原子性,
SET key value NX EX是单条命令,Redis 单线程执行,并发下不会出现"先检查后设置"的竞态问题。 - 键可以设置过期时间,天然适合"短时间窗口内防重"的语义,不需要自己维护定时清理。
- 分布式环境下所有实例访问同一个 Redis,可以做到全局一致。
但它的边界我也要说清楚:Redis 是实现"防重"的高效工具,不是"幂等"的最终保障。Redis 宕机或者网络抖动时,防重会失效,所以严谨的架构里,Redis 防重只是第一道防线,数据库唯一键和对账任务才是兜底。这个后面详细展开。
3. 防重设计的核心原理:它不是一把普通锁
3.1 请求唯一标识:token与指纹两条路线
用 Redis 做防重,第一步是想清楚"怎么判断两次请求是同一个请求"。常见的有两条路线。
token 模式(一次性票据):前端进入页面时,向后端申请一个一次性 token,后端把 token 存到 Redis 并返回给前端。提交时前端把 token 带上,后端消费这个 token——消费成功就放行,消费失败就拒绝。这个模式对用户最友好,适合 H5、小程序、Web 等有前端页面的场景。
fingerprint 模式(请求指纹):后端根据请求的方法、URL、关键参数和当前用户,生成一个唯一指纹,然后判断这个指纹是否在窗口期内出现过。这个模式不需要前端配合,适合开放 API、服务间调用、回调通知等没有页面的场景。
我在大多数项目里是两种都保留,页面类接口用 token,原因是有明确的一次性消费语义;服务间调用用 fingerprint,因为它零接入成本。
3.2 SET NX EX:为什么必须一步到位
防重占位在最开始很容易写成"先 GET 判断,再 SET 写入":
// 错误写法:先检查再写入,并发下必然有漏洞 if (Boolean.FALSE.equals(redisTemplate.hasKey(key))) { redisTemplate.opsForValue().set(key, value, Duration.ofSeconds(3)); }两个请求同时执行到 hasKey,都发现 Redis 里没有 key,于是都走到 set,都被放行。这个写法的本质问题是检查与写入不是原子操作。
正确姿势是用 Redis 的单命令:
SET key value NX EX 3- NX:只有 key 不存在时才写入,存在则不做任何事。
- EX:设置过期时间,防止 key 永远占用。
- 整个命令在 Redis 内部是串行执行的,天然原子。
对应到 Spring Boot 的StringRedisTemplate就是:
Boolean success = redisTemplate.opsForValue() .setIfAbsent(key, value, 3, TimeUnit.SECONDS);这个setIfAbsent返回值就是"我是不是占位成功的那个",返回 true 放行,返回 false 说明同一个 key 在窗口期内已经被别人占过,直接拒绝。
3.3 释放与消费:也要讲原子性
占位要原子,释放更不能大意。常见的错误是:
// 错误写法:先取值,再比较,最后删除 —— 三步之间会有并发间隙 String cached = redisTemplate.opsForValue().get(key); if (expected.equals(cached)) { redisTemplate.delete(key); }假设请求 A 执行完毕,读到值等于 expected,准备删除。此时请求 B 已经占位成功(A 的 key 到期后 B 写入新值)。A 继续执行 delete,把 B 的锁删掉了,B 的防重瞬间失效。所以"比较并删除"必须是一个原子操作。
用 Lua 脚本可以完美解决:
if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) end return 0这个脚本既用于 fingerprint 模式下释放占位锁,也用于 token 模式下"检测并消费"一次性票据。一个脚本解决两个场景,后面代码里我会直接用它。
4. 完整落地实现:基于注解与AOP的Spring Boot实践
4.1 工程依赖与整体结构
我用的是 Spring Boot 2.x + Redis 的老组合,依赖只要两个:
<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>项目结构上我习惯分成这样几部分:
- annotation 包:定义
@NoRepeatSubmit注解。 - aspect 包:定义防重切面。
- handler 包:定义业务异常
RepeatSubmitException。 - controller 包:提供 token 获取接口和业务演示接口。
4.2 自定义注解:让接入成本降到最低
用注解 + AOP 的好处是业务代码零侵入,在接口方法上标一行就能生效:
package com.example.repeat.annotation; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; import java.util.concurrent.TimeUnit; /** * 接口防重复提交注解 */ @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface NoRepeatSubmit { /** * 防重模式:FINGERPRINT 请求指纹模式;TOKEN 一次性票据模式 */ KeyMode keyMode() default KeyMode.FINGERPRINT; /** * 防重窗口 */ long interval() default 3000L; /** * 窗口时间单位 */ TimeUnit timeUnit() default TimeUnit.MILLISECONDS; /** * 被拦截时的提示语 */ String message() default "提交太频繁,请稍后再试"; }配套的枚举:
package com.example.repeat.annotation; public enum KeyMode { FINGERPRINT, TOKEN }这样使用者只需要关心三个参数:用什么模式、窗口多长、提示语是什么。默认值保证最懒的用法也不会出事。
4.3 切面核心逻辑:拦截、占位、放行、清理
切面是整套方案的心脏,完整代码我贴在这里:
package com.example.repeat.aspect; import com.example.repeat.annotation.KeyMode; import com.example.repeat.annotation.NoRepeatSubmit; import com.example.repeat.exception.RepeatSubmitException; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.reflect.MethodSignature; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.TreeMap; import java.util.UUID; import java.util.concurrent.TimeUnit; @Aspect @Component public class NoRepeatSubmitAspect { /** * 比较并删除的 Lua 脚本,保证释放锁和消费 token 的原子性 */ private static final String RELEASE_LUA = "if redis.call('get', KEYS[1]) == ARGV[1] then " + "return redis.call('del', KEYS[1]) " + "else return 0 end"; @Autowired private StringRedisTemplate stringRedisTemplate; private final ObjectMapper objectMapper = new ObjectMapper(); @Around("@annotation(noRepeatSubmit)") public Object around(ProceedingJoinPoint joinPoint, NoRepeatSubmit noRepeatSubmit) throws Throwable { String key = buildKey(joinPoint, noRepeatSubmit.keyMode()); String value = UUID.randomUUID().toString(); boolean admitted; if (noRepeatSubmit.keyMode() == KeyMode.TOKEN) { // token 是一次性票据:值匹配则删除(消费),只允许消费成功一次 admitted = consumeToken(key); } else { // fingerprint 模式:相同指纹在窗口内只允许占位成功一次 admitted = Boolean.TRUE.equals(stringRedisTemplate.opsForValue() .setIfAbsent(key, value, noRepeatSubmit.interval(), noRepeatSubmit.timeUnit())); } if (!admitted) { throw new RepeatSubmitException(noRepeatSubmit.message()); } try { return joinPoint.proceed(); } finally { if (noRepeatSubmit.keyMode() == KeyMode.FINGERPRINT) { releaseLock(key, value); } } } private String buildKey(ProceedingJoinPoint joinPoint, KeyMode keyMode) { MethodSignature signature = (MethodSignature) joinPoint.getSignature(); String clazz = joinPoint.getTarget().getClass().getName(); String method = signature.getMethod().getName(); String userId = currentUserId() == null ? "anonymous" : currentUserId(); if (keyMode == KeyMode.TOKEN) { String token = obtainTokenFromRequest(); return "repeat:submit:token:" + userId + ":" + token; } String fingerprint = md5(buildFingerprint(joinPoint.getArgs())); return "repeat:submit:fp:" + userId + ":" + clazz + "." + method + ":" + fingerprint; } private String buildFingerprint(Object[] args) { if (args == null || args.length == 0) { return "empty"; } try { List<Object> normalizedArgs = new ArrayList<>(); for (Object arg : args) { if (arg instanceof HttpServletRequest || arg instanceof HttpServletResponse) { continue; } normalizedArgs.add(normalize(arg)); } return objectMapper.writeValueAsString(normalizedArgs); } catch (JsonProcessingException e) { throw new IllegalStateException("序列化请求参数失败", e); } } private Object normalize(Object arg) { if (arg instanceof Map) { Map<?, ?> rawMap = (Map<?, ?>) arg; TreeMap<String, Object> sortedMap = new TreeMap<>(); for (Map.Entry<?, ?> entry : rawMap.entrySet()) { sortedMap.put(String.valueOf(entry.getKey()), entry.getValue()); } return sortedMap; } return arg; } private String md5(String content) { try { MessageDigest digest = MessageDigest.getInstance("MD5"); byte[] bytes = digest.digest(content.getBytes(StandardCharsets.UTF_8)); StringBuilder hex = new StringBuilder(); for (byte b : bytes) { hex.append(String.format("%02x", b)); } return hex.toString(); } catch (Exception e) { throw new IllegalStateException("MD5 计算失败", e); } } private void releaseLock(String key, String value) { stringRedisTemplate.execute( new DefaultRedisScript<>(RELEASE_LUA, Long.class), List.of(key), value); } private boolean consumeToken(String key) { Long result = stringRedisTemplate.execute( new DefaultRedisScript<>(RELEASE_LUA, Long.class), List.of(key), "unused"); return Long.valueOf(1L).equals(result); } private String obtainTokenFromRequest() { ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attributes == null) { return "anonymous"; } HttpServletRequest request = attributes.getRequest(); String token = request.getHeader("Repeat-Token"); if (StringUtils.hasText(token)) { return token; } return request.getParameter("repeatToken"); } private String currentUserId() { ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attributes == null) { return null; } HttpServletRequest request = attributes.getRequest(); String userId = request.getHeader("X-User-Id"); return StringUtils.hasText(userId) ? userId : request.getParameter("userId"); } }这里有几个我认为比较关键的设计决定,单独说明:
buildKey一定带上用户 ID,否则两个不同用户对同一个接口发相同的请求会被互相拦截,这是最常见的误伤。- fingerprint 的 MD5 内容会过滤掉
HttpServletRequest这类非业务参数,避免把框架对象序列化进指纹。 - 释放锁和消费 token 共用同一个 Lua 脚本,但语义方向相反:指纹模式是"我的占位到期了我自己删",token 模式是"这个票据本来就要被消费掉"。
4.4 token 模式与 fingerprint 模式的差异
这个差异我在前文提过,但实现之后更明显:指纹模式是"窗口期内防重,执行完立即释放窗口",适合拦截"瞬时重复点击";token 模式是"一次性票据,消费后就作废",它的生命周期更长,语义也更严格。
所以 token 模式需要配合一个生成接口:
@RestController @RequestMapping("/repeat") public class TokenController { @Autowired private StringRedisTemplate stringRedisTemplate; @GetMapping("/token") public Map<String, String> acquireToken() { String token = UUID.randomUUID().toString().replace("-", ""); // 票据默认 60 秒内有效,值固定为 unused,等待被 AOP 消费 stringRedisTemplate.opsForValue().set("repeat:token:" + token, "unused", Duration.ofSeconds(60)); return Map.of("repeatToken", token); } }流程是这样的:
- 前端进入页面时调用
/repeat/token,拿到 token。 - 提交业务请求时把 token 放在
Repeat-Token请求头里。 - 后端 AOP 对
repeat:submit:token:{userId}:{token}执行 Lua 脚本,值等于 unused 就删除并放行;值不存在或已被消费就拒绝。
业务接口上只需要标注:
@PostMapping("/order") @NoRepeatSubmit(keyMode = KeyMode.TOKEN, interval = 60, timeUnit = TimeUnit.SECONDS) public Result<?> createOrder(@RequestBody OrderRequest request) { // 业务逻辑 }注意 token 模式下 interval 是票据本身的存活期,一般建议和生成票据时的过期时间保持一致,我这里统一用 60 秒。用户在这个窗口内没有提交,票据自动失效,重新进入页面再申请一个就行。
4.5 前端怎么配合
前端要做的事其实不多,就两条:
- 进页面拉一次 token,存到变量里。
- 提交请求时把 token 放到 header。
async function fetchToken() { const res = await axios.get('/repeat/token'); window.repeatToken = res.data.repeatToken; } async function submitOrder() { await axios.post('/order', payload, { headers: { 'Repeat-Token': window.repeatToken } }); }如果后端返回"提交太频繁"的异常,前端弹一个轻提示就行,不需要再做额外处理。真正值得提醒的是:前端拿到新 token 的时机,应该在每次提交完成之后(无论成功还是失败),而不是只在页面加载时。否则用户第一次提交成功但网络回了错误提示,再次提交时用的还是已经消费掉的 token,会被后端直接拦截。
5. 实测中踩过的坑与排查推荐思路
5.1 指纹不一致导致防重直接失效
指纹模式上线后,我第一次压测就发现防重一点用都没有:同一笔请求连发多次,Redis 里的 key 每次都不同。排查下来是请求参数里带了一个traceId,每次请求都会重新生成,导致指纹每次都不一样。
这个问题的本质是:指纹里混入了"每次必然变化"的字段。除了 traceId,还有时间戳、随机数、Token 之类的字段,凡是跟业务语义无关的参数,都应该在生成指纹前剔除。
我的做法是在buildFingerprint里增加一个忽略列表:
private static final Set<String> IGNORED_FIELDS = Set.of("traceId", "timestamp", "random", "sign"); private Object normalize(Object arg) { if (arg instanceof Map) { TreeMap<String, Object> sortedMap = new TreeMap<>(); Map<?, ?> rawMap = (Map<?, ?>) arg; for (Map.Entry<?, ?> entry : rawMap.entrySet()) { String fieldName = String.valueOf(entry.getKey()); if (IGNORED_FIELDS.contains(fieldName)) { continue; } sortedMap.put(fieldName, entry.getValue()); } return sortedMap; } return arg; }另一个坑是 Map 序列化顺序问题。同一个参数对象用 HashMap 装,两次请求的 key 顺序可能不同,导致 JSON 字符串不同。用 TreeMap 排序后再序列化,能保证相同内容永远生成相同的字符串。
5.2 窗口时间设置:太短和太长都不行
窗口时间看似只是一个数字,但设错会产生两种完全相反的问题。
设得太短:第一次请求还没处理完,窗口就过期了,第二次请求进来时 key 不存在,又被放行。我有一次把创建订单接口的窗口设成 2 秒,结果接口平均耗时就有 3 秒,防重形同虚设。
设得太长:用户第一次提交成功后马上再点一次,会被当成重复请求拒绝。有一种情况特别容易引发投诉:网络波动导致前端没收到成功响应,用户重试,结果一直被拦,直到窗口过期。
我的经验是:窗口时间至少要覆盖"接口最长耗时 + 合理的网络重试间隔",通常取接口 P99 耗时的 2~3 倍,再留 1~2 秒缓冲。如果接口本身要做异步处理(先接单、后处理),窗口还要覆盖异步链路的总时长,否则这个方案就不适用,需要考虑换成幂等令牌或者状态机。
5.3 误删他人锁:为什么必须用Lua
这个坑我在原理部分讲过,但在实测中真真切切遇到过一次,场景是这样的:
请求 A 的指纹 key 在 Redis 里占位,A 执行业务比较慢,超过了窗口时间,key 过期。请求 B 带着相同的指纹进来,占位成功。此时 A 处理完毕,finally 块执行释放逻辑。如果释放逻辑是redisTemplate.delete(key),就会把 B 的占位删掉。紧接着请求 C 又进来了,占位成功放行——防重被击穿。
我当时用的还是"先 get 再判断再 delete"的写法,在并发测试里偶发出现。改成 Lua 之后击穿现象彻底消失。所以这里再强调一次:释放占位时,必须比对 value 并原子删除,这个不能偷懒。
5.4 注解不生效:代理失效排查链路
还有一个常见问题:注解标了,切面也写了,但请求进来时完全没有走防重逻辑。我遇到过两种情况:
第一种是同一个类内部方法调用。比如 Controller 里的方法 A 调用了同类的私有方法 B,B 上标了@NoRepeatSubmit,但 Spring AOP 基于代理对象,内部调用走的是this引用,不经过代理,注解自然不生效。
第二种是方法不是 public。Spring AOP 默认对 public 方法织入切面,非 public 方法不会生效。
排查链路我整理成一个清单:
- 确认注解在 public 方法上。
- 确认调用方注入的是 Spring 管理的 Bean 代理,而不是
new出来的对象。 - 确认
spring-boot-starter-aop已引入,切面类上有@Aspect和@Component。 - 在切面方法里临时打日志,看有没有进
around。 - 如果方法是类内部互相调用,就拆到另一个 Spring Bean 里,或者注入
AopContext.currentProxy()来调用。
这个坑不影响主流程,但很隐蔽,排查起来挺磨人的。
5.5 Redis抖动时怎么办:降级而非阻断
防重方案依赖 Redis,Redis 一旦不可用,如果防重逻辑直接抛异常,所有业务接口都会被拖垮。这是绝对不能接受的。
我的做法是对 Redis 操作做降级处理:
private boolean tryAcquire(String key, String value, Duration duration) { try { return Boolean.TRUE.equals(stringRedisTemplate.opsForValue() .setIfAbsent(key, value, duration)); } catch (Exception e) { // 记录告警日志,立即放行 log.error("Redis 不可用,防重降级放行", e); return true; } }降级的原则是:防重是锦上添花,不能成为业务可用性的短板。Redis 抖动时可以暂时失去防重能力,但不能让用户提交不了订单。真正的兜底交给数据库唯一键和后续的对账任务,而不是在入口处把服务打死。
如果要更稳一点,还可以加上本地兜底:用一个带过期时间的 ConcurrentHashMap 记录近几秒的请求指纹。但要注意,这只在单实例内有效,多实例环境下不能作为统一防重手段,只能作为降级时的临时补充。
6. 从防重到幂等的进阶思考
6.1 数据库唯一键:真正兜底的最后一道防线
Redis 防重能挡住绝大多数重复,但它依赖 Redis 可用性、网络稳定性、以及代码逻辑本身的正确性。任何一环出问题,重复请求都可能漏进来。所以对于核心资金、库存类业务,我都会再补一层数据库唯一键兜底。
比如创建订单接口,可以在订单表加一个业务唯一键order_biz_id,这个 ID 在业务开始时生成,插入时如果唯一索引冲突,说明这条业务已经被创建过,直接返回已存在的订单信息,不再重复创建。
ALTER TABLE t_order ADD UNIQUE KEY uk_biz_id (biz_id);唯一索引撞了之后,代码里捕获DuplicateKeyException,查一次已有数据返回即可。虽然侵入性比 Redis 方案强,但它是"即使所有前置防重都失效,数据也不会脏"的最终保证。
6.2 与限流结合:防重挡单次重复,限流挡整体洪峰
防重和限流经常被放在一起聊,但解决的问题不一样:防重针对"同一次请求的重复",限流针对"不同请求的洪峰"。遇到秒杀、抢购类接口,光有防重是不够的,因为即使每个用户只提交一次,总体流量也可能把后端打爆。
Redis 做简单固定窗口限流非常方便:
public boolean rateLimit(String key, int limit, Duration duration) { Long count = redisTemplate.opsForValue().increment(key); if (count == 1) { redisTemplate.expire(key, duration); } return count <= limit; }生产环境一般建议用更平滑的令牌桶或滑动窗口,但固定窗口配合合理的阈值在大多数业务里已经够用了。我在实践中更喜欢把防重和限流分开:防重用SET NX EX占位,限流用INCR + EXPIRE计数,两个中间件逻辑互不干扰,出了问题也容易排查。
6.3 状态机与对账:从入口控制走向结果校验
防重是入口控制,但入口控制永远不是 100% 完备的。真正成熟的系统,会把最终一致性作为底牌。
以订单为例,订单状态应该是一个状态机:创建 -> 待支付 -> 已支付 -> 已完成。重复支付回调进来时,直接判断当前状态,如果已经是已支付,直接忽略这次回调。
UPDATE t_order SET status = 'PAID', pay_time = NOW() WHERE order_id = #{orderId} AND status = 'UNPAID'这种update ... where 条件的写法,可以根据影响行数判断这次更新是否真的发生。影响行数为 0 就说明状态不满足,重复回调直接被语义化规避。
最后还有一道保险:定时对账任务。比如每 5 分钟扫一次"支付成功但订单状态未更新"的数据,主动触发补偿。在非常关键的场景里,入口防重、业务幂等、状态机、对账任务四层叠加,才算真正闭环。
我在实际项目里一般默认采用三层防御:Redis 防重拦截绝大多数重复提交,数据库唯一键做业务幂等兜底,对账任务处理一切漏网之鱼。这套组合跑了一年多,线上几乎没有再出现过因为重复提交产生的脏数据。如果你也在设计这类接口,我的建议是想清楚自己的核心诉求:只是拦截用户短时间内的连点,用 fingerprint 占位就够了;如果是资金、库存这类绝不能重复执行的接口,那就要把幂等设计和数据库约束一起做进去,防重只是其中一个环节。