数据库设计到底在解决什么问题
很多项目上线后的“数据库问题”,根源其实不在查询优化,而在建表当天。字段类型选大了一号,十年后磁盘和内存都在为它买单;把可变数据冗余进多张表,改一处要同步改五处,早晚出不一致;缺少合适的主键与索引,上线三个月表就慢得没法用。这些都是设计阶段埋下的账。
所以数据库设计的目标可以用一句话概括:用最少的冗余、最清晰的约束,把业务数据结构化地表达出来,并让最常见的读写路径都能高效走通。它同时受三股力量牵制——数据一致性要求、查询性能要求、以及未来的扩展与维护成本。范式是为了前两者中的“一致性”,反范式是为了“性能”,而好的设计就是在两者之间找到那个属于当前业务阶段的平衡点。
需要强调的是,数据库设计不是一次性画完就锁死的图纸。业务在变,数据量在涨,今天三范式的表明天可能要为报表做汇总冗余,后天要按用户维度分库。把设计当成持续演进、可迭代的过程,比追求“一步到位的完美模型”重要得多。
- 三范式一范式列不可再分,二范式非主属性完全依赖主键,三范式消除传递依赖。
- 反范式为读性能有意引入冗余,代价是写放大与一致性维护成本。
- 字段类型够用就好,金额用整数分或定点数,时间统一用 UTC 存储。
- 主键优先自增或雪花 ID,避免用会变的业务字段做主键。
从 ER 模型到物理表结构
规范的建模通常分两步走。先做概念模型:和业务一起梳理出有哪些实体(用户、商品、订单)、它们之间是什么关系(一个用户有多个订单,一个订单含多个商品)、每个实体有哪些属性。这一步只关心业务语义,不碰具体数据库,通常用 ER 图或简单的实体列表来表达。
再做逻辑与物理模型:把实体落成表,把“一对多”落成外键列或关联表,把“多对多”落成中间表,给每个字段选定类型与约束,最后为高频查询补上索引。这一步会产生真正的建表语句。
这条路径的价值在于:概念模型阶段改动的成本几乎为零,而表建好、数据写入之后,改动的成本会指数级上升。先把实体关系讨论清楚,再动手写 DDL,能省掉后期大量的迁移麻烦。如果团队里有人直接从“新建一个表、加几个字段”开始,往往就是后续数据不一致的源头。
第一二三范式:消除冗余的三把尺子
范式是关系数据库用来消除冗余、避免更新异常的规则。三个范式一层比一层严格,但工程实践中通常做到第三范式就够了。
- 第一范式(1NF):每列都是原子值,不可再分。把“多个标签”挤进一个逗号分隔的字符串,就违反了一范式,正确做法是拆出标签关联表。
- 第二范式(2NF):在满足一范式的基础上,非主属性必须完全依赖主键,不能只依赖主键的一部分。这在联合主键的中间表里最常见:订单明细以 (order_id, product_id) 为联合主键时,商品名称只依赖 product_id,就不应放在明细表里。
- 第三范式(3NF):在满足二范式的基础上,非主属性之间不能互相依赖(即不能有传递依赖)。订单表里若存了 user_id 又存了 user_city,城市是依赖用户的而不是依赖订单的,就产生了传递依赖,应把城市放在用户表。
下面这段 DDL 是一份符合第三范式的订单相关表结构,把用户、商品、订单、明细分得清清楚楚:
CREATE TABLE t_user (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
nickname VARCHAR(32) NOT NULL,
city VARCHAR(32) NOT NULL DEFAULT '',
PRIMARY KEY (id)
) ENGINE=InnoDB;
CREATE TABLE t_product (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(64) NOT NULL,
price_cent INT NOT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB;
CREATE TABLE t_order (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
status TINYINT NOT NULL DEFAULT 0,
amount_cent INT NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_order_no (order_no),
KEY idx_user_time (user_id, created_at)
) ENGINE=InnoDB;
-- 订单明细:多对多关系拆出的关联表,(order_id, product_id) 联合主键
CREATE TABLE t_order_item (
order_id BIGINT UNSIGNED NOT NULL,
product_id BIGINT UNSIGNED NOT NULL,
quantity INT NOT NULL,
price_cent INT NOT NULL,
PRIMARY KEY (order_id, product_id)
) ENGINE=InnoDB;
反范式:为查询性能做有意的冗余
严格三范式的模型读起来清晰,但查询时往往要连着 JOIN 好几张表。当一个报表页面要展示订单列表,并且每条订单都要显示用户昵称和商品名称时,三范式的写法可能要连四张表,大表 JOIN 在数据量上来后非常吃力。这时就轮到反范式出场。
反范式的做法是:在下单那一刻,把用户昵称、商品名称、下单时的单价这些“当时的值”直接冗余进订单表或明细表。这带来两个好处:查询列表时单表即可完成,不 JOIN;而且订单存的是“下单时的快照”,商品后来改了价格也不会影响历史订单的金额,这本身就是业务需要的语义。
但冗余有代价。它意味着同一份数据存在多处,写的时候要同步更新,一旦漏了某处就产生不一致;它也让表变宽,占用更多存储。所以反范式的正确姿势是有意识地、局部地引入,并明确由谁负责维护一致性——通常是下单服务在写订单时一并写入冗余字段,之后这些“快照类字段”就不再更新(因为它们代表历史事实)。这与“为了省事随手复制一份可变数据”有本质区别,后者是灾难。
-- 反范式后的订单表:冗余下单时的昵称与金额快照,列表查询免 JOIN
CREATE TABLE t_order_wide (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
user_nick VARCHAR(32) NOT NULL, -- 下单时快照,不随后续改名而变化
amount_cent INT NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_order_no (order_no)
) ENGINE=InnoDB;
-- 列表页一条 SQL 搞定,不用再关联用户表
SELECT order_no, user_nick, amount_cent, created_at
FROM t_order_wide
WHERE user_id = 10086
ORDER BY created_at DESC
LIMIT 20;
字段类型与约束的选择清单
表设计里最容易埋雷的地方之一,是字段类型。类型选得过大浪费空间、拖慢扫描;选得过小则面临溢出与迁移。下面是几条可以直接照做的经验。
| 场景 | 推荐类型 | 理由与提醒 |
|---|---|---|
| 主键、自增 ID | BIGINT UNSIGNED | 避免用 INT 撑爆,预留增长空间 |
| 金额 | INT / BIGINT(单位分)或 DECIMAL | 浮点会带来精度误差,绝不能用 FLOAT/DOUBLE |
| 状态、枚举 | TINYINT + 注释 | 比字符串省空间,取值含义写进字段注释 |
| 时间 | DATETIME / TIMESTAMP | 统一存 UTC,展示时再转时区 |
| 定长编码 | CHAR | 如国家码、单字符标记,定长性能更稳 |
| 变长文本 | VARCHAR(N) | N 按真实上限给,别一律 255 或 TEXT |
约束方面,几条硬性建议:能用 NOT NULL 就不要允许空,因为 NULL 会让索引与统计更复杂,也逼迫应用层写两套判断;唯一性约束尽量交给数据库的 UNIQUE KEY,别只在应用层用“先查再插”来保证,那在并发下必然出问题;枚举值、状态迁移规则若能在数据库层用约束或触发器守住,就多一道防线。
主键、外键与索引的落地取舍
主键的选择直接影响写入性能。InnoDB 是聚簇索引,数据按主键顺序存放,所以自增 BIGINT 主键是最省心的选择——插入永远发生在索引最右侧,页几乎不会被分裂。反之,用随机 UUID 做主键会让插入位置随机跳动,页分裂和索引碎片会让写入显著变慢,通常用有序的雪花 ID 来替代。
外键在互联网业务里要谨慎。数据库层面的外键约束能保证引用完整性,但会在插入和删除时加锁、做额外校验,在高并发写入场景下容易成为瓶颈,也会给分库分表带来麻烦。因此更常见的做法是:不在数据库建物理外键,改为在应用层保证引用完整性,只在核心的、写入不密集的表上保留外键。这是一个典型的“规范让位于性能”的取舍。
索引则要围绕查询来建,而不是围绕字段来建。先把最频繁、最耗时的查询列出来,为它们设计联合索引(顺序遵循“等值在前、范围在后、排序列紧跟”),再考虑其他次要查询。索引不是越多越好,单表控制在五六个以内,每个索引都该有明确的“服务对象”。
分库分表前的建模准备
很多人把分库分表当成性能问题,其实它首先是建模问题。一旦决定拆分,迁移的成本极高,动辄数月,所以要在设计早期就为它留好路。
- 确定分片键:按 user_id 还是 order_id 分?这决定了哪些查询能带分片键、能只落一个库,哪些查询会变成跨库全扫。选分片键本质上是在选择“大多数查询长什么样”。
- 避免跨分片 JOIN:需要关联的数据尽量落在同一分片。真需要跨片的宽表查询,用冗余字段或异步同步的汇总表来解。
- 全局唯一性:自增主键在多库下会冲突,需改用雪花 ID、号段或独立发号器,且要在建表时就规划好。
- 分布式事务:跨库写入无法再靠单机事务,要提前想清楚用最终一致、本地消息表还是其他补偿方案。
结论很朴素:能不分就不分,优先用加索引、读写分离、分区表扛住增长;真的到了单表上亿、单库写入打满时,再用前面这些准备从容拆分。但无论分不分,这些约束都该在建模阶段想清楚,因为它们会反过来影响字段设计。
一次真实的订单库建模复盘
拿一个常见的中型电商订单库来复盘。最初的设计是三范式的:t_order 只存 user_id 和 product_id,需要展示时再 JOIN 用户表和商品表。上线的头半年一切正常,数据量到几百万后,运营后台的订单列表接口开始超时——因为它要 JOIN 三张表并对结果排序。
第一次改造做了三件事:把下单时的用户昵称、商品名称与成交价冗余进订单表,让列表查询回到单表;为 (user_id, created_at) 建联合索引,消除排序;把状态的枚举含义写进字段注释并在应用层统一常量。改造后该接口的响应时间从数秒降到几十毫秒,数据模型也更贴合“订单是历史快照”这一业务事实。
第二次改造发生在大促前:预估订单量将突破单表容量,于是按 user_id 做了分片。由于第一次改造时已经让常用的买家侧查询都带上了 user_id,分片几乎没有伤及主要接口;真正需要跨片的只有运营侧的统计,而它们本来就走异步汇总表。这两次改造说明同一件事:好的设计不是在第一次就把所有问题解决,而是每一次演进都让它更容易往下走一步。
范式与反范式从来不是对错之争,而是取舍之争。判断标准只有一条:这个模型是否既守住了数据一致性,又让最常见的读写路径足够快,同时让未来那次必然会来的改造不至于推倒重来。
到这里,数据库开发频道的三篇内容就走完了一个闭环:从 MySQL 的索引原理,到 Redis 的缓存与持久化,再回到设计源头思考范式与反范式的边界。把这三块连起来看,你会发现它们其实都在回答同一个问题——如何让数据在正确的时间、以正确的代价,出现在正确的地方。