乐于分享
好东西不私藏

从FPW到数据重现:从源码看PostgreSQL Delete恢复的完整实现路径

从FPW到数据重现:从源码看PostgreSQL Delete恢复的完整实现路径

上一篇比较通俗,大家都乐意看,这一篇就比较硬核了,认真从代码层面剖析从WAL中精准恢复误删数据的原理,请系好安全带,发车!

引言:DELETE之后,数据如何才能恢复?

在PostgreSQL中,当执行DELETE操作后,被删除的数据并不会立即从磁盘上消失。PostgreSQL的MVCC机制会将被删除的行保留为死亡元组(dead tuple),通过读取这些死亡元组可以实现数据恢复。

然而,这种方式存在明显的时效性限制:一旦autovacuum完成清理,死亡元组将在物理层面被彻底移除,基于数据文件的恢复手段随之失效。

此时,WAL(Write-Ahead Log)提供了另一条恢复路径。具体而言,WAL中的FPW(Full Page Write,全页写)机制是这条路径的核心。包括PDU(PostgreSQL Data Unloader)在内的PostgreSQL DELETE恢复工具,均采用了这一技术路线——只要删除操作期间产生的WAL文件仍然存在,无论过去多久,数据都可以完整恢复

本文将基于PostgreSQL 18源码,从内核层面逐步拆解这条恢复路径的每一个环节。


第一章:全页写(FPW)——WAL中的完整页面快照

1.1 FPW是什么?

FPW的全称是Full Page Write。规则很简单:在每次checkpoint之后,当某个数据页被第一次修改时,PostgreSQL会将该页的完整内容(8KB)写入这条修改对应的WAL记录中

为什么要这么做?因为操作系统写磁盘并非原子操作——一个8KB的页可能只写了一半就断电了(partial write)。如果崩溃恢复时用WAL去重做一个”写了一半”的页,结果将是灾难性的。FPW机制正是为此设计的:在WAL中保存一份完整的页面快照,确保在任何情况下都能恢复出一致的页面状态。

对于DELETE恢复而言,FPW具有另一层价值——它在WAL中保存了数据页的完整副本,恢复工具可以从中提取出被删除的数据。

1.2 内核如何决定”需不需要写FPW”?

这个决策的核心逻辑在src/backend/access/transam/xloginsert.cXLogRecordAssemble函数中:

// 文件:src/backend/access/transam/xloginsert.c
// 函数:XLogRecordAssemble

for (block_id = 0; block_id < max_registered_block_id; block_id++)
{
    registered_buffer *regbuf = &registered_buffers[block_id];
bool needs_backup;

if (!regbuf->in_use)
continue;

/* Determine if this block needs to be backed up */
if (regbuf->flags & REGBUF_FORCE_IMAGE)
        needs_backup = true;
elseif (regbuf->flags & REGBUF_NO_IMAGE)
        needs_backup = false;
elseif (!doPageWrites)
        needs_backup = false;
else
    {
        XLogRecPtr  page_lsn = PageGetLSN(regbuf->page);
        needs_backup = (page_lsn <= RedoRecPtr);
    }

/* ... 如果needs_backup为true,将整个页面写入WAL ... */
}

判断逻辑可以概括如下:

  1. REGBUF_FORCE_IMAGE:强制写FPW,不论任何条件。
  2. REGBUF_NO_IMAGE:明确禁止写FPW。
  3. !doPageWritesfull_page_writes = off且没有在线备份,跳过。
  4. 核心判断page_lsn <= RedoRecPtr ——如果页面的LSN小于或等于上次checkpoint的重做点,说明这个页面在上次checkpoint之后还没有被修改过,这是第一次修改,必须写FPW。

PostgreSQL还提供了一个简化的外部查询接口XLogCheckBufferNeedsBackup,让调用者可以提前判断某个缓冲区是否需要FPW:

// 文件:src/backend/access/transam/xloginsert.c

bool
XLogCheckBufferNeedsBackup(Buffer buffer)
{
    XLogRecPtr  RedoRecPtr;
bool        doPageWrites;
    Page        page;

    GetFullPageWriteInfo(&RedoRecPtr, &doPageWrites);
    page = BufferGetPage(buffer);

if (doPageWrites && PageGetLSN(page) <= RedoRecPtr)
returntrue;            /* buffer requires backup */

returnfalse;
}

一句话总结:checkpoint之后对某页的首次修改 = 触发FPW

1.3 FPW中存了什么?

FPW的存储结构由DecodedBkpBlock描述,定义在src/include/access/xlogreader.h中:

// 文件:src/include/access/xlogreader.h

typedefstruct
{

bool        in_use;           /* 此block ref是否在使用 */
    RelFileLocator rlocator;      /* 表的物理位置标识 */
    ForkNumber  forknum;          /* fork编号 */
    BlockNumber blkno;            /* 数据块编号 */
/* ... 省略prefetch_buffer、flags ... */

/* 全页镜像信息——FPW恢复的核心字段 */
bool        has_image;        /* 是否包含全页镜像 */
bool        apply_image;      /* 恢复时是否需要应用此镜像 */
char       *bkp_image;        /* 指向全页镜像数据的指针 */
    uint16      hole_offset;      /* 页面空洞起始偏移 */
    uint16      hole_length;      /* 页面空洞长度 */
    uint16      bimg_len;         /* 镜像数据长度(可能已压缩) */
    uint8       bimg_info;        /* 压缩算法等元信息 */

/* ... 省略rmgr-specific data字段 ... */
} DecodedBkpBlock;

几个关键字段值得关注:

  • **rlocator + blkno**:定位这是哪张表的第几个数据块——恢复工具据此将FPW与目标表关联。
  • **bkp_image**:指向全页镜像数据——这就是那完整的8KB页面(可能经过压缩)。
  • **hole_offset / hole_length**:页面中pd_lowerpd_upper之间的”空洞”。PostgreSQL在写入WAL时会跳过这段零填充区域以节省空间,恢复时再填零还原。
  • **bimg_info**:标记压缩算法(pglz / LZ4 / ZSTD)和是否需要在恢复时应用(BKPIMAGE_APPLY)。

1.4 验证FPW的存在

为了直观感受FPW,我们做一个简单实验:

DROPTABLEIFEXISTS xman;
CREATETABLE xman (a int, b varchar);
INSERTINTO xman VALUES(1,'sdfghgfdsadfghgfds');
INSERTINTO xman VALUES(2,'wertyuijhbhghgtgvyhjujkgfdswerfcdsw');
INSERTINTO xman VALUES(3,'qwertyuiopoiufdfghjkjhvcvbnbvcfgfcfgdxfcd');
INSERTINTO xman VALUES(4,'wsxdedcfrfgbvgyhnujmjikm,kl,lp;.llkjhghjhgfg');
CHECKPOINT;
UPDATE xman SET b='aaaaaaaa'WHERE a=1;
UPDATE xman SET b='bbbbbbbb'WHERE a=2;
UPDATE xman SET b='cccccccc'WHERE a=3;
DELETEFROM xman WHERE a=1;

查询表的文件名后,执行pg_waldump观察WAL记录:

rmgr: Heap  desc: HOT_UPDATE off 1 xmax 203416 flags 0x20 ; new off 5 xmax 0,
      blkref #0: rel 1663/352324/602913 blk 0 FPW
rmgr: Heap  desc: HOT_UPDATE off 2 xmax 203417 flags 0x20 ; new off 6 xmax 0,
      blkref #0: rel 1663/352324/602913 blk 0
rmgr: Heap  desc: HOT_UPDATE off 3 xmax 203418 flags 0x20 ; new off 7 xmax 0,
      blkref #0: rel 1663/352324/602913 blk 0
rmgr: Heap  desc: DELETE off 5 flags 0x00 KEYS_UPDATED,
      blkref #0: rel 1663/352324/602913 blk 0

注意第一条UPDATE带有FPW标记——它是checkpoint后对blk 0的首次修改。后续的UPDATE和DELETE都不再携带FPW,因为blk 0已经在本次checkpoint周期内被修改过了。

这就意味着一个关键事实:FPW不一定出现在DELETE记录本身中,它可能出现在同一checkpoint周期内更早的任何一条修改记录中。数据恢复工具必须回溯到这条更早的记录才能获取FPW。

在PG 16+版本中,pg_waldump命令新增了--save-fullpage参数,可以将FPW导出到指定目录:

pg_waldump --save-fullpage=./fpw_output WAL_FILE_NAME

但请注意,用--save-fullpage获取出来的FPW仅仅是一个”基础快照”。如果我们想利用它进行delete恢复,必须将其往前重做到删除时刻才行。


第二章:还原FPW——从WAL中提取完整页面

2.1 RestoreBlockImage:FPW的解码器

当需要从WAL中恢复一个全页镜像时,核心函数是RestoreBlockImage,定义在src/backend/access/transam/xlogreader.c中:

// 文件:src/backend/access/transam/xlogreader.c

bool
RestoreBlockImage(XLogReaderState *record, uint8 block_id, char *page)
{
    DecodedBkpBlock *bkpb;
char       *ptr;
    PGAlignedBlock tmp;

/* 校验block_id合法性,省略错误处理... */

    bkpb = &record->record->blocks[block_id];
    ptr = bkpb->bkp_image;

/* 如果FPW经过压缩,先解压(支持pglz/LZ4/ZSTD三种算法) */
if (BKPIMAGE_COMPRESSED(bkpb->bimg_info))
    {
/* 按bimg_info标志位选择对应的解压算法,省略各分支... */
if (!decomp_success)
returnfalse;
        ptr = tmp.data;
    }

/* 重建完整的8KB页面,中间的空洞用零填充 */
if (bkpb->hole_length == 0)
    {
memcpy(page, ptr, BLCKSZ);
    }
else
    {
memcpy(page, ptr, bkpb->hole_offset);
        MemSet(page + bkpb->hole_offset, 0, bkpb->hole_length);
memcpy(page + (bkpb->hole_offset + bkpb->hole_length),
               ptr + bkpb->hole_offset,
               BLCKSZ - (bkpb->hole_offset + bkpb->hole_length));
    }

returntrue;
}

这个函数做了三件事:

  1. 校验:确认block_id有效且确实包含全页镜像。
  2. 解压:支持pglz、LZ4、ZSTD三种压缩算法,按bimg_info标志位选择对应的解压路径。
  3. 重建页面:将压缩时省略的”空洞”用零填充,还原出完整的8KB页面。

空洞处理的逻辑值得细看:当hole_length == 0时直接全量拷贝;否则分三段——空洞前的数据、零填充的空洞、空洞后的数据。这个空洞对应的是页面中pd_lower(行指针数组末尾)到pd_upper(元组数据起始)之间的空闲区域。

2.2 XLogReadBufferForRedo:WAL回放的入口枢纽

在WAL回放过程中,每条记录的重做函数都会调用XLogReadBufferForRedo来获取需要操作的数据页。这个函数是整个回放机制的枢纽:

// 文件:src/backend/access/transam/xlogutils.c

XLogRedoAction
XLogReadBufferForRedoExtended(XLogReaderState *record,
                              uint8 block_id,
                              ReadBufferMode mode, bool get_cleanup_lock,
                              Buffer *buf)

{
    XLogRecPtr  lsn = record->EndRecPtr;

/* 如果WAL记录携带了FPW且标记了APPLY,直接用FPW恢复页面 */
if (XLogRecBlockImageApply(record, block_id))
    {
        *buf = XLogReadBufferExtended(rlocator, forknum, blkno,
              get_cleanup_lock ? RBM_ZERO_AND_CLEANUP_LOCK : RBM_ZERO_AND_LOCK,
              prefetch_buffer);
        page = BufferGetPage(*buf);

if (!RestoreBlockImage(record, block_id, page))
            ereport(ERROR, ...);

if (!PageIsNew(page))
            PageSetLSN(page, lsn);

        MarkBufferDirty(*buf);
return BLK_RESTORED;   /* 页面已从FPW完整恢复 */
    }
else
    {
/* 没有FPW,从磁盘读取页面 */
        *buf = XLogReadBufferExtended(rlocator, forknum, blkno,
                                      mode, prefetch_buffer);
if (BufferIsValid(*buf))
        {
if (lsn <= PageGetLSN(BufferGetPage(*buf)))
return BLK_DONE;       /* 无需重做 */
else
return BLK_NEEDS_REDO; /* 需要重做 */
        }
else
return BLK_NOTFOUND;       /* 页面不存在 */
    }
}

返回值的含义对恢复至关重要:

返回值
含义
对恢复的意义
BLK_RESTORED
FPW已完整恢复页面
无需额外修改,页面状态已就绪
BLK_NEEDS_REDO
页面比WAL记录旧
需要应用WAL中的增量变更
BLK_DONE
页面已是最新
跳过此条记录
BLK_NOTFOUND
页面不存在
可能已被truncate

对于delete恢复工具而言,我们复用RestoreBlockImage从WAL中提取FPW得到”基础页面”,然后模拟后续WAL记录的重做逻辑,将页面推进到delete发生时的精确状态。


第三章:重做FPW——将页面推进到删除时刻

3.1 重做的必要性

需要明确的是:FPW记录的是checkpoint后某页第一次被修改时的完整状态,而非DELETE发生时的状态

假设时间线如下:

18:00  checkpoint
18:01  UPDATE → 产生FPW(页面在此刻的快照)
18:02  INSERT → 往同一页面添加新行
18:03  UPDATE → 修改页面中另一行
18:04  DELETE → 我们想恢复的删除操作

如果直接使用18:01的FPW去定位18:04的DELETE数据,会遇到两个问题:

  • 18:02的INSERT改变了行指针数组,导致偏移量错位。
  • 18:03的UPDATE可能修改了元组内容,导致数据不一致。

因此,18:01到18:04之间数据库对该页面做的所有操作,我们都必须重新做一遍,才能让FPW达到删除时刻的正确数据状态。在PDU(GitHub仓库)的实现过程中,这一问题得到了验证——直接使用FPW中的原始页面进行数据恢复,会导致恢复结果不正确或目标偏移位置不存在。

3.2 需要重做哪些操作?

根据对数据页内容的影响程度,可以分为两类:

必须重做(改变数据内容和行指针分布):

操作
影响
INSERT / MULTI_INSERT
添加新元组,扩展行指针数组
UPDATE / HOT_UPDATE
标记旧元组死亡,添加新元组
DELETE
标记元组的xmax
PRUNE
回收死亡元组空间,重定向行指针
VACUUM
清理页面,释放行指针

可选重做(仅修改可见性标记,不改变数据布局):

操作
影响
LOCK
修改infomask中的锁标记
VISIBLE
设置visibility map标记
FREEZE_PAGE
冻结事务ID
CONFIRM
确认speculative insert

下面我们逐一看看内核中这些重做函数的实现。

3.3 INSERT重做:页面中添加新元组

// 文件:src/backend/access/heap/heapam_xlog.c

staticvoid
heap_xlog_insert(XLogReaderState *record)
{
    XLogRecPtr  lsn = record->EndRecPtr;
    xl_heap_insert *xlrec = (xl_heap_insert *) XLogRecGetData(record);

/* 获取页面(如果INIT_PAGE则初始化空白页,否则从磁盘读取) */
if (XLogRecGetInfo(record) & XLOG_HEAP_INIT_PAGE)
    {   /* 省略PageInit逻辑... */   }
else
        action = XLogReadBufferForRedo(record, 0, &buffer);

if (action == BLK_NEEDS_REDO)
    {
        page = BufferGetPage(buffer);

/* 从WAL数据中提取元组头和元组数据 */
        data = XLogRecGetBlockData(record, 0, &datalen);
        newlen = datalen - SizeOfHeapHeader;
memcpy(&xlhdr, data, SizeOfHeapHeader);
        data += SizeOfHeapHeader;

/* 构建完整的元组(设置infomask、xmin、ctid等) */
        htup = &tbuf.hdr;
        MemSet(htup, 0, SizeofHeapTupleHeader);
memcpy((char *) htup + SizeofHeapTupleHeader, data, newlen);
        newlen += SizeofHeapTupleHeader;
/* ... 省略htup各字段赋值 ... */

/* 将元组插入页面的指定偏移位置——这一步改变了行指针数组! */
if (PageAddItem(page, (Item) htup, newlen, xlrec->offnum,
truetrue) == InvalidOffsetNumber)
            elog(PANIC, "failed to add tuple");

        PageSetLSN(page, lsn);
        MarkBufferDirty(buffer);
    }
}

重做INSERT时,函数从WAL中还原完整元组(header + data),然后调用PageAddItem放回指定偏移位置。这会改变页面的行指针数组,直接影响后续DELETE定位数据的偏移量——如果跳过INSERT重做,偏移量就会错位。

3.4 UPDATE重做:旧元组标记死亡,新元组插入

UPDATE的重做逻辑最为复杂,因为它同时涉及旧页面和新页面(跨页UPDATE时两者不同),并且包含一个精巧的优化——前缀/后缀复用:

// 文件:src/backend/access/heap/heapam_xlog.c

staticvoid
heap_xlog_update(XLogReaderState *record, bool hot_update)
{
    xl_heap_update *xlrec = (xl_heap_update *) XLogRecGetData(record);

/* === 第一步:处理旧元组——标记死亡 === */
    oldaction = XLogReadBufferForRedo(record,
                    (oldblk == newblk) ? 0 : 1, &obuffer);
if (oldaction == BLK_NEEDS_REDO)
    {
        lp = PageGetItemId(page, xlrec->old_offnum);
        htup = (HeapTupleHeader) PageGetItem(page, lp);

/* 设置xmax标记旧元组为"已更新",设置t_ctid指向新元组 */
        htup->t_infomask &= ~(HEAP_XMAX_BITS | HEAP_MOVED);
        HeapTupleHeaderSetXmax(htup, xlrec->old_xmax);
        htup->t_ctid = newtid;
/* ... 省略infomask位操作和HOT标记 ... */
    }

/* === 第二步:处理新元组——关键的前缀/后缀优化 === */
if (newaction == BLK_NEEDS_REDO)
    {
char *recdata = XLogRecGetBlockData(record, 0, &datalen);

/* WAL中可能只记录了变化的部分,前缀/后缀从旧元组复用 */
if (xlrec->flags & XLH_UPDATE_PREFIX_FROM_OLD)
        {
memcpy(&prefixlen, recdata, sizeof(uint16));
            recdata += sizeof(uint16);
        }
if (xlrec->flags & XLH_UPDATE_SUFFIX_FROM_OLD)
        {
memcpy(&suffixlen, recdata, sizeof(uint16));
            recdata += sizeof(uint16);
        }

/* 从 WAL变更数据 + 旧元组前缀 + 旧元组后缀 三段拼接重建新元组 */
        htup = &tbuf.hdr;
if (prefixlen > 0)
        {
/* 复制bitmap → 复制旧元组前缀 → 复制WAL变更数据 */
memcpy(newp, (char *) oldtup.t_data + oldtup.t_data->t_hoff,
                   prefixlen);
/* ... 省略具体拼接过程 ... */
        }
if (suffixlen > 0)
memcpy(newp, (char *) oldtup.t_data + oldtup.t_len - suffixlen,
                   suffixlen);

/* 将新元组插入页面 */
        offnum = PageAddItem(page, (Item) htup, newlen, offnum, truetrue);
if (offnum == InvalidOffsetNumber)
            elog(PANIC, "failed to add tuple");
    }
}

xl_heap_update结构体记录了UPDATE操作的关键信息:

// 文件:src/include/access/heapam_xlog.h

typedefstructxl_heap_update
{

    TransactionId old_xmax;      /* 旧元组的xmax */
    OffsetNumber old_offnum;     /* 旧元组的偏移量 */
    uint8       old_infobits_set;
    uint8       flags;
    TransactionId new_xmax;      /* 新元组的xmax */
    OffsetNumber new_offnum;     /* 新元组的偏移量 */
} xl_heap_update;

值得特别关注的是XLH_UPDATE_PREFIX_FROM_OLDXLH_UPDATE_SUFFIX_FROM_OLD这两个优化标志。当UPDATE只修改了元组中间的一小段数据时,WAL只记录变化部分,前缀和后缀从旧元组中复用。重做时必须正确处理这个逻辑,否则还原出的新元组将是错误的。

3.5 PRUNE重做:行指针重排——最容易被忽视的关键操作

PRUNE(页面裁剪)是影响最大却最容易被忽视的操作。它会重定向行指针、标记行指针为DEAD或UNUSED,甚至压缩页面空间。PG18中的重做函数是heap_xlog_prune_freeze

// 文件:src/backend/access/heap/heapam_xlog.c

staticvoid
heap_xlog_prune_freeze(XLogReaderState *record)
{
    xl_heap_prune xlrec;
memcpy(&xlrec, XLogRecGetData(record), SizeOfHeapPrune);

    action = XLogReadBufferForRedoExtended(record, 0, RBM_NORMAL,
                    (xlrec.flags & XLHP_CLEANUP_LOCK) != 0, &buffer);

if (action == BLK_NEEDS_REDO)
    {
/* 反序列化WAL中的裁剪和冻结数据 */
        heap_xlog_deserialize_prune_and_freeze(dataptr, xlrec.flags,
                        &nplans, &plans, &frz_offsets,
                        &nredirected, &redirected,
                        &ndead, &nowdead,
                        &nunused, &nowunused);

/* 核心:执行行指针的重定向、标记死亡、标记未使用 */
if (nredirected > 0 || ndead > 0 || nunused > 0)
            heap_page_prune_execute(buffer,
                        (xlrec.flags & XLHP_CLEANUP_LOCK) == 0,
                        redirected, nredirected,
                        nowdead, ndead,
                        nowunused, nunused);

/* 按freeze plan逐个冻结元组(省略遍历逻辑)... */

        PageSetLSN(page, lsn);
        MarkBufferDirty(buffer);
    }
}

heap_page_prune_execute中的行指针重定向尤其关键——例如在HOT链中将lp[1]重定向到lp[5]。如果跳过PRUNE的重做,后续通过DELETE记录中的offnum定位数据时,行指针指向的可能是错误的位置甚至是无效区域。


第四章:定位DELETE数据——从WAL记录中精确导航

4.1 DELETE的WAL记录结构

当DELETE被写入WAL时,核心数据结构是xl_heap_delete

// 文件:src/include/access/heapam_xlog.h

/* xl_heap_delete flag values, 8 bits are available */
#define XLH_DELETE_ALL_VISIBLE_CLEARED   (1<<0)
#define XLH_DELETE_CONTAINS_OLD_TUPLE    (1<<1)
#define XLH_DELETE_CONTAINS_OLD_KEY      (1<<2)
#define XLH_DELETE_IS_SUPER              (1<<3)
#define XLH_DELETE_IS_PARTITION_MOVE      (1<<4)

typedefstructxl_heap_delete
{

    TransactionId xmax;         /* 执行删除的事务ID */
    OffsetNumber offnum;        /* 被删除元组在页面中的偏移量 */
    uint8       infobits_set;   /* 需要设置的infomask位 */
    uint8       flags;          /* 附加标志位 */
} xl_heap_delete;

字段含义:

  • xmax:执行DELETE的事务ID——知道是”谁”删的。
  • offnum:被删除元组在数据页中的行指针偏移量——这是定位被删除数据的钥匙
  • flags:附加信息,如XLH_DELETE_CONTAINS_OLD_TUPLE在逻辑复制场景下会携带完整的旧元组数据。

同时,WAL记录的block reference中存储了数据块号:

XLogRecGetBlockTag(record, 0, &target_locator, NULL, &blkno);

结合blkno(第几个数据块)和offnum(块内第几条),就能精确定位到被删除的元组。

4.2 DELETE的WAL记录是如何构建的?

heap_delete函数(src/backend/access/heap/heapam.c)中:

// 文件:src/backend/access/heap/heapam.c

if (RelationNeedsWAL(relation))
{
    xl_heap_delete xlrec;
    XLogRecPtr  recptr;

/* 填充DELETE的WAL记录结构 */
    xlrec.flags = 0;
if (all_visible_cleared)
        xlrec.flags |= XLH_DELETE_ALL_VISIBLE_CLEARED;
    xlrec.infobits_set = compute_infobits(tp.t_data->t_infomask,
                                          tp.t_data->t_infomask2);
    xlrec.offnum = ItemPointerGetOffsetNumber(&tp.t_self);  /* 关键:记录偏移量 */
    xlrec.xmax = new_xmax;

    XLogBeginInsert();
    XLogRegisterData(&xlrec, SizeOfHeapDelete);
    XLogRegisterBuffer(0, buffer, REGBUF_STANDARD);  /* 注册缓冲区,可能触发FPW */

/* 如果启用了逻辑复制,额外记录旧元组数据(省略)... */

    recptr = XLogInsert(RM_HEAP_ID, XLOG_HEAP_DELETE);
    PageSetLSN(page, recptr);
}

注意XLogRegisterBuffer(0, buffer, REGBUF_STANDARD)——它注册了当前操作的缓冲区。如果这恰好是checkpoint后的首次修改,XLogRecordAssemble会自动将整个页面作为FPW附加到这条DELETE的WAL记录中。

4.3 heap_xlog_delete:DELETE的重做函数

heap_xlog_delete是我们定位被删除数据的核心参考:

// 文件:src/backend/access/heap/heapam_xlog.c

staticvoid
heap_xlog_delete(XLogReaderState *record)
{
    XLogRecPtr  lsn = record->EndRecPtr;
    xl_heap_delete *xlrec = (xl_heap_delete *) XLogRecGetData(record);

/* 从WAL记录中获取目标块号和偏移量 */
    XLogRecGetBlockTag(record, 0, &target_locator, NULL, &blkno);

if (XLogReadBufferForRedo(record, 0, &buffer) == BLK_NEEDS_REDO)
    {
        page = BufferGetPage(buffer);

/* 通过offnum定位到被删除的元组——这就是数据恢复的关键定位! */
        lp = PageGetItemId(page, xlrec->offnum);
        htup = (HeapTupleHeader) PageGetItem(page, lp);

/* DELETE并不物理删除数据,只是设置xmax标记 */
        htup->t_infomask &= ~(HEAP_XMAX_BITS | HEAP_MOVED);
        htup->t_infomask2 &= ~HEAP_KEYS_UPDATED;
        fix_infomask_from_infobits(xlrec->infobits_set,
                                   &htup->t_infomask, &htup->t_infomask2);
        HeapTupleHeaderSetXmax(htup, xlrec->xmax);
/* ... 省略Cmax设置和边界检查 ... */

        PageSetLSN(page, lsn);
        MarkBufferDirty(buffer);
    }
if (BufferIsValid(buffer))
        UnlockReleaseBuffer(buffer);
}

这段代码揭示了一个关键事实:DELETE重做并不物理删除数据,只是在元组头部设置了xmax 。数据仍然完整地存在于页面中。我们只需要:

  1. offnum通过PageGetItemId找到行指针
  2. PageGetItem获取元组指针
  3. 读取元组内容

至此,被删除的数据即可完整提取。


第五章:完整恢复流程——五步恢复被删除数据

综合以上内核机制,一个完整的DELETE数据恢复流程可以归纳为五步:

步骤一:扫描WAL,建立操作索引

遍历指定范围内的WAL文件,记录:

  • 所有DELETE类型记录的LSN位置、目标表(rlocator)、目标块号(blkno)和偏移量(offnum
  • 所有携带FPW的WAL记录的位置及其对应的数据块号
  • 所有会影响目标数据块的中间操作(INSERT、UPDATE、PRUNE等)

步骤二:提取FPW作为基础页面

对于每个涉及删除操作的数据块,找到时间上最近且位于DELETE之前的FPW记录,调用RestoreBlockImage的逻辑将其从WAL中提取并还原为完整的8KB页面。

步骤三:按序重做中间操作

将FPW和DELETE之间的所有中间WAL记录,按LSN顺序逐条应用到基础页面上。复用内核的重做逻辑:

  • heap_xlog_insert → 添加元组,扩展行指针数组
  • heap_xlog_update → 标记旧元组、添加新元组、处理前缀/后缀优化
  • heap_xlog_prune_freeze → 重排行指针、冻结元组
  • 其他影响数据布局的操作

步骤四:通过offnum定位被删除数据

从DELETE记录的xl_heap_delete.offnum中获取偏移量,在重做完成的页面上执行:

ItemId lp = PageGetItemId(page, offnum);      /* 获取行指针 */
HeapTupleHeader htup = (HeapTupleHeader) PageGetItem(page, lp); /* 获取元组 */

此时htup指向的就是被删除的完整元组数据。

步骤五:解析元组,还原用户数据

读取元组头部的t_infomaskt_hoff等字段,跳过系统列(xmin, xmax, cmin, cmax, ctid),按照表的列定义(从pg_attribute获取类型、长度、对齐方式等)依次解析每一列的值,最终还原为用户可读的行数据。

对于变长类型数据(如text、varchar),还需要处理可能存在的TOAST外部存储——被TOAST化的数据存储在关联的TOAST表中,需要额外的定位和拼接。


结语

综合全文,DELETE恢复的核心逻辑可以归纳为:

FPW提供基础页面快照,WAL重做将页面推进到删除时刻的精确状态,xl_heap_delete.offnum定位被删除的具体元组。

这套机制完全不依赖数据文件中的死亡元组,因此不受vacuum的影响。只要WAL文件保留完整,恢复就具备可行性。

实际的恢复工具实现远比本文描述的复杂,需要处理跨页UPDATE、TOAST数据、多种FPW压缩算法、逻辑复制场景下的特殊标记、HOT链追踪,以及大量边界条件。但核心原理不变——本文描述的五步流程,构成了所有基于WAL的PostgreSQL DELETE恢复工具的基础框架。

PDU(PostgreSQL Data Unloader)正是基于上述原理实现的开源PostgreSQL DELETE恢复工具,涵盖了FPW提取、WAL重做、元组解析等完整恢复流程。对本领域感兴趣的读者,可以参考PDU的源码实现,或从PostgreSQL内核的heapam_xlog.c入手,进一步深入研究WAL回放机制。

在备份策略设计中,保留充足的WAL文件是确保数据可恢复性的关键措施

本站文章均为手工撰写未经允许谢绝转载:夜雨聆风 » 从FPW到数据重现:从源码看PostgreSQL Delete恢复的完整实现路径

猜你喜欢

  • 暂无文章