页面

数据库文件怎么存:不是文本,而是页、缓存池和日志

← 返回兔子洞总览 · database 主题地图

数据库文件通常不是一行行可读文本,而是数据库自己设计的一套二进制磁盘结构。核心单位是页;表数据、索引、空闲空间、事务日志和元数据一起配合,才补出"可靠账本"这个抽象。

我追问的链

  • 普通文件也能写数据,为什么还需要数据库?
  • 表、行、列在文件里到底怎么落下去?
  • SQL 说"我要这个用户",数据库怎么找到对应字节?
  • 索引为什么能加速?
  • 事务为什么能做到"要么都成功,要么都失败"?
  • 数据库文件里总不能是文本吧,那它到底怎么存?

1. 普通文件为什么不够用

普通文件当然能存数据,比如:

1
2
1,Alice,alice@example.com
2,Bob,bob@example.com

但真实业务一上来,就会撞到几个硬问题:

  • 并发写入会乱:两个人同时改同一个文件,谁先谁后?会不会写一半被另一个覆盖?
  • 查找很慢:几千万行里找一个邮箱,普通文本文件通常只能一行行扫。
  • 部分失败危险:转账时 A 扣钱成功,B 加钱前程序崩了,普通文件不会自动回滚。
  • 结构没约束:邮箱能不能重复?余额能不能小于 0?普通文件本身不管这些规则。
  • 恢复困难:写到一半断电,文件可能处在半新半旧的状态。

所以数据库不是因为"文件不能存",而是因为普通文件缺少一整套并发、查询、约束、恢复、事务机制。

2. 表、行、列抽象了什么

关系型数据库把现实世界拆成表:

1
2
3
4
5
users
id | name | email
---|-------|------------------
1 | Alice | alice@example.com
2 | Bob | bob@example.com
  • :一种事物,比如用户、订单、文章。
  • :一个具体对象,比如 Alice 这个用户。
  • :对象的属性,比如名字、邮箱、创建时间。
  • 主键:这一行的唯一身份,比如 id = 1
  • 外键:表和表之间的关系,比如订单属于哪个用户。

订单表可能这样:

1
2
3
4
orders
id | user_id | amount
---|---------|-------
10 | 1 | 99.00

orders.user_id = 1 指向 users.id = 1。这就是数据库的第一层抽象:把现实里的对象和关系,压成一张张有规则的表。

3. SQL 怎么变成磁盘读取

你写:

1
SELECT * FROM users WHERE email = 'alice@example.com';

数据库内部大概会走:

1
2
3
4
5
6
7
8
9
10
11
SQL 文本

解析器:看语法对不对

优化器:决定怎么查最快

执行器:按计划读取数据

缓存 / 内存页 / 磁盘文件

返回结果

SQL 是声明式的。你只说:

我要 email 等于 alice@example.com 的用户。

你没有说从哪个文件读、第几个字节开始读、要不要用索引、先扫哪张表。这些由数据库根据表大小、索引和统计信息来决定。

没有索引时,可能是:

1
全表扫描:从第一行扫到最后一行

email 索引时,可能是:

1
先查索引,快速定位到对应行,再读那行数据

4. 文件里不是文本,而是页

数据库文件通常是二进制格式,核心单位是 ,也叫 page 或 block。

1
2
3
4
5
6
7
数据库文件
├─ page 0:文件头 / 元信息
├─ page 1:表的一部分数据
├─ page 2:索引的一部分数据
├─ page 3:空闲空间记录
├─ page 4:另一批行数据
└─ ...

数据库不会每次只读一行。磁盘太慢,它通常一次读一整页,比如 4KB、8KB、16KB。MySQL InnoDB 默认页大小常见是 16KB,PostgreSQL 常见是 8KB。

一页内部大概像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Page Header
- 这页属于哪个表 / 索引
- 这页前后相邻是谁
- 空闲空间在哪里
- 校验信息

Row Directory
- 第 1 行在页内偏移 128
- 第 2 行在页内偏移 240
- 第 3 行在页内偏移 376

Row Data
- id=1, name=Alice, email=...
- id=2, name=Bob, email=...

Alice 这种字符串内容本身可能还是 UTF-8 字节,但整份文件不是可读文本,而是一堆有结构的二进制块。

一行在文件里可能不是:

1
1,Alice,alice@example.com

而更接近:

1
[行头][字段数量][null 标记][id 的二进制值][name 长度][name 字节][email 长度][email 字节]

这样存,是为了:

  • 快速定位:知道第几页、第几个偏移就能找到数据。
  • 节省空间:数字直接用二进制存,比文本更紧凑。
  • 支持变长字段:字符串、JSON、TEXT 可能长度不同。
  • 支持更新和删除:页里要管理空洞、空闲空间和行版本。
  • 支持索引结构:B+Tree 节点也要存在页里。
  • 支持崩溃恢复:数据页还要和日志文件配合。

5. 索引也是页:B+Tree 存在文件里

索引文件也不是文本。B+Tree 的每个节点通常也是一页:

1
2
3
索引页
keys: [alice@example.com] [bob@example.com] [zack@example.com]
ptrs: 指向子页 / 指向数据行位置

email = 'bob@example.com' 时,数据库不是打开文本搜索字符串,而是:

1
2
3
4
5
6
7
8
9
从索引根页开始

根据 key 比较,跳到下一层页

找到叶子页

叶子页里拿到数据行的位置

去数据页读真正的行

所以索引像一本书的目录:不用从第一页翻到最后一页,而是先走目录,直接跳到目标附近。

索引的代价也很真实:

  • 查得快。
  • 写入变慢,因为插入 / 更新数据时,索引也要更新。
  • 占空间,因为索引本身也是一套数据结构。
  • 索引太多会拖慢写入和维护。

一句话:索引用额外空间和写入成本,换查询速度。

6. 运行时先查内存:Buffer Pool

数据库不是每次都直接去磁盘文件里翻页。真正运行时,中间还有一层关键结构:内存缓存,常叫 buffer pool 或 page cache。

一次查询大概是:

1
2
3
4
5
6
7
8
9
10
11
12
SQL

执行计划

需要读取某个数据页 page 42

先看内存里有没有 page 42

有:直接读内存
没有:从磁盘文件把整页读进内存

在内存页里找具体那一行

所以磁盘上是一页一页存的,数据库平时主要是在内存里的页副本上工作。

1
2
3
4
5
6
7
8
9
磁盘文件:
page 1
page 2
page 3

内存 Buffer Pool:
page 2 的副本
page 8 的副本
page 42 的副本

更新数据时,也通常不是立刻把整页写回磁盘:

1
2
3
4
5
1. 把 page 42 从磁盘读进内存
2. 在内存里改这一行
3. 标记 page 42 是 dirty page
4. 先写日志,保证能恢复
5. 之后某个时机再把 dirty page 刷回磁盘

这就是为什么数据库可以又快又可靠:读写热点尽量在内存里完成,但可靠性由日志兜底。

7. 先写日志,再改账本

如果数据页还没刷回磁盘,机器突然断电怎么办?

数据库靠日志兜底。核心思想叫 Write-Ahead Logging,也就是:

先记账,再改账本。

大致流程:

1
2
3
4
5
6
7
先写日志:
我要把 user 1 的 balance 从 500 改成 400

再改内存页:
page 42 里 balance = 400

之后慢慢刷数据页到磁盘

如果崩了,重启后数据库读日志:

  • 这个事务已经提交了,但数据页没来得及写:重放日志。
  • 这个事务没提交:撤销它。

所以磁盘上不止有"表数据文件",通常还有:

1
2
3
4
5
数据文件:真正的表和索引页
日志文件:redo log / WAL,负责崩溃恢复
undo 信息:负责回滚和多版本读取
元数据:表结构、字段类型、索引定义
临时文件:排序、临时结果等

数据库维护的是几套东西的配合:

1
2
3
4
5
6
表数据:现在是什么
索引:怎么快速找到
日志:崩了怎么恢复
undo:错了怎么回滚
内存页:运行时怎么快
元数据:这些字节该怎么解释

逻辑闭环 / 锚点

数据库文件不是文本,而是数据库自己设计的二进制磁盘数据结构。它把普通文件和磁盘这种朴素能力,包装成一个更强的抽象:

  • 页:让磁盘读写有稳定单位。
  • 行格式:让对象属性能落成可解释的字节。
  • 索引:让查询不用全表扫描。
  • Buffer Pool:让热点数据在内存里快读快改。
  • WAL / redo log:让崩溃后能恢复。
  • undo / 事务信息:让回滚和并发读取可控。

一句话:

现实里磁盘慢、机器会崩、请求会并发;数据库在文件之上补出一个像账本一样可靠、可查询、可恢复的系统。

关联


来源:与 Codex 的对话,2026-07。