在[15丨存储与数据库:B+树]中,我们讨论了 B+ 树如何为外存数据提供高效的索引结构。然而,B+ 树只是一个物理层的"查找引擎"——它解决的是"怎么找"的问题,而非"找什么"和"找的关系是什么"。
SQL(Structured Query Language)是关系型数据库的查询语言,它建立在关系模型这一数学基础之上,将数据的逻辑组织与物理存储彻底分离。用户只需声明"我要什么"(What),数据库负责决定"怎么给"(How)——这正是声明式编程的核心思想,也是关系型数据库历经半个世纪仍然不可替代的根本原因。
本节的核心问题是:SQL 为什么能成为数据查询的通用语言?关系模型提供了怎样的数学基础?查询优化器如何将声明式 SQL 转化为高效的执行计划?
关系模型由 E.F. Codd 于 1970 年提出,其核心由三部分组成:
关系模型的核心概念:
| 概念 | 数学定义 | SQL 对应 |
|---|---|---|
| 关系 (Relation) | 笛卡尔积的子集 | 表 (Table) |
| 元组 (Tuple) | 关系中的元素 | 行 (Row) |
| 属性 (Attribute) | 元组的分量 | 列 (Column) |
| 域 (Domain) | 属性的取值集合 | 数据类型 |
| 候选键 (Candidate Key) | 最小唯一标识属性集 | UNIQUE + NOT NULL |
| 主键 (Primary Key) | 选定的候选键 | PRIMARY KEY |
| 外键 (Foreign Key) | 引用其他关系的属性 | FOREIGN KEY |
关键性质:
关系代数是 SQL 背后的形式化运算体系,其基本运算如下:
SQL 与关系代数的映射:
| 关系代数 | SQL |
|---|---|
| σ_{age>25}(Student) | SELECT * FROM Student WHERE age > 25 |
| π_{name,age}(Student) | SELECT name, age FROM Student |
| Student ⋈_{Student.id=Enroll.sid} Enroll | SELECT * FROM Student JOIN Enroll ON Student.id = Enroll.sid |
| γ_{dept, AVG(salary)}(Employee) | SELECT dept, AVG(salary) FROM Employee GROUP BY dept |
关系代数的意义在于:SQL 语句可以被等价转换为不同的关系代数表达式,而查询优化器正是基于这种等价变换寻找最优执行计划。
SQL 并非单一语言,而是由多个子语言组成:
一条 SQL 语句从提交到返回结果,经过以下完整流程:
查询优化器是数据库最核心、最复杂的组件。它将声明式的 SQL 转化为命令式的执行计划。
逻辑优化不依赖数据分布,仅基于关系代数的等价规则:
σ_{age>25}(Student ⋈ Enroll) ≡ (σ_{age>25}(Student)) ⋈ Enroll π_{name}(σ_{age>25}(Student)) ≡ π_{name}(σ_{age>25}(π_{name,age}(Student)))连接重排序(Join Reordering):根据连接条件和基数估计,选择最优 JOIN 顺序
子查询展开(Subquery Unnesting):将相关子查询转化为 JOIN 或半连接
物理优化依赖统计信息(行数、直方图、唯一值数等)和代价模型:
代价模型:Cost = I/O Cost + CPU Cost,其中 I/O Cost 通常是主导因素。优化器估算每种执行计划的代价,选择代价最小的方案。
SQL 的事务模型(TCL)保证了数据的一致性:
隔离级别与并发问题:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 实现方式 |
|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 无锁 |
| READ COMMITTED | 不可能 | 可能 | 可能 | 行级锁 / MVCC |
| REPEATABLE READ | 不可能 | 不可能 | 可能 | 间隙锁 / MVCC |
| SERIALIZABLE | 不可能 | 不可能 | 不可能 | 全范围锁 / 串行化 |
InnoDB 默认 REPEATABLE READ,通过 MVCC + Next-Key Lock 在大多数场景下避免幻读。
SQL 是声明式语言的典范:
| 维度 | 声明式 (SQL) | 命令式 (代码) |
|---|---|---|
| 描述 | 描述"要什么" | 描述"怎么做" |
| 优化 | 优化器自动选择执行路径 | 开发者手动优化 |
| 可移植 | 不同数据库可优化为不同执行计划 | 逻辑硬编码 |
| 表达力 | 关系代数范围内表达力强 | 无限制 |
| 适用范围 | 结构化数据查询 | 任意计算 |
核心洞察:声明式语言将优化权交给数据库,使其能根据数据分布、索引状态、硬件特性做出最优决策。这是 SQL 经久不衰的根本原因——同样的 SQL,在数据量增长后可能自动切换执行计划。
关系模型追求规范化(Normalization),消除数据冗余和更新异常:
| 范式 | 核心约束 | 消除的异常 |
|---|---|---|
| 1NF | 属性不可再分 | 重复组 |
| 2NF | 非主属性完全依赖于候选键 | 部分依赖 → 插入/删除异常 |
| 3NF | 非主属性不传递依赖于候选键 | 传递依赖 → 更新异常 |
| BCNF | 每个决定因素都是候选键 | 更严格的依赖约束 |
但实践中往往需要反规范化:
权衡:OLTP 追求规范化(写优化,避免更新异常);OLAP 追求反规范化(读优化,减少 JOIN)。
查询优化本身是有代价的:
权衡:数据库通常在优化时间和执行时间之间取折中——不完全搜索所有计划,而使用启发式剪枝。
SQL 并非万能:
这些局限性催生了 NoSQL、NewSQL、图数据库等分支——但它们并非替代 SQL,而是在特定场景下的补充(参见[37丨键值存储与数据库]中不同存储引擎的定位)。
-- 原始查询:查找每个系中年龄大于 25 的学生及其选课数
SELECT d.dept_name, s.name, COUNT(c.course_id) AS course_count
FROM Department d
JOIN Student s ON d.dept_id = s.dept_id
JOIN Enroll e ON s.id = e.student_id
JOIN Course c ON e.course_id = c.course_id
WHERE s.age > 25 AND d.region = 'East'
GROUP BY d.dept_name, s.name
HAVING COUNT(c.course_id) > 3
ORDER BY course_count DESC;优化器可能执行的变换:
s.age > 25 和 d.region = 'East' 在 JOIN 前过滤d.region 走索引,s.age 走索引EXPLAIN SELECT * FROM orders WHERE user_id = 123 AND status = 'PAID';关键指标解读:
| EXPLAIN 字段 | 含义 | 优化关注点 |
|---|---|---|
| type | 访问类型 | const > eq_ref > ref > range > index > ALL |
| key | 使用的索引 | 是否命中预期索引 |
| rows | 预估扫描行数 | 过大则考虑加索引 |
| Extra | 额外信息 | Using index = 覆盖索引, Using filesort = 需优化 |
type 字段的性能排序:system > const > eq_ref > ref > fulltext > ref_or_null > index_merge > unique_subquery > index_subquery > range > index > ALL
# 反模式:N+1 查询
students = query("SELECT id, name FROM Student")
for s in students:
courses = query("SELECT * FROM Enroll WHERE student_id = ?", s.id)
# N 次额外查询!-- 正确做法:JOIN 一次查询
SELECT s.id, s.name, e.course_id
FROM Student s
LEFT JOIN Enroll e ON s.id = e.student_id;N+1 问题在 ORM 框架中尤为常见,本质是将集合操作降级为逐行操作,丧失了关系代数的批量处理优势。
正确做法:明确列出所需列名,配合覆盖索引避免回表。
-- 反模式:索引失效
SELECT * FROM orders WHERE YEAR(create_time) = 2024;
-- 正确做法:范围查询,索引有效
SELECT * FROM orders WHERE create_time >= '2024-01-01' AND create_time < '2025-01-01';对索引列使用函数(包括隐式类型转换)会阻止优化器使用该索引,因为 B+ 树中存储的是原始值,函数变换后的值无法在 B+ 树中直接定位。
大事务(长事务)的危害:
正确做法:事务粒度尽量小,只包含必要的操作,避免在事务中执行 RPC、文件 I/O 等外部调用。
延伸阅读:本节与[15丨存储与数据库:B+树]构成完整的数据库内部视角——B+ 树是索引的物理结构,SQL 是查询的逻辑表达。在[37丨键值存储与数据库]中,许式伟将视角从单机数据库提升到分布式存储中间件,讨论键值存储与关系数据库的定位差异。