你设计了一张”完美”的第三范式表结构,上线后却发现查询要 JOIN 五张表、响应时间 3 秒——于是你开始加冗余字段,范式被打破了。与此同时,API 返回的 JSON 字段从 v1 的 5 个膨胀到 v7 的 23 个,旧客户端还在用旧格式,新客户端需要新字段——Schema 演化成了一场噩梦。
数据建模是数据库设计的起点,也是工程实践中最容易被低估的环节。在 数据库全景 中讨论了数据模型的选择(关系、文档、图、键值),本章则聚焦于选定关系模型之后的核心问题:如何组织表结构?如何平衡范式与性能?如何让 Schema 在业务演化中保持兼容?如何选择数据编码格式?
一、数据建模概述
1.1 三层建模过程
数据建模不是一蹴而就的,它经历从抽象到具体的三个层次:
| 层次 | 关注点 | 产出物 | 变更代价 |
|---|---|---|---|
| 概念模型 | 业务实体与关系 | ER 图、实体定义 | 低 — 只需修改图表 |
| 逻辑模型 | 数据结构与约束 | 关系模式、函数依赖 | 中 — 影响应用逻辑 |
| 物理模型 | 存储与访问效率 | 索引、分区、存储参数 | 高 — 需要数据迁移 |
三层建模的价值在于分离关注点:概念模型让业务方和开发者用同一种语言沟通,逻辑模型确保数据结构的正确性,物理模型针对性能做优化。跳过前两层直接建表,是很多项目技术债的根源。
1.2 ER 图与实体关系
概念模型的核心工具是 ER 图(Entity-Relationship Diagram)。以电商系统为例:
ER 图到关系模式的映射遵循固定规则:
- 实体 → 一张表,属性 → 列
- 1
关系 → 在 N 端加外键 - M
关系 → 建立关联表(如 ORDER_ITEM) - 1:1 关系 → 合并为一张表或在任一端加外键
二、范式理论
2.1 为什么需要范式
范式(Normal Form)是一组规则,用于消除数据冗余和更新异常。没有范式化的设计会带来三大问题:
| 异常类型 | 描述 | 示例 |
|---|---|---|
| 插入异常 | 无法独立插入某些信息 | 没有订单就无法存储用户的城市信息 |
| 更新异常 | 同一信息多处存储,更新时可能遗漏 | 用户城市在 100 条订单中重复,改了 99 条漏了 1 条 |
| 删除异常 | 删除一行可能丢失其他有用信息 | 删除某用户的最后一个订单,用户地址信息也丢失了 |
2.2 第一范式(1NF)
规则:每个列的值都是原子的(不可再分)。
-- 违反 1NF:orders 列包含多个值CREATE TABLE users_bad ( id BIGINT PRIMARY KEY, name VARCHAR(100), orders VARCHAR(500) -- "1001,1002,1003" — 非原子值);
-- 满足 1NF:拆分为独立行CREATE TABLE users ( id BIGINT PRIMARY KEY, name VARCHAR(100));
CREATE TABLE orders ( id BIGINT PRIMARY KEY, user_id BIGINT REFERENCES users(id), status VARCHAR(20));1NF 的”原子性”是相对的。在关系模型中,原子意味着”数据库不提供分解该值的操作”。JSON 列在 MySQL 8.0 中可以通过 JSON_EXTRACT 查询内部字段,严格来说违反了 1NF——但这在实践中是可接受的权衡。
2.3 第二范式(2NF)
规则:在满足 1NF 的基础上,消除非主属性对候选键的部分依赖。
-- 违反 2NF:(order_id, product_id) 是主键,但 product_name 只依赖 product_idCREATE TABLE order_items_bad ( order_id BIGINT, product_id BIGINT, quantity INT, product_name VARCHAR(200), -- 只依赖 product_id,不依赖 order_id PRIMARY KEY (order_id, product_id));
-- 满足 2NF:将 product_name 移到 products 表CREATE TABLE order_items ( order_id BIGINT, product_id BIGINT, quantity INT, PRIMARY KEY (order_id, product_id));
CREATE TABLE products ( id BIGINT PRIMARY KEY, name VARCHAR(200));2.4 第三范式(3NF)
规则:在满足 2NF 的基础上,消除非主属性对候选键的传递依赖。
-- 违反 3NF:user_id → city → city_timezone(传递依赖)CREATE TABLE orders_bad ( id BIGINT PRIMARY KEY, user_id BIGINT, city VARCHAR(50), city_timezone VARCHAR(50), -- 依赖 city,不直接依赖 order 的主键 status VARCHAR(20));
-- 满足 3NF:city 和 timezone 移到 users 表CREATE TABLE orders ( id BIGINT PRIMARY KEY, user_id BIGINT REFERENCES users(id), status VARCHAR(20));
CREATE TABLE users ( id BIGINT PRIMARY KEY, name VARCHAR(100), city VARCHAR(50), city_timezone VARCHAR(50) -- 或者进一步拆分到 cities 表);2.5 BCNF(Boyce-Codd 范式)
规则:每个决定因素都是候选键。BCNF 是 3NF 的加强版,处理 3NF 中”主属性对候选键的部分依赖”问题。
-- 满足 3NF 但违反 BCNF-- 假设一个学生选一门课只有一个老师,一个老师只教一门课-- 候选键:(student_id, course_id) 和 (student_id, teacher_id)-- teacher_id → course_id(teacher_id 是决定因素但不是候选键)
CREATE TABLE enrollments_bad ( student_id BIGINT, course_id BIGINT, teacher_id BIGINT, PRIMARY KEY (student_id, course_id) -- teacher_id → course_id,但 teacher_id 不是候选键);
-- 满足 BCNF:拆分为两张表CREATE TABLE teacher_courses ( teacher_id BIGINT PRIMARY KEY, course_id BIGINT -- 每个老师只教一门课);
CREATE TABLE enrollments ( student_id BIGINT, teacher_id BIGINT, PRIMARY KEY (student_id, teacher_id));2.6 范式层级总结
| 范式 | 核心约束 | 消除的异常 | 实际建议 |
|---|---|---|---|
| 1NF | 列值原子 | 重复组 | 必须满足 |
| 2NF | 无部分依赖 | 插入/更新异常 | 必须满足 |
| 3NF | 无传递依赖 | 冗余更新异常 | 通常满足即可 |
| BCNF | 决定因素必为候选键 | 主属性依赖异常 | 视情况而定 |
三、反范式设计
3.1 为什么需要反范式
范式化消除了冗余,但代价是查询时需要大量 JOIN。一个典型的电商查询在 3NF 下可能需要关联 5-6 张表:
-- 3NF:查询订单详情需要 JOIN 5 张表SELECT o.id, u.name, oi.quantity, p.name, p.price, c.name AS categoryFROM orders oJOIN users u ON o.user_id = u.idJOIN order_items oi ON o.id = oi.order_idJOIN products p ON oi.product_id = p.idJOIN categories c ON p.category_id = c.idWHERE o.id = 1001;在 OLTP 场景中,这种 JOIN 的代价尚可接受(每次查一条订单)。但在 OLAP 场景中,分析”各品类月度销售额”需要扫描百万级订单,5 表 JOIN 的 I/O 代价极高。这就是反范式存在的理由。
3.2 反范式策略
| 策略 | 描述 | 适用场景 | 一致性风险 |
|---|---|---|---|
| 冗余列 | 在子表中复制父表的列 | 高频读取、低频更新 | 中 — 更新父表时需同步 |
| 预计算列 | 存储计算结果 | 聚合查询频繁 | 低 — 可异步刷新 |
| 汇总表 | 独立的聚合表 | OLAP 报表 | 低 — 定时刷新即可 |
| JSON 宽表 | 将关联数据打包为 JSON | 文档型访问模式 | 高 — 部分更新困难 |
-- 策略1:冗余列 — 在 order_items 中冗余 product_nameALTER TABLE order_items ADD COLUMN product_name VARCHAR(200);
-- 查询不再需要 JOIN products 表SELECT oi.order_id, oi.product_name, oi.quantityFROM order_items oiWHERE oi.order_id = 1001;
-- 策略2:预计算列 — 在 orders 中存储 total_amountALTER TABLE orders ADD COLUMN total_amount DECIMAL(10,2);
-- 策略3:汇总表 — 按月按品类汇总销售额CREATE TABLE monthly_category_sales ( month DATE, category_id BIGINT, category_name VARCHAR(100), -- 冗余 total_sales DECIMAL(15,2), order_count INT, PRIMARY KEY (month, category_id));3.3 OLTP 与 OLAP 的建模差异
| 维度 | OLTP(事务型) | OLAP(分析型) |
|---|---|---|
| 范式程度 | 3NF 为主,局部反范式 | 星型/雪花模型,大量反范式 |
| 表结构 | 窄表多表 | 宽表少表 |
| 写入模式 | 随机、单行 | 批量、追加 |
| 查询模式 | 点查、范围查 | 全表扫描、聚合 |
| 冗余策略 | 谨慎冗余,触发器同步 | 大量冗余,ETL 刷新 |
| 典型模型 | 关系模型 | 星型模型 / 宽表 |
反范式不是”违反规则”,而是有意识的权衡。关键原则:冗余的数据必须是可推导的(从源数据重新计算得到),而非独立产生的。这样即使冗余数据不一致,也可以通过重新计算修复。
3.4 星型模型与雪花模型
在 OLAP 场景中,数据建模通常采用维度建模方法:
星型模型查询简单(单层 JOIN),但维度表有冗余;雪花模型节省空间,但查询更复杂。实践中,星型模型更常见——存储便宜,查询性能更重要。
四、Schema 演化
4.1 ALTER TABLE 的代价
Schema 变更不是简单的”改个字段”。在 MySQL 的 InnoDB 中,某些 ALTER TABLE 操作需要重建整张表:
-- MySQL 中不同 ALTER 操作的代价-- 即时操作(仅修改元数据)ALTER TABLE users ALTER COLUMN name SET DEFAULT 'unknown';
-- INPLACE 操作(不阻塞读写,但可能耗时)ALTER TABLE users ADD INDEX idx_city (city); -- Online DDL
-- COPY 操作(重建整表,长时间锁表)ALTER TABLE users MODIFY COLUMN name VARCHAR(300); -- 增大 VARCHAR 可能需要重建ALTER TABLE users CHANGE COLUMN id user_id BIGINT; -- 改列名需要重建| 操作类型 | MySQL InnoDB | PostgreSQL | 锁表时间 |
|---|---|---|---|
| 添加列(无 DEFAULT) | INPLACE | 即时 | 极短 |
| 添加列(有 DEFAULT) | INPLACE(8.0+即时) | 即时(11+) | 短 |
| 删除列 | INPLACE | 即时(标记删除) | 极短 |
| 修改列类型 | COPY | 重写表 | 长 |
| 添加索引 | INPLACE | 不锁写(12+) | 中 |
| 改列名 | INPLACE | 即时 | 极短 |
4.2 在线变更工具
生产环境中直接 ALTER TABLE 可能导致长时间锁表,通常使用在线变更工具:
# gh-ost:GitHub 开源的在线表变更工具# 原理:创建幽灵表 → 增量同步 binlog → 切换表名gh-ost \ --host=mysql-master \ --database=shop \ --table=orders \ --alter="ADD COLUMN shipping_address VARCHAR(500)" \ --allow-on-master \ --execute
# pt-online-schema-change:Percona 工具# 原理:创建新表 → 增量同步触发器 → 切换表名pt-online-schema-change \ --host=mysql-master \ --user=admin \ --alter="ADD COLUMN shipping_address VARCHAR(500)" \ D=shop,t=orders \ --execute在线变更工具并非银弹。大表变更时需要注意:1) binlog 消费延迟可能导致切换时数据不一致;2) 磁盘空间需要原表 2 倍以上;3) 切换瞬间有短暂的写阻塞。务必在非高峰期执行,并提前演练回滚方案。
4.3 零停机迁移:双写 + 回填
对于复杂的 Schema 变更(如拆分表、改数据类型),在线 DDL 工具无法胜任,需要采用双写 + 回填策略:
# 双写伪代码示例class OrderRepository: def create(self, order): # 写旧表(兼容期) old_db.execute( "INSERT INTO orders (id, user_id, status, amount) VALUES (%s, %s, %s, %s)", (order.id, order.user_id, order.status, order.amount) ) # 写新表(新增 shipping_address 列) new_db.execute( "INSERT INTO orders_v2 (id, user_id, status, amount, shipping_address) " "VALUES (%s, %s, %s, %s, %s)", (order.id, order.user_id, order.status, order.amount, order.shipping_address) )
def get(self, order_id): # 读取仍走旧表(切换前) return old_db.query("SELECT * FROM orders WHERE id = %s", (order_id,))4.4 版本化迁移工具
Schema 变更应该像代码一样被版本化管理:
-- Flyway 迁移脚本:V10__add_shipping_address.sqlALTER TABLE orders ADD COLUMN shipping_address VARCHAR(500);
-- Flyway 迁移脚本:V11__create_order_stats_table.sqlCREATE TABLE order_stats ( stat_date DATE PRIMARY KEY, total_orders INT, total_amount DECIMAL(15,2), avg_order_amount DECIMAL(10,2));# Liquibase 变更日志databaseChangeLog: - changeSet: id: 10 author: team-data changes: - addColumn: tableName: orders columns: - column: name: shipping_address type: VARCHAR(500) - changeSet: id: 11 author: team-data changes: - createTable: tableName: order_stats columns: - column: { name: stat_date, type: DATE, constraints: { primaryKey: true } } - column: { name: total_orders, type: INT } - column: { name: total_amount, type: DECIMAL(15,2) }| 工具 | 迁移命名 | 回滚支持 | 多环境 | 适用场景 |
|---|---|---|---|---|
| Flyway | V{version}__{desc}.sql | 付费版 | SQL 优先、简单直接 | |
| Liquibase | XML/YAML/JSON | 原生 | 跨数据库、复杂变更 | |
| Alembic | Python 脚本 | Python/SQLAlchemy 生态 | ||
| Prisma Migrate | 自管理 | Node.js/TypeScript 生态 |
五、编码格式
数据在内存中是对象/结构体,在磁盘上或网络上是字节序列。编码(序列化) 是两者之间的桥梁。编码格式的选择直接影响存储效率、跨语言兼容性和 Schema 演化能力。
5.1 编码流程
5.2 JSON
JSON 是最通用的编码格式,几乎被所有语言和系统支持:
{ "orderId": 1001, "userId": 42, "items": [ {"productId": 7, "quantity": 2, "unitPrice": 29.99}, {"productId": 13, "quantity": 1, "unitPrice": 149.00} ], "status": "shipped", "createdAt": "2026-04-21T10:00:00Z"}JSON 的优势是可读性和通用性,劣势是体积大、无类型约束、数字精度问题(IEEE 754 双精度浮点)。
5.3 Protocol Buffers(Protobuf)
Protobuf 是 Google 开发的二进制编码格式,通过 .proto 文件定义 Schema:
syntax = "proto3";
message Order { int64 order_id = 1; int64 user_id = 2; repeated OrderItem items = 3; OrderStatus status = 4; string created_at = 5; // ISO 8601}
message OrderItem { int64 product_id = 1; int32 quantity = 2; double unit_price = 3;}
enum OrderStatus { ORDER_STATUS_UNSPECIFIED = 0; PENDING = 1; SHIPPED = 2; DELIVERED = 3; CANCELLED = 4;}Protobuf 编码紧凑(字段编号代替字段名)、跨语言(自动生成代码)、有 Schema 约束,但不可读。
5.4 Apache Avro
Avro 是 Hadoop 生态的编码格式,Schema 定义使用 JSON:
{ "type": "record", "name": "Order", "namespace": "com.example", "fields": [ {"name": "orderId", "type": "long"}, {"name": "userId", "type": "long"}, {"name": "items", "type": {"type": "array", "items": "OrderItem"}}, {"name": "status", "type": {"type": "enum", "name": "OrderStatus", "symbols": ["PENDING", "SHIPPED", "DELIVERED", "CANCELLED"]}}, {"name": "createdAt", "type": "long", "logicalType": "timestamp-millis"} ]}Avro 的独特之处:编码数据中不包含字段编号或名称,解码完全依赖 Schema。这使得 Avro 在 Schema 演化方面最为灵活。
5.5 编码格式对比
| 维度 | JSON | Protobuf | Avro | Thrift |
|---|---|---|---|---|
| 编码方式 | 文本 | 二进制 | 二进制 | 二进制 |
| Schema 要求 | 可选 | 必须(.proto) | 必须(JSON 定义) | 必须(.thrift) |
| 可读性 | 人类可读 | 二进制 | 二进制 | 二进制 |
| 编码体积 | 大 | 小 | 最小 | 小 |
| Schema 演化 | 无约束 | 有限规则 | 最灵活 | 有限规则 |
| 跨语言支持 | 所有语言 | 12+ 语言 | 10+ 语言 | 15+ 语言 |
| 典型场景 | REST API、配置 | gRPC、内部服务 | Kafka、Hadoop | 跨语言 RPC |
| 字段标识 | 字段名 | 字段编号 | 无(纯 Schema) | 字段编号 |
选择编码格式的核心考量不是”哪个更好”,而是使用场景。面向外部 API 或需要调试,选 JSON;面向内部微服务间通信,选 Protobuf;面向数据流管道(Kafka + Schema Registry),选 Avro。在 数据库选型 中我们会进一步讨论技术选型的决策框架。
六、模式演化与兼容性
6.1 为什么需要兼容性
Schema 不是一成不变的。业务在演化,字段在增减,类型在调整。但生产环境中往往同时存在新旧版本的代码——滚动更新期间,部分实例用 v1 Schema,部分用 v2 Schema。如果新旧 Schema 不兼容,轻则数据丢失,重则系统崩溃。
6.2 向前兼容与向后兼容
| 兼容性方向 | 含义 | 典型场景 |
|---|---|---|
| 向后兼容(Backward) | 新代码能读旧数据 | 新版本服务读取旧版本写入的数据 |
| 向前兼容(Forward) | 旧代码能读新数据 | 旧版本服务读取新版本写入的数据 |
| 完全兼容 | 同时满足向前和向后 | 滚动更新期间零停机 |
6.3 Protobuf 演化规则
Protobuf 通过字段编号实现演化,核心规则:
// v1: 原始定义message User { int64 id = 1; string name = 2; string email = 3;}
// v2: 演化后的定义message User { int64 id = 1; string name = 2; // string email = 3; ← 删除字段:编号 3 保留,不可复用 string username = 4; // 新增字段:使用新编号 int32 age = 5; // 新增字段 reserved 3; // 声明保留,防止误用 reserved "email"; // 保留字段名}| 操作 | 向后兼容 | 向前兼容 | 注意事项 |
|---|---|---|---|
| 新增字段 | 旧数据无该编号,取默认值 | 旧代码忽略未知编号 | 使用新编号 |
| 删除字段 | 新代码忽略旧编号 | 旧数据中该编号取默认值 | 编号不可复用 |
| 修改字段名 | 编码只看编号 | 旧代码用旧名解码 | 不推荐 |
| 修改字段类型 | 仅兼容类型(如 int32→int64) | 同左 | 大部分类型变更不兼容 |
| 修改字段编号 | 绝对禁止 |
6.4 Avro 读写模式解析
Avro 的 Schema 演化机制最为精巧。解码时需要两个 Schema:写入模式(数据编码时使用的 Schema)和读取模式(解码方期望的 Schema)。Avro 通过对比两个模式来解析数据:
// 写入模式(v1){ "type": "record", "name": "User", "fields": [ {"name": "id", "type": "long"}, {"name": "name", "type": "string"}, {"name": "email", "type": ["null", "string"], "default": null} ]}
// 读取模式(v2)— 删除 email,新增 age{ "type": "record", "name": "User", "fields": [ {"name": "id", "type": "long"}, {"name": "name", "type": "string"}, {"name": "age", "type": ["null", "int"], "default": null} ]}// 解析规则:email 在写模式有、读模式无 → 忽略// age 在读模式有、写模式无 → 使用默认值 null6.5 兼容性规则对比
| 操作 | Protobuf | Avro | JSON Schema |
|---|---|---|---|
| 新增可选字段 | 双向兼容 | 双向兼容(有默认值) | 双向兼容 |
| 删除可选字段 | 双向兼容 | 双向兼容 | 向后兼容 |
| 重命名字段 | 需用 alias | 通过 alias | |
| 修改类型 | 有限兼容 | 可转换类型兼容 | |
| 新增必填字段 | 破坏向前兼容 | 破坏向前兼容 |
在 Kafka 生态中,Schema Registry 是管理 Avro/Protobuf Schema 演化的核心组件。它维护所有 Schema 版本,并在注册新版本时检查兼容性(向后/向前/完全)。生产环境务必启用兼容性检查,防止不兼容的 Schema 变更上线。
七、总结
数据建模与 Schema 设计贯穿了数据库系统的整个生命周期,从建模到演化再到编码,每一步都需要在正确性与效率之间做出权衡。
核心要点回顾
| 主题 | 核心取舍 | 关键原则 |
|---|---|---|
| 范式理论 | 减少冗余 vs 查询性能 | 至少满足 3NF,再按需反范式 |
| 反范式设计 | 读取性能 vs 更新一致性 | 冗余数据必须可推导、可修复 |
| Schema 演化 | 变更灵活性 vs 迁移代价 | 小步快跑、双写回填、版本化迁移 |
| 编码格式 | 可读性 vs 紧凑性 vs 演化能力 | 外部 JSON、内部 Protobuf、管道 Avro |
| 模式兼容性 | 新旧代码共存 vs 字段约束 | 新增可选字段安全,修改/删除需谨慎 |
数据建模没有”完美方案”,只有适合当前阶段的方案。初期严格范式化保证正确性,随着性能瓶颈出现再逐步反范式化——这是大多数成功项目的演化路径。Schema 演化同理:提前规划兼容性规则,比事后修补数据要便宜得多。
在 数据库全景 中我们建立了数据库分类的认知框架,本章深入了关系模型的建模方法。下一章 数据库选型与实践 将讨论如何根据业务特征选择合适的数据库——建模方法只是起点,选对数据库才能让模型真正发挥价值。
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






