电商订单幂等性
电商订单创建幂等性设计方案
目录
- 概述
- 为什么需要幂等性
- 重复请求的来源
- 没有幂等性的后果
- 核心设计思路
- 前置拦截(预防重复)
- 后置去重(容忍重复)
- 具体方案实现
- 3.1 后置去重方案
- 数据库唯一索引
- 状态机幂等
- 乐观锁版本号
- 3.2 前置拦截方案
- Token 令牌机制
- 分布式锁 + 数据库去重
- 3.1 后置去重方案
- 方案对比与选型
- 方案对比表
- 选型建议
- 核心设计要点
- 电商场景推荐组合
一、概述
1.1 为什么需要幂等性
在分布式系统中,网络超时、重试机制、消息重复投递等问题不可避免。幂等性保证同一操作执行多次,结果与执行一次相同。
1.2 重复请求的来源
| 来源 | 场景 |
|---|---|
| 用户操作 | 重复点击提交按钮 |
| 客户端重试 | 网络超时后自动重试 |
| 网关转发 | 负载均衡重复转发请求 |
| 第三方回调 | 支付/物流重复通知 |
1.3 没有幂等性的后果
- 重复扣款
- 重复扣减库存
- 同一用户收到多份商品
- 数据不一致
二、核心设计思路
所有幂等方案可归纳为两种核心思路:
2.1 前置拦截(预防重复)
在业务执行前拦截重复请求,不执行业务逻辑。
请求 → 检查是否已处理 → 已处理?→ 是 → 直接返回缓存结果
↓
否 → 执行业务 → 记录已处理 → 返回结果
特点:
- 业务逻辑只执行一次
- 需要额外存储记录状态
- 适合资源消耗大的操作(扣款、发券)
对应方案:Token 机制、分布式锁、幂等记录表
2.2 后置去重(容忍重复)
允许请求执行业务,通过唯一约束保证最终数据一致。
请求 → 执行业务 → 写入数据 → 唯一约束冲突?→ 是 → 返回成功(幂等)
↓ 否 → 正常返回
成功
特点:
- 实现简单,无需前置检查
- 业务逻辑可能执行多次(需保证可重入)
- 适合天然有唯一字段的场景
对应方案:数据库唯一索引、状态机幂等、乐观锁版本号
三、具体方案实现
3.1 后置去重方案
3.1.1 数据库唯一索引
核心思想:利用数据库唯一约束,让重复请求写入时自然失败。
-- 幂等记录表
CREATE TABLE idempotent_record (
idempotency_key VARCHAR(64) NOT NULL,
business_type VARCHAR(32) NOT NULL,
request_params TEXT,
response_result TEXT,
expire_at DATETIME,
created_at DATETIME DEFAULT NOW(),
PRIMARY KEY (idempotency_key, business_type)
);
执行流程:
- 生成幂等键(userId + clientOrderNo)
- INSERT 幂等记录表
- 成功 → 执行业务 → 更新为"已完成"
- 失败 → 重复请求 → 返回已有结果
要点:
- 幂等键由客户端生成
- 需设置过期时间
3.1.2 状态机幂等
核心思想:利用状态机不可逆性,通过状态条件控制流转。
-- 支付回调:只有 CREATED 才能变为 PAID
UPDATE orders
SET status = 'PAID', paid_at = NOW()
WHERE order_no = 'O001' AND status = 'CREATED';
-- affected_rows = 0 表示已处理过,返回成功
状态流转规则:
CREATED → PAID → SHIPPED → COMPLETED
↓
CANCELLED
要点:
- 状态流转单向,不能回退
- 通过 WHERE 状态条件实现幂等
3.1.3 乐观锁版本号
核心思想:通过版本号控制并发,同时实现幂等。
-- 订单表增加 version 字段
CREATE TABLE orders (
order_no VARCHAR(32) PRIMARY KEY,
status VARCHAR(16),
version INT DEFAULT 0,
...
);
-- 更新时带版本号条件
UPDATE orders
SET status = 'PAID', version = version + 1
WHERE order_no = 'O001' AND version = #{oldVersion};
执行流程:
- 查询获取当前版本号
- 执行业务逻辑
- 更新时带版本号条件
- affected_rows = 1 → 成功
- affected_rows = 0 → 版本已变,查询最新状态返回
与状态机幂等的区别:
| 对比项 | 状态机幂等 | 乐观锁版本号 |
|---|---|---|
| 控制维度 | 业务状态流转 | 数据版本一致性 |
| WHERE 条件 | status = '旧状态' | version = 旧版本 |
| 适用场景 | 状态明确的业务流程 | 通用并发控制 + 幂等 |
3.2 前置拦截方案
3.2.1 Token 令牌机制
核心思想:服务端预发一次性 Token,客户端提交时携带验证。
流程:
客户端 → 申请 Token → 服务端生成并缓存(Redis)
↓
客户端 → 提交订单+Token → 服务端验证并删除 Token
↓
验证通过 → 执行业务
验证失败 → 拒绝(重复提交)
Redis + Lua 实现(原子性):
local token = redis.call('get', KEYS[1])
if token then
redis.call('del', token)
return 1 -- 成功
else
return 0 -- 失败
end
为什么用 Lua 而不是事务?
| 对比项 | Redis 事务 | Lua 脚本 |
|---|---|---|
| 条件判断 | ❌ 不支持 | ✅ 支持 |
| 命令依赖 | ❌ 独立执行 | ✅ 可依赖结果 |
| 典型问题 | GET 后无法决定是否 DEL | 可判断后执行 |
要点:
- Token 一次性使用
- 需设置过期时间
3.2.2 分布式锁 + 数据库去重
核心思想:先加锁防并发,再查重保证幂等。
执行流程:
1. 获取分布式锁(Redis SETNX)
└─ 失败 → 返回"处理中"
2. 查询幂等记录表
├─ 已完成 → 释放锁 → 返回结果
└─ 无记录 → 继续执行
3. 执行业务逻辑
4. 写入幂等记录表
5. 释放分布式锁
要点:
- 分布式锁解决并发控制
- 幂等记录表解决重复请求
- 两者结合实现完整幂等
四、方案对比与选型
4.1 方案对比表
| 方案 | 思路 | 适用场景 | 复杂度 |
|---|---|---|---|
| 数据库唯一索引 | 后置去重 | 天然有唯一业务字段 | 低 |
| 状态机幂等 | 后置去重 | 有明确状态流转的业务 | 中 |
| 乐观锁版本号 | 后置去重 | 并发控制 + 幂等 | 中 |
| Token 机制 | 前置拦截 | 防用户重复提交 | 中 |
| 分布式锁 | 前置拦截 | 高并发并发控制 | 中 |
| 幂等记录表 | 前置拦截 | 需要缓存响应结果 | 中 |
| 组合方案 | 前置拦截 | 核心业务流程 | 高 |
4.2 选型建议
| 场景 | 推荐方案 |
|---|---|
| 简单场景(订单号唯一) | 数据库唯一索引 |
| 防用户重复提交 | Token 机制 |
| 高并发场景 | 分布式锁 + 幂等记录表 |
| 资金类操作 | MySQL + 分布式锁 |
| 支付回调 | 状态机幂等 |
五、核心设计要点
| 要点 | 说明 |
|---|---|
| 幂等键设计 | 业务类型 + 用户标识 + 客户端单号 |
| 客户端生成 | 幂等键由客户端生成,服务端只校验 |
| 过期清理 | 设置 TTL 或定时清理,避免数据膨胀 |
| 响应缓存 | 重复请求返回首次结果,保证一致 |
| 一致性校验 | 重试时参数不同应拒绝 |
| 原子操作 | 查重、更新必须原子,避免竞态条件 |
| 异常处理 | 业务异常标记失败,允许重试 |
六、电商场景推荐组合
实际系统中通常组合使用多种方案:
创建订单流程:
├─ 前端:按钮置灰 + Token 机制(防用户手抖)
├─ 网关:限流 + 请求去重(短时间相同请求拦截)
├─ 服务层:幂等记录表 + 分布式锁(核心幂等保障)
├─ 数据库:唯一索引(order_no)+ 状态机(最终兜底)
└─ 支付回调:幂等键 + 状态条件更新(防重复回调)
多层防御设计,最大程度保证订单创建的幂等性。
七、高频面试题
技术理解类
Q1:幂等性和并发控制有什么区别?
答:
- 幂等性:解决重复请求问题,同一请求执行多次结果相同
- 并发控制:解决同时修改问题,多个请求同时操作同一数据
- 关系:两者常结合使用,如分布式锁既控制并发又实现幂等
举例:
重复请求:用户双击提交按钮 → 幂等性解决
并发请求:两个用户同时抢最后一件库存 → 并发控制解决
Q2:为什么 Redis 事务不能保证原子性,而 Lua 脚本可以?
答:
- Redis 事务(MULTI/EXEC):只是批量执行命令,没有条件判断能力
- Lua 脚本:在 Redis 服务端作为一个整体执行,执行期间其他命令排队
关键区别:
事务:GET → DEL(无条件执行)
Lua:if GET then DEL(条件判断后执行)
Q3:分布式锁和数据库唯一索引都能防重,怎么选?
答:
| 维度 | 分布式锁 | 数据库唯一索引 |
|---|---|---|
| 作用时机 | 执行前拦截 | 执行后去重 |
| 性能 | 高(Redis) | 低(数据库压力) |
| 可靠性 | 可能失效(锁过期) | 绝对可靠 |
| 适用 | 高并发场景 | 最终兜底 |
最佳实践:两者结合,Redis 锁防并发,数据库唯一索引兜底
Q4:Token 机制中,如果用户拿到 Token 后一直不提交,会有什么风险?
答:
- 风险:Token 堆积占用内存
- 解决:设置 Token 过期时间(如 5 分钟)
- 补充:Token 申请频率限制,防止恶意刷 Token
Q5:幂等记录表数据量过大怎么处理?
答:
- 设置过期时间:expire_at 字段,定期清理
- 分库分表:按 business_type 或时间分表
- 归档:历史数据迁移到冷存储
- Redis 预热:热点数据放 Redis,减少数据库压力
业务场景类
Q6:支付回调超时,第三方重复通知,怎么保证幂等?
答:
方案:状态机幂等 + 幂等记录表
1. 接收回调时,先查幂等记录表
- 已处理 → 直接返回成功
2. 未处理,执行状态更新
UPDATE orders SET status = 'PAID'
WHERE order_no = 'O001' AND status = 'CREATED'
3. 更新成功 → 写入幂等记录表 → 返回成功
4. 更新失败(status 已变)→ 查询最新状态 → 返回成功
关键:重复通知返回相同结果,不重复执行业务
Q7:用户连续快速点击提交订单,如何设计?
答:
多层防御:
前端:
- 按钮置灰(立即禁用)
- Loading 状态
网关:
- 同一用户短时间限流(如 1 秒 1 次)
服务层:
- Token 机制(一次性令牌)
- 分布式锁(用户维度)
数据库:
- 唯一索引(order_no)兜底
Q8:库存扣减和订单创建如何保持一致性?
答:
方案:后置去重 + 分布式事务
1. 先扣库存(乐观锁)
UPDATE stock SET count = count - 1, version = version + 1
WHERE sku_id = 1001 AND count > 0 AND version = #{version}
2. 后创建订单(唯一索引)
INSERT INTO orders (order_no, ...) VALUES ('O001', ...)
3. 库存扣减成功但订单创建失败?
- 捕获异常,发送补偿消息
- 定时任务回滚库存
或者使用 TCC 事务:
- Try:预扣库存
- Confirm:确认扣减,创建订单
- Cancel:释放库存
Q9:如果幂等键被恶意伪造,怎么防护?
答:
防护措施:
1. 幂等键生成规则加密
- 客户端:AES 加密(userId + timestamp + nonce)
- 服务端:解密验证合法性
2. 请求签名验证
- 参数 + 密钥生成签名
- 服务端验签,防止篡改
3. 用户权限校验
- 幂等键中的 userId 必须与登录用户一致
4. 限流
- 单用户幂等键申请频率限制
Q10:微服务架构下,跨服务的幂等性怎么保证?
答:
场景:订单服务 → 调用 → 库存服务 → 调用 → 优惠券服务
方案:传递幂等上下文
1. 入口服务生成全局幂等键(如 TraceId)
Header: X-Idempotency-Key: ORDER_20240511_001
2. 所有下游服务接收并传递该 Key
3. 每个服务内部:
- 查询是否已处理该 Key
- 已处理 → 返回缓存结果
- 未处理 → 执行业务 → 记录 Key
4. 配合分布式事务(Seata/Saga)保证一致性
场景设计类
Q11:设计一个支持 10万 QPS 的订单创建接口
答:
架构设计:
接入层:
- CDN 静态资源
- Nginx 负载均衡(轮询/一致性哈希)
- 限流:令牌桶算法,单 IP/用户限流
应用层:
- 集群部署(100+ 实例)
- Token 机制防重复(Redis Cluster)
- 本地缓存热点数据(Caffeine)
数据层:
- 分库分表(按 user_id 分 128 库)
- 读写分离(主写从读)
- 异步写订单(MQ 削峰)
幂等设计:
- 前置:Redis 分布式锁(SETNX EX)
- 后置:数据库唯一索引(order_no)
- 缓存:Redis 存储处理中的请求
降级策略:
- Redis 故障 → 降级为数据库唯一索引兜底
- MQ 堆积 → 降级为同步处理,限流保护
Q12:如何排查线上重复订单问题?
答:
排查步骤:
1. 确认重复特征
- 同一用户?同一时间段?同一商品?
- 查看订单创建时间差(毫秒级?秒级?)
2. 检查日志
- 是否收到重复请求(请求日志)
- 幂等校验是否通过(幂等日志)
- 分布式锁是否生效(Redis 日志)
3. 检查幂等实现
- 幂等键是否唯一?
- 锁是否过期过早?
- 数据库唯一索引是否存在?
4. 常见原因
- 客户端超时重试 → 增加 Token 机制
- 锁过期但业务未完成 → 调整锁时间 + 看门狗
- 幂等键生成冲突 → 增加业务类型前缀
5. 修复后验证
- 压测验证
- 线上观察
一句话总结
幂等性设计 = 前置拦截(性能)+ 后置去重(可靠)+ 多层防御(完整)

浙公网安备 33010602011771号