1. 关系模型与 SQL
1.1 关系模型核心概念
关系模型(Codd 1970)把数据组织为二维表:行(tuple)为记录,列(attribute)为字段。关系模型的三大要素:结构(relation)、完整性(约束)、操作(关系代数/SQL)。
| 概念 | 含义 | 例子 |
|---|---|---|
| 关系(Relation) | 一张二维表 | users 表 |
| 元组(Tuple) | 一行记录 | 一个用户 |
| 属性(Attribute) | 一列字段 | name, age |
| 候选键(Candidate Key) | 可唯一标识元组的属性集 | id, (name, dept) |
| 主键(Primary Key) | 选定的候选键 | id |
| 外键(Foreign Key) | 引用他表主键的约束 | user_id → users.id |
1.2 SQL 分类与示例
-- DDL:数据定义
CREATE TABLE users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(64) NOT NULL,
email VARCHAR(128) UNIQUE,
age INT CHECK (age >= 0),
dept_id BIGINT,
FOREIGN KEY (dept_id) REFERENCES dept(id)
);
-- DML:数据操作
INSERT INTO users (name, email, age) VALUES ('Alice', 'alice@x.com', 23);
UPDATE users SET age = 24 WHERE name = 'Alice';
DELETE FROM users WHERE id = 1;
-- DQL:查询
SELECT d.name, COUNT(u.id) AS cnt
FROM dept d
LEFT JOIN users u ON u.dept_id = d.id
WHERE u.age >= 20
GROUP BY d.id
HAVING COUNT(u.id) > 0
ORDER BY cnt DESC
LIMIT 10;
| SQL 类别 | 作用 | 关键字示例 |
|---|---|---|
| DDL | 定义结构 | CREATE / ALTER / DROP |
| DML | 操作数据 | INSERT / UPDATE / DELETE |
| DQL | 查询数据 | SELECT / FROM / WHERE |
| DCL | 权限控制 | GRANT / REVOKE |
| TCL | 事务控制 | BEGIN / COMMIT / ROLLBACK |
SQL 是声明式语言:你声明"想要什么",优化器负责"怎么查"。理解执行计划(第 8 节)是写出高效 SQL 的前提。
2. 存储引擎与页
2.1 数据在磁盘上的组织
数据库以**页(Page)**为最小 IO 单位读写,页大小通常 4KB/8KB/16KB。表在磁盘上由一组页组成,页内是槽位化的记录数组。
Page Header (页元信息)
| PageLSN | 校验和 | 记录数 | 空闲指针 |
数据区
| slot0 | slot1 | slot2 | ... | 变长记录 |
页尾
| 空闲空间指针 | ... |
磁盘 IO 是数据库性能的最大瓶颈:一次随机读页约 10ms,而内存读纳秒级。所有数据库优化的本质都是减少随机 IO、提升顺序 IO、让热数据驻留内存。
| 存储参数 | 典型值 | 影响 |
|---|---|---|
| 页大小 | 16KB(InnoDB) | 决定单行上限与索引扇出 |
| 预读(read ahead) | 顺序访问自动批量加载 | 提升全表扫描吞吐 |
| Buffer Pool | 内存中缓存页的池 | 命中率决定读性能 |
2.2 行存储 vs 列存储
| 维度 | 行存储(OLTP) | 列存储(OLAP) |
|---|---|---|
| 组织方式 | 一行连续存放 | 一列连续存放 |
| 典型场景 | 点查、高频更新 | 全表聚合、扫描 |
| 压缩率 | 低 | 高(同类型数据聚集) |
| 代表 | MySQL/PostgreSQL | ClickHouse/列存引擎 |
3. 索引结构:B+ 树
3.1 B+ 树结构
B+ 树是多叉平衡搜索树,所有数据都存放在叶子节点,叶子之间通过指针链表相连;内部节点只存键用于路由。
B+ 树相对 B 树/BST 的优势:
- 扇出大(一个节点几百个键),树高仅为 3-4 层——查询 10 亿条记录只需 3 次磁盘 IO;
- 叶子串成链表,范围查询(BETWEEN、ORDER BY)天然高效;
- 内部节点不存数据,能缓存更多路由键。
[ 50 | 100 ]
/ | \
[10|30|40] [60|70] [110|130|140]
→ (叶子) → (叶子) → (叶子) ← 叶子链表
| 操作 | 复杂度 | 说明 |
|---|---|---|
| 等值查找 | O(log_m n) | 沿树高 m 叉二分 |
| 范围查询 | O(log_m n + k) | 叶子链表顺序扫描 k 个结果 |
| 插入/删除 | O(log_m n) | 节点分裂/合并,保持平衡 |
# B+ 树插入引发节点分裂的示意(m=3 阶)
def b_plus_insert(node, key):
if node.is_leaf:
node.keys.insert_sorted(key)
if len(node.keys) > node.max_keys: # 上溢出
left, right = split_leaf(node) # 从中间切分
return left, right # 中间键上升为父路由键
else:
child = node.find_child(key)
res = b_plus_insert(child, key)
if res: # 子节点分裂
node.keys.insert(res.mid_key)
if len(node.keys) > node.max_keys: # 内部节点也分裂
return split_internal(node)
return None
4. 索引结构:LSM-Tree
4.1 写优化型索引
LSM-Tree(Log-Structured Merge Tree)面向写密集场景(日志、时序、消息队列)。写入只追加内存表(MemTable),达到阈值后落盘为不可变的 SSTable,后台按层级合并(Compaction)。
写入路径: WAL(持久化) → MemTable(内存, 有序) → SSTable L0 → ... → Ln
读取路径: 先查 MemTable → 逐层查 SSTable(布隆过滤器快速排除)
# LSM 读路径:布隆过滤器 + 二分,逐层查找
def lsm_get(leveled_ssts, memtable, key, bloom):
if key in memtable:
return memtable[key]
for sst in leveled_ssts: # 从 L0 到 Ln 逐层
if key in bloom[sst]: # 布隆过滤器说"可能存在"
val = sst.binary_search(key) # SSTable 内部有序,二分
if val is not None:
return val
return None
4.2 B+ 树 vs LSM 对比
| 维度 | B+ 树 | LSM-Tree |
|---|---|---|
| 写放大 | 低(原地更新) | 高(多轮合并重写) |
| 读放大 | 低(1-3 次 IO) | 高(多层查找) |
| 写性能 | 随机写,受页 IO 限制 | 顺序追加,写吞吐高 |
| 空间放大 | 低 | 中(未合并的冗余版本) |
| 适用场景 | OLTP 点查/范围查询 | 写密集、日志、时序 |
| 代表 | InnoDB, PostgreSQL | RocksDB, HBase, Cassandra |
选择判据:读多写少用 B+ 树,写多读少用 LSM。生产系统常混合使用——例如 MySQL 用 B+ 树做主存储,用内存/外置 LSM 引擎做写入缓冲。
5. 事务与 ACID
5.1 事务定义
事务是数据库执行的最小逻辑单元,要么全部成功要么全部回滚。ACID 四性:
| 特性 | 含义 | 实现机制 |
|---|---|---|
| A 原子性 | 全部执行或全部不执行 | Undo Log / 回滚段 |
| C 一致性 | 事务前后数据满足约束 | 应用逻辑 + 数据库约束 |
| I 隔离性 | 并发事务互不干扰 | 锁 / MVCC |
| D 持久性 | 提交后数据不丢失 | Redo Log / WAL |
-- 经典转账事务
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- 若任一步失败则 ROLLBACK,保证余额守恒
COMMIT;
5.2 并发问题
| 并发异常 | 描述 | 被哪个隔离级别阻止 |
|---|---|---|
| 脏读 | 读到未提交数据 | Read Committed |
| 不可重复读 | 同一查询两次结果不同 | Repeatable Read |
| 幻读 | 范围查询两次行数不同 | Serializable |
6. 隔离级别与锁
6.1 四种隔离级别
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 实现方式 |
|---|---|---|---|---|
| Read Uncommitted | 允许 | 允许 | 允许 | 不加读锁 |
| Read Committed | 阻止 | 允许 | 允许 | 读后即释放快照(每语句新快照) |
| Repeatable Read | 阻止 | 阻止 | 允许* | MVCC 事务级快照 |
| Serializable | 阻止 | 阻止 | 阻止 | 全表锁 / 区间锁 / 串行化调度 |
MySQL InnoDB 默认 Repeatable Read,且通过 Next-Key Lock(间隙锁) 同时阻止了幻读(表中 * 即此特殊点);PostgreSQL 默认 Read Committed,其 Repeatable Read 下无幻读。隔离级别越高,并发度越低。
6.2 锁的类型
| 锁 | 兼容性 | 说明 |
|---|---|---|
| 共享锁 S | S 与 S 兼容 | 读锁,可多事务同时持有 |
| 排他锁 X | 不兼容 | 写锁,互斥 |
| 意向锁 IS/IX | 表级 | 声明"即将在行上加 S/X",加速冲突检测 |
| 间隙锁 Gap | - | 锁定区间,阻止幻读(InnoDB) |
| 记录锁 Record | - | 锁定单行索引记录 |
-- 加锁语句示例
SELECT * FROM accounts WHERE id = 1 FOR UPDATE; -- X 锁(写意图)
SELECT * FROM accounts WHERE id = 1 LOCK IN SHARE MODE; -- S 锁
7. 日志与恢复:WAL
7.1 WAL 原理
Write-Ahead Logging(预写日志):数据页的修改必须先写 Redo Log 并落盘,之后才允许写数据页。崩溃恢复时重放 Redo Log,即可保证已提交事务的修改不丢失。
事务 T1: [BEGIN] → 写 Redo 记录 → [COMMIT, LSN=120] (先日志后数据)
崩溃恢复: 从最后检查点向后,重放所有 Redo 记录 → 数据页重建
| 日志类型 | 作用 | 恢复方向 |
|---|---|---|
| Redo Log | 重做已提交事务的修改(持久性) | 前滚(Redo) |
| Undo Log | 回滚未提交事务的修改(原子性) | 后滚(Undo) |
# WAL 恢复示意
def crash_recovery(redo_log, checkpoint_lsn):
for record in redo_log.after(checkpoint_lsn): # 从检查点后重放
if record.txn_committed:
apply_redo(record) # 把修改重新应用到数据页
return 0 # 恢复完成,已提交数据不丢失
**检查点(Checkpoint)**定期把内存脏页刷盘并记录 LSN,缩短崩溃恢复时间;缓冲池淘汰脏页前必须确保其 Redo 已落盘。
7.2 刷盘策略
| 策略 | 提交时行为 | 性能/安全权衡 |
|---|---|---|
| 每次都 fsync | 每组提交刷盘 | 最安全,性能最差 |
| 组提交 Group Commit | 合并多次提交一次 fsync | 兼顾性能与安全 |
| 延迟刷盘 | 定时批量刷盘 | 高吞吐,断电可能丢已提交事务 |
8. 查询优化与执行计划
8.1 查询执行流程
SQL → 词法/语法分析 → 逻辑优化(谓词下推、等价改写)→ 物理优化(选择执行计划)→ 执行算子(扫描/连接/聚合/排序)。
| 连接算法 | 复杂度 | 适用场景 |
|---|---|---|
| Nested Loop Join | O(n·m) | 小表驱动大表,带索引更好 |
| Hash Join | O(n+m) | 无索引、等值连接(哈希) |
| Merge Join | O(n+m) | 两侧已排序(按连接键) |
8.2 读懂 EXPLAIN
EXPLAIN SELECT d.name, COUNT(u.id)
FROM dept d LEFT JOIN users u ON u.dept_id = d.id
WHERE d.location = 'Beijing'
GROUP BY d.id;
-- 输出关键列
-- type: ALL | index | range | ref | eq_ref | const (访问方式,从上到下性能递增)
-- key: 实际使用的索引
-- rows: 预估扫描行数
-- Extra: Using index(覆盖索引)/ Using filesort / Using temporary
优化铁律:
type尽量到ref/eq_ref,避免ALL(全表扫描);rows与真实行数偏差大时,检查统计信息是否过期(ANALYZE TABLE);Using filesort/Using temporary出现时,考虑为排序列建索引;- 覆盖索引(Extra: Using index)可让查询只读索引页,避免回表,是最高效的加速手段。
9. 范式与反范式
9.1 规范化(Normalization)
| 范式 | 核心要求 | 解决的问题 |
|---|---|---|
| 1NF | 属性不可再分(原子性) | 表结构混乱 |
| 2NF | 非主属性完全依赖候选键 | 部分依赖(冗余) |
| 3NF | 非主属性不传递依赖主键 | 传递依赖(更新异常) |
| BCNF | 每个决定因素都是候选键 | 3NF 的残余异常 |
-- 违反 2NF:订单明细表存在部分依赖
CREATE TABLE order_items (
order_id INT,
product_id INT,
product_name VARCHAR(100), -- 只依赖 product_id,与 order_id 无关
qty INT,
PRIMARY KEY (order_id, product_id)
);
-- 修正:拆出 product 表,订单明细只存 product_id
9.2 反范式(Denormalization)
范式消除冗余,但带来更多 JOIN。读多写少的场景常用反范式换性能:冗余热点字段、预先聚合、引入快照列。
| 手段 | 收益 | 代价 | 适用场景 |
|---|---|---|---|
| 冗余字段 | 免 JOIN | 更新一致性成本 | 点赞数、粉丝数 |
| 汇总表 | 免实时聚合 | 定时任务延迟 | 报表、排行榜 |
| 缓存列 | 免子查询 | 需双写 | 商品详情页 |
| 宽表 | 免多表关联 | 表结构臃肿 | 数仓、OLAP |
设计决策本质是权衡:读性能 ↔ 写一致性与存储。OLTP 坚持 3NF,OLAP/读场景按需反范式,是业界共识。
参考文章
- Wikipedia — Database normalization
- Wikipedia — ACID
- Wikipedia — LSM-Tree (Log-structured merge-tree)
- PostgreSQL 文档 — 并发控制与事务隔离
- MySQL 官方文档 — InnoDB 存储引擎
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。