WAL / CDC / 复制槽
一、WAL 是什么
WAL = Write-Ahead Log,预写日志。就是数据库的一本流水账。
PostgreSQL 有条铁律:
先把要做的事记在账上,再真去做。
你执行 UPDATE sale.merchants SET name='张三' WHERE id=123,数据库实际干的是:
① 往账本写一行:「merchants 表 id=123 的 name 改成张三」
② 这行落盘(fsync)
③ 然后才去改真正的数据文件
为什么要这么绕? 为了崩溃恢复。如果 ② 之后 ③ 之前断电,重启时读账本就能把没做完的补上。
关键点:账本记的是「谁、什么时候、改了什么」------ 这正好就是「变化」本身。
所以「同步数据」这件事,本质上就是读这本账。
二、CDC 是什么
CDC = Change Data Capture,变更数据捕获。
要解决的场景:两个库要保持一致(同步 / 数仓 / 审计),但不能每 5 分钟全量拷一遍。
三种做法:
| 做法 | 原理 | 问题 |
|---|---|---|
| 全量快照 | 定期 SELECT 全表 | 数据量大跑不完;跑的过程中源端还在变,永远对不齐 |
| 触发器 | 在表上挂触发器,改一行记一条 | 拖慢业务写入;DDL 变更要跟着改触发器;漏掉 TRUNCATE 之类 |
| 读日志 ★ | 读数据库自己的流水账 | 对业务几乎零侵入,最完整 |
现在说的 CDC 基本都指第三种 ------ 读 WAL。
读日志的好处:不用改表结构、不影响业务性能、什么变更都能拿到(INSERT/UPDATE/DELETE/TRUNCATE)。
代价:日志是二进制私有的,要「翻译」。
三、逻辑解码
问题:WAL 是二进制格式,而且是给数据库自己用的。
它记的是「第几号文件第几页第几个字节改成什么」,不是「merchants 表 id=123 的 name 改成张三」。
所以需要一个翻译官,把账本翻译成「表 + 行 + 新值」。
这个翻译官叫 逻辑解码插件(output plugin):
| 插件 | 谁在用 |
|---|---|
| pgoutput | PostgreSQL 内置(publication/subscription) |
| pglogical_output | pglogical |
| test_decoding | 调试用的,把变更打成文本 |
翻译官翻译的时候会「挑」:
账本上: [你的表] [别人的表] [系统表] [你的表] [别人的表]
↓ ↓ ↓ ↓ ↓
翻译官: 发 扔掉 扔掉 发 扔掉
它只发「订阅范围里」的表。范围怎么定?
- 内置复制:看 publication(发布)
- pglogical:看 replication set(复制集)
四、复制槽
为什么需要
账本一直在写、一直在滚。写完的旧账本,数据库觉得没用了会回收掉(删文件、腾磁盘)。
但翻译官要是还没读完就被回收了怎么办?
所以要有个东西告诉数据库:
「我还读到这儿呢,从这儿往后的先别扔。」
这个东西就是复制槽(replication slot)。
是什么
槽不是容器,是书签。 它不存数据,只记一个位置。
槽 = 插在流水账上的一个夹子:「夹子往后的先别扔」
真正占磁盘的是 WAL,不是槽。 槽只是让 WAL 不被回收。
两种槽
| 物理槽 | 逻辑槽 | |
|---|---|---|
| 用在哪 | 流复制(主备) | 逻辑复制(CDC) |
| 解码 | 不解码,原样传 | 走解码插件 |
| 有没有「确认点」 | 没有 | 有 |
CDC 用的都是逻辑槽。
槽里的变量
| 变量 | 含义 |
|---|---|
| restart_lsn | 夹子 ------ 这个位置之后的 WAL 不能回收 |
| confirmed_flush_lsn | 确认点 ------ 消费者说它收到哪了 |
| catalog_xmin | 挡住 vacuum,别清掉解码还要用的系统表旧版本 |
什么时候创建 / 删除
槽跟着订阅的生命周期走,不用手动管:
- 建订阅成功 → 立刻创建
- 删订阅 → 立刻删除
槽会拖累什么
一个活着的逻辑槽会:
| 行为 | 后果 |
|---|---|
| 持有 WAL(订阅跟不上时) | 占磁盘 |
| 挡住 vacuum(catalog_xmin) | 表和系统表膨胀 |
追平状态下槽只落后几十字节 (实测 56 字节)。只有订阅挂了才会涨。
五、位点(LSN)
LSN = Log Sequence Number,就是一个位置标记。
长这样:0/231BD810 ------ 十六进制,越大越靠后。
有几个位点
① 账本写到哪 一直往前走 ── 数据库在记账,跟谁都没关系
↓
② 解码器读到哪 一直往前走 ── 它必须读过去才知道下一条是不是你的表
↓
③ confirmed_flush_lsn 等消费者回报才走 ← 走得快
↓
④ restart_lsn(夹子) 比确认点更保守 ← 走得慢,★ 决定磁盘
你在 pg_replication_slots 里能看到的,是 ③ 和 ④。① 和 ② 看不到。
什么时候走
只要有 WAL 记录写出来,位点就走。
| 谁写的 | 位点走吗 | 会传到目标端吗 |
|---|---|---|
| 你的表 | ✅ | ✅ |
| 别人的表 | ✅ | ❌ |
| 数据库自己(checkpoint / autovacuum) | ✅ | ❌ |
| DDL | ✅ | ❌ |
实测数据:
| 事件 | 位点走多少 |
|---|---|
| 写 1 行 | 280 字节 |
| UPDATE 1 行 | 128 字节 |
| 一次事务 100 行 | 13 KB |
| 完全空闲 20 秒 | 56 字节 |
| 建个索引 | 3.6 KB |
看最后两行 ------ 你不动,位点也走,因为数据库后台自己也在写账本。
什么时候不走(★ 关键)
只有一种:消费者不回报了。
订阅挂了 → 没人回报 → ③④ 冻住 → 夹子后面的 WAL 不能回收 → 磁盘涨
实测对照:
订阅正常跑: 夹子落后 56 字节
停掉订阅 + 往别的表写 20 万行: 落后涨到 63 MB
恢复订阅: 立刻降回 56 字节
为什么「别的表在写、我的表不动」位点也走
因为位点属于整条 WAL 流,不属于你那几张表。
位点 = 翻译官读到哪了 (整条流)
传输 = 翻译官说了什么 (只有你的表)
消费者回报的是「这条流我到 X 了」------ 是位置,不是数据清单。
所以哪怕一段区间里全是别人的表,它照样回报,位点照样走。
关键:位点冻住 = 数据能补回来
停机期间写的变更 → 变成 WAL 堆在那儿
↓
这不是「垃圾」,是「还没送出去的货」
↓
重启后从冻结的位置接着读 → 全部补发
正因为确认点冻着,数据才补得回来。 如果它会自己往前跑,停机期间的变更就永久丢了。
六、一次同步的完整流程
【源端】 【目标端】
业务写数据
↓
══════ WAL ══════ 一直写
↓
解码器从头往后读 ── 不在范围内的表:扔掉
↓ 在范围内的表:发出去 ──────▶ 收到
↓ ↓
↓ 写进目标表
↓ ↓
◀────────── 回报「我到 X 了」──────────────────┘
↓
确认点 / 夹子 往前挪
↓
夹子之前的 WAL 可以回收(磁盘释放)
首同步(初始全量)是另一回事 ------ 一次性把历史数据 COPY 过去,之后才走上面这个增量流程。
七、最容易搞混的六个点
| 错误理解 | 正确 |
|---|---|
| 槽是容器,存数据 | 槽是书签,只记位置;占磁盘的是 WAL |
| 位点是给某几张表的 | 位点属于整条 WAL 流 |
| confirmed_flush_lsn 一直往前走 | 它等回报,没人回报就冻住 |
| 读到范围内的表要等确认才能读下一条 | 不停,一直读一直发;等确认的是那两个变量 |
| 别的表一直写会把我的槽撑爆 | 不会 ------ 只要订阅活着,别的表怎么写都不影响 |
| 槽要手动创建 | 跟着订阅生命周期,不用管 |
八、一句话记忆
| WAL | 数据库的流水账,记的是变化本身 |
| CDC | 读这本账,别用触发器和全量快照 |
| 解码插件 | 把二进制账本翻译成人话,顺便「挑」该发的 |
| 复制槽 | 插在账本上的书签,防止还没读就被回收 |
| 位点 | 整条流的位置,不是你那几张表的位置 |
| 磁盘涨 | 只有一种原因 ------ 没人回报,夹子冻住了 |
再往下看
- 用 PostgreSQL 内置方案:<publication-subscription.md>
- 用 pglogical:<pglogical.md>