网关超时重试导致事务重复执行的本质是“不确定状态重试”遇上“无幂等保障事务”,需在事务外前置幂等校验,推荐Token+Redis分布式锁+Lua原子校验方案。

网关超时重试引发的事务重复执行,本质是「不确定状态下的重试」撞上了「无幂等保障的事务」。这不是单纯加个@Transactional就能解决的问题——它要求你在事务边界之外,先稳住请求身份,再决定是否进入事务。
核心原则:幂等校验必须在事务开启前完成
事务一旦开始,就可能写库、发消息、调远程服务。如果此时才发现是重复请求,回滚代价高、副作用难清理(比如下游已扣款但本地回滚,钱就没了)。所以校验动作要前置到 Controller 或 Filter 层,且必须原子、可靠、可跨节点。
- 不能依赖内存或单机 Map,多实例部署下完全失效
- 不能只靠数据库唯一索引兜底——它只能拦 insert,拦不住 update 扣款、send 消息这类操作
- 校验通过后才开启 @Transactional,失败直接返回 409 Conflict 或业务提示
推荐方案:Token + Redis 分布式锁 + Lua 原子校验
这是目前生产环境最稳妥的组合,兼顾性能、可靠性与可维护性。
- 前端配合生成一次性 token:页面加载时请求 /api/token 获取,按钮点击时带入 header(如 X-Idempotent-Token: abc123)或 body
-
后端用 Lua 脚本原子校验并消费 token:
脚本内容:
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
传入 key(如 idempotent:uid1001:submit),value 为 token 字符串 - 校验成功才走业务逻辑:包括数据库操作、MQ 发送、Feign 调用等全部包裹在同一个事务中
- token 过期时间设为业务最大耗时 + 安全余量(例如 5 分钟),避免误释放;也不宜过长(影响故障恢复)
关键细节避坑指南
很多团队卡在“看似能用,压测就翻车”的环节,问题往往出在细节:
立即学习“Java免费学习笔记(深入)”;
- 不要用 UUID 当 token 值:它只是随机,不绑定请求上下文。应使用 SHA256(requestBody + timestamp + salt) 或直接用订单号+用户ID拼接,确保相同请求生成相同 token
- Feign 客户端必须关闭重试:除非下游明确支持幂等。否则你刚防完网关重试,内部 RPC 又重试一次,等于白防
- 数据库唯一约束仍是必要兜底:在订单表建联合唯一索引 (user_id, biz_type, biz_ref_id),捕获 MySQLIntegrityConstraintViolationException 后统一转成「操作已存在」提示,而不是抛 500
- 状态机比纯锁更健壮:对复杂流程(如支付),用 status 字段控制流转(INIT → PROCESSING → SUCCESS/FAILED),update where status = INIT and id = ?,失败说明已被处理
补充建议:网关层协同优化
纯后端防重有天花板,需前后端+网关联动:
- 网关(如 Spring Cloud Gateway)配置重试策略时,排除 POST/PUT/DELETE 接口,或仅对明确幂等的接口(如查询类)开启重试
- 前端按钮点击后立即置灰 + 加载态,禁用 submit 按钮,防止用户手抖连点
- 对非关键操作(如日志上报、埋点),可接受少量重复,不必强防;把资源留给真正要保一致性的核心链路(下单、支付、退款)


















