建表规范:字段类型、范式与冗余
建表是后端最容易埋雷的一步。金额用 float、状态用 varchar、多对多拿逗号拼 ID,上线时都没事,出问题时都是大问题。这篇给出六步建表法,并说明范式什么时候该让位于冗余。
一、建表是"无法回滚"的那一步
接口写错了,改代码发版就行;索引建错了,DROP INDEX 也能救。但表结构一旦上线、数据一旦写进去,改动的成本就完全不是一个量级了:
- 字段类型要改,可能触发整表重建(
ALTER TABLE的代价随数据量线性上升) - 金额精度丢了,只能靠对账一点点补
- 该拆的表没拆,等到几千万行再拆,就得停机迁移
所以建表这件事值得"慢即是快"——花在设计上的时间,远小于事后补救的时间。下面是一套可以照着走的六步法。
二、第一步:理清实体与关系(ER 图)
先确定系统里有哪些核心对象,比如用户、订单、商品、库存。然后把关系标出来,重点是两类:
- 一对多:一个用户有多个订单。外键放在"多"的一方,即订单表里存
user_id。 - 多对多:一个订单有多个商品,一个商品属于多个订单。必须引入中间表。
-- ✅ 多对多:用中间表,结构清晰、可加属性、可建索引
CREATE TABLE order_item (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_id BIGINT UNSIGNED NOT NULL,
product_id BIGINT UNSIGNED NOT NULL,
quantity INT NOT NULL DEFAULT 1,
price DECIMAL(10, 2) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_order_product (order_id, product_id),
KEY idx_product (product_id)
);
-- ❌ 绝对不要这样:用逗号分隔存 ID
-- order 表里放一个 product_ids varchar 字段,值形如 "1001,1002,1003"
逗号分隔存 ID 的问题是全方位的:
| 问题 | 说明 |
|---|---|
| 无法建索引 | WHERE FIND_IN_SET(1001, product_ids) 必然全表扫描 |
| 无法保证引用完整性 | 删掉的商品 ID 还留在字符串里 |
| 无法携带属性 | 数量、单价这些中间表才能存的字段无处安放 |
| 无法聚合 | 统计"每个商品被下单多少次"需要字符串切割 |
| 长度受限 | 一个订单 500 个商品就撑爆字段 |
宁可多一张表,也绝不使用逗号分隔存 ID。
三、第二步:字段类型精打细算
类型的核心原则是:用最小的类型装下业务范围,同时保证精度不出错。
数值类型
-- 状态值:tinyint 足够(0~255)
status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已发货 3已完成 4已取消'
-- ❌ 反面教材:用 varchar 存"是/否"
-- is_vip VARCHAR(2) -- 不仅浪费空间,还无法做范围比较和高效索引
-- 金额:绝对不用 float / double
amount DECIMAL(10, 2) NOT NULL COMMENT '订单金额,单位元'
-- 或者用"分"为单位的整数
amount_cent BIGINT NOT NULL COMMENT '订单金额,单位分'
为什么金额不能用浮点? float/double 遵循 IEEE 754 二进制浮点标准,无法精确表示十进制小数:
SELECT 0.1 + 0.2 = 0.3; -- 用 DOUBLE 计算返回 0(false)
在涉及金额累加的场景里,这个误差会累积,最后表现为对账差几分钱。DECIMAL 是定点数,按十进制存储,没有这个问题。
类型选择速查
| 场景 | 推荐类型 | 说明 |
|---|---|---|
| 主键 / 外键 | BIGINT UNSIGNED |
自增,避免用 UUID 做主键(页分裂 + 占空间) |
| 状态、枚举 | TINYINT |
配合注释写清取值含义 |
| 金额 | DECIMAL(10,2) 或 BIGINT(分) |
禁用 FLOAT/DOUBLE |
| 布尔 | TINYINT(1) |
0/1 |
| 固定长度字符串 | CHAR(n) |
如手机号、身份证、MD5 |
| 可变长度字符串 | VARCHAR(n) |
按实际最大长度给,不要一律 VARCHAR(255) |
| 时间 | DATETIME / TIMESTAMP |
DATETIME 范围到 9999 年,TIMESTAMP 只到 2038 年 |
| 超长文本 | 拆到独立的 TEXT 表 |
见下 |
| 非检索扩展字段 | JSON |
只用于展示,不用于查询条件 |
大字段一定要拆表
TEXT/BLOB 这类大字段如果和主表放在一起,会带来两个问题:
- InnoDB 的行格式里,大字段可能溢出到外部页,读主表时要多一次 IO。
- 即使不溢出,行变长也意味着单个 16KB 页能放的行数变少,扫描主表时 IO 次数上升。
-- ✅ 商品主表保持"瘦",详情单独一张表
CREATE TABLE product (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(128) NOT NULL,
price DECIMAL(10, 2) NOT NULL,
PRIMARY KEY (id)
);
CREATE TABLE product_detail (
product_id BIGINT UNSIGNED NOT NULL,
detail TEXT,
PRIMARY KEY (product_id)
);
列表页只查主表,详情页才 JOIN 到详情表——这是分表最直接的收益。
四、第三步:遵守三大范式,但在冗余上开绿灯
三大范式
| 范式 | 要求 | 违反的后果 |
|---|---|---|
| 第一范式(1NF) | 每个字段都是原子值,不可再分 | 出现"1001,1002"这种多值字段 |
| 第二范式(2NF) | 非主键列完全依赖主键,而非依赖主键的一部分 | 联合主键下出现部分依赖,更新时容易不一致 |
| 第三范式(3NF) | 非主键列不依赖其他非主键列(消除传递依赖) | 同一事实存多份,改一处忘一处 |
什么时候主动冗余
范式解决的是"数据一致性"和"存储浪费",但它用 JOIN 换来的。在高并发读场景下,JOIN 的代价往往比那点冗余空间大得多。
所以有一类冗余是被鼓励的——冗余"下单时刻的快照":
CREATE TABLE `order` (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
-- 冗余:下单时的用户信息快照
user_nickname VARCHAR(64) NOT NULL COMMENT '下单时的昵称快照',
user_phone VARCHAR(20) NOT NULL COMMENT '下单时的手机号快照',
-- 冗余:下单时的商品信息快照
product_name VARCHAR(128) NOT NULL COMMENT '下单时的商品名快照',
amount DECIMAL(10, 2) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_order_no (order_no),
KEY idx_user_time (user_id, created_at)
);
这里的关键认识是:
订单里的昵称,语义上不是"这个用户现在叫什么",而是"下单那一刻他叫什么"。
用户改了昵称,历史订单里的昵称本来就不应该跟着变。这不是为了性能牺牲范式,而是业务语义本身要求快照。性能提升只是顺带的收益:订单列表页不需要 JOIN 用户表和商品表。
冗余的判断标准
| 该冗余 | 不该冗余 |
|---|---|
| 时点快照(下单时的价格、昵称、地址) | 会持续变化且要求强一致的余额 |
| 变更极少的基础数据(商品名、分类名) | 高频更新的计数类字段(除非有明确的重算机制) |
| 用于避免高频 JOIN 的展示字段 | 大字段、长文本 |
冗余的代价是一致性维护。一旦冗余,就必须明确:谁是权威源,冗余什么时候更新。快照类冗余没有这个问题(写完就不变),这也是它安全的原因。
五、第四步:索引设计"慢即是快"
先写下所有查询 SQL,再反推建索引。
-- 已知查询:按状态 + 时间倒序查列表
-- SELECT ... WHERE status = ? ORDER BY created_at DESC LIMIT 20
KEY idx_status_time (status, created_at)
-- 已知查询:按用户查自己的订单
-- SELECT ... WHERE user_id = ? ORDER BY created_at DESC
KEY idx_user_time (user_id, created_at)
三条硬约束:
- 遵循最左前缀法则,联合索引的列顺序按"等值在前、范围在后、兼顾排序"排。
- 严格控制索引数量,单表不超过 5~6 个。每多一个索引,INSERT / UPDATE / DELETE 就多维护一棵 B+ 树。
- 拒绝"无脑全字段索引",写性能和读性能同样重要。
另外,主键的选择在建表时就要定下来:
-- ✅ 自增 BIGINT 主键:单调递增,新数据总在 B+ 树最右侧插入,几乎不触发页分裂
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY
-- ❌ UUID 主键:随机值导致插入位置分散,频繁页分裂 + 页空间利用率低
-- 且二级索引要存 36 字节的主键值,索引体积膨胀
六、第五步:必备"审计追踪"三字段
每一张表(哪怕是字典表)都应该包含这三个字段:
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
is_deleted TINYINT NOT NULL DEFAULT 0 COMMENT '0存在 1已删除'
为什么这三条几乎是强制的:
created_at:没有它,线上排查"这条数据什么时候进来的"就只能靠猜。updated_at+ON UPDATE CURRENT_TIMESTAMP:由数据库自动维护,应用层忘记赋值也不会漏。排查"这条数据什么时候被改过"时是唯一线索。is_deleted软删除:坚决不用物理删除。物理删掉之后,数据回溯时欲哭无泪——关联的流水、日志全都指向一个不存在的 ID。
软删除的代价是查询都要带 is_deleted = 0,所以它应该出现在联合索引的最左列:
KEY idx_deleted_status_time (is_deleted, status, created_at)
如果
is_deleted的取值极度倾斜(99.9% 都是 0),把它放最左反而可能降低索引选择性。更彻底的做法是用归档表:删除时把行搬到xxx_archive表,主表保持干净。
七、第六步:预留扩展思维
JSON 字段用在哪
对于非检索性的扩展参数,用独立的 JSON 字段比硬塞列要好——列太多会让表结构僵化,加一个属性就要 ALTER TABLE。
extra_info JSON COMMENT '扩展属性,仅用于展示,不参与查询条件'
边界要划清:JSON 字段里的内容不要作为检索条件。MySQL 对 JSON 内部字段建索引需要生成列(generated column),复杂度高且容易被误用。要检索的字段,就该老老实实抽成正式的列。
提前规划分库分表键
对于核心大表(订单表、流水表),在建表时就要预估数据量,并在表设计中埋下水平扩展的伏笔:
-- 分片键要提前想清楚,并且保证它出现在每个高频查询的 WHERE 里
-- 按 user_id 取模分片:
-- ✅ WHERE user_id = 1001 → 能定位到单个分片
-- ✅ WHERE user_id = 1001 AND status = 1 → 能定位
-- ❌ WHERE status = 1 → 需要扫全部分片
KEY idx_user_time (user_id, created_at)
这就是分片键必须进索引的原因——它决定了查询能否路由到单个分片。选分片键的三条标准:
- 分布均匀:避免热点分片(比如按
status分片就是灾难,因为分布极度倾斜)。 - 高频查询必带:否则每次查询都要广播到所有分片。
- 不可变:
user_id不可变,status会变——变了就要跨分片迁移数据。
八、小结
- 六步建表法:ER 图 → 字段类型 → 范式与冗余 → 索引 → 审计三字段 → 扩展思维
- 多对多必须用中间表,绝不用逗号分隔存 ID(无法索引、无法携带属性、无法聚合)
- 类型精打细算:状态用
tinyint,金额禁用float,用decimal或"分"为单位的bigint,超长文本拆到独立表 - 遵守三大范式,但时点快照类字段要主动冗余——订单里的昵称本质是"下单那一刻的名字",不是当前值
- 索引先有 SQL 再反推,遵循最左前缀,单表不超过 5~6 个,主键用自增
bigint而非 UUID - 每张表必备
created_at/updated_at/is_deleted,坚决不用物理删除 - 扩展用 JSON 字段承载非检索属性,分片键要在建表时就定好并进索引
建表没有"以后再改",只有"现在想清楚"。这六步花掉的一两个小时,抵得上事后一次停机迁移。
留下点什么
评论基于 GitHub Discussions(giscus)。在项目根目录配置.env.local填入 NEXT_PUBLIC_GISCUS_* 四个变量后自动开启。