KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
06 · 复制、分区与选型 — keel 龙骨
前面五章都在单机上。这一章把数据放到第二台机器上,并且回答两个反复被问到的问题:流复制和逻辑复制到底差在哪,以及分区到底解决什么、不解决什么。末尾用 pgvector 给一个「PostgreSQL 的边界在哪」的具体数字。
前面五章都在单机上。这一章把数据放到第二台机器上,并且回答两个反复被问到的问题:流复制和逻辑复制到底差在哪,以及分区到底解决什么、不解决什么。末尾用 pgvector 给一个「PostgreSQL 的边界在哪」的具体数字。
现场:两种复制,两种世界观
「复制」这个词把两件不同的事挤在了一起:
- 物理复制(流复制):备库拿到的是 WAL 记录 —— 页级别的二进制改动。备库必须和主库同版本、同平台、同数据目录结构,因为它在重放物理地址。备库整体只读。
- 逻辑复制:备库拿到的是「对某张表的某一行发生了什么」的解码结果 —— INSERT/UPDATE/DELETE。备库可以是不同版本、只复制部分表、可以做双向、可以跨版本升级。
一句话区分:物理复制复制的是「页怎么变」,逻辑复制复制的是「数据怎么变」。备库能不能写、能不能只复制一张表、能不能跨大版本,都从这句推出来。
物理复制:从基础备份到追平
搭一个备库要先做一次基础备份。这台机器上 pg_basebackup 反复失败——服务端的 BASE_BACKUP 后端进程异常退出(0xC0000409),主库触发自动恢复(第 03 章记过这件事)。换成等价做法:停主库 → 文件级复制 → 写 standby.signal 和 primary_conninfo → 同时启动。pg_basebackup 内部做的也是这件事,只是它在线完成。
===== 0) 启动自建主库 5435 =====
rows_on_primary: 5000
===== 1) 干净停主库,做一次一致的文件级基础备份 =====
--- 备库目录已就绪:standby.signal + primary_conninfo ---
pgstandby3/standby.signal
primary_conninfo = 'host=127.0.0.1 port=5435 user=postgres'
===== 3) 备库只读:pg_is_in_recovery + 写操作被拒 =====
in_recovery
-------------
t
ERROR: cannot execute INSERT in a read-only transaction
standby.signal 是「我是一台备库」的标记文件,primary_conninfo 是「去哪找主库」。备库启动后 pg_is_in_recovery()=t,任何写操作直接报 cannot execute INSERT in a read-only transaction——这是物理复制的硬约束,不是权限问题。
主库上查复制状态:
===== 4) 主库 pg_stat_replication =====
pid | application_name | state | sync_state | sent_lsn | write_lsn | flush_lsn | replay_lsn | reply_time
-------+------------------+-----------+------------+-----------+-----------+-----------+------------+------------------------------
17400 | walreceiver | streaming | async | 0/3000278 | 0/3000278 | 0/3000278 | 0/3000278 | 2026-10-05 14:30:07.87787+08
四个 LSN 是一段流水线的四个位置,理解了它们就能解释各种「复制延迟」:
| 字段 | 含义 |
|---|---|
sent_lsn |
主库已经通过网络发出去了多少 |
write_lsn |
备库已经收到并写进自己的 WAL 缓冲 |
flush_lsn |
备库已经 fsync 落盘 |
replay_lsn |
备库已经把 WAL 重放进了数据页 |
sent_lsn 到 write_lsn 之间是网络,write_lsn 到 replay_lsn 之间是备库的处理能力。state=streaming 表示连接正常;sync_state=async 表示这是异步复制,主库不等备库。
制造一次滞后
把备库的回放暂停,主库写入 2 万行:
===== 5) 制造滞后:暂停备库回放,主库写入 2 万行 =====
pg_wal_replay_pause -- 备库执行
state | sent_lsn | replay_lsn | lag_bytes | replay_lag
-----------+-----------+------------+-----------+-----------------
streaming | 0/347A8E8 | 0/3000278 | 4695664 | 00:00:00.767139
sent_lsn 已经推进到 0/347A8E8,replay_lsn 停在原地,差了 4695664 字节。这期间备库上 repl_t 这张表还不存在——因为创建表的 WAL 躺在 replay_lsn 之后还没被重放。这一点在排查「备库读不到刚写的表」时是决定性的:问题不在表,在回放位置。
恢复回放:
===== 6) 恢复回放,观察追平 =====
state | sent_lsn | replay_lsn | lag_bytes
-----------+-----------+------------+-----------
streaming | 0/347A8E8 | 0/347A8E8 | 0
standby_rows_after_resume
---------------------------
20000
replay_lsn 追平,20000 行全部可见。注意 pg_wal_replay_pause 只是暂停重放,WAL 该收还是在收——它模拟的是「备库处理不过来」,不是「网络断了」。网络断开的场景下 sent_lsn 会停止推进,两者从指标上能区分开。
逻辑复制:把 WAL 解码成可读的记录
逻辑复制从同一个 WAL 出发,但中间多一层解码插件。用 test_decoding 把一次 INSERT/UPDATE/DELETE 解出来:
===== 3) 把槽里的逻辑解码记录原文取出来 =====
lsn | xid | data
------------+-------+--------------------------------------------------------------------
0/1F027170 | 21631 | BEGIN 21631
0/1F027170 | 21631 | table public.cdc_t: INSERT: id[integer]:1 note[text]:'hello'
0/1F027288 | 21631 | COMMIT 21631
0/1F027288 | 21632 | BEGIN 21632
0/1F027288 | 21632 | table public.cdc_t: UPDATE: id[integer]:1 note[text]:'hello world'
0/1F027310 | 21632 | COMMIT 21632
0/1F027310 | 21633 | BEGIN 21633
0/1F027310 | 21633 | table public.cdc_t: DELETE: id[integer]:1
0/1F027380 | 21633 | COMMIT 21633
和 pg_waldump 的输出对比一下就有感觉了:物理日志给你的是 blkref #0: rel 1663/24940/25054 blk 0(物理页),逻辑解码给你的是 table public.cdc_t: INSERT: id[integer]:1 note[text]:'hello'(表名、列名、值)。这就是逻辑复制的全部价值——它把「哪张表的哪一行的哪个字段变成了什么」暴露出来,下游可以是另一个 PG、也可以是 Kafka 消费者(流式管道那门课直接用这条输出做原材料)。
pg_recvlogical 则是把这条流做成命令行工具,用法和上面的查询等价,只是持续接收:
$ pg_recvlogical -h 127.0.0.1 -p 5433 -U postgres -d labpg -S lab_recv --start -f - --endpos=0/1F029318
BEGIN 21634
table public.cdc_t: INSERT: id[integer]:7 note[text]:'via recvlogical'
COMMIT 21634
BEGIN 21635
table public.cdc_t: UPDATE: id[integer]:7 note[text]:'changed'
COMMIT 21635
复制槽:既是保险也是负债
两种复制都依赖复制槽(replication slot)。它的作用是记录「下游已经消费到哪了」,让主库知道哪些 WAL 还不能删。
好处很明显:备库短暂掉线再回来,只要 WAL 还在,就能从断点续上,不用重做基础备份。坏处同样明显——下游不消费,主库就不能回收 WAL。实测:
===== 6) 复制槽滞留:没人消费时它一直压着 WAL =====
slot_name | active | retained_bytes
-----------+--------+----------------
lab_lag | f | 56
(写入 20 万行、槽仍未消费之后)
slot_name | active | retained_bytes
-----------+--------+----------------
lab_lag | f | 52975352
retained_bytes 从 56 涨到约 5000 万字节,只因为写了一批数据没人来取。一个被遗忘的槽能让 pg_wal 涨到撑满磁盘,进而让主库停止接受写入——这是生产上排得上号的故障类型。监控里必须有「每个槽的 restart_lsn 落后当前 LSN 多远」和 pg_wal 目录大小两条。
消费完之后,槽的确认位点会前进:
slot_name | active | restart_lsn | confirmed_flush_lsn
-----------+--------+-------------+---------------------
lab_slot | f | 0/1F027138 | 0/1F027380
confirmed_flush_lsn 是下游已确认消费到的位置,主库据此放开 WAL 回收。实验最后两个槽都已 pg_drop_replication_slot 清掉——临时实验一定要记得清槽,这是本机实验里最容易遗留的垃圾。
sequenceDiagram
participant App as 应用写入
participant Pri as 主库 5433 (labpg)
participant WAL as 主库 pg_wal / 逻辑槽 lab_slot
participant SBY as 物理备库(5434)
participant Cdc as 逻辑下游(pg_recvlogical / 其它 PG)
App->>Pri: INSERT / UPDATE / DELETE
Pri->>WAL: 写 WAL 记录(Heap / Btree / Transaction)
Note over WAL: 物理槽记 sent/flush/replay 位点<br/>逻辑槽额外做解码
WAL-->>SBY: 按 WAL 原样流式发送,备库重放页面
SBY-->>Pri: 回传 write/flush/replay LSN
Pri->>WAL: 逻辑槽解码出 table/schema/列值
WAL-->>Cdc: test_decoding 格式的记录流
Cdc-->>Pri: 回传 confirmed_flush_lsn
Pri->>WAL: 两个下游都确认后才回收 WAL
Note over SBY: 备库只读:INSERT 报 read-only transaction
Note over WAL: 下游长期不消费 → retained_bytes 持续上涨
分区:什么时候真的有用
建一张按月分区的表,200 个子分区,灌 60 万行:
partition_count
-----------------
200
===== 3) 带分区键的查询:只扫命中的那个子分区(分区裁剪)=====
Seq Scan on pt_big_201005 pt_big (cost=0.00..77.75 rows=100 width=69) (actual time=0.008..0.152 rows=100 loops=1)
Filter: (created = '2010-05-17'::date)
Planning Time: 0.105 ms
Execution Time: 0.163 ms
条件里带了 created,200 个子分区里只碰了一个——这就是分区裁剪(partition pruning)。同样数据、同样条件,非分区表走索引:
===== 4) 非分区表对照:同样数据 + created 索引 =====
Index Scan using np_big_created_idx on np_big (cost=0.42..11.19 rows=100 width=69) (actual time=0.036..0.075 rows=100 loops=1)
Planning Time: 0.126 ms
Execution Time: 0.088 ms
单点查询上,分区并不比索引快(0.163 ms vs 0.088 ms)。分区裁剪省的是「不用扫无关数据」,索引省的是「不用扫命中行之外的行」,单点查询时后者更直接。
那分区什么时候有用?两个场景。一是查询条件天然按分区键聚合:
===== 6) 分区键上的范围查询:裁剪掉大部分子分区 =====
Aggregate (cost=214.25..214.26 rows=1 width=8) (actual time=1.060..1.061 rows=1 loops=1)
-> Append (cost=0.00..199.00 rows=6100 width=0) (actual time=0.015..0.856 rows=6100 loops=1)
-> Seq Scan on pt_big_201005 pt_big_1 (cost=0.00..85.50 rows=3100 width=0) (actual time=0.014..0.310 rows=3100 loops=1)
-> Seq Scan on pt_big_201006 pt_big_2 (cost=0.00..83.00 rows=3000 width=0) (actual time=0.013..0.296 rows=3000 loops=1)
Planning Time: 3.483 ms
Execution Time: 1.083 ms
两个月的范围只扫 2 个子分区,Planning Time 3.483 ms。二是维护操作变成 DDL:删老数据用 DETACH PARTITION 一条命令,不用 DELETE 几千万行再等 VACUUM。
反过来,条件不带分区键就完全失效:
===== 5) 不带分区键:200 个子分区全要扫 =====
Finalize Aggregate (cost=12973.47..12973.48 rows=1 width=8) (actual time=316.884..323.794 rows=1 loops=1)
-> Gather (cost=12973.26..12973.47 rows=2 width=8) (actual time=37.523..323.787 rows=3 loops=1)
-> Parallel Append (cost=0.00..11972.76 rows=200 width=0) (actual time=11.132..11.676 rows=0 loops=3)
Planning Time: 7.861 ms
Execution Time: 324.682 ms
按 id 查(不在分区键上),200 个子分区全部进入 Parallel Append,324 ms。还有个容易忽略的成本:子分区越多,规划时间越长。单分区查询 Planning 0.105 ms,两分区 3.483 ms,200 分区 7.861 ms。子分区数量本身是有代价的,别一上来就按月切 200 份;从「查询模式和生命周期」反推分区粒度,才是正确的顺序。
pgvector:PG 做向量检索到什么程度
同一套思路:先量出数字,再判断边界。10 万条 128 维向量:
===== 1) 灌 10 万条 128 维向量 =====
rows | size
--------+-----
100000 | 58 MB
===== 2) 建 HNSW 索引(计时)=====
NOTICE: hnsw graph no longer fits into maintenance_work_mem after 52689 tuples
DETAIL: Building will take significantly more time.
HINT: Increase maintenance_work_mem to speed up builds.
CREATE INDEX
Time: 5099.683 ms (00:05.100)
hnsw_index_size
-----------------
49 MB
HNSW 索引建了 5.1 秒,索引本身 49 MB。那条 NOTICE 是关键信息:图构建到 52689 个节点就超出了 maintenance_work_mem(本机 65536 kB),后面的构建速度明显变慢。这是 pgvector 最需要调的参数,索引结构本身和内存预算直接挂钩。
ef_search 控制查询时搜索的广度,三档实测:
hnsw.ef_search |
Execution Time | Buffers shared hit |
|---|---|---|
| 10 | 0.107 ms | 116 |
| 40(默认) | 0.094 ms | 150 |
| 200 | 0.293 ms | 475 |
ef_search 越大,访问的图节点越多、Buffers 越多、耗时越长,换来的是召回率更高。这三档之间的墙钟时间差别不大(都在毫秒以下),因为数据量还小、全在缓存;到千万级、磁盘 IO 参与进来之后,这个差距才会拉开。
拿精确检索做对照——关掉索引,全表算距离:
===== 5) 精确检索对照:关掉索引,做全表暴力算距离 =====
-> Parallel Seq Scan on vec_items (cost=0.00..7663.83 rows=41667 width=12) (actual time=0.003..10.098 rows=33333 loops=3)
Execution Time: 303.356 ms
303 ms。HNSW 大约 0.1 ms,差三个数量级。但注意这是 10 万条的量级——pgvector 的适用区间大致就是「百万级以下、维度中等、能接受近似检索」。再往上,要么上专门的向量引擎,要么把 pgvector 当「业务数据 + 少量向量」的混合查询用(WHERE tenant_id = ? ORDER BY embedding <-> ? 这种,正是专门引擎不擅长的)。这门课只给 PG 侧的数字,横向对比见 Milvus 那门课。
选型
| 需求 | 选什么 | 代价 |
|---|---|---|
| 整库容灾、故障切换 | 物理流复制 | 备库只读、同版本同平台 |
| 跨版本升级、只复制部分表、多向同步 | 逻辑复制 | 需解码开销、DDL 不自动同步、冲突要自己处理 |
| 喂 CDC 到 Kafka / 数仓 | 逻辑槽 + pg_recvlogical |
槽必须有人消费,否则压 WAL |
| 大表按时间维度的查询与归档 | 声明式分区 | 子分区过多会拉长规划时间 |
| 百万级以下向量近似检索 | pgvector + HNSW | 建索引吃内存,检索是近似的 |
生产边界
- 教学里备库和主库在同一台机器、复制几乎是瞬时的。真实网络的延迟、带宽、丢包都会进入
write_lag,跨地域复制尤其要按 RTT 估算可接受延迟。 - 物理复制的备库是整库只读;要在备库上做分析类查询,要评估它会不会反过来拖慢回放、以及会不会拉长主库的最老
backend_xmin。 - 逻辑复制不会自动同步 DDL,加一列、改一个类型都要在两端手工协调,顺序错了会直接中断复制。
- 上线要盯的:
pg_stat_replication的replay_lsn落后量、pg_replication_slots的retained_bytes与active、pg_wal目录大小、以及备库的pg_is_in_recovery()是否仍为 t。 - pgvector 上线要盯:索引构建时的
maintenance_work_mem、ef_search与召回率的实测曲线、以及向量列的写入放大(HNSW 是增量更新的,频繁更新会拖慢)。
动手
- 搭一台备库,
pg_wal_replay_pause制造滞后,记录sent_lsn、replay_lsn、lag_bytes三个值随主库写入的变化,再resume看它追平。 - 建一个
test_decoding槽,做一批增删改,用pg_logical_slot_get_changes打印解码记录;用完立刻pg_drop_replication_slot。 - 建一张按月分区的表,用带分区键和不带分区键的条件各跑一次
EXPLAIN,数一数Append下面有几个子分区。
可观察结果:你能判断「这条复制延迟是网络问题还是备库处理问题」,能说出「这个槽最迟什么时候必须清理」,以及「这个查询能不能吃到分区裁剪」。
自测
- 物理复制和逻辑复制的记录内容分别是什么?哪些需求只有逻辑复制能满足?
sent_lsn到write_lsn、write_lsn到replay_lsn分别代表哪一段?网络断了哪个先停?- 复制槽为什么能撑爆主库磁盘?监控里该看哪两个值?
- 分区裁剪在单点查询上为什么不一定比索引快?它真正的价值在哪两个场景?
- pgvector 的 NOTICE 提示图超出
maintenance_work_mem,说明索引构建和什么资源强相关?ef_search调大换来了什么、付出了什么?
这门课到这里结束。回头看一条链路:一条 UPDATE 在页里留下两个版本(00)→ 谁能看见由快照决定(01)→ 看不见的版本靠 VACUUM 回收,回收不动就膨胀、就逼近回卷(02)→ 每一次改动都先过 WAL,崩溃靠它恢复(03)→ 读得快不快由计划和统计决定(04)→ 并发写谁先谁后由锁和死锁检测裁决(05)→ 数据要到别的机器上,靠物理或逻辑复制(06)。这条链上任何一环出问题,症状往往出现在另一环——这正是排查数据库问题最难的地方。