数据库的第一性原理:从一个查询看系统全貌
数据库的第一性原理:从一个查询看系统全貌
1.1 一个被忽视的真理:数据库为什么不能只是"读写文件"
`SELECT * FROM users WHERE id = 1` —— 这大概是程序员写过的最频繁的一行 SQL。在很多人眼里,它就是"打开文件、读出第 1 行、返回"。如果数据库就是这么简单,那 MySQL、PostgreSQL、Oracle 这些动辄几十万行代码的怪物,凭什么存在?
让我们做一个思想实验。假设你被要求用最朴素的方式实现一个"用户表",要求支持"按 id 查询"和"按 id 修改"。一个新手程序员通常会在十分钟内写出下面这段 Python:
```python import json import os
def get_user(user_id): with open('users.json', 'r') as f: users = json.load(f) return users.get(str(user_id))
def update_user(user_id, fields): with open('users.json', 'r') as f: users = json.load(f) users[str(user_id)].update(fields) with open('users.json', 'w') as f: json.dump(users, f) ```
十行代码,看上去完美支持需求。但只要把它放到生产环境,七种灾难就会接踵而至。
第一,并发写会互相覆盖。进程 A 读到 `balance=100`,进程 B 也读到 `balance=100`,A 写入 `balance=50`,B 写入 `balance=80`,结果最后磁盘上只剩 B 的 `80`,A 的扣款 50 元凭空消失。这就是著名的"丢失更新"问题。
第二,写到一半崩溃会损坏文件。如果 `json.dump` 写完一半进程被杀,文件会变成半截 JSON,下次启动直接解析失败,所有用户数据丢失。生产环境是 7×24 小时的,崩溃不是例外,是常态。
第三,每次查询都要全文件解析。一万用户还好,一百万用户就是几十 MB 的 JSON 每次都要 `json.load` 一遍。一千 QPS 就是每秒几十 GB 的解析开销。
第四,没有索引就只能全表扫描。如果查询条件换成 `WHERE name = 'Alice'`,Python 这段代码只能遍历所有用户。最坏情况是 100 万次字符串比较。
第五,没有权限控制。任何能访问文件的进程都能读出全部用户密码、手机号、身份证。
第六,没有事务。"转账"这个操作要扣 A 加 B,必须同时成功或同时失败。这段代码连"原子写"都做不到,更别说隔离级别。
第七,无法横向扩展。当一台机器装不下 1 亿用户数据时,文件 IO 模型无法切分到多台机器上。
这七个问题不是孤立的,它们分别对应数据库领域里七个独立的子方向:并发控制、持久化与恢复、查询优化、索引、访问控制、事务、副本与分片。每一个子方向都足以养活一个研究组、一本教科书、几代工程师。PostgreSQL 的源代码超过 100 万行,本质上就是为了把这七件事同时做对。
所以,请记住这一章的第一条原则:数据库不是"读文件的封装",而是一组相互纠缠的、解决分布式系统经典难题的精巧工程。这一原则是后续 14 章所有讨论的出发点。
1.2 性能鸿沟:当内存比磁盘快 10 万倍时会发生什么
上一节回答了"为什么不能只读写文件",但还没回答"为什么数据库这么复杂"。答案藏在一个让人倒吸一口凉气的数字里:内存访问比机械磁盘随机读快大约 10 万倍。
下面这张表是不同存储介质的典型访问延迟(2024 年的量级):
| 存储介质 | 访问延迟 | 相对 CPU 时钟 | |---|---|---| | L1 缓存 | ~1 纳秒 | 1 倍 | | L2 缓存 | ~4 纳秒 | 4 倍 | | 主内存(DRAM) | ~100 纳秒 | 100 倍 | | NVMe SSD(随机读) | ~100 微秒 | 100,000 倍 | | 机械硬盘(寻道+读) | ~10 毫秒 | 10,000,000 倍 | | 跨机房网络 | ~1 毫秒 | 1,000,000 倍 |
把数字换算成更直观的比喻:CPU 在主内存上访问一次数据的时间里,一块机械硬盘刚刚完成 0.0001 次寻道。或者反过来:CPU 闲逛 1 秒钟,磁盘要忙 1 天。这就是数据库设计的"第一性约束"——所有架构决策都源于如何"少碰几次磁盘"。
数学上可以写成:
$$T_{disk\_seek} \approx 10^5 \times T_{mem\_access}$$
这个 5 个数量级的鸿沟产生了三个推论,它们将贯穿本书剩余的所有章节:
推论一:顺序 I/O 远比随机 I/O 友好。机械硬盘的寻道时间(约 5–10 毫秒)远大于数据本身的传输时间(每 MB 几十微秒)。这意味着如果你的查询需要 1000 次随机读,光寻道就要 5–10 秒;但如果能改成 1 次顺序读,几百毫秒搞定。所以你会看到 InnoDB 把数据按主键组织成 B+Tree,让范围查询变成顺序扫描;你会看到 LSM-Tree 把随机写转成顺序写;你会看到 PostgreSQL 的 Heap-Only Tuples 试图把更新也变成顺序写。
推论二:必须把热点数据放进内存。10 万倍的差距意味着——只要能把 99% 的查询从磁盘"挪"到内存里,性能就提升 100 倍以上。这就是为什么几乎所有现代数据库都有一个"缓冲池"(Buffer Pool):一块固定大小的内存(比如 16 GB),用来缓存磁盘上的数据页。第一次访问某行要读磁盘,第二次以后直接从内存拿。生产环境里缓冲池命中率通常在 95%–99.9% 之间。
推论三:必须避免全表扫描。假设一张表有 100 万行,每行 1 KB,总共约 1 GB,机械硬盘顺序读 100 MB/s 也要 10 秒。如果这 1 GB 是随机分布的,按 100 IOPS 计算就是 10,000 秒 ≈ 2.78 小时。但如果建立了 B+Tree 索引,对主键等值查询的 I/O 次数从 100 万次降到 $\log_{100}(10^6) = 3$ 次,性能提升 33 万倍。这就是为什么"加索引"是性能优化最便宜的招数。
把上述三个推论放在一起,就能看到数据库内核工程师每天在思考的核心矛盾:数据量远大于内存,但程序又必须感觉不到磁盘的存在。所有的索引、缓冲、压缩、预读、合并、分区,本质上都是在解决这个矛盾。
到这里我们已经回答了"为什么数据库这么复杂"。接下来要看的是:这种复杂性具体长什么样。让我们追踪一行 SQL 在数据库内部的完整旅程。
1.3 一次 SELECT 查询的完整旅程
假设你写了一段 Python 代码,用 `psycopg2` 连接 PostgreSQL,执行了一条查询:
```python import psycopg2 conn = psycopg2.connect("dbname=test user=postgres") cur = conn.cursor() cur.execute("SELECT * FROM users WHERE id = 1") row = cur.fetchone() ```
从你按下回车到 `row` 这个变量被赋值,背后至少经历了 11 个步骤、5 个不同的功能层、跨越 CPU、内存、网络、磁盘四种介质。下面用时序图把这个过程完整画出来:
```mermaid sequenceDiagram participant C as 客户端 participant N as 网络/连接层 participant P as 解析器 participant O as 查询优化器 participant E as 执行器 participant B as 缓冲池 participant D as 存储引擎/磁盘
C->>N: 1. 建立 TCP 连接并认证 C->>N: 2. 发送 SQL 文本 N->>P: 3. 词法分析 P->>P: 4. 语法分析生成解析树 P->>O: 5. 传递解析树 O->>O: 6. 逻辑优化与代价估算 O->>E: 7. 生成物理执行计划 E->>B: 8. 申请数据页 alt 缓冲池未命中 B->>D: 9a. 从磁盘加载数据页 D-->>B: 9b. 返回页面 end B-->>E: 10. 返回行数据 E->>E: 11. WHERE 过滤并序列化 E-->>C: 12. 编码后通过连接返回 ```
这张图可能比你想象的复杂。但请不要被它吓到——每一步都对应着数据库内核中一个独立的模块,每个模块在本书中都有专门的章节。下面我们一步一步拆开看。
步骤 1–2:网络通信。客户端和数据库之间通过 TCP 连接通信,MySQL 协议、PostgreSQL 协议在这一层规定了字节序、加密方式、认证流程、消息边界。生产环境里,客户端不是每次都新建连接的——连接池(HikariCP、Druid)会维护一组长连接,避免 TCP 三次握手的开销(每次约 1 个 RTT,跨地域可能 100 毫秒)。一次查询的网络成本常常比数据库本身的计算成本还要高,这是后续做架构设计时必须时刻记住的事实。
步骤 3–4:词法与语法分析。解析器(Parser)把 `SELECT * FROM users WHERE id = 1` 这段字符流转成结构化的"解析树"。词法分析把字符流切成 token:`SELECT`、`*`、`FROM`、`users`、`WHERE`、`id`、`=`、`1`;语法分析根据 SQL 语法规则验证 token 序列是否合法,并构建一棵抽象语法树(AST)。如果表名拼错、字段不存在、少写了引号,这一关就会报错并返回语法错误。解析器本质上是一个编译器前端——它和 GCC 解析 C 代码做的事情一脉相承。
步骤 5–6:逻辑优化与代价估算。解析树送进优化器(Optimizer),优化器要做三件事:等价改写(比如把 `WHERE a=1 AND b=2` 规范化为更易处理的形式)、选择访问路径(全表扫描、索引扫描、索引合并、嵌套循环连接、哈希连接……)、估算每个路径的代价(I/O 次数、CPU 计算量、内存占用)。这一步是数据库"聪明"的核心。同一个查询,可能有几十种执行方式,优化器要选代价最低的那种。对于 `SELECT * FROM users WHERE id = 1`,优化器会根据统计信息决定:是走主键索引($O(\log_B n)$ 次 I/O)还是直接全表扫描($O(1)$ 次顺序读,如果表很小的话)。一个优秀的优化器是数十位博士几十年工作的结晶。
步骤 7:生成物理执行计划。优化器把逻辑计划翻译成"执行计划",这是一棵由物理算子组成的树:SeqScan、IndexScan、HashJoin、Sort、Aggregate……每个算子知道自己的输入数据来自哪里、输出什么格式、如何被上层调用。注意:执行计划本身是数据——`EXPLAIN` 命令就是把这个内部数据结构打印出来给你看。这一特性让优化过程变得可观察、可调试,是学习数据库最好的窗口。
步骤 8–10:执行器与存储引擎协作。执行器(Executor)从计划树的根节点开始,按照"迭代器模型"(Volcano Model)逐行 pull 数据。比如 IndexScan 算子被父节点 `Filter(id=1)` 要数据时,它会向存储引擎请求 `id=1` 对应的数据页。存储引擎首先在缓冲池(Buffer Pool)里查找——如果页面已经缓存在内存(命中),就直接返回;如果不在(未命中),就要从磁盘读入(步骤 9a-9b),再返回。生产环境里,缓冲池命中率每提高 1%,性能就能提升数个百分点。MySQL InnoDB 默认缓冲池大小 128 MB,生产部署通常调到物理内存的 70%–80%。
步骤 11–12:过滤、编码、返回。执行器拿到原始行数据后,应用 WHERE 过滤(即使走了索引,也可能因为索引覆盖不全需要回表再过滤一次),按 SELECT 列表做投影(去掉用户没请求的列),把结果编码成数据库线协议格式(MySQL 是 Binary Protocol,PostgreSQL 是自研协议),通过原来的 TCP 连接发回客户端。客户端驱动解码后填入 `row` 这个 Python 变量。
整个过程从用户角度看是一行代码、一行结果;从数据库内核角度看是11 个模块、上百万行代码、4 种介质、几个数量级的性能差异。这个巨大的认知鸿沟,就是为什么"会用 SQL"和"懂数据库"是两回事。
接下来我们要做的,是把刚才那 11 步按"功能边界"重新分组,形成一个更宏观、更稳定的认知框架——五层架构模型。
1.4 架构分层:数据库的"五层模型"
把上一节的 11 个步骤按职责归类,会得到一个几乎所有主流关系型数据库都遵循的五层架构:
第一层:传输层(Transport Layer)。负责客户端与服务端之间的字节流通信。核心组件是连接管理器(Connection Manager),它处理 TCP 监听、TLS 握手、用户认证、连接池、多路复用(PgBouncer、ProxySQL 这类中间件可以视为传输层的扩展)。这一层解决的是"网络可达性与安全性"问题。
第二层:查询层(Query Layer)。负责把人类可读的 SQL 翻译成机器可执行的指令。包含词法/语法分析器、语义分析器(检查表/字段是否存在、权限是否足够)、查询重写器(Query Rewriter,做常量折叠、子查询改写、谓词下推)、查询优化器(Optimizer)。这一层解决的是"如何让数据库理解人话"以及"如何让数据库选对执行方式"的问题。优化器是查询层的皇冠,也是最难写好的部分——PostgreSQL 的 GEQO 遗传算法优化器、ORACLE 的 CBO(基于代价的优化器)、TiDB 的 Cascades 框架,都是这一层几十年来最重要的工程进展。
第三层:执行层(Execution Layer)。负责按照执行计划实际跑查询。核心是执行器(Executor),主流实现有三种风格:迭代器模型(Volcano,逐行 pull)、物化模型(一次算出所有结果)、向量化模型(一次算一批 1024 行,MonetDB-X100、ClickHouse 走这条路)。执行层还会处理事务管理(Transaction Manager):事务的 ACID 属性、隔离级别(READ COMMITTED、REPEATABLE READ、SERIALIZABLE)、锁与多版本并发控制(MVCC),都在这一层落地。
第四层:存储层(Storage Layer)。负责把数据持久化到磁盘,并在需要时高效地读出来。这一层是数据库"内功"的核心,主要组件包括:
- **缓冲池**(Buffer Pool):内存缓存层,把磁盘页面缓存在内存里。
- **页管理**(Page Manager):数据在磁盘上以"页"(Page,通常 8 KB 或 16 KB)为单位组织,页是 I/O 的最小单位。
- **索引结构**(Index):B+Tree、Hash Index、GiST、GIN、BRIN、LSM-Tree 等,作用是把"找某行"从 $O(n)$ 降到 $O(\log n)$。
- **日志系统**(WAL / Redo Log):Write-Ahead Logging,事务提交前先把修改写入日志,保证崩溃后能恢复。
- **表与行格式**(Row Format):InnoDB 的 Compact、Dynamic,PostgreSQL 的 Heap Tuple,决定了行的物理存储方式。
第五层:复制层(Replication Layer)。负责把数据复制到多台机器上,实现高可用和水平扩展。包括主从复制(Primary-Replica Replication)、基于 binlog 的逻辑复制(MySQL)、基于 WAL 的物理复制(PostgreSQL Streaming Replication)、多主复制、共识协议(Raft、Paxos,TiDB/CockroachDB 的核心)。这一层解决的是"单点故障"和"读写扩展"的问题。
把五层关系可视化,可以想象成一条流水线:客户端的字节流从第一层"下沉"到第五层,存储引擎在第五层把数据从磁盘读出来;然后结果再从第五层"上浮"回第一层,编码成字节流返回客户端。整个数据库就是这条流水线上 50+ 个相互协作的模块。
和 NoSQL 的对比:键值数据库(Redis)只保留了五层中最精简的部分——基本没有优化器,SQL 被换成 GET/SET 等简单命令,存储层往往是内存数据结构(Hash、SkipList),复制层用异步复制或 Raft。文档数据库(MongoDB)介于 KV 和 RDBMS 之间,保留了部分查询优化能力,存储用 BSON 格式。NewSQL(TiDB、CockroachDB)则试图保留 RDBMS 的完整五层,但底层用分布式 KV 替换单机的存储引擎。理解了这五层,你就能把任何数据库"拆"开来看,也能在不同的数据库之间做"功能对位"。
到这里,我们已经在概念层面把数据库"装"进了读者的脑子里。下一节,我们将真正"动"起来——用 Python 代码观察查询的内部世界。
1.5 实践:让查询"显形"——用 psycopg2 观察数据库内部
理论再漂亮,不落代码就是空中楼阁。这一节,我们用 PostgreSQL 自带的 `pg_stat_statements`、 `EXPLAIN ANALYZE`、和 `psycopg2` 客户端,实地观察一次查询在数据库内部的状态变化。
准备工作:假设你已经安装 PostgreSQL 14+ 和 `psycopg2-binary`:
```bash pip install psycopg2-binary
在 psql 中开启统计插件
CR