表设计是那种"当下写完没感觉、半年后天天骂"的东西。这篇不讲范式,只讲我踩过的坑和现在固定的几条约定——都是真出了事才改过来的。
一、主键:自增、雪花还是 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=已退款',
-- 需要追溯时再加一张状态流水表
配套两条纪律:
- 状态只能按状态机流转,代码里用显式的迁移表(
允许从 A 到 B),非法迁移直接报错而不是硬写; - 状态变更是业务事件,需要追溯就往
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) | 语义与索引 |
| IP | VARCHAR(45) 或 VARBINARY(16) | INT(IPv6 装不下) | 兼容 IPv6 |
七、改表:六步走,且必须能回滚
任何加列/改语义的变更,我现在都按这六步(和生产上线无关的"加个新列"也一样):
① 加列(给默认值,老代码无感)
② 双写(新代码同时写新旧字段)
③ 回填(后台任务分批把老数据补齐,可中断续跑)
④ 切读(读路径切到新字段,用开关控制)
⑤ 观察(至少一个发布周期,指标与日志确认)
⑥ 删旧列(真的稳定之后再删,删之前确认没有代码引用)
对应地:每次迁移都要写清楚回滚方式。如果某一步的回滚是"再写个脚本反向同步",那这一步就应该重新设计。
小结
表设计里最贵的错误是"改起来要动数据":改类型、改语义、拆表、补数据,每一种都要停机窗口、双写和回滚方案。所以值得在第一版就定死的其实就四件事:主键方案、状态表达方式、时间与金额的类型、索引按查询模式给。剩下的(加字段、加索引、加子表)都是可以渐进演进的。