字段类型选错了,表就废了一半 —— 表设计与约束范式
建表是"一锤定音"的事:表结构上线后再改,代价极高(大表 ALTER 锁表、历史数据要迁移)。面试里"给你一个用户表,你怎么设计"是必考题。这一篇把字段类型选型、约束、主键设计、范式与反范式一次讲透,全是能直接用的经验。
字段类型选型:选错类型的三种代价
类型选错会带来三种代价:浪费磁盘/内存(BIGINT 存年龄)、精度错误(用 FLOAT 存金额)、隐式转换导致索引失效(字符串列存数字)。逐类过一遍:
整数类型
| 类型 | 字节 | 范围(有符号) | 适用 |
|---|---|---|---|
| TINYINT | 1 | -128 ~ 127 | 状态码、年龄、开关(0/1) |
| SMALLINT | 2 | -32768 ~ 32767 | 小范围计数 |
| INT | 4 | ±21 亿 | 常规 ID、计数 |
| BIGINT | 8 | ±9.2×10¹⁸ | 主键、订单号等大 ID |
经验:主键一律 BIGINT UNSIGNED(自增不会轻易撞上限);状态/枚举用 TINYINT 而不是字符串(省空间 + 比较快);INT(11) 里的 (11) 只是显示宽度,不限制取值范围,别被误导。
小数类型:金额必须用 DECIMAL
| 类型 | 特点 | 坑 |
|---|---|---|
| FLOAT / DOUBLE | 浮点,快 | 精度会丢(0.1+0.2 ≠ 0.3) |
| DECIMAL(p, s) | 定点数,精确 | 慢一点但绝对精确 |
-- 金额、价格、汇率一律 DECIMAL(10, 2)(10 位有效数字,2 位小数)
price DECIMAL(10, 2) NOT NULL COMMENT '价格,单位元,精确到分'面试追问:为什么余额计算不能用 FLOAT?—— 浮点数用二进制近似表示十进制小数,
0.1 + 0.2 = 0.30000000000000004,累计运算后金额会错;DECIMAL 是字符串式定点存储,精确但占用空间更大、运算更慢。钱的精度 > 性能。
字符串:VARCHAR vs CHAR vs TEXT
| 类型 | 特点 | 适用 |
|---|---|---|
| VARCHAR(n) | 变长,n 是字符数,占 n+1~2 字节 | 绝大多数文本:姓名、邮箱、地址 |
| CHAR(n) | 定长,不足补空格 | 定长编码:手机号、订单号(长度固定) |
| TEXT / BLOB | 大文本/二进制 | 文章正文(但通常建议拆表或走对象存储) |
name VARCHAR(64) NOT NULL COMMENT '姓名' -- 64 个字符,不是 64 字节
phone CHAR(11) NOT NULL COMMENT '手机号' -- 定长,无碎片,检索快坑 1:
VARCHAR(n)的 n 是字符数,utf8mb4 下最多65535/4 ≈ 16383字符,别超。 坑 2:TEXT列不能有默认值、建索引要指定前缀长度(INDEX (content(100))),大文本列混在主表里会拖慢全表扫描——正文、日志这类数据建议单独拆表或直接上对象存储。
日期时间:DATETIME vs TIMESTAMP
| 类型 | 范围 | 存储 | 时区 | 适用 |
|---|---|---|---|---|
| DATETIME | 1000~9999 年 | 8 字节 | 与时区无关 | 业务时间,首选 |
| TIMESTAMP | 1970~2038 年 | 4 字节 | 跟随会话时区 | 有跨时区显示需求时 |
经验:默认用 DATETIME(范围大、不受 2038 问题影响);要"UTC 存储、本地展示"的用 TIMESTAMP。存"创建/更新时间"统一 DEFAULT CURRENT_TIMESTAMP。
其他值得知道的类型
JSON(8.0+):存结构化但格式不固定的数据(如埋点、扩展属性);可以JSON_EXTRACT查询,但别拿它当主查询条件(无法走常规索引,要配合虚拟列/多值索引)。BOOL:本质是TINYINT(1)。ENUM:有顺序的枚举,能用但扩展要 ALTER,加枚举值锁表,慎用;状态码建议TINYINT+ 注释/字典表。
约束:数据正确的最后防线
| 约束 | 作用 | 写法 |
|---|---|---|
| NOT NULL | 非空 | name VARCHAR(64) NOT NULL |
| UNIQUE | 唯一 | UNIQUE KEY uk_email (email) |
| PRIMARY KEY | 主键(唯一+非空,聚簇索引) | PRIMARY KEY (id) |
| FOREIGN KEY | 外键,保证引用完整性 | FOREIGN KEY (dept_id) REFERENCES departments(id) |
| CHECK | 值范围校验(8.0 真正生效) | CHECK (age >= 0 AND age <= 150) |
| DEFAULT | 默认值 | DEFAULT 0 |
两个高频观点:
- 外键:互联网大厂基本禁用。外键让数据库在每次插入/删除时做完整性检查,高并发下是性能杀手;而且分库分表后外键直接失效。正确做法:业务层保证引用关系(代码里先查父表存在再插入),数据库只保留逻辑关联(普通索引)。
- CHECK 约束:5.7 及以前 CHECK 只解析不生效(形同虚设),8.0 才真正执行。依赖版本,别把业务校验全押在它身上。
主键设计:自增 vs UUID 的世纪之争
主键在 InnoDB 里就是聚簇索引,它的选择直接影响整张表的写入性能(聚簇索引原理见深入篇《存储引擎与 B+ 树》):
| 方案 | 优点 | 缺点 | 结论 |
|---|---|---|---|
BIGINT AUTO_INCREMENT | 顺序插入、页不分裂、性能最好 | 可被遍历猜测(可配合业务做混淆)、跨库合并会冲突 | 单库首选 |
| UUID(字符串) | 全局唯一、适合分布式合并 | 随机顺序 → 频繁页分裂 → 写放大;32 字符占空间 | 分布式场景考虑 |
| 雪花 ID / 雪花变体(BIGINT) | 全局唯一 + 趋势递增 + 数字型 | 需要 ID 生成服务(见高并发场景题) | 分布式首选 |
一句话:单机用自增 BIGINT;分布式用趋势递增的雪花 ID(BIGINT 存储);纯 UUID 字符串做主键是最差选择(随机写 + 大索引)。订单号、流水号这类"业务可见 ID"应单独设唯一索引列,与自增主键分离。
三范式:要守,但更要会"反"
范式是"消除冗余"的设计规范,三个级别:
| 范式 | 要求 | 通俗理解 |
|---|---|---|
| 1NF | 列不可再分 | 一个字段只存一个值(别用逗号拼多个手机号) |
| 2NF | 消除部分依赖 | 联合主键下,非主键列不能只依赖主键的一部分 |
| 3NF | 消除传递依赖 | 非主键列不能依赖其他非主键列(如 city 依赖 province,不该都放同一张订单表) |
但互联网业务讲究反范式:为了查询性能,故意冗余。经典案例:订单表里冗余一份"商品名称 + 商品快照价"——因为商品信息会变,订单里必须存下单那一刻的快照;用户信息大表拆"热表/冷表";统计字段(如帖子回复数)冗余存储而不是每次 COUNT。
面试观点:范式是"逻辑正确"的底线(至少守到 3NF 的语义),反范式是"性能优化"的手段。设计顺序是:先按 3NF 建模,再针对高频查询路径做有意识的冗余/拆表,而不是一上来就到处冗余。
命名规范与设计陷阱清单
命名规范(大厂风格,面试写出来加分):
- 库名/表名/字段名:小写 + 下划线(
user_profile),不用驼峰、不用保留字。 - 表名复数或单数统一一种;前缀区分模块(
order_,user_)。 - 索引命名:
idx_字段名、uk_字段名(唯一)、idx_字段1_字段2(联合)。 - 必备字段:
id、created_at、updated_at,可选is_deleted(逻辑删除)、version(乐观锁)。
设计陷阱清单(每一条都是真实事故):
| 陷阱 | 说明 |
|---|---|
| 预留字段 | field1, field2 预留列——永远不知道类型,且没法建索引;要扩展直接 ALTER 加列(8.0 INSTANT 很快) |
| 用字符串存日期/数字 | 浪费空间 + 无法用日期/数值函数 + 排序错误('10' < '9') |
| 大字段混主表 | TEXT 大文本拖慢全表扫描,拆表或走对象存储 |
| 冗余索引 | idx_a 和 idx_a_b 同时存在:前者完全没用(最左前缀已被覆盖),白占写放大 |
| 索引过多 | 每个索引都是写放大 + 内存占用;单表索引数建议 < 5 |
| 无注释 | 三个月后没人知道 status=3 是什么意思 |
| 无 created_at/updated_at | 排查问题、对账时抓瞎 |
串起来
表设计这一关的核心判断力是:类型选对(金额 DECIMAL、主键 BIGINT、中文 utf8mb4)、约束用对(外键交给业务层)、冗余是策略不是失误(先 3NF 建模、再按查询反范式)。设计完先自问:这张表的高频查询是什么?索引够不够?会不会写放大?
下一篇进入 事务与索引入门:ACID 是什么、事务怎么用、索引到底是什么东西,为深入篇的 B+ 树、隔离级别、慢查询优化做铺垫。