综合案例:订单系统设计

本章把前面各章的技术点串成一个完整案例:为中型电商设计订单系统,覆盖量级假设、分层架构、分库分表、下单与支付幂等、超时关单、查询分页与监控降级。

一、需求与量级假设

项目假设值推导
日订单量100 万单日活 200 万,人均 0.5 单
峰值下单 QPS3000日均约 12,大促瞬时集中,峰值系数取 250
峰值查询 QPS20000订单列表与详情,读远大于写
日增数据量约 2GB100 万 × 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 逐项自检,一个可上线的订单系统就成形了。

笔记加载中…