一连接一进程:为什么 PG 必须上连接池
MySQL 是多线程,一个进程里跑几十个连接;PostgreSQL 是多进程,每一个连接都 fork 出一个独立的后端进程。这一个设计差别,决定了两边完全不同的容量规划方式。
只负责监听、fork、崩溃后统一重启
把连接数拉起来看看
work_mem 不是每连接只分一份,而是每个排序 / 哈希节点各分一份。一条 SQL 里有 3 个 Sort 节点,这一个连接就能吃掉 3 份 work_mem。所以 work_mem 调大是「按查询复杂度爆炸」的,不是线性增长。max_connections 只有 100。这跟 MySQL 默认 151 差不多,但 PG 的每个连接重得多——基础私有内存约 10MB,加上 work_mem 之后一条复杂查询能到几十 MB。500 个连接直接把机器吃光,而且 fork 本身有成本,高并发下 context switch 会把 CPU 打满。所以生产上必须挂连接池
| 方式 | 说明 | 适用 |
|---|---|---|
| PgBouncer(transaction 模式) | 连接在后端复用,一个事务结束就归还。几百个应用连接只对应几十个真实后端进程 | 绝大多数 Web 应用。这是默认答案 |
| PgBouncer(session 模式) | 整个会话独占一个后端 | 用了 SET、临时表、advisory lock 这类会话级状态的场景 |
| 应用侧连接池(HikariCP 等) | 只能减少新建连接,不能减少真实后端数 | 必要但不充分,通常和 PgBouncer 叠加用 |
PG 内置的 max_connections 调大 | 调到几百也能跑 | 只在「连接不多但都很重」时用,不是解法 |
SET、advisory lock)在 transaction 模式下不可靠。」——最后半句是加分项,说明你知道它的坑。顺带说清 memory 三层
| 参数 | 是什么 | 默认值 | 怎么定 |
|---|---|---|---|
shared_buffers | PG 自己的页缓存(8KB 页),所有进程共享 | 128MB | 物理内存的 25% 左右,一般不超过 8~16GB(再大不如给 OS cache) |
work_mem | 每个排序 / 哈希算子可用的内存,私有 | 4MB | 从 4~16MB 起步,按「最大连接数 × 平均算子数」反算,别拍脑袋调大 |
maintenance_work_mem | VACUUM、CREATE INDEX 用的内存 | 64MB | 可以开大(1~2GB),因为维护操作并发少 |
innodb_buffer_pool_size 通常开到物理内存 70~80%,因为 InnoDB 不依赖 OS 缓存。PG 依赖 OS 页缓存——它读数据走的是普通文件 IO,OS 会再缓存一份。所以 shared_buffers 只给 25% 是合理的,剩下的交给 OS。给 shared_buffers 开太大会和 OS 缓存打架(双缓冲)。堆表 + 独立索引:PG 里每一次索引查找都要回表
MySQL 的 InnoDB 是聚簇索引——主键索引的叶子节点就是整行数据,按主键查一次就拿到全部。PG 完全不是这样:表是一个无序的堆文件,所有索引(包括主键)都是独立的 B-tree,叶子节点只存 ctid。
这个结构带来三个直接后果
一、主键在 PG 里没有「聚集」的优待。InnoDB 的主键索引就是数据本身,按主键范围查是顺序 IO;PG 按主键查是先走一棵 B-tree 拿到 ctid,再去堆里按物理位置取行——两个随机 IO。行数一大,这个差距很明显。
二、没有「按主键聚簇存储」这回事。CLUSTER 命令能按某个索引重排一次物理顺序,但它是一次性的,后续的插入不会维护这个顺序,而且执行期间会锁表(8.0 之后可以 CLUSTER ... USING,但仍然要排他锁,通常配合 pg_repack 做)。
三、所有索引都平等。PG 里建 5 个索引就是 5 棵独立的树,没有「主键索引特殊、二级索引叶子存主键值」这一层。代价是二级索引的叶子直接存 ctid(6 字节),比存主键值更省——但如果行被更新导致换了物理位置,所有索引都要更新。
index-only scan:PG 版的「覆盖索引」,但有个陷阱
当查询要的列全在索引里,PG 可以做 index-only scan,不取堆。但——它仍然要判断这一行的可见性,而可见性信息不在索引里,在可见性映射表(visibility map)里。
EXPLAIN 里写着 Index Only Scan,但 Heap Fetches 却是几万——这就是 VACUUM 没跟上。这个细节在面试里很少有人说得出,但它直接连到第四个考点:VACUUM 不只是回收空间,它还决定索引能不能真正免回表。
EXPLAIN (ANALYZE, BUFFERS)
SELECT name FROM users WHERE name = 'zhangwei';
Index Only Scan using idx_users_name on users
Index Cond: (name = 'zhangwei'::text)
Heap Fetches: 0 ← 0 才是真的没回堆
Buffers: shared hit=4
| InnoDB(MySQL) | PostgreSQL | |
|---|---|---|
| 表数据存在哪 | 主键 B+ 树的叶子节点 | 独立的堆文件(无序) |
| 主键索引叶子存什么 | 整行 | ctid(物理地址) |
| 二级索引叶子存什么 | 主键值 | ctid |
| 按主键查一次 | 1 次 B+ 树查找 | 索引树 + 回堆,2 次 |
| 覆盖索引 | 二级索引包含全部列 | index-only scan(依赖 visibility map) |
| 物理顺序重排 | 天然按主键有序 | CLUSTER / pg_repack,一次性 |
MVCC:旧版本不在 undo log 里,就在表里
这是 PG 和 MySQL 最本质的分歧。InnoDB 把旧版本放在 undo log,回滚段里;PG 把旧版本直接留在表的数据页里,每一行都带自己的出生和死亡标记。
堆里真实存在的东西
三个会话各自看到什么
每一行头上的四个隐藏列
| 列 | 含义 | 作用 |
|---|---|---|
xmin | 创建这一行的事务 ID | 判断「这行是不是我能看到的」 |
xmax | 删除 / 更新这一行的事务 ID;0 表示还活着 | 判断「这行是不是已经被别人删了」 |
ctid | 物理位置(块号, 行偏移) | 索引指向它;UPDATE 之后会变 |
| infomask 位 | 一堆标志位(是否已提交、是否被冻结等) | 为了不每次都去查 clog(事务状态表),把结果缓存在行头上 |
一条 UPDATE 到底做了什么
-- 表里原来有一行 balance = 100,由事务 1000 插入
UPDATE accounts SET balance = 200 WHERE id = 1; -- 当前事务 1001
-- PG 内部做的是:
-- 1) 把旧行的 xmax 改成 1001(标记「被我删了」)
-- 2) 在同一个页(或新页)插入一条新行:xmin = 1001, xmax = 0, balance = 200
-- 3) 新行 ctid 变了 —— 所有索引都需要指向新 ctid
SELECT ctid, xmin, xmax, balance FROM accounts;
-- (0,1) | 1000 | 1001 | 100 ← 旧版本,还占着空间
-- (0,5) | 1001 | 0 | 200 ← 新版本
ctid 变了,意味着所有索引都要插一条指向新位置的新条目(除了 HOT 更新,见下一段)。这是 PG 更新比 MySQL 贵的一个原因:更新不修改原地,而是「标记旧行 + 插入新行 + 更新所有索引」。HOT 更新:PG 的一个优化,也是一道面试题
如果满足两个条件,PG 可以走 HOT(Heap-Only Tuple),不新建索引条目,只在新旧行之间加一条指针,索引还指向旧位置,顺着指针能找到新版本:
① 被更新的列没有被任何索引引用;② 新版本能放进同一个页的空闲空间(这时候 fillfactor 就很重要)。
实践做法:高频更新的表把
fillfactor 调低(比如 70~80),给页里留出更新空间,让 HOT 有机会生效。三种隔离级别的实现
| 级别 | PG 里的实现 | 会不会幻读 |
|---|---|---|
| READ COMMITTED(默认) | 每条语句开始时取一个新快照 | 会。同一条 SQL 两次执行结果可能不同 |
| REPEATABLE READ | 快照隔离(SI):整个事务用同一个快照 | 不会,也不需要间隙锁 |
| SERIALIZABLE | SSI(可串行化快照隔离),在 SI 之上检测读写冲突 | 不会,冲突时直接报错让你重试 |
MySQL 的 RR 靠「MVCC + 间隙锁」两套机制防幻读;PG 的 RR 只靠快照——因为快照读到的是一个时间点,本来就不会出现幻影行。
代价是写冲突不排队,直接报错:
ERROR: could not serialize access due to concurrent update。业务必须捕获这个错并重试整个事务,否则用户看到的是失败。这一点在 PG 里是常态,不是异常。没有 undo log,代价是什么
InnoDB 的事务一回滚或提交,undo 里的旧版本就可以被 purge 掉,空间能复用。PG 的旧版本就躺在表里,谁都不能动——因为可能还有别的长事务的事务快照需要看到它。
所以:PG 的 UPDATE 越多、事务越长,表就越大。这个「越大」不是逻辑行数变多,是物理文件膨胀。这就引出了下一个考点:VACUUM。
「InnoDB 是把旧版本写在别处(undo log),PG 是把旧版本留在原地(堆表里)。所以 MySQL 需要 purge 线程,PG 需要 VACUUM;MySQL 的表空间不会因为更新而暴涨(会复用),PG 的表会——如果不 vacuum 的话。」
VACUUM 与表膨胀:PG 最常见的线上问题
VACUUM 干两件事,很多人只知道第一件:① 把死元组占的空间标记为可复用;② 推进事务 ID 的冻结(防止回卷)。注意是「标记可复用」,不是「还给操作系统」。
autovacuum 什么时候才动手
不是「一有死元组就清」,而是有一个触发阈值:
触发条件:n_dead_tup > autovacuum_vacuum_threshold
+ autovacuum_vacuum_scale_factor × 表的估算行数
默认值:threshold = 50,scale_factor = 0.2 (PG 默认)
标准做法:大表按表单独调低系数,比如
ALTER TABLE big_table SET (autovacuum_vacuum_scale_factor = 0.02);,让它按 2% 触发。为什么会出现「vacuum 跑了但表还是越来越大」
| 原因 | 机制 | 怎么查 |
|---|---|---|
| 长事务 | 老事务的快照还要看旧版本,vacuum 只能跳过这些行,不敢清 | SELECT pid, now()-xact_start, state, query FROM pg_stat_activity WHERE state <> 'idle' ORDER BY 2 DESC; |
| 失效的复制槽 | slot 保留了 WAL,也保留了「必须还能解析出的旧版本」的约束 | SELECT slot_name, active, restart_lsn FROM pg_replication_slots; |
| 长 SQL 的游标没关 | DECLARE CURSOR WITH HOLD 会一直持有快照 | 同上,看 pg_stat_activity |
| autovacuum 被别的表占满 | autovacuum_max_workers 默认只有 3,大表一跑就是几小时 | SELECT * FROM pg_stat_progress_vacuum; |
VACUUM 的四种形态,别搞混
| 命令 | 做什么 | 锁 | 什么时候用 |
|---|---|---|---|
VACUUM | 标记死元组空间可复用 | 不阻塞读写 | 日常,autovacuum 自动跑 |
VACUUM ANALYZE | 顺带更新统计信息 | 同上 | 大批量增删改之后手动跑一次 |
VACUUM FULL | 重写整张表,真正把空间还给 OS | ACCESS EXCLUSIVE,全程锁表 | 几乎不用。大表上等于停服 |
pg_repack | 在线重写表,效果同 VACUUM FULL | 只在最后切换时短暂锁 | 真的需要缩表时用这个 |
pg_repack。一上来就 VACUUM FULL 是新手答案,在生产大表上会直接锁死业务。PG 19 的两个改进(2026 年 11 月 GA)
当前生产稳定版是 18.6,19 计划 11 月 19 日 GA。19 里和这一块相关的有两个:autovacuum 支持并行 worker(autovacuum_max_parallel_workers),以及新增 REPACK 命令(带 CONCURRENTLY),算是把 pg_repack 的能力收进了内核。面试里提一句「19 把在线重表做进内核了」,是个很新的加分点。
事务 ID 回卷:PG 独有的停机事故
PG 的事务 ID 是 32 位无符号整数,约 42 亿个。写满之后会绕回 0——一旦绕回,新事务的 ID 比老事务还小,所有过去的数据看起来都「来自未来」,整个库就废了。所以 PG 必须定期把老行的 xid「冻结」成特殊值。
把数据库的年龄拖一拖
为什么「28 亿」和「20 亿」是两个不同的数字
| 阈值 | 数值 | 发生什么 |
|---|---|---|
vacuum_freeze_max_age | 2 亿(默认) | autovacuum 强制对该表做 freeze,忽略其他参数。这是「防回卷 vacuum」 |
| 告警线(惯例) | 10 ~ 15 亿 | 该查为什么 age 涨这么快了,通常是有表没被 vacuum 到 |
| 硬上限 | 约 20 亿 | 数据库拒绝新的写入事务:ERROR: database is not accepting commands to avoid wraparound data loss |
| 理论回绕点 | 约 42 亿 | 真正的数据损坏点,实际上到不了这里,前面就会拦 |
三个必须会查的 SQL
-- 1. 每个库离回卷还有多远(最该进监控的一条)
SELECT datname, age(datfrozenxid) AS xid_age,
current_setting('autovacuum_freeze_max_age')::bigint
- age(datfrozenxid) AS remaining
FROM pg_database ORDER BY xid_age DESC;
-- 2. 哪张表最老(找出那个没被 vacuum 的表)
SELECT relname, age(relfrozenxid) AS xid_age, pg_size_pretty(pg_total_relation_size(oid))
FROM pg_class
WHERE relkind = 'r' AND relnamespace = 'public'::regnamespace
ORDER BY age(relfrozenxid) DESC LIMIT 20;
-- 3. 谁在阻止 vacuum 推进(长事务 / 复制槽,必查)
SELECT pid, now() - xact_start AS dur, state, left(query, 60)
FROM pg_stat_activity WHERE xact_start IS NOT NULL ORDER BY dur DESC LIMIT 5;
SELECT slot_name, active, age(xmin), age(catalog_xmin)
FROM pg_replication_slots;
常见的三种成因
① 有一张冷表从来没被访问过。autovacuum 是按「表的变更量」触发的,一张只读的冷表没有变更,就不会被 vacuum——但它的 relfrozenxid 一直停在那里,把整个库的 age 顶上去。这是最常见的原因。
② 长事务或失效的复制槽。它们的 xmin 卡在很早的位置,vacuum 无法冻结更晚的行。
③ autovacuum 被关掉过。有些「优化」教程会建议关掉 autovacuum,这在 PG 里是自杀行为。
「PG 的 xid 是 32 位,会绕回。防止绕回靠 VACUUM 把老行冻结。有两个阈值:2 亿触发强制 freeze,20 亿直接拒绝写入。日常要看
age(datfrozenxid),超过 10 亿就该查原因。最常见的成因不是大表,是没人访问的冷表——它没有变更就不会触发 autovacuum,但它的 relfrozenxid 会把整个库的年龄顶上去。所以我们会对冷表配定期手动 vacuum,并且在监控里同时盯库级和表级 age。」和 MySQL 的对照
| MySQL(InnoDB) | PostgreSQL | |
|---|---|---|
| 事务 ID 位宽 | 8 字节(48 位有效),不会绕回 | 32 位,约 42 亿 |
| 旧版本存在哪 | undo log,purge 线程清理 | 表里,VACUUM 标记可复用 |
| 需要防回卷吗 | 不需要 | 需要,且是硬约束 |
| 膨胀风险 | 有,但表空间能复用 | 更明显,靠 autovacuum 兜底 |