综合案例:订单系统设计
本章把前面各章的技术点串成一个完整案例:为中型电商设计订单系统,覆盖量级假设、分层架构、分库分表、下单与支付幂等、超时关单、查询分页与监控降级。
一、需求与量级假设
| 项目 | 假设值 | 推导 |
|---|---|---|
| 日订单量 | 100 万单 | 日活 200 万,人均 0.5 单 |
| 峰值下单 QPS | 3000 | 日均约 12,大促瞬时集中,峰值系数取 250 |
| 峰值查询 QPS | 20000 | 订单列表与详情,读远大于写 |
| 日增数据量 | 约 2GB | 100 万 × 2KB,在线保留 1 年约需 730GB |
非功能要求:下单 P99 低于 300ms、库存绝不超卖、支付回调不重复扣款、数据不丢,且核心链路可降级。
二、架构分层
接入层 CDN 与网关(鉴权、限流、路由、灰度分流)
↓
应用层 订单服务(下单/取消/查询)|支付服务|库存服务
↓
异步层 消息队列(下单事件、支付事件、关单延迟消息)
↓
缓存层 Redis:库存、幂等令牌、订单热点、限流计数
↓
存储层 订单库(分库分表)|库存库|支付流水库|归档库
分层原则:同步链路只做必要的事(校验、扣库存、写订单),通知、积分、统计、推送全部异步化,失败可重试可补偿。
三、表设计与分库分表
-- 订单主表:user_id 为分片键,同一用户的订单落在同一库表,便于“我的订单”查询
CREATE TABLE t_order (
order_id BIGINT NOT NULL COMMENT '全局订单号',
user_id BIGINT NOT NULL,
sku_id BIGINT NOT NULL,
amount DECIMAL(12,2) NOT NULL,
status TINYINT NOT NULL COMMENT '10待支付 20已支付 30已关闭',
create_time DATETIME(3) NOT NULL,
update_time DATETIME(3) NOT NULL,
PRIMARY KEY (order_id),
KEY idx_user_ctime (user_id, create_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表';
分片方案:按 user_id 哈希分 16 库 × 64 表,单表控制在 300 万行以内。选 user_id 是因为主查询是“我的订单列表”,按它路由可单库单表完成;按 order_id 查询(详情、支付回调)通过“订单号与用户 id 的映射”定位。幂等去重表以 (req_no, biz_type) 为唯一索引,把重复请求挡在业务逻辑之前。不要同时按两个维度分片,否则会产生大量跨库聚合查询。
四、下单流程:幂等 + 锁库存 + 事务
1. 参数校验与风控(同步,失败直接返回)
2. 幂等校验:reqNo 写入幂等表,唯一键冲突则返回上一次结果
3. 库存预扣:Redis 用 Lua 脚本原子扣减,失败返回“库存不足”
4. 本地事务:写订单主表与明细(同库,单机事务)
5. 事务提交后发送“下单成功”消息(或用本地消息表保证不丢)
6. 超时未支付由延迟消息与定时任务双保险关单
// 以下片段需放入 main 函数中运行(省略仓储与库存组件实现)
func CreateOrder(ctx context.Context, req *CreateOrderReq) (*Order, error) {
if !idem.TryLock(ctx, req.ReqNo, "order_create") { // 幂等:插入成功才继续
return idem.GetLastResult(ctx, req.ReqNo) // 重放首次结果
}
ok, err := stock.TryDeduct(ctx, req.SkuID, req.Quantity) // Lua 原子预扣库存
if err != nil || !ok {
return nil, ErrStockNotEnough
}
order, err := orderRepo.CreateInTx(ctx, req) // 本地事务写订单
if err != nil {
stock.Rollback(ctx, req.SkuID, req.Quantity) // 失败必须回补库存
return nil, err
}
idem.MarkSuccess(ctx, req.ReqNo, order.OrderID) // 记录结果供重放
mq.Publish(ctx, "order_created", order) // 异步通知下游
return order, nil
}
三条要点:幂等必须靠唯一索引而不是“先查后插”(并发下后者必然漏);缓存预扣与数据库扣减要成对出现,数据库侧用 UPDATE ... WHERE stock >= n 形式做兜底;数据库事务只包住必要写操作,绝不跨库。
五、支付回调幂等
支付渠道会重复回调(渠道重试、网络超时后我方重发),因此回调必须幂等且不重复扣款:
1. 验签:验证渠道签名,防止伪造回调
2. 幂等:以渠道流水号 payment_no 为唯一键插入支付流水表
插入成功 → 首次处理;唯一键冲突 → 已处理过,直接返回成功
3. 状态机校验:仅当订单为“待支付”时才置为“已支付”
4. 发消息:通知发货、积分、统计(异步且幂等消费)
5. 无论是否首次处理,都按渠道要求返回成功,避免持续重试
CREATE TABLE t_payment (
payment_no VARCHAR(64) NOT NULL COMMENT '渠道流水号,唯一',
order_id BIGINT NOT NULL,
amount DECIMAL(12,2) NOT NULL,
PRIMARY KEY (payment_no)
) COMMENT='支付流水表';
-- 状态用条件更新推进,天然幂等:影响行数 0 说明已经处理过
UPDATE t_order SET status = 20, update_time = NOW(3) WHERE order_id = #{orderId} AND status = 10;
六、超时关单
| 方案 | 原理 | 优缺点 |
|---|---|---|
| 延迟消息 | 投递 15 分钟后可见的消息 | 精度高、实现简单,依赖消息队列的延迟能力 |
| 定时扫描 | 每分钟扫描超时未支付订单 | 不依赖特殊能力,有扫描成本,需索引支撑 |
UPDATE t_order SET status = 30, close_time = NOW(3)
WHERE order_id = #{orderId} AND status = 10;
-- 影响行数为 1 才回补库存;为 0 说明已支付或已关闭,直接跳过
关单与支付会竞争同一订单状态,两者都必须用条件更新(状态机),避免“用户已支付却被关单”。
七、查询与分页
| 查询场景 | 路由方式 | 分页策略 |
|---|---|---|
| 我的订单列表 | 按 user_id 定位单库单表 | 游标分页 (create_time, order_id) < (?, ?) |
| 订单详情 | 按 order_id 反查 user_id 后定位 | 单条查询,无深分页问题 |
| 后台条件检索 | 走搜索引擎或离线宽表 | 不做在线跨库深分页 |
八、监控与降级
- 监控:下单成功率、支付成功率、库存扣减失败率、消息堆积量、下单 P99、幂等表增长速率。
- 降级顺序(从轻到重):关闭非核心功能(推荐、优惠)→ 异步链路放弃执行(积分、推送)→ 下单限流排队 → 只读模式。
- 兜底对账:每日核对缓存与数据库库存差异、订单与支付流水差异,异常自动告警并生成人工处理单。
九、设计方法论 Checklist
| 维度 | 必答问题 |
|---|---|
| 需求 | 量级多少?峰值系数多少?读多还是写多?可否接受最终一致? |
| 数据 | 分片键选谁?全局唯一 id 怎么生成?冷热数据如何归档? |
| 一致性 | 哪些操作必须幂等?幂等键是什么?失败如何补偿或对账? |
| 性能 | 缓存哪些数据?索引是否覆盖查询?深分页是否已避免? |
| 可用性 | 依赖挂掉怎么办?限流与降级阈值是多少?能否一键回滚? |
| 可观测 | 有哪些业务指标?告警是否可行动?能否按 TraceID 追链路? |
| 风险 | 热点数据是谁?如何做容量评估?演练覆盖了哪些场景? |
小结:订单系统的设计主线是“幂等 + 状态机 + 原子扣减 + 异步补偿”——幂等表与唯一索引挡住重复请求,状态机条件更新保证支付与关单不会互相覆盖,缓存预扣配合数据库条件更新保证不超卖,异步消息与每日对账把少卖与不一致收敛;再按 Checklist 逐项自检,一个可上线的订单系统就成形了。