Appearance
数据库设计时主要注意哪些事情?
声明:默认指 MySQL 的 InnoDB 存储引擎,以 MySQL 8.0 为主视角,涉及 5.7/8.0 行为差异处会单独标注。
表结构是系统的地基。数据量翻 100 倍之后还能不能顺畅地查询、变更、扩容,很大程度在建表那一刻就决定了。面试问"数据库设计注意什么",是考你有没有被生产毒打过。下面按"规范 → 类型 → 主键 → 索引 → 大字段 → 反范式 → 扩展性"的顺序展开。
1 总览:设计目标
- 性能:写入顺序、索引可控、单表体量可控;
- 可维护:命名统一、字段自解释、有注释、可平滑 DDL;
- 可扩展:预留分片键与归档策略,避免后期推倒重来;
- 一致性:null 语义清晰、冗余字段有明确的维护机制。
2 三范式与反范式的取舍
- 1NF 列原子性(不要在一个字段里塞逗号分隔的 id 列表——虽然 JSON 类型是受控的例外);
- 2NF 非主键列完全依赖主键(消除部分依赖);
- 3NF 非主键列不传递依赖主键(消除传递依赖)。
互联网业务的普遍做法:遵循 3NF 设计基础表,再按查询热点做定向反范式。冗余计数(点赞数)、冗余名称(订单里冗余商品名快照,还兼顾历史正确性)、宽表(报表/详情页)。反范式省掉 JOIN,代价是写入侧要维护一致性(事务内更新、或 binlog 异步对账)。冗余必须显式设计,而不是写着写着顺手复制的。
3 命名与基础规范
- 库表列名全小写 + 下划线分隔(Linux 上表名大小写敏感取决于
lower_case_table_names,统一小写最稳)。不用 MySQL 保留字(order、desc、groups都是常踩的坑); - 同一语义跨表同名:
user_id在所有表里都叫user_id,不要uid/buyer_id混用; - 布尔语义用
is_xxx(tinyint 存 0/1);时间用create_time/update_time; - 每张表必备三件套:
sql
CREATE TABLE t_order (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
-- ... 业务字段 ...
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';- 不使用外键与级联(互联网规范):外键把一致性校验放进数据库。带来额外锁、级联风暴和分库分表障碍。一致性由应用层/事务保证(逻辑外键);
- 不使用 ENUM:加一个枚举值要 DDL;用
tinyint unsigned + 注释或字典表。8.0 加列虽可 INSTANT,但 ENUM 改值仍属变更,不如 tinyint 灵活。
4 字段类型选择(最容易体现功力的部分)
4.1 选型速查表
| 需求 | 推荐 | 避免/原因 |
|---|---|---|
| 主键/ID | BIGINT UNSIGNED(或雪花 ID) | UUID 字符串:空间大、插入随机导致页分裂 |
| 状态/类型/枚举 | TINYINT UNSIGNED | ENUM(改值需 DDL) |
| 金额 | DECIMAL(12,2) | FLOAT/DOUBLE 有精度误差,钱不能算错 |
| 短字符串 | VARCHAR(N),N 按真实上限 | 无脑 VARCHAR(255):影响排序内存与索引长度 |
| 定长编码(身份证 MD5 等) | CHAR(32) | VARCHAR 有 1~2 字节长度前缀 |
| 时间 | DATETIME(或 TIMESTAMP 见 4.3) | VARCHAR 存时间无法按时间索引比较 |
| 大文本/二进制 | TEXT/BLOB,且垂直拆到附表 | 与主表混放拖累扫描效率 |
| 标签/灵活属性 | JSON(5.7+) | 滥用 JSON 当万能字段:无法建常规索引、校验弱 |
4.2 整数与字符串细节
- INT 显示宽度(
INT(11))只影响 zerofill 展示,不限制取值范围,8.0 已弃用该语法(5.7 常见,升级时会被清理); - 能用 UNSIGNED 就用:正整数范围翻倍且语义更清晰(如
ip2long存 IP); - VARCHAR(N) 的 N 是字符数,utf8mb4 下每字符最多 4 字节。索引长度限制默认 3072 字节(InnoDB DYNAMIC 行格式,5.7.7+/8.0 默认;更老版本是 767 字节)。所以 utf8mb4 单列索引最长约 768 字符;
ORDER BY varchar_col时排序缓冲按定义长度预留内存,无脑 255 放大内存占用。
4.3 时间类型:DATETIME vs TIMESTAMP
| 维度 | DATETIME | TIMESTAMP |
|---|---|---|
| 存储 | 8 字节(5.6.4+ 支持小数秒,最多 +3 字节) | 4 字节(有小数秒时 +3) |
| 范围 | 1000-01-01 ~ 9999-12-31 | 1970 ~ 2038-01-19(2038 问题) |
| 时区 | 存什么是什么,不做转换 | 按 UTC 存储,读写自动做时区转换 |
| 默认值 | 5.6+ 支持 DEFAULT CURRENT_TIMESTAMP | 同左;explicit_defaults_for_timestamp 在 8.0 默认 ON,不再隐式给首个 TIMESTAMP 列塞默认行为 |
经验:新系统一律 DATETIME,时区在应用层统一处理;存量 TIMESTAMP 在 2038 前要有迁移计划。
4.4 JSON 类型
5.7 引入,8.0 增强(部分更新减少重写整份文档、->> 便捷取值):
sql
ALTER TABLE t_order ADD COLUMN ext JSON;
SELECT ext->>'$.channel' FROM t_order;
CREATE INDEX idx_channel ON t_order ((CAST(ext->>'$.channel' AS CHAR(64)))); -- 8.0.13+ 虚拟列函数索引定位:低频、结构多变的扩展属性;高频查询条件仍应落到普通列。8.0.13+ 可对 JSON 表达式建函数索引弥补检索。
5 主键设计:为什么强烈推荐有序主键
InnoDB 是聚集索引组织表:数据行物理上按主键顺序存放在 B+ 树叶子节点。
自增/雪花主键插入(顺序写) UUID 主键插入(随机写)
[1][2][3][4][5][6][7][ ] [7][2][500][9][88][3][1]
新行总落在最后一页的空闲位置 每次插入都可能命中已满的页
-> 顺序追加,页写满才开新页 -> 页分裂:把半页数据挪到新页 + 更新父节点
-> 写放大、缓存命中率下降、空间碎片- 自增 BIGINT:最优写入性能;缺点是分库分表下会冲突、暴露业务量(可用随机起始值缓解);
- 雪花 Snowflake:趋势递增(高位时间戳),既保住顺序写又全局唯一。是分片场景标配(详见同目录《美团-Leaf》《雪花算法》两篇);
- UUID v4(随机):主键场景最差选择——页分裂 + 36 字符空间 + 缓存局部性差。若必须用 UUID,至少用 UUID v7(时间有序,MySQL 侧仍占空间)或转换为有序形式;
- 没有显式主键时,InnoDB 会选第一个"非空唯一索引"。再没有就生成隐藏的 6 字节行 ID(不可用、不可见,binlog 主从同步场景还有坑)。每表必须有显式主键。
6 索引设计规范
- 单表索引建议不超过 5~6 个、单个联合索引不超过 4~5 列。每个索引都是一棵独立的 B+ 树,拖累写入、占空间;
- 联合索引顺序:等值列在前、范围列在后、排序列衔接;把最常用的过滤列放最左;
- 区分度
COUNT(DISTINCT col)/COUNT(*)低的列(性别/状态)不要单独建索引,要么进联合索引、要么靠其他条件先过滤; - 避免冗余:有
(a,b)时(a)是冗余索引;(a)和(id,a)也是(主键天然在二级索引尾部); - 高频查询尽量设计成覆盖索引;
- 给字符串列建前缀索引控制长度;8.0.13+ 可用函数索引覆盖带函数的查询;
- 删索引先 INVISIBLE 灰度(8.0),确认无依赖再 DROP。
7 大字段与表结构拆分
- TEXT/BLOB 可能触发行溢出。超过约 768/4096 字节的部分存到溢出页。DYNAMIC 格式下只在本页留 20 字节指针。SELECT 未取大列也可能因为二级索引回表整行读取而受影响;
- 常见拆法:主表保持窄(扫描快),
t_order_detail_ext(order_id, content TEXT)一对一垂直拆分; - 需要
WHERE content LIKE '%kw%'的检索不要指望索引,走全文索引或 ES; - 临时表注意:含大字段/JSON 的查询在内存临时表放不下时落盘。8.0 TempTable 引擎改善了内存处理,但溢出仍存在。GROUP BY 大字段先取摘要值再 JOIN 回取内容。
8 NULL 的处理
- 建议列都 NOT NULL 并给默认值,理由:
COUNT(col)、SUM(col)会忽略 NULL 行,聚合结果和直觉不符;- NULL 参与比较是三值逻辑(UNKNOWN),
WHERE col != 'x'查不出 NULL 行,易漏数据; NOT IN (含 NULL 的子查询)恒为空这类"埋雷"行为;- NULL 使索引统计和优化器估算更复杂;
- 澄清一个常见误区:NULL 值本身可以被索引,
IS NULL能走索引,"NULL 不走索引"是错的。问题在语义和统计,不在能不能走; - 表示"没值"用 NOT NULL + 语义默认值(空串、0)或专门的状态列。
9 冗余、冷热与扩展性
- 预估容量:按 3~5 年增量估算行数与磁盘,评估单表是否到千万级、是否需要分表;
- 分库分表要提前定分片键(通常选租户/用户维度),分片键进所有查询路径。跨分片 JOIN/聚合靠冗余宽表、数据仓库或绑定表;
- 冷热分离:历史订单按时间归档到归档库/归档表,线上主表保持小而快;
- 预留无侵入的扩展列(如
extJSON)应对临时需求,但严控使用范围; - 长文本快照类需求(订单里的收货地址、商品名)按当时快照存储,不做外键关联。历史数据要还原当时的样子。
10 MySQL 8 视角的设计差异(版本加分项)
- 默认字符集 utf8mb4 / collation utf8mb4_0900_ai_ci:5.7 默认 latin1。跨版本 JOIN 时 collation 不一致会导致索引失效,迁移后要统一核查(
SHOW CREATE TABLE); - 默认认证插件 caching_sha2_password:老客户端/驱动连不上,需调整用户插件或升级驱动(设计账号体系时考虑);
- Instant DDL:8.0.12 起 ADD COLUMN 可 INSTANT 秒级完成,8.0.29 扩展到 DROP COLUMN。5.7 时代大表 DDL 要 gh-ost/pt-osc。但"能秒加列"不等于可以随意改表,索引重建仍是重量级;
- 数据字典改为 InnoDB 表:frm 文件消失,跨库元数据一致;
- 降序索引、函数索引、Skip Scan(见《数据库索引失效总结》),为设计提供更多"用索引解决"的选项;
- GROUP BY 不再隐式 ORDER BY:依赖隐式排序的老表/老 SQL 升级时要显式补 ORDER BY。
11 追问链(面试官会怎么往下问)
- Q:为什么不用 UUID 做主键? A:聚簇索引按主键有序存储,随机主键导致频繁页分裂、写放大、buffer pool 命中率下降。雪花/自增是趋势递增,顺序追加。
- Q:为什么金额不能用 FLOAT/DOUBLE? A:二进制浮点无法精确表示十进制小数,累加误差会算错账。DECIMAL 按字符串/定点存,或存最小单位分(BIGINT)。
- Q:DATETIME 和 TIMESTAMP 选哪个? A:范围与时区语义:TIMESTAMP 4 字节、自动时区转换,但有 2038 上限;DATETIME 8 字节、无时区语义。新系统 DATETIME + 应用层统一时区。
- Q:为什么互联网规范禁用外键? A:外键的约束检查与级联在高并发下放大锁竞争,且是分库分表的拦路虎;一致性交给应用事务。
- Q:字段为什么建议 NOT NULL? A:三值逻辑埋雷(!=、NOT IN 查不出 NULL)、聚合忽略 NULL、统计复杂;但 NULL 本身可走索引,别把理由说错。
- Q:一张表最多能存多少数据? A:技术上没有硬上限(受磁盘限制),工程经验单表千万行/10 GB 开始评估分表。真正信号是树高增长、缓存命中率与维护成本。
- Q:TEXT 大字段为什么建议拆出去? A:行溢出、扫描放大、内存临时表落盘;拆附表让主表窄,常用查询不受大字段拖累。
12 小结
- 设计顺序建议:先按 3NF 理清实体,再按查询热点做定向反范式;
- 类型选型三条铁律:金额 DECIMAL、时间 DATETIME、状态 tinyint;字符串按真实长度,统一 utf8mb4;
- 主键要"有序 + 全局唯一":单库自增,分布式雪花;
- 索引少而精:联合索引对齐查询,覆盖优先,删索引走 INVISIBLE 灰度(8.0);
- 列尽量 NOT NULL + 默认值,避开三值逻辑的坑;
- 为未来留路:预估容量、定分片键、冷热分离、快照式冗余;
- MySQL 8 的默认值变化(utf8mb4、认证插件、Instant DDL、GROUP BY 行为)是升级与设计时必须心里有数的差异点。
2026-10 依据主题重写(原文件为空壳)。