数据库设计到底在解决什么问题

很多项目上线后的“数据库问题”,根源其实不在查询优化,而在建表当天。字段类型选大了一号,十年后磁盘和内存都在为它买单;把可变数据冗余进多张表,改一处要同步改五处,早晚出不一致;缺少合适的主键与索引,上线三个月表就慢得没法用。这些都是设计阶段埋下的账。

所以数据库设计的目标可以用一句话概括:用最少的冗余、最清晰的约束,把业务数据结构化地表达出来,并让最常见的读写路径都能高效走通。它同时受三股力量牵制——数据一致性要求、查询性能要求、以及未来的扩展与维护成本。范式是为了前两者中的“一致性”,反范式是为了“性能”,而好的设计就是在两者之间找到那个属于当前业务阶段的平衡点。

需要强调的是,数据库设计不是一次性画完就锁死的图纸。业务在变,数据量在涨,今天三范式的表明天可能要为报表做汇总冗余,后天要按用户维度分库。把设计当成持续演进、可迭代的过程,比追求“一步到位的完美模型”重要得多。

本文核心知识点
  • 三范式一范式列不可再分,二范式非主属性完全依赖主键,三范式消除传递依赖。
  • 反范式为读性能有意引入冗余,代价是写放大与一致性维护成本。
  • 字段类型够用就好,金额用整数分或定点数,时间统一用 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;

字段类型与约束的选择清单

表设计里最容易埋雷的地方之一,是字段类型。类型选得过大浪费空间、拖慢扫描;选得过小则面临溢出与迁移。下面是几条可以直接照做的经验。

场景推荐类型理由与提醒
主键、自增 IDBIGINT 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 来替代。

外键在互联网业务里要谨慎。数据库层面的外键约束能保证引用完整性,但会在插入和删除时加锁、做额外校验,在高并发写入场景下容易成为瓶颈,也会给分库分表带来麻烦。因此更常见的做法是:不在数据库建物理外键,改为在应用层保证引用完整性,只在核心的、写入不密集的表上保留外键。这是一个典型的“规范让位于性能”的取舍。

索引则要围绕查询来建,而不是围绕字段来建。先把最频繁、最耗时的查询列出来,为它们设计联合索引(顺序遵循“等值在前、范围在后、排序列紧跟”),再考虑其他次要查询。索引不是越多越好,单表控制在五六个以内,每个索引都该有明确的“服务对象”。