表设计:能给未来留多少余地

不讲范式,只讲踩过的坑:主键方案怎么选、状态为什么不能用布尔、软删除的真实代价、JSON 列不是垃圾场、索引按查询模式给,以及必须一次定对的六种字段类型和改表的六步流程。

表设计是那种"当下写完没感觉、半年后天天骂"的东西。这篇不讲范式,只讲我踩过的坑和现在固定的几条约定——都是真出了事才改过来的。

一、主键:自增、雪花还是 UUID

方案优点代价我什么时候用
自增 BIGINT索引小、写入顺序、好排查分库分表要改造;对外暴露会泄露业务量单库、内部表(日志、关联表)
雪花 ID全局唯一、趋势递增、可分库依赖时钟(回拨要处理)、位数要规划需要对外暴露的实体 ID(订单、消息)
UUID无中心、生成简单随机写入导致页分裂、占空间大、可读性差幂等键、请求追踪这类"不参与主键索引"的场景

我现在的默认组合:自增主键做物理主键 + 雪花 ID 做业务唯一键(id BIGINT AUTO_INCREMENT PRIMARY KEY + biz_id BIGINT UNIQUE)。这样索引还是顺序写,对外也只需要暴露 biz_id。

一个具体教训:曾经把 UUID 当主键写消息表,插入量大之后写入性能掉了三四成,SHOW ENGINE INNODB STATUS 里全是页分裂——换成自增主键立刻恢复。

二、状态字段:别用布尔,也别只存"当前状态"

is_deleted、is_paid、is_active 这种布尔字段的问题不是"不够用",而是组合起来会自相矛盾:一条数据 is_paid=1 且 is_deleted=1 且 is_active=0 是什么意思?

现在的写法:

status TINYINT NOT NULL DEFAULT 0 COMMENT '0=待支付 1=已支付 2=已取消 3=已退款',
-- 需要追溯时再加一张状态流水表

配套两条纪律:

  1. 状态只能按状态机流转,代码里用显式的迁移表(允许从 A 到 B),非法迁移直接报错而不是硬写;
  2. 状态变更是业务事件,需要追溯就往 xxx_status_log 写一行(谁、什么时候、从什么到什么、原因)。

三、软删除:它比你想的贵

软删除(deleted_at / is_deleted)看着安全,代价是每一条查询里都得记得带上它——漏掉一处就是脏数据泄漏或统计口径错误。

我现在的取舍:

  • 用户可见、需要留痕的表(订单、消息、评论)用软删除,但只在仓储层统一加条件,业务代码里不允许手写 WHERE deleted_at IS NULL;
  • 配置类、关联类表直接物理删除(配了外键约束或应用层校验);
  • 不管哪种,唯一索引都要把删除标志带进去,否则"删掉后同名再也建不了":
UNIQUE KEY uk_username (username, deleted_at)   -- 软删后可以重建同名

四、扩展字段:JSON 列不是垃圾场

新需求总想加字段,于是有人加了一列 extra JSON 什么往里塞。我的规则是:

  • 参与查询/排序/统计的 → 老老实实加列 + 建索引;
  • 只用于展示、且形态不稳定 → 可以放 JSON 列,但必须约定 schema(在注释里写清有哪些 key、类型是什么),并且不允许对它做范围查询;
  • 一对多且会增长 → 建子表,别塞数组。

判断标准一句话:"我会不会对它做 WHERE / ORDER BY / GROUP BY"——会,就加列;不会,才考虑 JSON。

五、索引:按查询模式给,不是按字段重要程度给

新手常犯的错是"给常查的字段都加上索引",结果是写入变慢、优化器选错索引。

我现在的流程是:先把主要查询列出来(写在建表注释或设计文档里),再按查询建索引:

主要查询:
1. 按用户拉会话列表,按最近活跃倒序          → idx_uid_active (uid, last_active_at DESC)
2. 按会话拉消息,按 seq 游标翻页             → uk_conv_seq (conv_id, seq)
3. 后台按状态+时间筛选                        → idx_status_created (status, created_at)

三条附带纪律:联合索引的字段顺序按"等值在前、范围在后";能覆盖查询就不回表(把 SELECT 的列也放进去);上线前用 EXPLAIN 验证一遍,看 type 是不是 ref/range、rows 是不是可接受。

六、类型与精度:这几个字段必须一次定对

字段用别用原因
金额BIGINT(分)或 DECIMAL(18,2)FLOAT/DOUBLE浮点累加会差钱
时间DATETIME(统一 UTC 或带明确时区约定)TIMESTAMP(注意时区与 2038)跨时区/长周期都不折腾
枚举TINYINT + 注释映射ENUM(改值要 DDL)或中文 VARCHAR改起来代价不同
手机号/证件VARCHAR(不存整数)BIGINT有前导 0、有加密需求
布尔TINYINT(1)CHAR(1)语义与索引
IPVARCHAR(45) 或 VARBINARY(16)INT(IPv6 装不下)兼容 IPv6

七、改表:六步走,且必须能回滚

任何加列/改语义的变更,我现在都按这六步(和生产上线无关的"加个新列"也一样):

① 加列(给默认值,老代码无感)
② 双写(新代码同时写新旧字段)
③ 回填(后台任务分批把老数据补齐,可中断续跑)
④ 切读(读路径切到新字段,用开关控制)
⑤ 观察(至少一个发布周期,指标与日志确认)
⑥ 删旧列(真的稳定之后再删,删之前确认没有代码引用)

对应地:每次迁移都要写清楚回滚方式。如果某一步的回滚是"再写个脚本反向同步",那这一步就应该重新设计。

小结

表设计里最贵的错误是"改起来要动数据":改类型、改语义、拆表、补数据,每一种都要停机窗口、双写和回滚方案。所以值得在第一版就定死的其实就四件事:主键方案、状态表达方式、时间与金额的类型、索引按查询模式给。剩下的(加字段、加索引、加子表)都是可以渐进演进的。

返回博客列表