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 字段拆表)都是在设计期介入最有价值。评审桌上多一个人,生产环境少一个坑。

延伸阅读