电商订单幂等性

电商订单创建幂等性设计方案

目录

  1. 概述
    • 为什么需要幂等性
    • 重复请求的来源
    • 没有幂等性的后果
  2. 核心设计思路
    • 前置拦截(预防重复)
    • 后置去重(容忍重复)
  3. 具体方案实现
    • 3.1 后置去重方案
      • 数据库唯一索引
      • 状态机幂等
      • 乐观锁版本号
    • 3.2 前置拦截方案
      • Token 令牌机制
      • 分布式锁 + 数据库去重
  4. 方案对比与选型
    • 方案对比表
    • 选型建议
  5. 核心设计要点
  6. 电商场景推荐组合

一、概述

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)
);

执行流程

  1. 生成幂等键(userId + clientOrderNo)
  2. 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};

执行流程

  1. 查询获取当前版本号
  2. 执行业务逻辑
  3. 更新时带版本号条件
    • 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:幂等记录表数据量过大怎么处理?

  1. 设置过期时间:expire_at 字段,定期清理
  2. 分库分表:按 business_type 或时间分表
  3. 归档:历史数据迁移到冷存储
  4. 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. 修复后验证
   - 压测验证
   - 线上观察

一句话总结

幂等性设计 = 前置拦截(性能)+ 后置去重(可靠)+ 多层防御(完整)

posted @ 2026-05-19 11:22  SeiunSky  阅读(69)  评论(0)    收藏  举报