2.1 表设计:订单、明细、状态字段
订单中心第一版表设计是我画的,当时图省事,订单和明细塞在一张表里,用 JSON 字段存商品列表。三个月后业务要按商品维度统计,JSON 字段没法走索引,报表跑了四十分钟。推翻重做的故事就在这一节。
场景:订单数据的三类读写
订单数据的读写模式分三类,决定了表必须分开。第一类是实时链路:写下单一行记录,状态机更新,永远按 orderNo 查。第二类是明细查询:用户看订单详情需要 join 明细,量小但频次高。第三类是批量分析:按时间、按商品、按渠道聚合,扫的数据多但不要求实时。
三类模式对索引、对存储格式的要求完全不同,硬塞一张表就是互相拖累。
方案:主表、明细表、扩展表三张
最终落地三张表,职责清楚、读写分开:
| 表 | 职责 | 主要索引 | 增长速度 |
|---|---|---|---|
| t_order | 订单主表,存状态与金额 | 主键 orderNo、userId | 每单一行 |
| t_order_item | 明细表,存商品与数量 | orderId、商品Id | 每单 3~8 行 |
| t_order_ext | 扩展表,地址发票等大字段 | orderId | 每单一行 |
主表的字段我们逐个讨论过,最后定下来的版本:
| 字段 | 类型 | 说明 |
|---|---|---|
| order_no | varchar(32) | 业务单号,唯一 |
| user_id | bigint | 用户 |
| status | tinyint | 状态机状态码 |
| total_amount | decimal(12,2) | 金额,禁用 float |
| stock_frozen | tinyint | 冻结标记,0 未 1 已 |
| pay_channel | varchar(16) | 支付渠道 |
| create_time | datetime | 创建时间,分区键 |
几个有故事的决策。金额必须 decimal,float 存金额在月初对账时会出现 0.01 的偏差,财务那一关过不了。status 用 tinyint 状态码而不是中文字符串,状态机转移是数值比较,快且不易错。stock_frozen 单列标记是为了回滚逻辑不依赖状态推断,两者语义解耦。
代码:建表语句的可读片段
主表只列关键字段,分区语法下一节展开:
CREATE TABLE t_order (
id bigint NOT NULL AUTO_INCREMENT,
order_no varchar(32) NOT NULL,
user_id bigint NOT NULL,
status tinyint NOT NULL,
total_amount decimal(12,2) NOT NULL,
stock_frozen tinyint NOT NULL DEFAULT 0,
create_time datetime NOT NULL,
PRIMARY KEY (id, create_time),
UNIQUE KEY uk_order_no (order_no),
KEY idx_user_time (user_id, create_time)
) COMMENT '订单主表';
注意主键里带了 create_time,这是分区表的强制要求,分区键必须属于唯一索引的一部分。这个约束直接改变了主键设计,也是从单表迁到分区表时最容易踩的细节。
踩坑:三个设计失误
第一个是前面说的 JSON 存明细。复盘时定了一条规则:任何要参与查询或统计的字段都不许进 JSON,JSON 只存不下沉的结构化快照,比如开票当时的抬头信息。
第二个是大字段拖慢主表。最初的版本把收货地址的 JSON 字符串放主表,单行从 200 字节膨胀到 2KB,InnoDB 单页存的行数骤降,范围扫描 IO 翻了好几倍。拆出 ext 表之后,主表查询轻松很多。这个道理和 MySQL 分库分表实战 讲的冷热分离是同一个思路,把热字段和冷字段在物理上隔开。
第三个是软删除的坑。最早设计有 is_deleted 字段,后来订单状态机里已经有「已关闭」状态,软删除纯属多余,两套删除语义把客服和财务都绕晕了。我们果断去掉软删除,所有删除统一走状态机,数据只有「状态不同」没有「被删除」。
进阶实战:字段评审会
表结构最终是靠字段评审会定下来的,规则是每个新增字段必须回答三个问题:谁写、谁读、怎么变。答不上来不给加。一次会上 PRD 要求加一个「运营备注」字段,没人说得清谁来写,最后发现需求是让客服在后台改单,但对修改无审计要求。于是字段加上了,但同时加了修改流水表,记录每一次人工改单的前后值与操作人。这条审计线在三个月后的一次客诉里救了我们:用户坚持说没改过地址,流水里写得清清楚楚是客服代改。
| 常见字段 | 要不要单列 | 理由 |
|---|---|---|
| 收货地址大 JSON | 拆 ext 表 | 冷字段不挡热查询 |
| 发票信息 | ext 表 | 低频读 |
| 运费 | 主表 | 与金额一起参与结算 |
| 优惠券抵扣金额 | 主表 | 对账口径必须落库 |
| 风控分值 | 主表扩展字段 | 下单时高频读 |
| 客服备注 | 主表 + 流水 | 人工改单需审计 |
运费与优惠抵扣这两个字段争论最久。反对者认为它们可以由券系统实时算,没必要落库。支持者的理由是:金额作为对账依据必须快照到订单,券系统的规则随时在变,半年后想还原当时的抵扣金额就对不上了。「金额即快照」后来成为我们所有财务相关字段的硬规则。
速查清单
订单表设计的四条红线:
| 红线 | 说明 |
|---|---|
| 金额 decimal | float 存金额必出偏差 |
| 明细单列表 | 统计字段不许进 JSON |
| 主表无大字段 | 单行控制在 300 字节内 |
| 无软删除 | 状态机管生命周期 |
小结
表设计三条原则:明细与主表分开,给统计留索引;金额用 decimal,状态用状态码;不要软删除,状态机管生命周期。表设计是状态机的前置条件,字段不干净,状态机就没法干净。
常见误解:表设计的三个陷阱
第一个陷阱是「索引越多越好」。我们在 t_order 上先后加了七个小索引,后来压测发现写入 QPS 随索引数直线下降,每个索引都是写入的额外开销。最终我们对索引做了一次断舍离,只保留三个高频索引,写入性能回升了 40%。索引的生命周期管理应当像代码一样定期 Review,冗余索引发现就删。
| 陷阱 | 后果 | 对策 |
|---|---|---|
| 索引堆砌 | 写入变慢 | 定期清理 |
| 外键防身 | 影响性能 | 约束走应用层 |
| 字段越细越好 | 过度拆分难维护 | 按业务粒度 |
第二个陷阱是「用外键保证一致性」。外键在单库时代可行,一旦分区或分库就成为负担,而且外键约束的级联行为极其容易触发锁升级。我们的做法是数据完整性靠应用层的状态机与对账保证,外键只作为最后手段留给极少数场景,订单系统全面禁用外键。这样做的代价是开发自律要求高,收益是性能与扩展性。
第三个陷阱是「字段拆得过细」。有同事把收货人姓名拆成姓与名两列、把地址拆到门牌号粒度,理由是「将来好统计」。这种过度设计半年内一次没用上,查询反而要拼字段。粒度按业务需求定,宁可粗一点也不过细,真有需求再拆。
团队协作:表结构的变更流程
表结构是长期资产,变更必须走流程。我们规定任何 DDL(哪怕加一列)都要提交一份变更评审:影响哪些查询、是否需要在线 DDL、回滚方案是什么。这个流程刚开始被嫌弃繁琐,直到一次「加字段锁表两小时」的事故让所有人闭嘴。从此 DDL 评审成了标准动作,复杂变更上云数据库 MySQL 的在线变更工具,普通变更避开高峰窗口。
| 变更类型 | 风险 | 执行方式 |
|---|---|---|
| 加列低风险 | 仅扩展 | 在线 DDL |
| 加索引 | 写入变慢 | 低峰期 |
| 改数据类型 | 数据可能截断 | 双跑迁移 |
| 大表历史迁移 | 锁表风险 | 影子表替换 |
表设计这件活还有一个经验:让 DBA 参与设计评审而不是只执行 DDL。我们的 DBA 提的几个意见(主键类型、字符集选择、text 字段拆表)都是在设计期介入最有价值。评审桌上多一个人,生产环境少一个坑。
