电商订单系统设计:为什么你的订单表不该只有一张?
从一张订单表打天下到 11 张分表协同作战,拆解电商多商户平台订单系统的核心架构决策,每张表为什么存在、每层数据为什么不放在一起。
引言
大多数开发者的第一个订单系统长这样:一张 order 表塞进所有字段,状态字段加几个,物流信息拼进去,退款金额也放进去。跑起来没问题,直到——来了多商户、跨店拆单、部分退款、大促秒杀。
订单系统的复杂度不是"存一条记录",而是在支付、履约、清算、售后四个维度的交叉点上,每张表只做一件事但协同工作。本文不罗列表字段,而是拆解每个设计决策背后的两难选择:为什么选 A 不选 B?
一、为什么拆成父子订单?
这是订单系统设计的第一问:一个订单号到底够不够?
方案 A:单层订单
一张 order 表,transaction_no 关联支付,status 从待付到完成一条线走完。看起来简洁。
问题出在多商户场景:用户在 A 店买手机、B 店买手机壳,一起付了款。如果用同一个订单号:
- 哪个商家发货?两个商家看到同一个订单,无法各自履约。
- 用户想退手机壳的款,整单退款还是部分?一条记录只能有一个退款金额。
- 财务怎么和商家结算?一笔钱两个商家分,没法以订单为粒度清算。
方案 B:父子订单
trade_main (父交易总单)
├── 职责:支付锚点、合并支付的唯一凭证
├── 不参与履约、不参与结算
│
└── sub_order × N (子履约订单)
├── 职责:独立发货、独立售后、独立财务清算
└── order_item × N (单品明细)
选择 B 的核心原因只有三个:
- 支付原子性:一次支付回调级联所有子单,合单支付对用户完全透明。
- 履约独立性:每家店各自发货、各自处理售后,互不干扰。
- 对账锚点:退款上限 ≤ 父订单总额,防止超退。
这不是"父子关系看起来更结构化"的设计美学问题,而是多商户平台的资金安全底线。没有父订单做总控,部分退款+多商家结算的组合可以轻易出现对不上账的情况。
自营单店的简化
如果你不做多商户平台,只是自营电商,直接砍掉 trade_main 和 sub_order,用一张 order_main 就够了。架构要为业务复杂度买单,不为过度设计买单。
二、11 张订单表,每张为什么存在?
父子订单解决了"谁负责什么"的问题,接下来要解决"数据放哪里"的问题。11 张表不是设计癖好,是按职责域自然生长出来的。
顶层交易拆分层
trade_main + sub_order 上面已经讲了。
交易明细层:order_item
最小 SKU 交易粒度。一个 sub_order 里可以有多个 order_item(买了同商家下的不同 SKU),但同一个 SKU 买 3 件只有 1 条 order_item,通过 buy_num 记录数量。
为什么不拆?拆分到单件粒度会导致:
- 部分退款时算优惠分摊变成噩梦
- 表行数爆炸(每件一行 vs 每种一行)
order_item 存什么?单品实付、分摊优惠、退款金额、售后状态、佣金分摊。这些是动态业务数据,每天在变。
快照冷热层:snapshot_main vs snapshot_detail
热表:order_snapshot_main (标题、主图、规格摘要)
└── 扛 90% 流量:订单列表、分页、筛选、商家后台批量查询
冷表:order_snapshot_detail (完整商品 JSON、富文本详情)
└── 扛 10% 流量:订单详情页、售后仲裁取证
核心规则:订单列表全程不 JOIN snapshot_detail。
原因直白:snapshot_detail 存的是完整商品 JSON 和富文本,一条记录可能几十 KB。如果订单列表页每次分页都加载这些大字段,索引再好也救不回来。
这也是为什么不能直接用快照替代 order_item ——快照是只读归档,下单瞬间固化,不存储退款、售后、佣金这些一直变的东西。你不可能在"筛选已退款订单"时去翻快照 JSON 找退款状态。
履约层:order_logistics
物流包裹表。一个 sub_order 可以拆成多个包裹发(超重、分仓),但包裹只是物流载体,不参与资金清算。
资金售后层:payment_record + refund + refund_item
支付退款的完整流水。为什么独立?因为对账需要追溯——第三方支付单号、回调报文、每笔退款的来源去向,必须有一条不可篡改的时间线。
三、sub_order 什么时候拆,order_item 什么时候不拆?
拆单的核心标准:履约能力是否相同。
满足以下任意一条,必须生成多条 sub_order:
- 跨不同店铺
- 同商家但多仓库分仓
- 履约模式不同:现货、预售、冷链、大件、同城配送
- 包裹超重需要分多包裹
- 跨境、危险品、奢侈品特殊品类
order_item 的拆分标准则简单得多:不同 SKU 才拆,同 SKU 多件不拆。
还有一道选择题:sub_order 是下单时生成还是支付后生成?
| 策略 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 下单实时生成 | 提交订单事务内创建全部 | 待支付阶段商家即可见,金额实时分摊 | 大促下单接口 DB 压力高 |
| 支付后 MQ 异步生成 | 下单只建 trade_main | 下单轻量吞吐高,可匹配就近仓库 | 未支付时商家看不到,需要定时补偿 |
大厂高并发场景基本选后者。前者适合中小平台,运维简单。
四、状态机 + 延时消息:谁来驱动订单流转?
订单不是只有"创建"和"完成"两个状态。完整的状态机必须覆盖支付异常、超时关单、部分退款、售后流转。
UNPAID
├── 支付成功 → UNSHIPPED → (发货) → WAIT_RECV → (确认) → COMPLETED
├── 15分钟超时 → CLOSED
└── 支付异常 → PAY_ERROR → 人工介入
状态流转的引擎是什么?定时任务扫表?
方案是 RocketMQ 延时消息:
| 延迟 | 动作 |
|---|---|
| 15 分钟 | 超时自动关单 |
| 10 分钟 | 推送支付提醒 |
| 7 天 | 自动确认收货 |
| 24 分钟 | 商家超时未发货撤销接单 |
对比定时任务扫表:定时任务需要维护游标、处理分页、扫描大量已处理数据,延时消息是精确的"到点触发 ",不浪费计算资源。但延时消息并非万能——超长延迟(如 30 天)受限于 MQ 存储,仍需兜底的定时任务。
五、下单一致性:RocketMQ 事务消息 vs Seata
下单涉及 6 步校验器链:活动校验 → SKU/库存 → 地址 → 优惠券计算 → 金额计算 → 钱包校验。每一步都可能失败,但前几步可能已经扣了库存、冻了钱包。
这个场景下,Seata 的三个问题:
- AT 模式:每个参与者要额外写一条 undo log,核心链路上多一次 DB 写。下单对延迟敏感,不值得。
- TCC 模式:6 个服务全部改造 Try/Confirm/Cancel 接口,工程量大,且 Confirm/Cancel 必须幂等——幂等本身就是一个分布式难题。
- 一个隐性问题:钱包服务可能已经在扣款链路中,此时 Seata 的参与者并非全部可控。
选择 RocketMQ 事务消息不是因为它"更好",而是项目已经用它做消息驱动,复用现有基础设施成本最低。
流程:
发送 half 消息 → 执行本地事务(建父子单+扣库存+冻钱包)
├── 成功 → commit MQ → 发延时关单消息
└── 失败 → rollback → 释放库存+解冻钱包
加上定时补偿任务做兜底,保证了最终一致性。
六、履约、包裹、清算的三层隔离
最容易混淆的三个概念,面试高频踩坑点:
| 层次 | 最小单元 | 驱动 |
|---|---|---|
| 履约 | sub_order | 待履约 → 已发货 → 运输中 → 签收 → 售后 → 关闭 |
| 包裹 | order_logistics | 物流载体,仅存运单轨迹,不控制履约状态 |
| 清算结算 | sub_order → 汇总至商家 | 佣金、退款抵扣、财务对账,多条子单汇总结算单 |
一条子单可以对应多个包裹(分批发货),但包裹数量不影响清算金额。财务结算是按商家维度汇总所有 sub_order,不是按包裹。这三层如果不隔离,就会出现"物流多发了几个包裹,财务以为是多笔订单"的乌龙。
总结
回顾订单系统的核心设计决策:
- 父子订单是业务必需的复杂度,不是过度设计。 多商户平台的资金安全需要父单做总控,履约独立性需要子单做单元。
- 分表不是为了分而分,是按职责域自然拆解。 快照只读 / 资金流水 / 售后工单 / 物流包裹——每张表有自己独立的查询场景和更新频率,塞进一张表只会互相拖慢。
- 冷热分离是用空间换查询体验。 列表页 90% 流量不该碰大 JSON。
snapshot_main和snapshot_detail的分层,是用户在订单列表上下滑动的体感来源。 - 延时消息 + 状态机替代定时任务扫表。 精确触发 + 零扫描开销,但长延迟仍需兜底。
- 一致性方案选 RocketMQ 事务消息而非 Seata,是"我有基础设施所以用这个"而不是"这个更好"。 架构决策永远是场景适配题,不是技术优劣题。
订单系统设计没有银弹。每张表的存在、每层数据的隔离,背后都是一个如果不做就会出的生产事故。

所有评论(0)