site logo

Marico's space

数据库引擎入门:工作原理、主要类型及在 PostgreSQL 中的切换方法

算法解析 2026-09-29 14:50:59 13

最近折腾数据库存储引擎,踩了几个坑,这篇把问题说清楚。

顺便做了一个实验:用 Docker 起一个 PostgreSQL,往两个不同的存储引擎里各塞 500 万行数据,然后故意把数据库掐掉,看看会发生什么。如果你已经懂原理了,直接跳到第三部分动手玩。

先说结论:数据库引擎就是那个把"保存这行"变成磁盘上的字节、再读回来的东西。没有银弹。行存储适合事务,LSM 树适合高并发写入,列存储适合分析场景,内存引擎适合追求极致速度。PostgreSQL 支持用 CREATE TABLE ... USING <engine> 按表选择引擎。

目录

  1. Part 1: 引擎是怎么工作的
  2. Part 2: 主流引擎类型
  3. Part 3: 动手实验
  4. 选型速查表:按场景选引擎

Part 1: 引擎是怎么工作的

当你执行 INSERT INTO users VALUES (1, 'alice', 1000) 时,实际上有两层完全不同的软件在配合:

 SQL 文本 │ ▼ ┌──────────────────────────┐ │ 查询层 │ 解析 → 计划 → 执行 │ "你想要什么?" │ └────────────┬─────────────┘ │ "存储这行","给我第X行" ▼ ┌──────────────────────────┐ │ 存储引擎 │ 页、行、日志、多版本 │ "字节到底怎么存放?" │ └────────────┬─────────────┘ ▼ 磁盘 / 对象存储

这篇文章讲的是下面那个框。要理解它为什么长这样,最简单的办法是实现一个最直观的版本,然后看着它挂掉。

最 naive 的实现:一行存一个文本行

with open("users.csv", "w") as f: for i in range(200_000): f.write(f"{i},user_{i:06d},{i * 7 % 100000}\n")

在我机器上,几乎立刻就暴露了三个问题:

  1. 读取耗时取决于你要找哪一行。 找第 N 行意味着要数 N 个换行符。第 0 行耗时 0.25 毫秒,第 199999 行耗时 17.6 毫秒,差了 70 倍——而这个文件还不到 5 MB。
  2. 一行数据变长会覆盖后面的行。 唯一的解决办法是重写之后所有的内容。我只改了 40 字节,却被迫写入了 4.6 MB,写放大 121667 倍。
  3. 崩溃会造出一条从未存在过的行。 如果在把 42,alice,1000 改成 42,bob,2000 的中途断电,最后得到的是 42,bobce,1000。它能正常解析,所以根本不会报警。

正经的引擎靠四个思路解决这些问题。

思路 1:页(Pages)

把文件切成固定大小的块,叫作页。PostgreSQL 用的是 8 KB。这样找第 5000 块就是简单的算术(5000 × 8192),每次读取成本相同。页成了磁盘 I/O、缓存和崩溃恢复的基本单位。

思路 2:槽页(Slotted Pages)

在每个页内部,开头放一个小的槽数组,指向存在末尾的行。两块区域从两头往中间长:

字节 0 ┌────────────────────────────────┐ │ 页头(24 字节) │ ├────────────────────────────────┤ │ 槽 1 │ 槽 2 │ 槽 3 │ ... │ │ ← 槽往下增长 ├────────────────────────────────┤ │ 空闲区域 │ ├────────────────────────────────┤ │ ... │ 行 3 │ 行 2 │ 行 1 │ ← 行往上增长
字节 8192 └────────────────────────────────┘

一行的地址是 (页号, 槽号),不是字节偏移。这样引擎可以在页内移动行来回收空间,而所有指向 (5, 3) 的索引仍然有效。

思路 3:校验和 + 预写日志(WAL)

校验和用于检测损坏:每个页存一份自己字节的校验和,半页写入会校验失败,不会返回 42,bobce,1000 这种脏数据。

预写日志(WAL,Write-Ahead Log)用于修复损坏。引擎在修改页之前,先把变更描述追加到顺序日志并刷盘:

1. 追加 "第 5 页槽 3:balance 1000 → 2000" 到 WAL → fsync
2. 在内存中修改第 5 页
3. 把第 5 页写入磁盘 ... 最终会写

如果在第 3 步之前崩溃,恢复时重放日志即可。实验中你会亲眼看到这个过程。

思路 4:MVCC

不覆盖旧行,而是写一个新版本,给每个版本打上创建它的事务 ID(xmin)和替代它的事务 ID(xmax)。每个事务从一个快照读数据,所以一份长报表可以继续看到旧的余额,而另一个更新在它旁边提交。读操作永远不会阻塞写操作。代价是旧版本会堆积,必须清理——这就是 PostgreSQL 里 VACUUM 的工作。

Part 2: 主流引擎类型

上面四个思路描述的是 PostgreSQL 默认的引擎。其他的引擎根据优化目标不同,做了不同的取舍。

行存储(B-tree + 页)

整行数据存在一起,原地更新,靠 B-tree 索引定位。这是经典设计。

  • 擅长:事务、按 ID 查询、频繁更新(OLTP,联机事务处理)。
  • 弱项:跨数十亿行扫描少数几列——因为无论如何都要读整行。
  • 代表:PostgreSQL heap、MySQL InnoDB、SQLite、Oracle、SQL Server。

日志结构合并树(LSM)

永远不原地更新。写操作先进入内存缓冲区(再加一份日志保安全)。缓冲区满了就刷到磁盘成为一个不可变的排序文件,后台合并进程逐步合并这些文件。

  • 擅长:超高写入和摄取速率,因为每次磁盘写都是顺序的。
  • 弱项:读取可能需要检查多个文件(Bloom 过滤器能缓解一些),合并过程会占用后台 CPU 和 I/O。
  • 代表:RocksDB、LevelDB、Cassandra、ScyllaDB、MyRocks、CockroachDB 的 Pebble。

列存储

按列分别存储并压缩。需要 12 列中某 2 列的查询只读那 2 列。

行存储(一个页) 列存储
┌──────────────────────────┐ id: 1 2 3 4 ...
│ 1 │ IN │ 12.50 │ chrome │ country: IN US IN DE ... ← 相似值相邻
│ 2 │ US │ 8.00 │ safari │ amount: 12.5 8.0 3.2 ... 压缩效果好
│ 3 │ IN │ 3.20 │ chrome │ browser: ...
└──────────────────────────┘
  • 擅长:聚合、仪表盘、扫描大表的分析场景(OLAP,联机分析处理)。磁盘占用小很多。
  • 弱项:单行更新或删除、按行取数据。
  • 代表:ClickHouse、DuckDB、Snowflake、BigQuery、Parquet 文件、Citus/Hydra columnar(PostgreSQL 扩展)。

内存存储

所有数据都放在内存里。持久化靠快照和追加日志,或者干脆放弃持久化换速度。

  • 擅长:微秒级延迟的缓存、会话、排行榜、队列。
  • 弱项:内存贵,持久化配置不当会丢数据。
  • 代表:Redis、Memcached、MySQL MEMORY、VoltDB。

可插拔引擎

有些数据库支持按表选引擎。MySQL 在这事儿上最有名:

CREATE TABLE orders (...) ENGINE=InnoDB;
CREATE TABLE cache (...) ENGINE=MEMORY;

PostgreSQL 从 12 版开始也有同样的能力,通过表访问方法(Table Access Method) API 实现:

CREATE TABLE orders (...) USING heap; -- 默认行存储
CREATE TABLE events (...) USING columnar; -- 由扩展提供

能插拔的还不止表:

可替换的部分 语法 内置 扩展提供
表引擎 CREATE TABLE ... USING x heap columnar(Citus、Hydra)、orioledb
索引引擎 CREATE INDEX ... USING x btree、hash、gin、gist、spgist、brin hnsw、ivfflat(pgvector)
持久化级别 CREATE UNLOGGED TABLE 跳过 WAL —

接下来上手试试。

Part 3: 动手实验

需要准备:Docker,大约 15 分钟。导入数据大概需要一分钟。

用 citusdata/citus 镜像:标准 PostgreSQL 17,自带 columnar 表引擎。我们只用它的列存储引擎,分布式功能碰都不碰。

Step 0: 启动 PostgreSQL

docker run -d --name pg-engines -e POSTGRES_PASSWORD=pw citusdata/citus:13.0
docker exec -it pg-engines psql -U postgres

进到 psql 后,开启计时:

\timing on

Step 1: 查看已安装的引擎

PostgreSQL 把所有引擎列在 pg_am 目录表里(am = access method,访问方法):

SELECT amname AS engine, CASE amtype WHEN 't' THEN 'table' ELSE 'index' END AS kind
FROM pg_am ORDER BY kind DESC, amname;
 engine | kind
----------+------- columnar | table ← 扩展添加的 heap | table ← PostgreSQL 默认 brin | index btree | index gin | index gist | index hash | index spgist | index

Step 2: 同一张表,两个引擎

模拟一个 12 列的网站事件表。两个表的唯一区别就是 USING 子句:

CREATE TABLE events_row ( id bigint, ts timestamptz, user_id int, country text, amount numeric(10,2), device text, browser text, page_url text, referrer text, session_id uuid, status smallint, latency_ms int
) USING heap; CREATE TABLE events_col (LIKE events_row) USING columnar;

往两个表各塞 500 万行假事件数据:

INSERT INTO events_row
SELECT g, '2026-01-01'::timestamptz + g * interval '1 second', ((g::bigint * 7919) % 100000)::int, (ARRAY['IN','US','DE','BR','JP'])[1 + g % 5], (g % 10000) / 100.0, (ARRAY['mobile','desktop','tablet'])[1 + g % 3], (ARRAY['chrome','firefox','safari','edge'])[1 + g % 4], '/products/' || (g % 5000) || '/details?ref=campaign_' || (g % 97), 'https://www.example-' || (g % 300) || '.com/search?q=item' || (g % 1000), md5(g::text)::uuid, (ARRAY[200,200,200,404,500])[1 + g % 5], (g % 900) + 20
FROM generate_series(1, 5000000) g; INSERT INTO events_col SELECT * FROM events_row;
VACUUM ANALYZE events_row;
ANALYZE events_col;

对比一下磁盘占用:

SELECT c.relname AS "table", a.amname AS engine, pg_size_pretty(pg_total_relation_size(c.oid)) AS size
FROM pg_class c JOIN pg_am a ON a.oid = c.relam
WHERE c.relname LIKE 'events_%';
 table | engine | size
------------+----------+-------- events_col | columnar | 127 MB events_row | heap | 868 MB

同样的数据,小了 6.8 倍。按列存储意味着相似的值挤在一起,压缩效率自然就高。

Step 3: 跑一个分析查询

按国家算平均订单金额。BUFFERS 显示每个引擎读了多少个 8 KB 页:

EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, TIMING OFF)
SELECT country, round(avg(amount), 2) FROM events_row GROUP BY country; EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, TIMING OFF)
SELECT country, round(avg(amount), 2) FROM events_col GROUP BY country;

精简后的输出:

-- heap -> Parallel Seq Scan on events_row Buffers: shared read=111093 ← 868 MB 的页 Execution Time: 852 ms -- columnar -> Custom Scan (ColumnarScan) on events_col Columnar Projected Columns: country, amount Buffers: shared hit=3164 read=16 ← 25 MB 的页 Execution Time: 1409 ms

列存储引擎少读了 35 倍的页。Projected Columns: country, amount 说明了原因:另外 10 列根本碰都没碰。

但看执行时间。在我笔记本上,行存储反而更快。因为整个数据集都在内存里,读页的成本几乎为零。PostgreSQL 还把行存储的扫描分到了 3 个 CPU 核心并行执行,而这个列存储引擎还是单线程扫描。

这就是核心教训:选引擎就是赌你的瓶颈在哪。当 I/O 是瓶颈时——比如表大于内存、网络挂载的云盘、按扫描字节数计费的存储——列存储胜出。当全在内存里时,差距缩小,优化良好的行存储甚至可以反超。

Step 4: 更新一行

UPDATE events_row SET status = 500 WHERE id = 42;
UPDATE events_col SET status