问题的本质
重复下单是电商 / 收银系统中最常见也最危险的 Bug 之一。一次重复下单可能导致:
- 用户被重复扣款,引发客诉
- 库存被多扣,数据不一致
- 商家对账失败,财务出问题
为什么会出现重复下单?
- 用户行为:快速连点”提交订单”按钮
- 网络层:请求超时后客户端自动重试
- 前端逻辑:页面重新渲染导致二次提交
- 恶意攻击:脚本模拟并发下单
解决这个问题,需要从 前端到后端多层防御。本文逐层拆解,从最简单的方案讲到高并发场景下的最优解。
一 ~ 二
基础防线 + 前端防抖
三 ~ 五
三种后端防重方案
六
幂等令牌演进实战
七 ~ 八
方案对比 + 最佳实践
一、
基础防线:订单号 + 数据库唯一索引
为什么需要唯一索引?
无论用什么算法生成订单号(雪花算法、时间戳 + 随机数、UUID 等),都存在 极小概率碰撞 的可能。数据库唯一索引是最后一道物理防线:
1 | |
当两个相同订单号的 INSERT 并发到达时,数据库会拒绝第二个,应用层捕获 DuplicateKeyException 返回错误即可。
唯一索引是兜底保障,不是主动防重手段。它只防”订单号重复”,不防”业务重复下单”。
局限性
- 每次提交都会生成 不同的订单号,唯一索引无法识别”同一笔业务的重复提交”
- 高并发下(如 10w QPS),大量
DuplicateKeyException会影响数据库连接池 - 无法提供友好的”请勿重复提交”提示
唯一索引是必要的兜底,但必须配合上层方案一起使用。
二、
前端防抖:成本最低的第一道防线
核心思路
用户点击”提交”按钮后,立即将按钮置灰(disabled),等待接口响应后再恢复。这是最简单也是效果最直接的防重复手段。
1 | |
1 | |
还能做什么?
按钮置灰
点击后立即 disabled,响应后恢复
请求节流
lodash throttle 限制 1s 内只发一次
遮罩层
全屏 Loading 遮罩,阻断所有操作
局限性
前端防抖只能防”用户手抖”,无法防网络重试、脚本攻击、多设备同时提交。后端防重才是关键。
三、
后端方案一:Redis 分布式锁 + 幂等 Token
这是 最经典也最通用 的后端防重方案。
核心原理
- 用户进入下单页时,服务端通过 Redis
SETNX生成一个 一次性 Token - 用户提交订单时 必须携带该 Token
- 服务端用
GETDEL原子消费 Token:成功则处理,失败则判定为重复提交 - Token 消费即删除,不可复用
查看请求时序图
1 | |
Redis Key 设计
Token 维度的 Key 推荐:
1 | |
Key 组成说明
user_token— 用户登录凭证,区分不同用户url— 请求接口路径,区分不同操作- 设置合理的 TTL(如 5 分钟),过期自动释放
业务维度的 Key 推荐(适合收银台场景):
1 | |
同一商家 + 同一收银员 + 同一会员 + 同一金额 + 短时间窗口 = 极大概率是同一笔订单,应拦截。
代码实现(Go)
1 | |
优缺点
优点
- Token 一次性(GETDEL 原子消费),天然防重放
- SetNX / GetDel 原子操作,并发安全
- Key 灵活设计,可按业务维度防重
- Redis 单机可支撑 10w+ QPS
缺点
- Token 需额外存储和管理(TTL 过期机制)
- 多一次 Token 请求的网络开销
- Token 生成与校验逻辑需前后端配合
四、
后端方案二:请求唯一 ID + 数据库唯一索引
核心思路
前端每次提交时自行生成一个 唯一请求 ID(request_id),服务端将其存入数据库唯一索引字段。重复请求因违反唯一约束而失败。
1 | |
request_id 的推荐方式:crypto.randomUUID()(浏览器)或 uuid.New().String()(客户端)
优缺点
优点
- 实现最简单,无需 Redis
- 数据库唯一约束是强保证
- 无额外网络请求
缺点
- 高并发下 DB 唯一索引锁竞争严重
- 10w QPS 直接打 DB,连接池扛不住
- 唯一索引维护成本随数据量上升
QPS < 1w 的中低并发场景,如内部管理后台、低频收银台。简单可靠,但不适合大促秒杀。
五、
后端方案三:Redis 分布式锁 + 请求唯一 ID
这是 方案二的优化版,将 DB 唯一索引替换为 Redis SETNX,兼顾高并发与实现简洁性。
核心思路
前端自行生成唯一请求 ID,后端直接用 Redis SETNX 做幂等校验,不再依赖数据库唯一索引,也 不需要额外的 Token 请求。
查看请求时序图
1 | |
代码实现(Go)
1 | |
对比 Token 方案
为什么比 Token 方案更优?
- 少一次请求:无需提前获取 Token,前端直接生成
request_id - 实现更简单:前端
uuid.v4()一行代码搞定 - 同样安全:Redis
SETNX保证原子性,TTL 自动过期 - 性能一致:都是 Redis 操作,QPS 上限相同
进一步优化
对于极端高并发场景(> 10w QPS),还可以:
本地缓存 + Redis 二级防重
先在本地 ConcurrentHashMap 做初筛,
再走 Redis 精确判重,减少 Redis 压力
异步落库
Redis 判重成功后先返回”受理中”,
订单通过 MQ 异步创建,最终一致性
六、
深度实战:幂等令牌设计演进(从裸奔到 HMAC 签名)
上面的方案讨论的是”用什么机制防重”,本节深入另一个关键问题:Token / Request ID 本身该怎么生成?
以下是一个真实案例,展示幂等令牌从”裸奔”到”密码学签名”的三次迭代。
6.1 背景:为什么支付接口不需要防重?
在讨论订单创建之前,先看一个有趣的现象——支付接口天然不怕重复:
1 | |
MySQL 行级锁保证只有第一个请求能拿到 RowsAffected=1,其余请求直接失败。这就是经典的 CAS(Compare And Swap) 保护。加上三方支付网关自身的订单号去重、微信/支付宝客户端支付弹窗只能拉起一次,形成三道纵深防线。
但订单创建没有 CAS
pay_status 在创建时就是 no_pay,INSERT 操作没有前置状态可以比对,两张相同内容的订单可以同时 INSERT 成功。这就是为什么订单创建需要额外的防重机制。
6.2 第一版:UUID 幂等令牌
设计:新增 idempotent_keys 表,idempotent_key 字段加唯一索引。前端生成 UUID 传入,服务端 INSERT 判重。
1 | |
1 | |
解决了什么
快速双击、网络重试、页面刷新重提交,
全部被唯一索引拦截
遗留问题
UUID 由前端生成,服务端无法验证来源。
攻击者自己生成 UUID 传入,INSERT 照样成功——
一个任何人都能伪造的令牌,不算真正的令牌
在支付环节保护了真正的资金操作)。但从”幂等令牌”的设计语义来看,这还远远不够。
6.3 第二版:预签发模式(Status 状态机)
核心想法:如果 token 在签发时就写入 DB(status=unused),消费时原子更新为 used,那么只有服务端签发过的 token 才存在于 DB 中,伪造的 UUID 在 DB 中没有记录,UPDATE 匹配 0 行,被拒绝。
1 | |
解决了什么:伪造 token 被拒绝 ✅ · 同一 token 只能用一次 ✅ · 服务端有完整的签发记录 ✅
但暴露了致命漏洞:
1 | |
getSubmitToken 注册在公开路由(C 端用户不登录也可下单),每次调用都写 DB → 零成本 DoS 攻击向量。
尝试过的修复方向:
方向 A:移到鉴权路由
C 端小程序 / H5 用户不登录也可以下单,getSubmitToken 必须在公开路由
→ ❌ 业务不允许
方向 B:IP 限频
需要 Redis 计数器,分布式 IP 伪造可绕过,治标不治本
→ ❌ 复杂度换安全性,不划算
根本矛盾
1 | |
要打破这个矛盾,必须找到一种 不写 DB 也能证明”这个 token 是服务端签发” 的方式。
6.4 最终版:HMAC 签名令牌
核心想法:服务端持有密钥(IDEMPOTENT_SECRET),对 token 内容做 HMAC-SHA256 签名。签名本身就是服务端背书,不需要 DB 存储。
Token 格式
1 | |
签发(纯计算,零 DB 写入)
1 | |
消费(五步验证)
1 | |
密钥管理代码
1 | |
部署时设置:
1 | |
证明”这个 token 是服务端签发的”有两种方式:查 DB(预签发,有 DoS 风险)和 密码学签名(HMAC,无 DoS 风险)。两者安全性等价,但 HMAC 在公开路由场景下没有资源耗尽风险。
6.5 三版方案对比与演进逻辑
各攻击场景对比
| 攻击场景 | 第一版(纯 UUID) | 第二版(预签发) | 最终版(HMAC) |
|---|---|---|---|
| 快速双击 | ✅ 唯一索引拦截 | ✅ UPDATE 0行 | ✅ 唯一索引拦截 |
| 伪造随机 token | ❌ 放行 | ✅ 拒绝(DB 无记录) | ✅ 拒绝(验签失败) |
| getSubmitToken DoS | ✅ 无 DB 写入 | ❌ 灌满 DB | ✅ 无 DB 写入 |
| token 超期使用 | ❌ 永久有效 | ✅ status=used | ✅ 拒绝(超 30 分钟) |
| order token 用于 plus 订单 | ❌ 放行 | ✅ bizType 字段拦截 | ✅ 拒绝(签名含 bizType) |
| 公开路由可用 | ✅ | ❌(DoS) | ✅ |
演进逻辑一目了然:
1 | |
6.6 三种令牌的本质对比
回头看 request_id、幂等 Token、HMAC 签名令牌——三者本质上都是幂等键,目的完全一样:让服务器识别”这是同一次请求意图,不要重复处理”。区别只在于谁生成、怎么防伪造。
| request_id | 幂等 Token | HMAC 签名令牌 | |
|---|---|---|---|
| 谁生成 | 前端 / 客户端 | 后端 | 后端 |
| 本质 | 一个随机 UUID | UUID,存 Redis / DB | Token + 密钥签名 |
| 防重复 | ✅ 靠唯一性 | ✅ 靠 Redis SETNX | ✅ 靠 Redis SETNX |
| 防伪造 | ❌ 前端随便编一个 | ❌ 可猜测 / 遍历 | ✅ 没密钥就造不出来 |
| 复杂度 | 最低 | 中 | 最高 |
三代演进
第 1 代:request_id
前端生成 UUID,后端去重
问题:前端可以随便编一个不存在的 id,后端无法验证合法性
第 2 代:幂等 Token
后端生成 UUID 存 Redis,前端拿到后带着它请求
问题:Token 被截获后可重放;公开路由预签发有 DoS 风险
第 3 代:HMAC 签名令牌
后端生成 Token + 密钥签名,前端带着签名请求,后端先验签再去重
解决:伪造、篡改、过期、跨类型全部拒绝
下单场景该选哪个?
实战建议
对于 防止重复下单,第 2 代(幂等 Token + Redis)通常就够了:
1 | |
HMAC 的额外价值是 防伪造——但即使有人伪造了一个 Token,也只是多创建一个订单,不会造成资金损失(支付环节有 CAS 保护)。所以 HMAC 在下单场景是 锦上添花,不是必须。
三者都是幂等键,安全等级不同,选哪个取决于你需要防什么——防手抖用 request_id,防重放用 Token,防伪造用 HMAC。
6.7 架构决策:令牌放 Header 还是 Body?
请求头 X-Idempotent-Key | 请求体 idempotentKey 字段 | |
|---|---|---|
| 语义 | ✅ 传输层关注点,与业务数据分离 | ❌ 混在业务字段中 |
| 拦截 | ✅ 中间件直接读取,无需解析 body | ❌ 需先 BindJSON,与 handler 冲突 |
| DTO | ✅ 不需要每个请求结构体重复定义字段 | ❌ 每个 DTO 都要加 IdempotentKey |
| 惯例 | ✅ Stripe / Shopify 均用此方式 | — |
幂等令牌是”这个请求是第几次提交”的传输层语义,不是业务数据。放在 Header 是行业标准做法。
6.8 架构决策:中间件统一拦截 vs 各 Handler 各自检查
各 Handler 各自调用
每个接口手动调用防重检查
- 新增接口可能忘记加防重,完全依赖开发者记忆
bizType字符串散落各处,容易写错- handler 混入了与业务无关的防重逻辑
路由层挂载中间件
中间件从 Header 读取令牌 → 验签 → 放行/拒绝
- 新增接口只需在路由挂载时加一行,不可能遗漏
bizType通过常量传入,编译器保证一致- handler 只有纯业务逻辑,完全无感知
设计思路:
1 | |
bizType 推荐用常量集中管理(如 IdempotentBizOrder = "order"),签发端和消费端共用同一组常量,新增业务类型只改一处。
6.9 前端接入设计
前端与后端的交互分为两步:获取令牌 → 携带令牌提交。
1 | |
前端封装建议
推荐将”获取令牌 + 携带令牌”封装为一个通用函数,调用方只需传入 URL、业务类型和数据,无需关心令牌细节:
1 | |
注意事项
- 令牌 一次性,每次提交前必须重新获取,不可缓存复用
- 令牌 30 分钟有效,获取后应尽快使用
bizType必须与实际接口匹配,否则验签失败
七、
方案对比总结
| 方案 | 防重粒度 | 性能上限 | 实现复杂度 | 额外依赖 | 适用场景 |
|---|---|---|---|---|---|
| 订单号唯一索引 | 订单号级 | 受限于 DB | ★☆☆ | 无 | 兜底层(必加) |
| 前端防抖 | 按钮级 | 不适用 | ★☆☆ | 无 | 辅助层(必加) |
| Token + Redis | 用户 + 接口级 | 10w+ QPS | ★★★ | Redis | 通用业务系统 |
| request_id + DB 索引 | 请求级 | < 1w QPS | ★☆☆ | 无 | 中低并发后台 |
| request_id + Redis | 请求级 | 10w+ QPS | ★★☆ | Redis | 高并发首选 |
| HMAC 签名令牌 | 请求级(密码学校验) | 10w+ QPS | ★★★ | 密钥管理 | 公开路由 + 高安全 |
八、
最佳实践建议
推荐组合
1 | |
- 前端生成
request_id,零额外请求 - Redis
SETNX扛住并发 - DB 唯一索引兜底
这是大多数互联网公司的标准方案,兼顾性能与可靠性。
1 | |
- 无需 Redis,签发端纯计算零 DB 写入
- 密码学签名防伪造,时间戳防过期,bizType 防跨类型
- 适合 C 端公开接口(无登录态)+ 对安全性要求高的场景
收银系统、C 端小程序下单、开放 API 等无法要求登录态的场景。
1 | |
- Token 方案更严谨,可绑定用户 + 接口维度
- 适合对安全性要求更高的金融 / 支付场景
1 | |
- 无需引入 Redis,实现最简
- 适合管理后台、内部工具等低频操作场景
容易忽略的细节
Redis 锁必须设 TTL
即使 Redis 宕机,锁也能自动释放。
推荐用 SET key value NX EX 一条命令搞定,
避免 SETNX + EXPIRE 之间的宕机窗口。
锁释放要验证身份DEL 前检查 value 是否是自己写入的(UUID),
防止误删别人的锁。推荐用 Lua 脚本原子操作。
幂等返回 vs 报错
如果重复请求到达时订单已创建成功,
应返回 已有订单信息 而不是报错,
用户体验更好。
全链路日志
被拦截的重复请求也要记录日志:
用户 ID、request_id、时间戳、拦截原因。
排查问题时非常有价值。
Redis 安全释放锁(Lua 脚本)
1 | |
1 | |
总结
防重复下单没有一劳永逸的万能方案,靠的是 多层防御、纵深兜底:
- 订单号唯一索引 — 数据库层面的最后一道防线
- 前端防抖 — 挡住 90% 的”手抖”用户
- 后端幂等控制 — 核心中的核心,Redis 分布式锁是标准答案
- 令牌安全 — 如果接口面向公开路由,务必用 HMAC 签名而非裸 UUID
对于大多数业务系统,前端防抖 + request_id + Redis SetNX + DB 唯一索引 这套组合已经足够。如果接口暴露在公开路由且无法要求登录态,升级为 HMAC 签名令牌 方案,用密码学取代 DB 存储,零 DoS 风险。