问题的本质

重复下单是电商 / 收银系统中最常见也最危险的 Bug 之一。一次重复下单可能导致:

  • 用户被重复扣款,引发客诉
  • 库存被多扣,数据不一致
  • 商家对账失败,财务出问题

为什么会出现重复下单?

  • 用户行为:快速连点”提交订单”按钮
  • 网络层:请求超时后客户端自动重试
  • 前端逻辑:页面重新渲染导致二次提交
  • 恶意攻击:脚本模拟并发下单

解决这个问题,需要从 前端到后端多层防御。本文逐层拆解,从最简单的方案讲到高并发场景下的最优解。

一 ~ 二
基础防线 + 前端防抖

三 ~ 五
三种后端防重方案


幂等令牌演进实战

七 ~ 八
方案对比 + 最佳实践


一、基础防线:订单号 + 数据库唯一索引

为什么需要唯一索引?

无论用什么算法生成订单号(雪花算法、时间戳 + 随机数、UUID 等),都存在 极小概率碰撞 的可能。数据库唯一索引是最后一道物理防线:

1
ALTER TABLE orders ADD UNIQUE INDEX uk_order_no (order_no);

当两个相同订单号的 INSERT 并发到达时,数据库会拒绝第二个,应用层捕获 DuplicateKeyException 返回错误即可。

关键点

唯一索引是兜底保障,不是主动防重手段。它只防”订单号重复”,不防”业务重复下单”。

局限性

  • 每次提交都会生成 不同的订单号,唯一索引无法识别”同一笔业务的重复提交”
  • 高并发下(如 10w QPS),大量 DuplicateKeyException 会影响数据库连接池
  • 无法提供友好的”请勿重复提交”提示
结论

唯一索引是必要的兜底,但必须配合上层方案一起使用。


二、前端防抖:成本最低的第一道防线

核心思路

用户点击”提交”按钮后,立即将按钮置灰(disabled),等待接口响应后再恢复。这是最简单也是效果最直接的防重复手段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<template>
<el-button :disabled="submitting" @click="submitOrder">
{{ submitting ? '提交中...' : '提交订单' }}
</el-button>
</template>

<script setup>
import { ref } from 'vue'

const submitting = ref(false)

async function submitOrder() {
if (submitting.value) return
submitting.value = true
try {
await api.createOrder(orderData)
// 跳转成功页
} catch (e) {
ElMessage.error('提交失败,请重试')
} finally {
submitting.value = false
}
}
</script>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function SubmitButton({ onSubmit }) {
const [loading, setLoading] = useState(false)

const handleClick = async () => {
if (loading) return
setLoading(true)
try {
await onSubmit()
} catch (e) {
toast.error('提交失败,请重试')
} finally {
setLoading(false)
}
}

return (
<button disabled={loading} onClick={handleClick}>
{loading ? '提交中...' : '提交订单'}
</button>
)
}

还能做什么?

按钮置灰
点击后立即 disabled,响应后恢复

请求节流
lodash throttle 限制 1s 内只发一次

遮罩层
全屏 Loading 遮罩,阻断所有操作

局限性

注意

前端防抖只能防”用户手抖”,无法防网络重试、脚本攻击、多设备同时提交。后端防重才是关键。


三、后端方案一:Redis 分布式锁 + 幂等 Token

这是 最经典也最通用 的后端防重方案。

核心原理

  1. 用户进入下单页时,服务端通过 Redis SETNX 生成一个 一次性 Token
  2. 用户提交订单时 必须携带该 Token
  3. 服务端用 GETDEL 原子消费 Token:成功则处理,失败则判定为重复提交
  4. Token 消费即删除,不可复用

查看请求时序图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
客户端                          服务端                        Redis
│ │ │
│ 1. 请求 Token │ │
├──────────────────────────────→│ │
│ │ SETNX token_key (TTL=5min)│
│ ├───────────────────────────→│
│ │◄─────────── OK ────────────┤
│◄──────── 返回 Token ──────────┤ │
│ │ │
│ 2. 提交订单 (携带 Token) │ │
├──────────────────────────────→│ │
│ │ GETDEL token_key │
│ ├───────────────────────────→│
│ │◄────── value / nil ────────┤
│ │ │
│ │ (value 非空 → 首次提交) │
│ │ 创建订单... │
│◄──────── 返回结果 ────────────┤ │

Redis Key 设计

Token 维度的 Key 推荐:

1
idempotent:{user_token}:{url}

Key 组成说明

  • user_token — 用户登录凭证,区分不同用户
  • url — 请求接口路径,区分不同操作
  • 设置合理的 TTL(如 5 分钟),过期自动释放

业务维度的 Key 推荐(适合收银台场景):

1
order:{merchant_id}:{cashier_id}:{member_id}:{amount}:{时间窗口}
设计思路

同一商家 + 同一收银员 + 同一会员 + 同一金额 + 短时间窗口 = 极大概率是同一笔订单,应拦截。

代码实现(Go)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 生成幂等 Token(用户进入下单页时调用)
func GenerateToken(ctx context.Context, rdb *redis.Client, userToken string) (string, error) {
token := uuid.New().String()
key := fmt.Sprintf("idempotent:%s:%s", userToken, token)
ok, err := rdb.SetNX(ctx, key, "1", 5*time.Minute).Result()
if err != nil {
return "", err
}
if !ok {
return "", errors.New("生成 Token 失败")
}
return token, nil
}

// 校验 Token + 创建订单
func CreateOrder(ctx context.Context, rdb *redis.Client, userToken, token string, req OrderReq) error {
// 1. GETDEL 原子消费 Token(取到值 = 首次提交,nil = 重复或过期)
tokenKey := fmt.Sprintf("idempotent:%s:%s", userToken, token)
val, err := rdb.GetDel(ctx, tokenKey).Result()
if err != nil && err != redis.Nil {
return err
}
if val == "" {
return errors.New("请勿重复提交")
}

// 2. 业务维度防重锁
bizKey := fmt.Sprintf("order:%s:%s:%s:%d:%d",
req.MerchantID, req.CashierID, req.MemberID,
req.Amount, time.Now().Unix()/5)
locked, err := rdb.SetNX(ctx, bizKey, "1", 10*time.Second).Result()
if err != nil {
return err
}
if !locked {
return errors.New("操作过于频繁,请稍后再试")
}
defer rdb.Del(ctx, bizKey)

// 3. 创建订单
return doCreateOrder(ctx, req)
}

优缺点

优点

  • Token 一次性(GETDEL 原子消费),天然防重放
  • SetNX / GetDel 原子操作,并发安全
  • Key 灵活设计,可按业务维度防重
  • Redis 单机可支撑 10w+ QPS

缺点

  • Token 需额外存储和管理(TTL 过期机制)
  • 多一次 Token 请求的网络开销
  • Token 生成与校验逻辑需前后端配合

四、后端方案二:请求唯一 ID + 数据库唯一索引

核心思路

前端每次提交时自行生成一个 唯一请求 IDrequest_id),服务端将其存入数据库唯一索引字段。重复请求因违反唯一约束而失败。

1
2
3
4
5
6
7
8
9
10
func CreateOrder(ctx context.Context, db *sql.DB, req OrderReq) error {
// request_id 由前端生成,放在 Header 或 Body 中
_, err := db.ExecContext(ctx,
`INSERT INTO orders (order_no, request_id, ...) VALUES (?, ?, ...)`,
generateOrderNo(), req.RequestID)
if isDuplicateKeyError(err) {
return errors.New("请勿重复提交")
}
return err
}
前端生成

request_id 的推荐方式:crypto.randomUUID()(浏览器)或 uuid.New().String()(客户端)

优缺点

优点

  • 实现最简单,无需 Redis
  • 数据库唯一约束是强保证
  • 无额外网络请求

缺点

  • 高并发下 DB 唯一索引锁竞争严重
  • 10w QPS 直接打 DB,连接池扛不住
  • 唯一索引维护成本随数据量上升
适用场景

QPS < 1w 的中低并发场景,如内部管理后台、低频收银台。简单可靠,但不适合大促秒杀。


五、后端方案三:Redis 分布式锁 + 请求唯一 ID

这是 方案二的优化版,将 DB 唯一索引替换为 Redis SETNX,兼顾高并发与实现简洁性。

核心思路

前端自行生成唯一请求 ID,后端直接用 Redis SETNX 做幂等校验,不再依赖数据库唯一索引,也 不需要额外的 Token 请求

查看请求时序图

1
2
3
4
5
6
7
8
9
10
11
前端                              后端                        Redis
│ │ │
│ 提交订单 (携带 request_id) │ │
├──────────────────────────────→│ │
│ │ SETNX req:{id} (TTL=10s) │
│ ├───────────────────────────→│
│ │◄─────────── OK ────────────┤
│ │ │
│ │ 创建订单... │
│ │ DEL lock_key │
│◄──────── 返回结果 ────────────┤ │

代码实现(Go)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func CreateOrder(ctx context.Context, rdb *redis.Client, req OrderReq) error {
// 1. Redis SetNX 防重(request_id 由前端生成)
lockKey := fmt.Sprintf("req_lock:%s", req.RequestID)
ok, err := rdb.SetNX(ctx, lockKey, "1", 10*time.Second).Result()
if err != nil {
return fmt.Errorf("Redis 操作失败: %w", err)
}
if !ok {
return errors.New("请勿重复提交")
}
defer rdb.Del(ctx, lockKey)

// 2. 创建订单
return doCreateOrder(ctx, req)
}

对比 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
2
UPDATE orders SET pay_status='paying'
WHERE order_no=? AND pay_status='no_pay'

MySQL 行级锁保证只有第一个请求能拿到 RowsAffected=1,其余请求直接失败。这就是经典的 CAS(Compare And Swap) 保护。加上三方支付网关自身的订单号去重、微信/支付宝客户端支付弹窗只能拉起一次,形成三道纵深防线。

但订单创建没有 CAS

pay_status 在创建时就是 no_pay,INSERT 操作没有前置状态可以比对,两张相同内容的订单可以同时 INSERT 成功。这就是为什么订单创建需要额外的防重机制。


6.2 第一版:UUID 幂等令牌

设计:新增 idempotent_keys 表,idempotent_key 字段加唯一索引。前端生成 UUID 传入,服务端 INSERT 判重。

1
2
3
4
5
6
7
CREATE TABLE idempotent_keys (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
idempotent_key VARCHAR(36) NOT NULL,
biz_type VARCHAR(20) NOT NULL DEFAULT 'order',
created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3),
UNIQUE INDEX idx_idempotent_key (idempotent_key)
);
1
2
3
4
前端生成 UUID → createOrders(uuid)
服务端 INSERT INTO idempotent_keys(idempotent_key, biz_type) VALUES(?, ?)
→ INSERT 成功:首次提交,继续创建订单
→ Duplicate entry:重复提交,拒绝

解决了什么

快速双击、网络重试、页面刷新重提交,
全部被唯一索引拦截

遗留问题

UUID 由前端生成,服务端无法验证来源。
攻击者自己生成 UUID 传入,INSERT 照样成功——
一个任何人都能伪造的令牌,不算真正的令牌

对于订单创建来说其实已经够用(CAS

在支付环节保护了真正的资金操作)。但从”幂等令牌”的设计语义来看,这还远远不够。


6.3 第二版:预签发模式(Status 状态机)

核心想法:如果 token 在签发时就写入 DB(status=unused),消费时原子更新为 used,那么只有服务端签发过的 token 才存在于 DB 中,伪造的 UUID 在 DB 中没有记录,UPDATE 匹配 0 行,被拒绝。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 签发
func GetSubmitToken(c *gin.Context) {
token := uuid.NewString()
IssueToken(token, "order") // INSERT token, status='unused'
c.JSON(200, gin.H{"token": token})
}

// 消费
func CreateOrder(c *gin.Context) {
rows := ConsumeToken(token, "order") // UPDATE status='used' WHERE status='unused'
if rows == 0 {
c.JSON(400, gin.H{"error": "请勿重复提交"})
return
}
doCreateOrder(c)
}

解决了什么:伪造 token 被拒绝 ✅ · 同一 token 只能用一次 ✅ · 服务端有完整的签发记录 ✅

但暴露了致命漏洞

1
2
攻击者:while(true) { GET /orders/getSubmitToken }
结果:idempotent_keys 表被无限灌满 → 磁盘耗尽 → 服务崩溃
致命问题

getSubmitToken 注册在公开路由(C 端用户不登录也可下单),每次调用都写 DB → 零成本 DoS 攻击向量。

尝试过的修复方向:

方向 A:移到鉴权路由
C 端小程序 / H5 用户不登录也可以下单getSubmitToken 必须在公开路由
→ ❌ 业务不允许

方向 B:IP 限频
需要 Redis 计数器,分布式 IP 伪造可绕过,治标不治本
→ ❌ 复杂度换安全性,不划算

根本矛盾

1
2
公开路由 + 预签发写 DB = DoS 漏洞
公开路由 + 不写 DB = 回到第一版(token 无服务端背书)

要打破这个矛盾,必须找到一种 不写 DB 也能证明”这个 token 是服务端签发” 的方式。


6.4 最终版:HMAC 签名令牌

核心想法:服务端持有密钥(IDEMPOTENT_SECRET),对 token 内容做 HMAC-SHA256 签名。签名本身就是服务端背书,不需要 DB 存储

Token 格式

1
2
3
order.a1b2c3d4-e5f6-7890-abcd-ef1234567890.1718234567.9f8e7d6c5b4a3...
↑ ↑ ↑ ↑
业务类型 UUID(防跨订单复用) Unix时间戳 HMAC-SHA256签名

签发(纯计算,零 DB 写入)

1
2
3
4
5
func GetSubmitToken(bizType string) string {
payload := fmt.Sprintf("%s.%s.%d", bizType, uuid.NewString(), time.Now().Unix())
sig := hmacSHA256(getSecret(), payload)
return payload + "." + sig // 4段拼接,约100字符
}

消费(五步验证)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func CheckAndRecord(token, bizType string) bool {
parts := strings.SplitN(token, ".", 4) // 1. 解析结构(必须是4段)
if len(parts) != 4 { return false }

if parts[0] != bizType { return false } // 2. 业务类型匹配

sig := hmacSHA256(getSecret(), strings.Join(parts[:3], "."))
if !hmac.Equal([]byte(sig), []byte(parts[3])) { // 3. HMAC 验签(常数时间比较)
return false
}

ts, _ := strconv.ParseInt(parts[2], 10, 64)
if time.Since(time.Unix(ts, 0)) > 30*time.Minute { // 4. 有效期(30分钟)
return false
}

return insertIdempotentKey(token) // 5. INSERT 唯一索引(防同一 token 跨订单复用)
}

密钥管理代码

1
2
3
4
5
6
7
8
// 从环境变量读取,生产环境必须设置 IDEMPOTENT_SECRET
func getSecret() string {
secret := os.Getenv("IDEMPOTENT_SECRET")
if secret == "" {
secret = "change-me-in-production" // 仅开发环境 fallback
}
return secret
}

部署时设置:

1
export IDEMPOTENT_SECRET="your-production-secret-key-at-least-32-chars"
核心洞察

证明”这个 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
第一版(UUID INSERT)
└─ 解决了并发重复 ✅
└─ 但 token 无服务端背书,任何人可伪造 ❌
└─ 本质:服务端无法区分"我签发的"和"攻击者随机生成的"

第二版(预签发 Status 状态机)
└─ 通过 DB 记录解决了伪造 ✅
└─ 但公开路由写 DB → DoS 漏洞 ❌
└─ 本质:证明 token 是服务端签发的,不一定需要 DB 记录

最终版(HMAC 签名)
└─ 签名 = 密码学背书(不需要 DB 存储)
└─ 时间戳 = 有效期控制(不需要 TTL 字段)
└─ bizType 嵌入签名 = 跨业务类型防护(不需要额外字段)
└─ DB 只在消费时写一次(唯一索引防跨订单复用)

6.6 三种令牌的本质对比

回头看 request_id、幂等 Token、HMAC 签名令牌——三者本质上都是幂等键,目的完全一样:让服务器识别”这是同一次请求意图,不要重复处理”。区别只在于谁生成、怎么防伪造。

request_id幂等 TokenHMAC 签名令牌
谁生成前端 / 客户端后端后端
本质一个随机 UUIDUUID,存 Redis / DBToken + 密钥签名
防重复✅ 靠唯一性✅ 靠 Redis SETNX✅ 靠 Redis SETNX
防伪造❌ 前端随便编一个❌ 可猜测 / 遍历✅ 没密钥就造不出来
复杂度最低最高

三代演进

第 1 代:request_id

前端生成 UUID,后端去重
问题:前端可以随便编一个不存在的 id,后端无法验证合法性

第 2 代:幂等 Token

后端生成 UUID 存 Redis,前端拿到后带着它请求
问题:Token 被截获后可重放;公开路由预签发有 DoS 风险

第 3 代:HMAC 签名令牌

后端生成 Token + 密钥签名,前端带着签名请求,后端先验签再去重
解决:伪造、篡改、过期、跨类型全部拒绝

下单场景该选哪个?

实战建议

对于 防止重复下单,第 2 代(幂等 Token + Redis)通常就够了:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 1. 下单前,前端先请求一个 Token
token := uuid.NewString()
rdb.Set(ctx, "idempotent:"+token, userId, 10*time.Minute)

// 2. 下单时带着 Token
func CreateOrder(token string) error {
deleted, _ := rdb.Del(ctx, "idempotent:"+token).Result()
if deleted == 0 {
return errors.New("请勿重复提交")
}
// 创建订单...
return nil
}

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
2
3
4
5
路由层:POST /orders/createOrders → Idempotent("order") → CreateOrders handler

中间件从 X-Idempotent-Key 读取令牌
验签 + 去重,失败直接 400 返回
handler 完全不知道幂等的存在

bizType 推荐用常量集中管理(如 IdempotentBizOrder = "order"),签发端和消费端共用同一组常量,新增业务类型只改一处。


6.9 前端接入设计

前端与后端的交互分为两步:获取令牌携带令牌提交

1
2
3
4
5
6
7
8
9
10
// 步骤1:获取签名令牌(每次提交前获取,不可复用)
GET /orders/getSubmitToken?bizType=order
→ 返回: { "token": "order.a1b2c3d4-...1718234567.9f8e7d..." }

// 步骤2:携带令牌提交(放在请求头,不放请求体)
POST /orders/createOrders
Headers:
X-Idempotent-Key: order.a1b2c3d4-...1718234567.9f8e7d...
Body:
{ "orderAmount": 1000, ... } // 只有业务字段

前端封装建议

推荐将”获取令牌 + 携带令牌”封装为一个通用函数,调用方只需传入 URL、业务类型和数据,无需关心令牌细节:

1
2
3
4
5
6
7
8
9
10
11
// 伪代码:带幂等保护的 POST 请求
async function idempotentPost(url, bizType, data) {
const token = await getSubmitToken(bizType) // 1. 获取令牌
return http.post(url, data, { // 2. 令牌放 Header
headers: { 'X-Idempotent-Key': token }
})
}

// 使用示例
await idempotentPost('/orders/createOrders', 'order', orderData)
await idempotentPost('/payment/refundPayment', 'refund', refundData)

注意事项

  • 令牌 一次性,每次提交前必须重新获取,不可缓存复用
  • 令牌 30 分钟有效,获取后应尽快使用
  • bizType 必须与实际接口匹配,否则验签失败

七、方案对比总结

方案防重粒度性能上限实现复杂度额外依赖适用场景
订单号唯一索引订单号级受限于 DB★☆☆兜底层(必加)
前端防抖按钮级不适用★☆☆辅助层(必加)
Token + Redis用户 + 接口级10w+ QPS★★★Redis通用业务系统
request_id + DB 索引请求级< 1w QPS★☆☆中低并发后台
request_id + Redis请求级10w+ QPS★★☆Redis高并发首选
HMAC 签名令牌请求级(密码学校验)10w+ QPS★★★密钥管理公开路由 + 高安全

八、最佳实践建议

推荐组合

1
前端防抖 + 请求唯一 ID + Redis 分布式锁 + 订单号唯一索引
  • 前端生成 request_id,零额外请求
  • Redis SETNX 扛住并发
  • DB 唯一索引兜底
推荐

这是大多数互联网公司的标准方案,兼顾性能与可靠性。

1
前端防抖 + HMAC 签名令牌 + DB 唯一索引
  • 无需 Redis,签发端纯计算零 DB 写入
  • 密码学签名防伪造,时间戳防过期,bizType 防跨类型
  • 适合 C 端公开接口(无登录态)+ 对安全性要求高的场景
适用

收银系统、C 端小程序下单、开放 API 等无法要求登录态的场景。

1
前端防抖 + Token + Redis + 订单号唯一索引
  • Token 方案更严谨,可绑定用户 + 接口维度
  • 适合对安全性要求更高的金融 / 支付场景
1
前端防抖 + request_id + DB 唯一索引
  • 无需引入 Redis,实现最简
  • 适合管理后台、内部工具等低频操作场景

容易忽略的细节

Redis 锁必须设 TTL
即使 Redis 宕机,锁也能自动释放。
推荐用 SET key value NX EX 一条命令搞定,
避免 SETNX + EXPIRE 之间的宕机窗口。

锁释放要验证身份
DEL 前检查 value 是否是自己写入的(UUID),
防止误删别人的锁。推荐用 Lua 脚本原子操作。

幂等返回 vs 报错
如果重复请求到达时订单已创建成功,
应返回 已有订单信息 而不是报错,
用户体验更好。

全链路日志
被拦截的重复请求也要记录日志:
用户 ID、request_id、时间戳、拦截原因。
排查问题时非常有价值。

Redis 安全释放锁(Lua 脚本)

1
2
3
4
5
6
-- 只有 value 匹配时才删除锁,防止误删
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
1
2
3
// Go 中使用
script := redis.NewScript(luaScript)
script.Run(ctx, rdb, []string{lockKey}, expectedValue)

总结

防重复下单没有一劳永逸的万能方案,靠的是 多层防御、纵深兜底

  1. 订单号唯一索引 — 数据库层面的最后一道防线
  2. 前端防抖 — 挡住 90% 的”手抖”用户
  3. 后端幂等控制 — 核心中的核心,Redis 分布式锁是标准答案
  4. 令牌安全 — 如果接口面向公开路由,务必用 HMAC 签名而非裸 UUID

对于大多数业务系统,前端防抖 + request_id + Redis SetNX + DB 唯一索引 这套组合已经足够。如果接口暴露在公开路由且无法要求登录态,升级为 HMAC 签名令牌 方案,用密码学取代 DB 存储,零 DoS 风险。