
最近折腾数据库存储引擎,踩了几个坑,这篇把问题说清楚。
顺便做了一个实验:用 Docker 起一个 PostgreSQL,往两个不同的存储引擎里各塞 500 万行数据,然后故意把数据库掐掉,看看会发生什么。如果你已经懂原理了,直接跳到第三部分动手玩。
先说结论:数据库引擎就是那个把"保存这行"变成磁盘上的字节、再读回来的东西。没有银弹。行存储适合事务,LSM 树适合高并发写入,列存储适合分析场景,内存引擎适合追求极致速度。PostgreSQL 支持用 CREATE TABLE ... USING <engine> 按表选择引擎。
当你执行 INSERT INTO users VALUES (1, 'alice', 1000) 时,实际上有两层完全不同的软件在配合:
SQL 文本 │ ▼ ┌──────────────────────────┐ │ 查询层 │ 解析 → 计划 → 执行 │ "你想要什么?" │ └────────────┬─────────────┘ │ "存储这行","给我第X行" ▼ ┌──────────────────────────┐ │ 存储引擎 │ 页、行、日志、多版本 │ "字节到底怎么存放?" │ └────────────┬─────────────┘ ▼ 磁盘 / 对象存储
这篇文章讲的是下面那个框。要理解它为什么长这样,最简单的办法是实现一个最直观的版本,然后看着它挂掉。
with open("users.csv", "w") as f: for i in range(200_000): f.write(f"{i},user_{i:06d},{i * 7 % 100000}\n")
在我机器上,几乎立刻就暴露了三个问题:
42,alice,1000 改成 42,bob,2000 的中途断电,最后得到的是 42,bobce,1000。它能正常解析,所以根本不会报警。正经的引擎靠四个思路解决这些问题。
把文件切成固定大小的块,叫作页。PostgreSQL 用的是 8 KB。这样找第 5000 块就是简单的算术(5000 × 8192),每次读取成本相同。页成了磁盘 I/O、缓存和崩溃恢复的基本单位。
在每个页内部,开头放一个小的槽数组,指向存在末尾的行。两块区域从两头往中间长:
字节 0 ┌────────────────────────────────┐ │ 页头(24 字节) │ ├────────────────────────────────┤ │ 槽 1 │ 槽 2 │ 槽 3 │ ... │ │ ← 槽往下增长 ├────────────────────────────────┤ │ 空闲区域 │ ├────────────────────────────────┤ │ ... │ 行 3 │ 行 2 │ 行 1 │ ← 行往上增长
字节 8192 └────────────────────────────────┘
一行的地址是 (页号, 槽号),不是字节偏移。这样引擎可以在页内移动行来回收空间,而所有指向 (5, 3) 的索引仍然有效。
校验和用于检测损坏:每个页存一份自己字节的校验和,半页写入会校验失败,不会返回 42,bobce,1000 这种脏数据。
预写日志(WAL,Write-Ahead Log)用于修复损坏。引擎在修改页之前,先把变更描述追加到顺序日志并刷盘:
1. 追加 "第 5 页槽 3:balance 1000 → 2000" 到 WAL → fsync
2. 在内存中修改第 5 页
3. 把第 5 页写入磁盘 ... 最终会写
如果在第 3 步之前崩溃,恢复时重放日志即可。实验中你会亲眼看到这个过程。
不覆盖旧行,而是写一个新版本,给每个版本打上创建它的事务 ID(xmin)和替代它的事务 ID(xmax)。每个事务从一个快照读数据,所以一份长报表可以继续看到旧的余额,而另一个更新在它旁边提交。读操作永远不会阻塞写操作。代价是旧版本会堆积,必须清理——这就是 PostgreSQL 里 VACUUM 的工作。
上面四个思路描述的是 PostgreSQL 默认的引擎。其他的引擎根据优化目标不同,做了不同的取舍。
整行数据存在一起,原地更新,靠 B-tree 索引定位。这是经典设计。
heap、MySQL InnoDB、SQLite、Oracle、SQL Server。永远不原地更新。写操作先进入内存缓冲区(再加一份日志保安全)。缓冲区满了就刷到磁盘成为一个不可变的排序文件,后台合并进程逐步合并这些文件。
按列分别存储并压缩。需要 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: ...
└──────────────────────────┘
所有数据都放在内存里。持久化靠快照和追加日志,或者干脆放弃持久化换速度。
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 | — |
接下来上手试试。
需要准备:Docker,大约 15 分钟。导入数据大概需要一分钟。
用 citusdata/citus 镜像:标准 PostgreSQL 17,自带 columnar 表引擎。我们只用它的列存储引擎,分布式功能碰都不碰。
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
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
模拟一个 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 倍。按列存储意味着相似的值挤在一起,压缩效率自然就高。
按国家算平均订单金额。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 是瓶颈时——比如表大于内存、网络挂载的云盘、按扫描字节数计费的存储——列存储胜出。当全在内存里时,差距缩小,优化良好的行存储甚至可以反超。
UPDATE events_row SET status = 500 WHERE id = 42;
UPDATE events_col SET status