乐于分享
好东西不私藏

动手实现一个不依赖插件的 WALMiner

动手实现一个不依赖插件的 WALMiner

一、为什么需要”不依赖插件”的方案?

作为数据库开发者,你是否遇到过这样的场景:

  • 误删数据后想找回:”那条 DELETE 到底删了什么?”
  • 故障排查时追根溯源:”这个数据是什么时候被改的?谁改的?”
  • 审计合规需求:”需要一份指定时间段内所有数据变更的记录”

PostgreSQL 的 WAL(预写式日志)记录了所有数据变更,是解决这些问题的关键。但 WAL 文件是二进制格式,直接阅读如同天书。

1.1 现有方案的局限

社区常用的 WAL 解析方案主要有:

方案
优点
局限性
pg_walminer
功能完善,支持多种操作类型
需要编译安装 C 扩展,有版本兼容问题,重点是收费
pglogical/decode
官方逻辑解码框架
需要配置复制槽,对生产有侵入
wal2json
输出格式友好
依赖逻辑复制,无法解析历史 WAL

这些方案都有一个共同点:需要在数据库服务器上安装插件或扩展

但在实际生产环境中,我们往往面临:

  • 无法在生产环境安装扩展(权限/合规限制)
  • 需要解析的是”冷” WAL 文件(已经归档,不在生产库)
  • 不同 PG 版本的插件兼容性问题
  • 只想快速查看某个 WAL 文件的内容

1.2 我们的目标

不依赖任何插件,纯 Python 实现一个 WAL 解析工具,能够:

  • ✅ 解析任意 WAL 文件(不需要连接数据库也能工作)
  • ✅ 还原 INSERT/UPDATE/DELETE 的实际数据值
  • ✅ 生成可读的 SQL 语句
  • ✅ 支持事务级别的操作追踪

二、WAL 文件长什么样?

2.1 WAL 的核心原理

WAL(预写式日志)是 PostgreSQL 保证数据持久性和崩溃恢复的核心机制:

┌─────────────┐    ①写入WAL     ┌─────────────┐
│   客户端    │ ─────────────→ │  WAL 文件   │
│   事务提交  │                │  (顺序写入) │
└─────────────┘                └─────────────┘
                                      │
                                      │ ②异步刷盘
                                      ▼
                               ┌─────────────┐
                               │  数据文件   │
                               │  (随机写入) │
                               └─────────────┘

关键原则:事务提交前,必须先将操作记录写入 WAL。这意味着 WAL 记录了所有数据变更的历史

2.2 WAL 文件的二进制本质

WAL 文件位于 $PG_DATA/pg_wal/ 目录,典型命名如 000000010000000000000006

$ hexdump -C 000000010000000000000006 | head -5
00000000  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  ................
00000010  a1 5f 02 00 00 00 00 00  00 00 00 00 00 00 00 00  ._..............
00000020  95 3e 02 00 01 00 00 00  f8 9b 02 00 00 00 00 00  .>..............

完全无法阅读!我们的目标就是解析这些二进制数据。

三、实现思路

3.1 整体架构

┌─────────────────────────────────────────────────────────────┐
│                   纯 Python WALMiner                        │
├─────────────────────────────────────────────────────────────┤
│  ┌─────────────┐    ┌─────────────┐    ┌─────────────┐      │
│  │ pg_waldump  │ →  │  正则解析   │ →  │ WalRecord   │      │
│  │ (官方工具)  │    │  提取元信息 │    │  数据结构   │      │
│  └─────────────┘    └─────────────┘    └─────────────┘      │
│                                               │              │
│                                               ▼              │
│  ┌─────────────┐    ┌─────────────┐    ┌─────────────┐      │
│  │  数据库连接  │ ←→ │ OID解析器   │ ←→ │ 表名/列信息 │      │
│  │ (可选)      │    │ (缓存优化)  │    │             │      │
│  └─────────────┘    └─────────────┘    └─────────────┘      │
│                                               │              │
│                                               ▼              │
│  ┌─────────────┐    ┌─────────────┐    ┌─────────────┐      │
│  │ 二进制解析  │ →  │ 元组解码    │ →  │  SQL 还原   │      │
│  │ (FPW处理)   │    │ (类型推断)  │    │  (事务级)   │      │
│  └─────────────┘    └─────────────┘    └─────────────┘      │
└─────────────────────────────────────────────────────────────┘

设计要点

  • 利用 PG 自带的 pg_waldump 做初步解析(避免重复造轮子)
  • 纯 Python 解析二进制数据,提取真实值
  • 数据库连接是可选的(无数据库时用 OID 显示)

3.2 第一步:借助 pg_waldump 提取元信息

pg_waldump 是 PostgreSQL 自带的工具,可以将 WAL 转为文本格式:

$ pg_waldump 000000010000000000000006
rmgr: Heap        len (rec/tot):     4/    70, tx:        758, lsn: 0/06000028, prev 0/05FFFFF0, desc: INSERT off 1, flags 0x00, blkref #0: rel 1663/13297/16422 blk 0
rmgr: Transaction len (rec/tot):     8/    34, tx:        758, lsn: 0/060000B8, prev 0/06000070, desc: COMMIT 2026-03-05 10:23:45.123456 CST

用正则提取关键信息:

_WALDUMP_RE = re.compile(
r'rmgr:\s+(\S+)\s+'# 资源管理器
r'len\s*\(rec/tot\):\s*(\d+)\s*/\s*(\d+),\s*'
r'tx:\s+(\d+),\s*'# 事务ID (tx)
r'lsn:\s+([0-9A-Fa-f/]+),\s*'# 日志序列号
r'prev\s+([0-9A-Fa-f/]+),\s*'
r'desc:\s+(.*)'# 描述信息 (desc)
)

3.3 第二步:OID 到表名的映射(可选)

WAL 中只有 OID,如 rel 1663/13297/16422。如果连接数据库,可以转换为表名:

classOidResolver:
def_load_all_tables(self):
"""一次性加载所有表/索引的 OID 映射"""
with self.conn.cursor() as cur:
            cur.execute("""
                SELECT c.oid::int,
                       CASE WHEN n.nspname = 'public' THEN c.relname
                            ELSE n.nspname || '.' || c.relname
                       END AS name
                FROM pg_class c
                JOIN pg_namespace n ON n.oid = c.relnamespace
            """
)
for oid, name in cur.fetchall():
                self._cache[oid] = name

不连接数据库时:直接显示 oid_16399,不影响核心功能。

3.4 第三步:深入二进制,提取真实数据

这是最硬核的部分!pg_waldump 只告诉你”这里有个 INSERT”,但不知道插入的具体值。

我们需要直接解析 WAL 文件的二进制结构:

defparse_insert_tuple(wal_file, lsn, rec_tot_len, columns):
"""
    从 WAL 文件的 INSERT 记录中解析元组数据

    核心挑战:
    1. 跳过 XLogRecord 头部 (24 bytes)
    2. 解析 Block Header 获取数据位置
    3. 处理 FPW (Full Page Write) 的情况
    4. 根据 null bitmap 判断空值
    5. 按列类型解码数据
    """

    raw = _read_wal_bytes(wal_file, file_offset, rec_tot_len)
# ... 复杂的二进制解析逻辑

类型解码示例

def_decode_datum(data, offset, col):
"""从元组数据中解码一个列值"""
    typname = col['type']

if typname == 'int4':
return struct.unpack('<i', raw)[0], offset
elif typname == 'int8':
return struct.unpack('<q', raw)[0], offset
elif typname == 'text':
# varlena 格式:首字节决定长度编码
        first_byte = data[offset]
if first_byte & 0x01:  # short varlena
            vl_len = (first_byte >> 1) & 0x7F
            raw = data[offset + 1:offset + vl_len]
return raw.decode('utf-8'), offset + vl_len
# ... 更多类型

3.5 第四步:事务级 SQL 还原

将一个事务内的所有操作聚合,还原出完整的 SQL:

definfer_transaction_sql(tx, resolver, wal_file):
"""
    根据事务内所有 WAL 记录推断出该事务执行的 SQL 语句

    策略:
    - Storage CREATE + 系统表 INSERT => CREATE TABLE (DDL)
    - 用户表 INSERT/UPDATE/DELETE => 对应 DML
    """

# 按 (操作类型, 表OID) 分组
    dml_groups = defaultdict(list)
for rec in tx.records:
if rec.operation in ('INSERT''UPDATE''DELETE'):
            dml_groups[(rec.operation, rec.rel_oid)].append(rec)

# 生成 SQL
for (op, oid), recs in dml_groups.items():
if op == 'INSERT':
            values = [_format_values(r.tuple_values) for r in recs]
returnf"INSERT INTO {table_name} VALUES {values};"

四、实战演示

4.1 基本用法

# 解析 WAL 文件(无数据库连接,显示 OID)
python3 wal_parser.py 000000010000000000000006

# 连接数据库获取表名(推荐)
python3 wal_parser.py 000000010000000000000006 \
    --db-host xx.xx.xx.xx \
    --db-port 5432 \
    --db-user xxx \
    --db-password 'xxx'

# JSON 格式输出(便于程序处理)
python3 wal_parser.py 000000010000000000000006 --json

4.2 真实解析结果

以下是使用 PostgreSQL 实例的真实 WAL 文件解析结果:

======================================================================
PostgreSQL WAL 解析结果
======================================================================
文件: 000000010000000000000006
总记录数: 67
事务数: 2

--- 记录类型分布 ---
  Btree           : 27
  Heap            : 14
  Heap2           : 13
  XLOG            : 5
  Standby         : 5
  Transaction     : 2
  Storage         : 1

--- 操作类型分布 ---
  INDEX_INSERT         : 27
  INSERT               : 14
  NEW_CID              : 13
  STANDBY              : 5
  CHECKPOINT           : 2
  COMMIT               : 2
  PARAMETER_CHANGE     : 1
  NEXTOID              : 1
  CREATE               : 1
  WAL_SWITCH           : 1

--- 系统记录 (tx=0) ---
  [0/06000028] CHECKPOINT: CHECKPOINT_SHUTDOWN redo 0/6000028; tli 1; prev tli 1; fpw true; xid 0:488; oid 16399...
  [0/060000A0] PARAMETER_CHANGE: PARAMETER_CHANGE max_connections=200 max_worker_processes=8 max_wal_senders=10...

--- 事务与 SQL ---

  Transaction 488  (56 records)
  Commit time: 2026-02-12 19:57:47.951814 CST;
  SQL:
    CREATE TABLE test (id int4);  -- 2026-02-12 19:57:47.951814 CST;

  Transaction 489  (2 records)
  Commit time: 2026-02-12 19:57:59.204532 CST
  SQL:
    INSERT INTO test (id) VALUES (123);  -- 2026-02-12 19:57:59.204532 CST

======================================================================

4.3 INSERT 值解析详情

工具成功从 WAL 二进制中解析出了 INSERT 语句的实际值:

{
"rmgr""Heap",
"operation""INSERT",
"lsn""0/0600FC18",
"rel_oid"16399,
"rel_name""test",
"tuple_values": {
"id"123
  }
}

可以看到,INSERT INTO test (id) VALUES (123) 中的 123 被成功从 WAL 二进制数据中解码出来!

五、关键技术点

5.1 FPW(Full Page Write)处理

PostgreSQL 在 checkpoint 后首次修改页面时会记录完整页面镜像:

┌──────────────────────────────────────────────────────┐
│                    WAL 记录结构                       │
├──────────────────────────────────────────────────────┤
│  XLogRecord Header (24 bytes)                        │
│  ├─ xl_crc (4 bytes)                                 │
│  ├─ xl_prev (8 bytes)                                │
│  ├─ xl_xid (4 bytes)                                 │
│  └─ ...                                              │
├──────────────────────────────────────────────────────┤
│  Block Header                                        │
│  ├─ block_id (1 byte)                                │
│  ├─ fork_flags (1 byte)  ← has_image 标志在这里      │
│  ├─ data_length (2 bytes)                            │
│  └─ [FPW Image] ← 如果有 FPW                         │
│     ├─ bimg_len                                      │
│     ├─ hole_offset                                   │
│     └─ image_data (压缩/未压缩)                       │
├──────────────────────────────────────────────────────┤
│  Block Data (元组数据)                                │
├──────────────────────────────────────────────────────┤
│  Main Data (操作特定数据)                             │
└──────────────────────────────────────────────────────┘

FPW 页面有”hole”(空洞),需要正确重建:

def_reconstruct_page(parsed):
"""从 FPW image 重建完整的 heap page (8192 bytes)"""
    hole_length = page_size - bimg_len  # 计算空洞长度

    page = bytearray(8192)
# hole 之前的数据
    page[0:hole_offset] = image_data[0:hole_offset]
# hole 之后的数据(跳过空洞)
    after_hole = hole_offset + hole_length
    page[after_hole:] = image_data[hole_offset:]
return page

5.2 Varlena 类型解码

PostgreSQL 的变长类型使用特殊的编码格式:

┌─────────────────────────────────────────────────────────┐
│  1-byte header (最低位=1)                                │
│  ┌─────────────────────────────────────────┬──────────┐ │
│  │  length (7 bits, >> 1)                  │ 1        │ │
│  │  最大 127 bytes                         │ (LSB=1)  │ │
│  └─────────────────────────────────────────┴──────────┘ │
│                                                         │
│  4-byte header (最低2位=00)                              │
│  ┌─────────────────────────────────────────┬──────────┐ │
│  │  length (30 bits, >> 2)                 │ 00       │ │
│  │  最大 1GB                               │ (LSB=00) │ │
│  └─────────────────────────────────────────┴──────────┘ │
└─────────────────────────────────────────────────────────┘

5.3 DDL 操作的完整追踪

从 Transaction 488 的解析结果可以看到,一个简单的 CREATE TABLE test (id int4) 实际触发了大量系统表操作:

系统表
操作次数
说明
pg_type
4
创建表和列类型
pg_attribute
7
记录表的列信息
pg_class
1
记录表元数据
pg_depend
4
记录依赖关系

这正是 WAL 能够完整还原数据库操作的证明——即使是复杂的 DDL,也能被完整追踪。

六、应用场景

场景
使用方式
数据恢复
解析 DELETE 前的 WAL,找到被删除的原始值
审计追踪
按时间范围解析 WAL,生成操作审计报告
故障排查
定位异常数据变更的事务和时间点
变更同步
解析 WAL 实现 CDC(Change Data Capture)
学习研究
深入理解 PostgreSQL 内部机制

七、局限性

  1. 版本兼容性:WAL 格式随 PG 版本变化,需适配不同版本
  2. TOAST 数据:超大字段可能被 TOAST,WAL 中只有指针
  3. 压缩页面:FPW 可能使用 pglz 压缩,需额外处理
  4. 需要 pg_waldump:依赖官方工具做初步解析(或自行解析 WAL 头部)

八、总结

通过这个工具,我们实现了不依赖任何插件的 WAL 解析能力:

  • ✅ 从二进制 WAL 提取事务信息
  • ✅ 将 OID 转换为可读的表名
  • ✅ 解析 INSERT/DELETE/UPDATE 的实际数据值
  • ✅ 还原出接近原始的 SQL 语句

与插件方案相比的优势

对比项
插件方案
本工具
安装部署
需要编译安装
纯 Python,开箱即用
版本兼容
依赖 PG 版本
适配多版本更容易
生产侵入
需要重启/加载扩展
无侵入,离线解析
归档 WAL
无法解析
完美支持

WAL 是 PostgreSQL 的”时光胶囊”,掌握它的解析方法,就像拥有了穿越数据历史的能力。无论是故障恢复、审计合规,还是深入理解数据库原理,都是一项宝贵的技能。

本站文章均为手工撰写未经允许谢绝转载:夜雨聆风 » 动手实现一个不依赖插件的 WALMiner

评论 抢沙发

4 + 4 =
  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址
×
订阅图标按钮