表设计从来不只是“把字段建出来就行”。一个字段的类型选得对不对,是否允许 NULL,该不该冗余,会直接影响存储空间、查询效率和后续的维护复杂度。下面从三个维度展开,都是日常工作中踩过坑才总结出来的经验。
14.2.1 类型选择:用最小的空间存正确的数据
字段类型的选择有两个核心原则:够用就好,不多占空间;预判增长,不给自己埋雷。 每占多 1 个字节,在百万级甚至千万级的表中都会被放大成实实在在的磁盘和内存消耗;反过来,如果用得太抠,未来数据增长后可能面临停服改表的灾难。
数值类型
- 整数类型:
TINYINT(1字节)、SMALLINT(2字节)、MEDIUMINT(3字节)、INT(4字节)、BIGINT(8字节)。能用小类型就不用大类型。比如状态字段(0/1/-1)用TINYINT,不要用INT。很多人看到一个INT(11)就以为是 11 位十进制数,其实这完全是误解:括号里的数字只是元数据上的显示宽度,不影响存储范围,实际占 4 字节。MySQL 8.0 已经不建议为整数类型指定显示宽度。 - 自增主键:一般用
INT UNSIGNED(0 ~ 约 43 亿)可满足绝大多数业务,但如果预估未来数据量会超过 40 亿(比如日志表、埋点表),直接上BIGINT UNSIGNED。一旦数据量超过 INT 上限,改主键类型会锁表、重建表,代价极大。 - 小数:精确计算(如金额、资金流水)必须用
DECIMAL(M,D),它精确存储,不会像FLOAT或DOUBLE那样产生浮点误差。DECIMAL(10,2)表示整数部分 8 位、小数部分 2 位。除非是做科学计算或统计,否则不要用浮点数存储需要精确的值。
字符串类型
- CHAR 与 VARCHAR:
CHAR(N)是定长,VARCHAR(N)是变长。定长意味着存储固定长度的字符,多余的用空格填充,取出时去掉尾部空格。对于长度非常固定的字段(如 MD5 哈希值、UUID、状态编码),用CHAR可以省去额外的长度标识开销;对于长度波动大的字段(如用户名、地址、备注),使用VARCHAR能节省大量空间。 - VARCHAR 长度要合理:很多人把任何字符串都弄成
VARCHAR(255),但更好的做法是依据业务定义上限来指定长度。比如姓名字段VARCHAR(50),手机号VARCHAR(20)。注意VARCHAR最大可指定 65535 字符(受行大小限制),但这不意味着你可以滥用。InnoDB 对于过长的VARCHAR可能会将数据存到溢出页,导致读取时需要额外 I/O。 - TEXT 与 BLOB 类型:对于超长文本(如文章内容、JSON 数据),可以用
TEXT或MEDIUMTEXT。它们与VARCHAR不同,数据通常单独存储在溢出页,主记录只保留一个指针。这会导致全表扫描不涉及这些大字段时速度尚可,但一旦 SELECT 包含这些列,就可能触发大量随机读,性能下降明显。设计上应尽量将大字段拆分到独立表,只在需要时关联查询。
日期时间类型
- TIMESTAMP vs DATETIME:
TIMESTAMP占 4 字节,存储的是从 '1970-01-01 00:00:01' UTC 到 '2038-01-19 03:14:07' UTC 之间的时间,会受时区影响。当应用和数据库的时区设置一致时,自动转换很方便。DATETIME占 5 字节(MySQL 5.6 之后,可支持小数秒时为 5 + 小数秒存储),范围 '1000-01-01' 到 '9999-12-31',不涉及时区转换,存什么就是什么。- 建议:如果时间需要跨时区或范围超过 2038 年,直接用
DATETIME。对于单纯记录“某条记录何时创建”这种时间,两者差别不大,但要特别注意迁移或复制时区差异导致的混乱。 - 避免用字符串存日期:把日期存成
VARCHAR会导致无法使用日期函数高效计算,也不能用索引进行范围查询。既浪费空间又牺牲性能。
14.2.2 避免 NULL:明确语义,规避索引和查询陷阱
设计字段时,建议优先使用 NOT NULL 并配合默认值。理由很务实,不是空谈理论:
1. NULL 会占用额外存储空间
对于 InnoDB,NULL 值在行格式中用一个位标记,但更重要的是 NULL 列的属性本身需要额外存储开销。而且二级索引中,NULL 值会被视为可以同时出现多次(是“未定义”值),索引的效率会因此受到影响。虽然实际感知不大,但在列数很多、行数巨大时累积起来就是浪费。
2. NULL 让 SQL 逻辑复杂化
- 查询必须用
IS NULL或IS NOT NULL,而不是简单的= NULL。新手经常在这上面出错。 - 聚合函数如
COUNT(列)会忽略 NULL 值,而COUNT(*)不会。比如你统计某个表的行数时,SELECT COUNT(字段)可能返回一个比预期小的值,因为你忘记这个字段有些行是 NULL。 CONCAT()、CONCAT_WS()等函数遇到 NULL 参数会返回 NULL,除非用COALESCE处理。导致代码中多出一堆防御性逻辑。
3. 索引与查询优化器对 NULL 的处理不友好
- 对于
SELECT * FROM table WHERE col IS NULL这样的查询,优化器可能会认为 NULL 值的分布不可预测,导致索引选择出现偏差。虽然 MySQL 8.0 有所改善,但依然不如对确切值的判断准确。 - 唯一索引约束下,多个 NULL 值被视为不相等(符合 SQL 标准),但 MySQL 在 InnoDB 中只允许一个 NULL(对于普通唯一索引),这与某些数据库行为不同,可能导致困惑。
最佳实践:建表时尽量给列加上 NOT NULL DEFAULT 默认值。比如状态列用 TINYINT NOT NULL DEFAULT 0,字符串字段用 VARCHAR(50) NOT NULL DEFAULT '',日期可以用 DATETIME NOT NULL DEFAULT '1970-01-01' 或 CURRENT_TIMESTAMP。如果实在无法确定默认值,再考虑使用 NULL,但需在文档中明确 NULL 的业务含义,比如“NULL 表示未设置,空字符串表示已设置为空”。
14.2.3 适度冗余:为查询效率牺牲一点写开销
“三大范式”教我们减少冗余、消除数据依赖,但在真实场景中,完全遵守范式的设计可能带来极其复杂的多表关联查询,拖慢整个系统。适度冗余就是在规范化与反规范化之间找平衡点。
常见冗余策略:
- 将高频查询中需要的“附属字段”冗余到主表:例如订单表(
orders)通常会存储用户 ID,但为了查询订单列表时直接显示用户名,可以在订单表冗余user_name字段。这样列表查询就不用每次都 JOIN 用户表。代价是用户改了昵称后,订单里的老数据不会更新,但这是可接受的(快照语义)。 - 将统计结果预先计算并存储:比如评论表,每条评论的点赞数可以冗余存储
like_count字段,由计数系统异步更新,而不是每次展示时都COUNT(*)。 - 冗余复杂计算的关系:比如组织架构表,如果频繁按某个层级查询所有下级节点,可以冗余存储一个路径字段
path(如1-2-5),避免递归查询。
冗余的坑与取舍:
- 一致性挑战:冗余字段一旦建立,就要面对“源数据更新时同步冗余字段”的问题。这通常通过应用层事务、消息队列异步更新,或者直接接受一定时间内的不一致(最终一致性)来解决。需要根据业务接受度决定。
- 写放大:更新一个用户昵称可能需要同时更新订单表、评论表、日志表等多个冗余列,加重写入负担。
- 存储成本:冗余自然占用更多磁盘空间,但现代存储成本很低,通常不是主要矛盾。
如何判断该不该冗余:
- 先做规范化设计,确保数据模型逻辑清晰。
- 上线后监控慢查询和性能瓶颈,发现某些 JOIN 查询特别频繁且耗时。
- 针对这些热点做冗余改造:在表上加字段,回填历史数据,修改写入代码同步更新。
- 做好注释,标明哪些字段是冗余的,以及更新策略,防止后来人踩坑。
简单说,不要为了冗余而冗余,但也不要因为学范式的教条牺牲查询性能。 一个好的数据库设计,应该在正确性、可维护性和性能之间找到一个务实的平衡点。