1. 从一个真实的翻车现场说起去年帮一个做电商的朋友排查线上问题,凌晨两点被电话叫醒:后台订单列表里出现了大量重复记录,同一个用户、同一个商品、同一个收货地址,连续下了十几单,而且每单都扣了库存。第一反应是用户恶意刷单,查了日志才发现,用户只是手抖点了两下提交按钮,加上当时网络抖动,前端请求重试机制又补了一刀,后端接口没有任何防护,来一个请求就落一条订单。这个场景就是典型的表单重复提交问题。它不是什么高深的安全漏洞,但杀伤力一点不小:重复下单、重复扣款、重复发券、重复提交工单,轻则数据脏乱,重则真金白银的损失。很多团队在业务初期根本不会考虑这件事,等到出事了才回头补,而补的时候往往又补得不对——加个前端按钮置灰就以为万事大吉,结果换个接口工具照样能刷。这篇内容我想把"防止表单重复提交"和"API 接口幂等设计"这两件事掰开揉碎讲清楚。它们经常被混为一谈,其实是两个层次的问题:前者是用户交互层面的防护,后者是服务端数据一致性的底线。适合后端开发、全栈工程师、以及正在做支付、订单、库存这类强一致业务的技术同学参考。读完你应该能自己设计一套扛得住并发、经得起重试的幂等方案,而不是只会抄一段 Redis 加锁的代码。2. 先搞清楚重复提交到底从哪来2.1 用户手抖只是冰山一角大部分人理解重复提交,第一反应就是"用户点了两次按钮"。这只占实际场景的一小部分。我把常见的触发路径列一下,你会发现真正的坑远比想象中多:触发来源具体表现是否容易被忽视用户重复点击手抖、着急、页面卡顿误以为没提交常见,容易防浏览器刷新/回退提交后按 F5,浏览器提示重新提交表单很常见,容易漏网络超时重试客户端等不到响应,自动重发请求隐蔽,最难防网关/负载均衡重试上游超时后向另一台机器重发极隐蔽,运维层面恶意刷接口用脚本高频调用下单/领券接口安全层面消息队列重复消费消费端 ack 丢失导致消息重投分布式场景高发你会发现,从第三行开始,问题就已经不是"前端加个 disabled"能解决的了。网络重试和网关重试这两个场景,用户完全无感知,前端防护形同虚设。这就是为什么幂等必须做在服务端,而且要做在业务逻辑的最内层。2.2 为什么前端防护永远不够前端能做的是什么?按钮点击后置灰、提交后跳转、加个 loading 遮罩。这些手段能挡住 80% 的普通用户误操作,成本极低,该做还是要做。但它的本质是"防君子不防小人",而且防不住任何技术层面的重试。我见过有团队把防重逻辑全压在前端,后端接口裸奔。结果测试同学用 Postman 连点几下,数据库直接多出几条记录。更麻烦的是,前端防护一旦和用户体验冲突——比如按钮置灰后请求失败,用户想重试却点不动——反而制造了新问题。所以正确的定位是:前端防护是体验优化,服务端幂等是数据底线,两者都要有,但重心在后端。2.3 幂等和防重提交不是一回事这两个词经常被混用,我习惯把它们分开理解:防重复提交:偏交互层,目标是"同一个用户的一次操作意图,只产生一次业务结果"。它关注的是短时间窗口内的重复请求。接口幂等:偏数据层,目标是"同一个请求(用唯一标识区分)执行一次和执行 N 次,对系统状态的影响相同"。它不关心时间窗口,关心的是请求本身的唯一性。举个例子:用户下单,防重提交保证他连点五次只生成一单;而幂等保证的是,哪怕这五次请求因为网络原因分散在五秒内到达不同服务器,最终数据库里也只有一单。前者是后者的一个子集场景,后者是更普适的工程约束。理解了这层区别,你设计方案时就不会只盯着"时间窗口"这一个维度。3. 幂等方案选型:没有银弹,只有取舍3.1 唯一索引:最朴素也最可靠的一道闸如果只能选一种幂等方案,我会毫不犹豫推荐数据库唯一索引。原因很简单:它由数据库保证原子性,不依赖任何外部组件,不会因为缓存宕机、网络分区而失效。做法是给业务表加一个业务唯一键,比如订单表的order_no,或者更通用的request_id。每次请求进来,先尝试插入,如果捕获到唯一键冲突异常,就说明这个请求已经处理过了,直接返回之前的结果。ALTER TABLE `order_info` ADD UNIQUE KEY `uk_request_id` (`request_id`);try { orderMapper.insert(order); } catch (DuplicateKeyException e) { // 说明重复请求,查询已有记录返回 return orderMapper.selectByRequestId(order.getRequestId()); }这里有个细节很多人踩坑:唯一键的选择。用自增 ID 没意义,用时间戳会冲突,正确做法是用业务上有意义的唯一标识,比如"用户ID + 业务类型 + 客户端生成的 UUID"。客户端在发起请求前生成一个 UUID 放在请求头里,服务端拿它做唯一键,这样即使同一用户并发提交,也能精准识别。注意:唯一索引方案在高并发下会有性能损耗,因为冲突时会走异常流程。但相比数据错乱,这点损耗完全值得。而且可以通过分库分表、缩短事务范围来缓解。3.2 Redis 令牌:适合高频场景的轻量方案唯一索引虽好,但每次冲突都要走一次数据库异常,QPS 高了之后数据库压力不小。这时候可以用 Redis 做前置拦截。核心思路是"令牌机制":用户进入表单页面时,服务端生成一个全局唯一的 token,存进 Redis 并返回给前端;提交时带上这个 token,服务端用DEL或Lua 脚本原子性地删除它,删除成功说明是首次提交,删除失败说明已经提交过。-- 原子性校验并删除 if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end为什么必须用 Lua 脚本?因为"先 GET 再 DEL"这两步之间有时间窗口,并发请求可能同时 GET 到值,然后都执行 DEL,结果两个请求都通过了校验。Lua 脚本在 Redis 里是原子执行的,能彻底堵住这个窗口。这个方案的优势是性能极高,适合秒杀、抢券这类瞬时高并发场景。缺点是依赖 Redis 可用性,如果 Redis 挂了,防护就失效了。所以我的建议是:Redis 令牌做前置拦截,数据库唯一索引做最终兜底,两层配合,既快又稳。3.3 状态机:用业务状态天然防重有些业务本身就有状态流转,比如订单从"待支付"到"已支付",从"已发货"到"已完成"。这种情况下,幂等可以直接用状态机来实现。做法是更新时带上当前状态作为条件:UPDATE order_info SET status = 'PAID', pay_time = NOW() WHERE order_no = ? AND status = 'PENDING';如果这条 SQL 影响行数为 0,说明订单状态已经不是"待支付"了,要么已经支付过,要么被取消,直接返回即可。这种方案不需要额外的存储,逻辑内聚在业务里,非常优雅。但它有个前提:业务必须有明确的状态流转。像"提交评论"这种没有状态的概念,就用不了。而且状态机的幂等粒度是"状态变更",如果同一个状态变更请求重复到达,它挡不住——这时候还得靠唯一索引或 token 补位。3.4 三种方案横向对比方案适用场景性能可靠性实现复杂度数据库唯一索引所有写操作,尤其强一致业务中极高低Redis 令牌高频、瞬时并发场景高中(依赖 Redis)中状态机有明确状态流转的业务高高低实际项目里,我通常不会只用一种。支付、下单这类核心链路,唯一索引 + 状态机双保险;领券、秒杀这类高并发场景,Redis 令牌 + 唯一索引兜底。选型的核心原则是:越靠近钱和库存的操作,越要用最可靠的方案。4. 手把手落地一套完整的幂等拦截4.1 客户端要配合做什么服务端方案再完善,客户端不配合也会打折扣。客户端需要做三件事:第一,生成并携带请求唯一标识。在用户点击提交的那一刻,生成一个 UUID(比如用uuid.v4()),放进请求头X-Request-Id。注意这个 UUID 要在"一次操作意图"内保持不变,如果用户改了表单内容重新提交,应该生成新的 UUID。第二,提交按钮防抖。点击后立即置灰,防止用户连点。但要注意,请求失败后要恢复按钮可用,否则用户无法重试。第三,不要自动重试写操作。很多 HTTP 客户端库默认会对失败请求重试,这在 GET 场景没问题,但 POST 下单接口自动重试就是灾难。一定要显式关闭写接口的自动重试,或者确保重试时携带同一个X-Request-Id。// 前端生成请求ID的示例 function submitOrder(formData) { const requestId = generateUUID(); return fetch('/api/order', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Request-Id': requestId }, body: JSON.stringify(formData) }); }4.2 服务端拦截器的设计思路服务端我习惯用一个统一的拦截器或切面来处理幂等,而不是在每个业务方法里手写。这样业务代码保持干净,幂等逻辑集中管理。设计思路是这样的:定义一个@Idempotent注解,标注在需要幂等的接口上,注解里可以配置过期时间、提示信息等参数。拦截器在方法执行前,从请求头取出X-Request-Id,拼上用户 ID 和接口路径,组成 Redis key,尝试写入。写入成功则放行,写入失败则直接返回"请勿重复提交"。@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface Idempotent { long expireSeconds() default 10; String message() default "请勿重复提交"; }拦截器核心逻辑:String key = "idempotent:" + userId + ":" + request.getRequestURI() + ":" + requestId; Boolean success = redisTemplate.opsForValue() .setIfAbsent(key, "1", expireSeconds, TimeUnit.SECONDS); if (Boolean.FALSE.equals(success)) { throw new BizException(message); }这里用setIfAbsent(对应 Redis 的SET NX EX)是关键,它把"判断是否存在"和"写入"合并成一个原子操作,避免了并发窗口。过期时间要设置合理,太短挡不住慢重试,太长会占用内存。一般业务设 10 到 30 秒足够,秒杀场景可以设 5 秒。4.3 唯一键冲突后的优雅返回前面提到唯一索引方案,冲突后会抛异常。但直接把异常抛给用户是很糟糕的体验——用户看到"系统错误"会以为下单失败,可能又点一次。正确做法是捕获冲突异常后,查询已有记录并返回,让用户看到"下单成功"。public OrderResult createOrder(OrderRequest req) { try { Order order = buildOrder(req); orderMapper.insert(order); return OrderResult.success(order); } catch (DuplicateKeyException e) { // 重复请求,返回已有订单 Order exist = orderMapper.selectByRequestId(req.getRequestId()); if (exist != null) { return OrderResult.success(exist); } throw e; // 不是重复请求导致的冲突,继续抛出 } }这里有个容易忽略的点:捕获异常后要判断是不是真的重复请求。如果唯一键冲突是因为其他字段(比如用户名唯一)导致的,直接返回已有订单就错了。所以查询时要严格用request_id去查,查不到说明是别的冲突,该抛还得抛。4.4 分布式锁在幂等里的正确用法有些场景光靠唯一索引和 token 不够,比如"检查库存再扣减"这种需要先读后写的操作。这时候需要分布式锁来保证同一时刻只有一个请求在处理。但分布式锁用在幂等里有个常见误区:把锁当成幂等方案。锁只能保证串行执行,不能保证"执行一次"。两个请求串行进来,第一个扣了库存,第二个还是会扣。所以锁必须配合"状态检查"使用:String lockKey = "lock:order:" + orderNo; RLock lock = redissonClient.getLock(lockKey); try { if (lock.tryLock(3, 10, TimeUnit.SECONDS)) { Order order = orderMapper.selectByNo(orderNo); if (order.getStatus() != PENDING) { return; // 已处理,直接返回 } // 执行扣减逻辑 } } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }注意tryLock的等待时间和持有时间要设置合理。等待时间太短会导致请求直接失败,太长会拖慢响应;持有时间要大于业务执行时间,否则锁提前释放会出问题。我一般设等待 3 秒、持有 10 秒,具体看业务耗时调整。5. 那些年我踩过的幂等坑5.1 过期时间设太短,慢重试照样穿透有一次做支付回调,我把幂等 key 的过期时间设成了 5 秒。测试环境一切正常,上线后偶尔出现重复入账。排查发现,某些渠道的回调因为网络原因会延迟十几秒才重试,这时候 key 早就过期了,第二次回调又被当成新请求处理。这个坑的本质是:过期时间必须大于业务的最大重试窗口。不同业务的重试策略不一样,支付回调可能要等几分钟,普通表单提交几秒就够。设置之前一定要搞清楚上游的重试机制,宁可设长一点,也不能设短。后来我把支付回调的过期时间改成了 24 小时,问题再没出现过。5.2 用请求参数做唯一键,结果被序列化坑了有段时间我用"请求体的 MD5"作为幂等 key,想着这样不用客户端传 UUID。结果发现同一个请求,有时候能识别成重复,有时候识别不了。查了半天才发现,JSON 序列化时字段顺序不固定,同样的数据序列化出来 MD5 不一样。这个坑提醒我:幂等 key 必须由客户端显式生成并传递,不要试图从请求内容里推导。内容可能因为序列化、编码、字段顺序等原因变化,推导出来的 key 不可靠。UUID 虽然看起来"笨",但它稳定、唯一、可控,这才是幂等 key 该有的品质。5.3 拦截器顺序不对,幂等失效Spring 的拦截器和过滤器执行顺序是有讲究的。我曾经把幂等拦截器放在了一个自定义的权限过滤器后面,结果发现未登录用户的重复请求没被拦住——因为权限过滤器先返回了 401,幂等拦截器根本没执行。后来我把幂等拦截器的优先级调到最前面,确保它在任何业务逻辑之前执行。这个经验是:幂等拦截应该尽可能靠前,在参数校验、权限校验之前就完成,这样才能覆盖所有请求路径。当然,如果幂等 key 需要用户 ID,那就得等认证完成之后,这个顺序要按实际依赖来排。5.4 只防了接口,没防消息重复消费接口层的幂等做完了,我以为高枕无忧。结果某次消息队列抖动,同一条"扣库存"消息被消费了两次,库存又对不上了。这才意识到,消息消费也需要幂等,而且它和接口幂等是两套独立的防护。消息幂等的做法类似:给每条消息一个唯一 ID,消费前先查这个 ID 是否处理过,处理过就跳过。可以用数据库的唯一索引,也可以用 Redis 记录已消费的 ID。关键是消费逻辑本身要幂等,不能依赖"消息只来一次"这个假设。6. 高并发场景下的进阶考量6.1 幂等 key 的存储选型低并发时用 Redis 存幂等 key 完全够用,但到了每秒几万请求的级别,单个 Redis 实例可能扛不住。这时候有几个方向:一是分片,把幂等 key 按用户 ID 哈希分散到多个 Redis 实例,降低单实例压力。二是本地缓存 + Redis 二级,热点 key 放本地,减少网络往返。三是改用布隆过滤器做前置判断,虽然它有误判率,但用在"判断是否重复"场景,误判为"已存在"只是让用户重试一次,影响可控。不过我要提醒一句:不要过早优化。大部分业务的 QPS 根本到不了需要分片的程度,先把唯一索引和 Redis 令牌用扎实,等真有性能瓶颈了再考虑这些。我见过太多团队一上来就搞复杂架构,结果维护成本高得离谱,收益却微乎其微。6.2 幂等和事务的边界幂等逻辑和数据库事务的关系要理清楚。理想情况下,幂等 key 的写入和业务数据的写入应该在同一个事务里,要么都成功,要么都失败。但 Redis 不支持事务回滚,这就产生了不一致的可能:Redis 写成功了,业务事务回滚了,用户重试时会被误判为重复提交。解决思路有两种。一种是先执行业务事务,成功后再写幂等 key,但这样在事务提交和写 key 之间有窗口,并发请求可能穿透。另一种是用数据库表存幂等记录,和业务数据同事务,牺牲一点性能换一致性。我倾向于后者用在核心链路,前者用在允许极小概率重复的非核心场景。6.3 幂等失败后的补偿再完善的方案也可能有漏网之鱼,所以要有补偿机制。常见做法是定时对账:扫描业务表,找出同一request_id或同一业务唯一键的多条记录,标记异常并触发人工或自动处理。对账的粒度可以是分钟级、小时级或天级,看业务敏感度。支付类业务建议分钟级对账,普通业务天级即可。对账脚本本身也要幂等,避免重复补偿造成二次伤害。7. 一套可以直接抄的落地清单把上面的内容浓缩成一份可执行的清单,你照着做基本能覆盖 90% 的场景:客户端:提交前生成 UUID 放请求头,按钮点击后置灰,关闭写接口自动重试。网关层:对写接口做限流,防止恶意高频刷。拦截器层:用@Idempotent注解 + RedisSET NX EX做前置拦截,过期时间按业务重试窗口设置。业务层:核心写操作加数据库唯一索引,冲突后查询已有记录优雅返回。状态流转:有状态的业务用条件更新(WHERE status = ?)做二次防护。消息消费:消费端用消息 ID 做幂等,不假设消息只来一次。对账补偿:定时扫描异常重复数据,自动或人工处理。最后分享一个我自己的习惯:每次设计新接口,我都会问自己三个问题——这个接口重复调用会怎样?客户端会重试吗?上游网关会重试吗?三个问题答完,幂等方案基本就清晰了。幂等不是加个注解就完事,它是对整个请求链路的重试行为的系统性防御。想清楚链路,方案自然就对了。
1. 从一个真实的翻车现场说起去年帮一个做电商的朋友排查线上问题,凌晨两点被电话叫醒:后台订单列表里出现了大量重复记录,同一个用户、同一个商品、同一个收货地址,连续下了十几单,而且每单都扣了库存。第一反应是用户恶意刷单,查了日志才发现,用户只是手抖点了两下提交按钮,加上当时网络抖动,前端请求重试机制又补了一刀,后端接口没有任何防护,来一个请求就落一条订单。这个场景就是典型的表单重复提交问题。它不是什么高深的安全漏洞,但杀伤力一点不小:重复下单、重复扣款、重复发券、重复提交工单,轻则数据脏乱,重则真金白银的损失。很多团队在业务初期根本不会考虑这件事,等到出事了才回头补,而补的时候往往又补得不对——加个前端按钮置灰就以为万事大吉,结果换个接口工具照样能刷。这篇内容我想把"防止表单重复提交"和"API 接口幂等设计"这两件事掰开揉碎讲清楚。它们经常被混为一谈,其实是两个层次的问题:前者是用户交互层面的防护,后者是服务端数据一致性的底线。适合后端开发、全栈工程师、以及正在做支付、订单、库存这类强一致业务的技术同学参考。读完你应该能自己设计一套扛得住并发、经得起重试的幂等方案,而不是只会抄一段 Redis 加锁的代码。2. 先搞清楚重复提交到底从哪来2.1 用户手抖只是冰山一角大部分人理解重复提交,第一反应就是"用户点了两次按钮"。这只占实际场景的一小部分。我把常见的触发路径列一下,你会发现真正的坑远比想象中多:触发来源具体表现是否容易被忽视用户重复点击手抖、着急、页面卡顿误以为没提交常见,容易防浏览器刷新/回退提交后按 F5,浏览器提示重新提交表单很常见,容易漏网络超时重试客户端等不到响应,自动重发请求隐蔽,最难防网关/负载均衡重试上游超时后向另一台机器重发极隐蔽,运维层面恶意刷接口用脚本高频调用下单/领券接口安全层面消息队列重复消费消费端 ack 丢失导致消息重投分布式场景高发你会发现,从第三行开始,问题就已经不是"前端加个 disabled"能解决的了。网络重试和网关重试这两个场景,用户完全无感知,前端防护形同虚设。这就是为什么幂等必须做在服务端,而且要做在业务逻辑的最内层。2.2 为什么前端防护永远不够前端能做的是什么?按钮点击后置灰、提交后跳转、加个 loading 遮罩。这些手段能挡住 80% 的普通用户误操作,成本极低,该做还是要做。但它的本质是"防君子不防小人",而且防不住任何技术层面的重试。我见过有团队把防重逻辑全压在前端,后端接口裸奔。结果测试同学用 Postman 连点几下,数据库直接多出几条记录。更麻烦的是,前端防护一旦和用户体验冲突——比如按钮置灰后请求失败,用户想重试却点不动——反而制造了新问题。所以正确的定位是:前端防护是体验优化,服务端幂等是数据底线,两者都要有,但重心在后端。2.3 幂等和防重提交不是一回事这两个词经常被混用,我习惯把它们分开理解:防重复提交:偏交互层,目标是"同一个用户的一次操作意图,只产生一次业务结果"。它关注的是短时间窗口内的重复请求。接口幂等:偏数据层,目标是"同一个请求(用唯一标识区分)执行一次和执行 N 次,对系统状态的影响相同"。它不关心时间窗口,关心的是请求本身的唯一性。举个例子:用户下单,防重提交保证他连点五次只生成一单;而幂等保证的是,哪怕这五次请求因为网络原因分散在五秒内到达不同服务器,最终数据库里也只有一单。前者是后者的一个子集场景,后者是更普适的工程约束。理解了这层区别,你设计方案时就不会只盯着"时间窗口"这一个维度。3. 幂等方案选型:没有银弹,只有取舍3.1 唯一索引:最朴素也最可靠的一道闸如果只能选一种幂等方案,我会毫不犹豫推荐数据库唯一索引。原因很简单:它由数据库保证原子性,不依赖任何外部组件,不会因为缓存宕机、网络分区而失效。做法是给业务表加一个业务唯一键,比如订单表的order_no,或者更通用的request_id。每次请求进来,先尝试插入,如果捕获到唯一键冲突异常,就说明这个请求已经处理过了,直接返回之前的结果。ALTER TABLE `order_info` ADD UNIQUE KEY `uk_request_id` (`request_id`);try { orderMapper.insert(order); } catch (DuplicateKeyException e) { // 说明重复请求,查询已有记录返回 return orderMapper.selectByRequestId(order.getRequestId()); }这里有个细节很多人踩坑:唯一键的选择。用自增 ID 没意义,用时间戳会冲突,正确做法是用业务上有意义的唯一标识,比如"用户ID + 业务类型 + 客户端生成的 UUID"。客户端在发起请求前生成一个 UUID 放在请求头里,服务端拿它做唯一键,这样即使同一用户并发提交,也能精准识别。注意:唯一索引方案在高并发下会有性能损耗,因为冲突时会走异常流程。但相比数据错乱,这点损耗完全值得。而且可以通过分库分表、缩短事务范围来缓解。3.2 Redis 令牌:适合高频场景的轻量方案唯一索引虽好,但每次冲突都要走一次数据库异常,QPS 高了之后数据库压力不小。这时候可以用 Redis 做前置拦截。核心思路是"令牌机制":用户进入表单页面时,服务端生成一个全局唯一的 token,存进 Redis 并返回给前端;提交时带上这个 token,服务端用DEL或Lua 脚本原子性地删除它,删除成功说明是首次提交,删除失败说明已经提交过。-- 原子性校验并删除 if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end为什么必须用 Lua 脚本?因为"先 GET 再 DEL"这两步之间有时间窗口,并发请求可能同时 GET 到值,然后都执行 DEL,结果两个请求都通过了校验。Lua 脚本在 Redis 里是原子执行的,能彻底堵住这个窗口。这个方案的优势是性能极高,适合秒杀、抢券这类瞬时高并发场景。缺点是依赖 Redis 可用性,如果 Redis 挂了,防护就失效了。所以我的建议是:Redis 令牌做前置拦截,数据库唯一索引做最终兜底,两层配合,既快又稳。3.3 状态机:用业务状态天然防重有些业务本身就有状态流转,比如订单从"待支付"到"已支付",从"已发货"到"已完成"。这种情况下,幂等可以直接用状态机来实现。做法是更新时带上当前状态作为条件:UPDATE order_info SET status = 'PAID', pay_time = NOW() WHERE order_no = ? AND status = 'PENDING';如果这条 SQL 影响行数为 0,说明订单状态已经不是"待支付"了,要么已经支付过,要么被取消,直接返回即可。这种方案不需要额外的存储,逻辑内聚在业务里,非常优雅。但它有个前提:业务必须有明确的状态流转。像"提交评论"这种没有状态的概念,就用不了。而且状态机的幂等粒度是"状态变更",如果同一个状态变更请求重复到达,它挡不住——这时候还得靠唯一索引或 token 补位。3.4 三种方案横向对比方案适用场景性能可靠性实现复杂度数据库唯一索引所有写操作,尤其强一致业务中极高低Redis 令牌高频、瞬时并发场景高中(依赖 Redis)中状态机有明确状态流转的业务高高低实际项目里,我通常不会只用一种。支付、下单这类核心链路,唯一索引 + 状态机双保险;领券、秒杀这类高并发场景,Redis 令牌 + 唯一索引兜底。选型的核心原则是:越靠近钱和库存的操作,越要用最可靠的方案。4. 手把手落地一套完整的幂等拦截4.1 客户端要配合做什么服务端方案再完善,客户端不配合也会打折扣。客户端需要做三件事:第一,生成并携带请求唯一标识。在用户点击提交的那一刻,生成一个 UUID(比如用uuid.v4()),放进请求头X-Request-Id。注意这个 UUID 要在"一次操作意图"内保持不变,如果用户改了表单内容重新提交,应该生成新的 UUID。第二,提交按钮防抖。点击后立即置灰,防止用户连点。但要注意,请求失败后要恢复按钮可用,否则用户无法重试。第三,不要自动重试写操作。很多 HTTP 客户端库默认会对失败请求重试,这在 GET 场景没问题,但 POST 下单接口自动重试就是灾难。一定要显式关闭写接口的自动重试,或者确保重试时携带同一个X-Request-Id。// 前端生成请求ID的示例 function submitOrder(formData) { const requestId = generateUUID(); return fetch('/api/order', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Request-Id': requestId }, body: JSON.stringify(formData) }); }4.2 服务端拦截器的设计思路服务端我习惯用一个统一的拦截器或切面来处理幂等,而不是在每个业务方法里手写。这样业务代码保持干净,幂等逻辑集中管理。设计思路是这样的:定义一个@Idempotent注解,标注在需要幂等的接口上,注解里可以配置过期时间、提示信息等参数。拦截器在方法执行前,从请求头取出X-Request-Id,拼上用户 ID 和接口路径,组成 Redis key,尝试写入。写入成功则放行,写入失败则直接返回"请勿重复提交"。@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface Idempotent { long expireSeconds() default 10; String message() default "请勿重复提交"; }拦截器核心逻辑:String key = "idempotent:" + userId + ":" + request.getRequestURI() + ":" + requestId; Boolean success = redisTemplate.opsForValue() .setIfAbsent(key, "1", expireSeconds, TimeUnit.SECONDS); if (Boolean.FALSE.equals(success)) { throw new BizException(message); }这里用setIfAbsent(对应 Redis 的SET NX EX)是关键,它把"判断是否存在"和"写入"合并成一个原子操作,避免了并发窗口。过期时间要设置合理,太短挡不住慢重试,太长会占用内存。一般业务设 10 到 30 秒足够,秒杀场景可以设 5 秒。4.3 唯一键冲突后的优雅返回前面提到唯一索引方案,冲突后会抛异常。但直接把异常抛给用户是很糟糕的体验——用户看到"系统错误"会以为下单失败,可能又点一次。正确做法是捕获冲突异常后,查询已有记录并返回,让用户看到"下单成功"。public OrderResult createOrder(OrderRequest req) { try { Order order = buildOrder(req); orderMapper.insert(order); return OrderResult.success(order); } catch (DuplicateKeyException e) { // 重复请求,返回已有订单 Order exist = orderMapper.selectByRequestId(req.getRequestId()); if (exist != null) { return OrderResult.success(exist); } throw e; // 不是重复请求导致的冲突,继续抛出 } }这里有个容易忽略的点:捕获异常后要判断是不是真的重复请求。如果唯一键冲突是因为其他字段(比如用户名唯一)导致的,直接返回已有订单就错了。所以查询时要严格用request_id去查,查不到说明是别的冲突,该抛还得抛。4.4 分布式锁在幂等里的正确用法有些场景光靠唯一索引和 token 不够,比如"检查库存再扣减"这种需要先读后写的操作。这时候需要分布式锁来保证同一时刻只有一个请求在处理。但分布式锁用在幂等里有个常见误区:把锁当成幂等方案。锁只能保证串行执行,不能保证"执行一次"。两个请求串行进来,第一个扣了库存,第二个还是会扣。所以锁必须配合"状态检查"使用:String lockKey = "lock:order:" + orderNo; RLock lock = redissonClient.getLock(lockKey); try { if (lock.tryLock(3, 10, TimeUnit.SECONDS)) { Order order = orderMapper.selectByNo(orderNo); if (order.getStatus() != PENDING) { return; // 已处理,直接返回 } // 执行扣减逻辑 } } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }注意tryLock的等待时间和持有时间要设置合理。等待时间太短会导致请求直接失败,太长会拖慢响应;持有时间要大于业务执行时间,否则锁提前释放会出问题。我一般设等待 3 秒、持有 10 秒,具体看业务耗时调整。5. 那些年我踩过的幂等坑5.1 过期时间设太短,慢重试照样穿透有一次做支付回调,我把幂等 key 的过期时间设成了 5 秒。测试环境一切正常,上线后偶尔出现重复入账。排查发现,某些渠道的回调因为网络原因会延迟十几秒才重试,这时候 key 早就过期了,第二次回调又被当成新请求处理。这个坑的本质是:过期时间必须大于业务的最大重试窗口。不同业务的重试策略不一样,支付回调可能要等几分钟,普通表单提交几秒就够。设置之前一定要搞清楚上游的重试机制,宁可设长一点,也不能设短。后来我把支付回调的过期时间改成了 24 小时,问题再没出现过。5.2 用请求参数做唯一键,结果被序列化坑了有段时间我用"请求体的 MD5"作为幂等 key,想着这样不用客户端传 UUID。结果发现同一个请求,有时候能识别成重复,有时候识别不了。查了半天才发现,JSON 序列化时字段顺序不固定,同样的数据序列化出来 MD5 不一样。这个坑提醒我:幂等 key 必须由客户端显式生成并传递,不要试图从请求内容里推导。内容可能因为序列化、编码、字段顺序等原因变化,推导出来的 key 不可靠。UUID 虽然看起来"笨",但它稳定、唯一、可控,这才是幂等 key 该有的品质。5.3 拦截器顺序不对,幂等失效Spring 的拦截器和过滤器执行顺序是有讲究的。我曾经把幂等拦截器放在了一个自定义的权限过滤器后面,结果发现未登录用户的重复请求没被拦住——因为权限过滤器先返回了 401,幂等拦截器根本没执行。后来我把幂等拦截器的优先级调到最前面,确保它在任何业务逻辑之前执行。这个经验是:幂等拦截应该尽可能靠前,在参数校验、权限校验之前就完成,这样才能覆盖所有请求路径。当然,如果幂等 key 需要用户 ID,那就得等认证完成之后,这个顺序要按实际依赖来排。5.4 只防了接口,没防消息重复消费接口层的幂等做完了,我以为高枕无忧。结果某次消息队列抖动,同一条"扣库存"消息被消费了两次,库存又对不上了。这才意识到,消息消费也需要幂等,而且它和接口幂等是两套独立的防护。消息幂等的做法类似:给每条消息一个唯一 ID,消费前先查这个 ID 是否处理过,处理过就跳过。可以用数据库的唯一索引,也可以用 Redis 记录已消费的 ID。关键是消费逻辑本身要幂等,不能依赖"消息只来一次"这个假设。6. 高并发场景下的进阶考量6.1 幂等 key 的存储选型低并发时用 Redis 存幂等 key 完全够用,但到了每秒几万请求的级别,单个 Redis 实例可能扛不住。这时候有几个方向:一是分片,把幂等 key 按用户 ID 哈希分散到多个 Redis 实例,降低单实例压力。二是本地缓存 + Redis 二级,热点 key 放本地,减少网络往返。三是改用布隆过滤器做前置判断,虽然它有误判率,但用在"判断是否重复"场景,误判为"已存在"只是让用户重试一次,影响可控。不过我要提醒一句:不要过早优化。大部分业务的 QPS 根本到不了需要分片的程度,先把唯一索引和 Redis 令牌用扎实,等真有性能瓶颈了再考虑这些。我见过太多团队一上来就搞复杂架构,结果维护成本高得离谱,收益却微乎其微。6.2 幂等和事务的边界幂等逻辑和数据库事务的关系要理清楚。理想情况下,幂等 key 的写入和业务数据的写入应该在同一个事务里,要么都成功,要么都失败。但 Redis 不支持事务回滚,这就产生了不一致的可能:Redis 写成功了,业务事务回滚了,用户重试时会被误判为重复提交。解决思路有两种。一种是先执行业务事务,成功后再写幂等 key,但这样在事务提交和写 key 之间有窗口,并发请求可能穿透。另一种是用数据库表存幂等记录,和业务数据同事务,牺牲一点性能换一致性。我倾向于后者用在核心链路,前者用在允许极小概率重复的非核心场景。6.3 幂等失败后的补偿再完善的方案也可能有漏网之鱼,所以要有补偿机制。常见做法是定时对账:扫描业务表,找出同一request_id或同一业务唯一键的多条记录,标记异常并触发人工或自动处理。对账的粒度可以是分钟级、小时级或天级,看业务敏感度。支付类业务建议分钟级对账,普通业务天级即可。对账脚本本身也要幂等,避免重复补偿造成二次伤害。7. 一套可以直接抄的落地清单把上面的内容浓缩成一份可执行的清单,你照着做基本能覆盖 90% 的场景:客户端:提交前生成 UUID 放请求头,按钮点击后置灰,关闭写接口自动重试。网关层:对写接口做限流,防止恶意高频刷。拦截器层:用@Idempotent注解 + RedisSET NX EX做前置拦截,过期时间按业务重试窗口设置。业务层:核心写操作加数据库唯一索引,冲突后查询已有记录优雅返回。状态流转:有状态的业务用条件更新(WHERE status = ?)做二次防护。消息消费:消费端用消息 ID 做幂等,不假设消息只来一次。对账补偿:定时扫描异常重复数据,自动或人工处理。最后分享一个我自己的习惯:每次设计新接口,我都会问自己三个问题——这个接口重复调用会怎样?客户端会重试吗?上游网关会重试吗?三个问题答完,幂等方案基本就清晰了。幂等不是加个注解就完事,它是对整个请求链路的重试行为的系统性防御。想清楚链路,方案自然就对了。