KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA

06 · 复制、分区与选型 — keel 龙骨

前面五章都在单机上。这一章把数据放到第二台机器上,并且回答两个反复被问到的问题:流复制和逻辑复制到底差在哪,以及分区到底解决什么、不解决什么。末尾用 pgvector 给一个「PostgreSQL 的边界在哪」的具体数字。

前面五章都在单机上。这一章把数据放到第二台机器上,并且回答两个反复被问到的问题:流复制和逻辑复制到底差在哪,以及分区到底解决什么、不解决什么。末尾用 pgvector 给一个「PostgreSQL 的边界在哪」的具体数字。

现场:两种复制,两种世界观

「复制」这个词把两件不同的事挤在了一起:

一句话区分:物理复制复制的是「页怎么变」,逻辑复制复制的是「数据怎么变」。备库能不能写、能不能只复制一张表、能不能跨大版本,都从这句推出来。

物理复制:从基础备份到追平

搭一个备库要先做一次基础备份。这台机器上 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 建索引吃内存,检索是近似的

生产边界

动手

  1. 搭一台备库,pg_wal_replay_pause 制造滞后,记录 sent_lsn、replay_lsn、lag_bytes 三个值随主库写入的变化,再 resume 看它追平。
  2. 建一个 test_decoding 槽,做一批增删改,用 pg_logical_slot_get_changes 打印解码记录;用完立刻 pg_drop_replication_slot。
  3. 建一张按月分区的表,用带分区键和不带分区键的条件各跑一次 EXPLAIN,数一数 Append 下面有几个子分区。

可观察结果:你能判断「这条复制延迟是网络问题还是备库处理问题」,能说出「这个槽最迟什么时候必须清理」,以及「这个查询能不能吃到分区裁剪」。

自测

  1. 物理复制和逻辑复制的记录内容分别是什么?哪些需求只有逻辑复制能满足?
  2. sent_lsn 到 write_lsn、write_lsn 到 replay_lsn 分别代表哪一段?网络断了哪个先停?
  3. 复制槽为什么能撑爆主库磁盘?监控里该看哪两个值?
  4. 分区裁剪在单点查询上为什么不一定比索引快?它真正的价值在哪两个场景?
  5. pgvector 的 NOTICE 提示图超出 maintenance_work_mem,说明索引构建和什么资源强相关?ef_search 调大换来了什么、付出了什么?

这门课到这里结束。回头看一条链路:一条 UPDATE 在页里留下两个版本(00)→ 谁能看见由快照决定(01)→ 看不见的版本靠 VACUUM 回收,回收不动就膨胀、就逼近回卷(02)→ 每一次改动都先过 WAL,崩溃靠它恢复(03)→ 读得快不快由计划和统计决定(04)→ 并发写谁先谁后由锁和死锁检测裁决(05)→ 数据要到别的机器上,靠物理或逻辑复制(06)。这条链上任何一环出问题,症状往往出现在另一环——这正是排查数据库问题最难的地方。

进入 keel 阅读