从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.c的XLogRecordAssemble函数中:
// 文件:src/backend/access/transam/xloginsert.c
// 函数:XLogRecordAssemble
for (block_id = 0; block_id < max_registered_block_id; block_id++)
{
registered_buffer *regbuf = ®istered_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 ... */
}
判断逻辑可以概括如下:
-
REGBUF_FORCE_IMAGE:强制写FPW,不论任何条件。 -
REGBUF_NO_IMAGE:明确禁止写FPW。 -
!doPageWrites:full_page_writes = off且没有在线备份,跳过。 -
核心判断: 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_lower到pd_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;
}
这个函数做了三件事:
-
校验:确认block_id有效且确实包含全页镜像。 -
解压:支持pglz、LZ4、ZSTD三种压缩算法,按 bimg_info标志位选择对应的解压路径。 -
重建页面:将压缩时省略的”空洞”用零填充,还原出完整的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 |
|
|
BLK_NEEDS_REDO |
|
|
BLK_DONE |
|
|
BLK_NOTFOUND |
|
|
对于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 需要重做哪些操作?
根据对数据页内容的影响程度,可以分为两类:
必须重做(改变数据内容和行指针分布):
|
|
|
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
可选重做(仅修改可见性标记,不改变数据布局):
|
|
|
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
下面我们逐一看看内核中这些重做函数的实现。
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,
true, true) == 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, true, true);
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_OLD和XLH_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 。数据仍然完整地存在于页面中。我们只需要:
-
用 offnum通过PageGetItemId找到行指针 -
用 PageGetItem获取元组指针 -
读取元组内容
至此,被删除的数据即可完整提取。
第五章:完整恢复流程——五步恢复被删除数据
综合以上内核机制,一个完整的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_infomask、t_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文件是确保数据可恢复性的关键措施。
夜雨聆风