PostgreSQL 物理数据恢复实录

摘要:本文记录一次在生产环境中通过裸盘文件雕刻(File Carving)恢复 PostgreSQL 数据的完整过程。事故起因于 docker compose down --remove-orphans -v 命令误删了数据库命名卷,导致 20 张业务表数据清空,且系统无任何有效备份与快照。在确认常规 ext4 文件系统恢复工具(debugfs、extundelete、ext4magic)因 inode 释放而失效后,方案下潜至块设备层,以 PostgreSQL 堆页默认大小(8192 字节)为粒度对 /dev/vda3 进行特征扫描,提取出 654,672 个候选页(约 5GB)。随后基于 Alembic 迁移与开发库元数据构建 Schema 字典,编写二进制 HeapTuple 解析器,解决了页归属消歧、NULL 位图极性反转、varlena 变长头解析、enum OID 映射等底层问题。最终完成 MVCC 去重、SQL 灌库、商品名与媒体资产分层重建,并通过正负向双向审计确认了恢复边界。全过程历时约 4 小时,成功去重恢复 13,653 行核心业务数据。


恢复策略框架

在缺乏文件系统元数据的情况下,直接从裸块设备恢复二进制数据存在高误报率与数据污染风险。为了保证恢复的准确性与工程效率,整个恢复过程基于以下五条核心策略:

  1. 成本递增与机理排查:优先尝试操作成本较低的文件系统级工具;一旦失败,必须查明其底层失效机理,再决定是否升级到块设备级雕刻,避免盲目尝试同质工具。
  2. 数据获取与数据解释解耦:磁盘 I/O 扫描与元组数据解析严格分离。全盘扫描仅执行一次,将命中页固化为 .bin 镜像;解析器与清洗脚本在离线副本上迭代调试,避免重复扫盘的时间与 I/O 损耗。
  3. 解析校验从严原则:解析器对任何存在越界、对齐异常、非法布尔值或违背非空约束的元组一律执行丢弃。在灾难恢复中,“漏恢复”可通过后续业务审计补全,而“误恢复”(将噪声误解析为看似合法的伪数据)会污染数据库且极难排查。
  4. 完整性双向证明:通过正向统计“存在但无法被当前 Schema 解释的结构”,以及负向穷尽搜索“确证缺失的目标 ID”,共同确立恢复的置信边界。
  5. 数据溯源全程留痕:在恢复数据中明确区分精确解析、上下文推断与占位状态(记录于 source_note),保证下游消费方能够清晰识别数据置信度。

1. 事故背景与影响评估

1.1 系统运行环境

  • 数据库引擎:PostgreSQL 16(采用 PG16 的 varlena 变长头部格式),运行于 Docker Compose 容器内。
  • 存储介质:数据持久化于 Docker 命名卷(Named Volume),底层挂载于宿主机 /dev/vda3 分区,文件系统为 ext4。
  • 备份现状:无 pg_dump 逻辑备份,无 WAL 归档(无法进行 PITR 基于时间点恢复),云主机未开启快照。

1.2 事故触发与机制

事故触发命令如下:

docker compose down --remove-orphans -v
  • down:停止并删除运行中的容器和网络,不影响卷数据。
  • -v--volumes):级联删除 Compose 文件中声明的所有命名卷

命令由自动化运维工具执行,因缺乏参数安全审查,导致包含 PostgreSQL 数据目录的命名卷被直接删除。容器重新拉起后,系统自动执行数据库初始化与 Alembic 迁移脚本,生成了 20 张结构完好但行数为 0 的空数据表。

1.3 存活资产排查

事故发生后,首先对宿主机文件系统进行存活资产摸排:

  • 数据库层面:20 张业务表数据全部丢失。
  • 物理文件层面:宿主机 /storage/products/ 路径下 249 个商品目录完好,内部保存的原始图片、海报及参考素材未被删除。

结论:本次事故丢失的是数据库内部的元数据关系与业务流转记录,物理素材资产依然存在。恢复目标明确为:从底层块设备中恢复 PostgreSQL 行记录,重新建立数据库行与物理文件的引用闭环。


2. 文件系统级恢复手段的失效分析

在进入裸盘雕刻之前,首先测试了常规的 Linux/ext4 文件恢复工具,测试结果及底层机理如下:

2.1 debugfs lsdel 返回 0 条

  • ext4 删除机理:ext4 删除文件时,会在对应目录的目录项(dentry)中解除文件名绑定(unlink),并在 inode 位图中将对应的 inode 标记为已释放。
  • lsdel 原理debugfs lsdel 遍历的是文件系统中的已删除 inode 列表,主要用于定位那些 link count 归零但仍有进程持有文件描述符、或处于孤儿链表(orphan inode list)中的节点。
  • 失效原因:Docker 卷的删除属于完整的 unlink 操作,inode 引用立即归零且无常驻进程保持句柄,不进入 orphan inode 链表,因此 lsdel 无法遍历到任何记录。

2.2 extundeleteext4magic 失效

  • extundelete:通过扫描 inode 表匹配被标记为 deleted 的记录并尝试通过块位图重建。在目录树被整体递归删除的场景下,父级目录索引损坏,工具未能成功定位目标文件。
  • ext4magic:依赖文件系统的日志(journal)恢复目录结构。由于新建数据库时执行了 initdb 并重新写入了系统表日志,原有的 ext4 事务日志已被快速轮转覆盖。

2.3 决策转移:从文件系统层下潜至块设备层

ext4 的删除行为仅修改元数据(inode 状态与块位图),并不会主动清空数据块的物理内容。在没有大规模新数据写入覆盖物理扇区的前提下,数据块依然完整保存在磁盘上。

由于通用文件系统恢复工具依赖的元数据索引链已断裂,恢复策略必须转向块设备本身:绕过文件系统抽象,直接按 PostgreSQL 物理存储格式从裸设备 /dev/vda3 中雕刻数据页。


3. PostgreSQL 8KB 堆页文件雕刻

3.1 页面可识别性与特征校验

通用文件雕刻工具(如 PhotoRec)主要针对具备明确魔数(Magic Bytes)的文件(如 JPEG、PNG、PDF)。数据库数据分散在固定大小的物理页中,单页内部没有通用文件头魔数,因此通用雕刻工具无效。

但 PostgreSQL 的堆页(Heap Page)具有极其严格的二进制内部结构,可以通过结构一致性算法实现高精度的程序化识别:

PostgreSQL 8192 字节 Heap Page 结构:
+-------------------------------------------------------------------+
| PageHeaderData (24 字节)                                          |
|  - pd_lsn (8B)                                                    |
|  - pd_checksum (2B)                                               |
|  - pd_flags (2B)                                                  |
|  - pd_lower (2B): 行指针数组结束偏移量 (向高地址增长)             |
|  - pd_upper (2B): 元组数据区起始偏移量 (向低地址增长)             |
|  - pd_special (2B): 特殊区偏移量 (Heap 页通常等于 8192)           |
|  - pd_pagesize_version (2B): 页面大小与版本 (PG16 为 4 / 0x2004)  |
+-------------------------------------------------------------------+
| Line Pointer 数组 (每个条目 4 字节: lp_off, lp_flags, lp_len)     |
| [lp_0] [lp_1] [lp_2] ...                                          |
+-------------------------------------------------------------------+
| 空闲空间 (Free Space: pd_lower 至 pd_upper)                       |
+-------------------------------------------------------------------+
| 元组数据区 (Tuple Data: 从页面底部向上填充)                       |
| [Tuple N] ... [Tuple 0]                                           |
+-------------------------------------------------------------------+

页面校验约束规则

  1. pd_pagesize_version:版本标识需匹配 40x2004
  2. 偏移量关系:24 \le pd\_lower \le pd\_upper \le pd\_special \le 8192
  3. 行指针合法性:遍历 ItemIdData 数组,若 lp\_flags = 1(有效元组),其偏移 lp\_off 与长度 lp\_len 必须满足 lp\_off \ge pd\_upperlp\_off + lp\_len \le 8192

3.2 扫描与聚合流程

编写物理扫描脚本,以 8192 字节为固定步长顺序遍历 /dev/vda3,根据上述约束对每个块进行校验:

  • 连续链页(Chains):连续命中的有效页面聚合成簇,保存为 chain_*.bin(多对应同一表文件的物理连续 Extent)。
  • 离散单页(Singles):不连续的独立有效页面保存为 singles.bin

3.3 扫描成果统计

统计项数量 / 容量说明
命中有效页654,672 个通过页头与行指针一致性校验的 8KB 数据块
连续页链20,752 个磁盘物理连续的页簇
离散单页13,067 个零散分布的单页
抓取数据总量约 5.0 GB固化为离线 .bin 文件,供后续解析器反复读取

4. Schema 字典逆向工程

4.1 为什么必须依赖外部 Schema

PostgreSQL 堆页中的元组(Heap Tuple)采用紧凑二进制存储,不包含列名、数据类型、存储长度与非空约束等元数据。对于同一段二进制字节,若按 int4int8text 等不同类型解析,会得到完全不同的结果;且变长列的解码直接影响后续所有列的起始偏移计算。

因此,必须构建一份与事故前数据库结构严格一致的物理布局字典。

4.2 字典来源与物理属性建模

字典通过整合以下两处信息构建,输出为 schema.jsoncols.txtenums.txt

  1. Alembic 迁移脚本:提供表结构演进历史,包含字段添加、删除与类型修改的精确顺序,用于适配历史旧版本页。
  2. 本地开发库 System Catalog:从开发环境导出的物理元数据:
    • typalign(对齐字节数)c(char, 1B)、s(short, 2B)、i(int, 4B)、d(double, 8B)。
    • attlen(物理长度):定长类型为具体字节数,变长类型统一为 -1
    • attnotnull(非空约束):作为解析器判定数据合法性的断言依据。
    • dropped 状态:标记历史已废弃列,保留其物理占位。

4.3 元组二进制物理布局

HeapTuple 二进制数据流结构:
+-----------------------------------------------------------------------------------+
| HeapTupleHeaderData (23 字节起)                                                   |
|  - t_xmin / t_xmax (4B + 4B) : 事务可见性 ID                                      |
|  - t_cid / t_xvac (4B)       : 命令 ID / Vacuum 标识                              |
|  - t_infomask2 (2B)          : 低 11 位为属性数 natts                             |
|  - t_infomask (2B)           : bit 0x0001 为 HEAP_HASNULL 标志位                  |
|  - t_hoff (1B)               : 用户数据起始偏移 (对齐到 MAXALIGN, 通常为 24 或 32)|
+-----------------------------------------------------------------------------------+
| NULL Bitmap (仅在 HEAP_HASNULL 置位时存在, 长度为 ceil(natts / 8) 字节)           |
|  [Byte 0] [Byte 1] ... (LSB-first: bit=1 表示字段有值, bit=0 表示字段为 NULL)     |
+-----------------------------------------------------------------------------------+
| 列物理数据区 (按 Schema 定义的 typalign 依次对齐排列)                             |
|  [Col 0] -> [Padding] -> [Col 1] -> [Col 2 (varlena)] -> ...                      |
+-----------------------------------------------------------------------------------+
| 尾部填充 (MAXALIGN 填充, 0~7 字节 0x00)                                           |
+-----------------------------------------------------------------------------------+

5. 解析器实现与核心暗坑剖析

解析器采用 Python struct 模块实现。在解码迭代过程中,逐一排查并攻克了以下 4 个深层技术暗坑:

5.1 暗坑一:页归属消歧与三级判定机制

  • 问题现象:初版解析器使用贪心算法——“按顺序遍历表结构,首个能解析成功的表即判定为该页归属”。由于部分表属性数量(natts)相同(如 product_workflows 为 6 列、canvas_agent_messages 为 6 列、image_sessions 旧版 5 列),且大量字段允许为 NULL,导致大量页面被排在首位的 app_settings 错误匹配。
  • 底层机理:PostgreSQL 堆页只存储单一数据表的元组。单行元组匹配具有偶然性,必须依赖整页一致性进行强约束判定。
  • 解决方案:引入三级递进判定机制:
    1. Level 1(属性数预筛):读取元组头 t_infomask2 低 11 位获取 natts,将候选表范围缩小至列数匹配的子集。
    2. Level 2(整页一致消费):要求页面内所有有效行指针指向的元组,必须全部能够无缝按同一表结构完成解析。堆页不会混合存放不同表的元组,错误的表结构几乎不可能使整页所有元组全部通过。
    3. Level 3(行可信度打分 row_score:在少数表结构极其相近的场景下,通过行特征加权投票(UUID 格式匹配加 4 分,合理时间戳区间加 1~3 分),得分最高的表胜出。

5.2 暗坑二:NULL 位图极性反转(静默数据错位)

  • 问题现象:解析不含 NULL 的记录完全正确;但当元组包含 NULL 字段时,后续字段(外键 ID、字符串、时间戳)发生整体偏移错位,但解析器不触发任何报错。
  • 底层机理
    PostgreSQL 的 NULL 位图采用 LSB-first(最低有效位优先) 编码:
    • bit = 1:表示该字段非空(有物理数据),需从数据区读取对应字节。
    • bit = 0:表示该字段为 NULL,无需读取物理数据,偏移量不推进。
    初版解析器将极性写反(误将 1 当作 NULL)。在无 NULL 元组中,由于 HEAP_HASNULL 标志未置位,解析器直接跳过位图,因而碰巧正确;而在有 NULL 的元组中,解析器在应当跳过的位置强行读取后续字节,造成静默错位。
  • 修复方案
# LSB-first 位图正确取值逻辑
bi = 23 + (col_idx // 8)
if bi >= len(tup):
    return None, False
is_present = (tup[bi] >> (col_idx % 8)) & 1
if not is_present:
    vals[col_name] = None
    continue
# 字段存在,按 typalign 对齐后读取数据

5.3 暗坑三:varlena 变长头部判别

  • 问题现象:解析出的字符串(如商品名称、Prompt、JSON)开头出现 1~4 字节的缺失或截断。
  • 底层机理:PostgreSQL 16 的变长类型(textvarcharjsonjsonb)统一采用 varlena 结构,包含三种形态:
    1. 1 字节短头(1-byte header):首字节 bit0 = 1,数据总长度(含头部自身)为 b0 >> 1,紧凑排列,无内存对齐要求
    2. 4 字节长头(4-byte header):首字节 bit0 = 0,需按 4 字节边界对齐。低 2 位为 flags,总长度为 header_uint32 >> 2
    3. TOAST 外部指针:首字节为 0x01,后跟 17 字节指针(整体占用 18 字节),指示数据存储在 TOAST 扩展表中。
  • 修复方案:必须先判定首字节标志位,再决定长度提取与对齐策略(详见附录 A)。

5.4 暗坑四:enum 类型的 OID 物理存储

  • 问题现象:解析枚举类型字段时,得到的不是标签文本,也不是 1..N 的序号,而是 1639416790 等离散的大整数。
  • 底层机理:PostgreSQL 在堆页中存储枚举值时,写入的是系统表 pg_enum 中全局分配的 oid(4 字节无符号整型)。由于系统库已重建,原有的 pg_enum 映射已丢失。
  • 修复方案:通过将提取到的 OID 与 workflow_node_runsimage_session_rounds 中的业务上下文、文件路径及错误日志进行交叉印证,反推还原了枚举映射关系:
    • #16394 \rightarrow reference_upload
    • #16396 \rightarrow generated_image
    • #16772 \rightarrow product_context
    • #16790 \rightarrow succeeded

5.5 其他关键校验约束

  • MAXALIGN 尾部清零校验:元组数据读取完毕后,末尾填充字节数必须在 [0, 7] 区间内,且填充字节必须全部为 0x00。出现非零值说明元组长度或字段偏移解析错误,整行丢弃。
  • JSONB 版本头剥离jsonb 物理存储的首字节为版本号(1),解析时需剥离首字节后再执行 UTF-8 解码与 JSON 合法性校验。
  • 严格值域过滤:布尔值仅允许 0x000x01timestamptz(基于 2000-01-01 UTC 的 int64 微秒数)必须落在合理年份区间内;主键必须符合 UUID 正则。

6. 全量扫描、去重与 SQL 灌库

6.1 两阶段流水线与中间格式

数据处理分为清晰的两个阶段:

  1. 第一阶段(扫描解析)full_scan2.py(处理链页)与 scan_singles.py(处理单页),输出统一格式的 JSONL 文件(每行为 {t: 表名, r: 行数据字典})。
  2. 第二阶段(清洗去重与 SQL 生成)make_sql3.py 流式读取 JSONL,执行业务级清洗、去重与 SQL 生成。

选择 JSONL 作为中间格式,使得处理过程具备流式可重入性,且便于使用 grep / jq 随时进行人工断点抽查。

6.2 MVCC 多版本去重规则

PostgreSQL 在执行 UPDATE 时会生成新元组并保留旧版本(直至 VACUUM 清理)。裸盘雕刻会同时捞出历史版本与最新版本。去重规则定义如下:

  • 去重键(table_name, primary_key)
  • 版本仲裁策略
    1. 优先选取时间戳(updated_at / created_at最新的元组。
    2. 若时间戳完全一致,选取非空(Non-null)字段数量最多的元组(更完整的快照)。

6.3 灌库过程中的问题排查

在生成 insert_v5.sql 并执行灌库时,排查并解决了以下两个关键问题:

  1. 多行文本换行符被转为 NULL 导致事务回滚
    • 原因:数据清洗函数将不可打印字符统一视作坏数据,误将 \n\t\r 也归入非法字符并转为 NULL,导致 image_session_rounds.prompt 触发 NOT NULL 约束。
    • 修复:调整字符过滤逻辑,白名单保留合法换行与制表符,仅过滤真正的 0x00(NUL)等无法存入 PostgreSQL 文本类型的字节。
  2. ON CONFLICT DO NOTHING 静默吞掉真实数据
    • 原因:在恢复初期,为提供前端骨架曾人工插入过一批带有占位名称的商品记录。随后使用 ON CONFLICT DO NOTHING 灌入恢复出的真实商品行时,主键冲突导致真实数据被静默跳过,前端仍显示占位名称。
    • 修复:将商品表插入逻辑切换为显式 UPDATE products SET ...,按主键覆盖占位记录。
  3. 绕过外键依赖约束
    • 在事务开头设置 SET session_replication_role = 'replica';,跳过外键插入顺序检查与触发器,灌库完成后恢复为 origin

7. 业务层分层补全

7.1 商品名称的 4 层递进挖掘

products 堆页直接恢复的记录中,部分名称因物理覆写而缺失。我们按可信度由高到低建立了 4 层补全链路:

商品名称分层挖掘流程:
┌─────────────────────────────────────────────────────────────┐
│ [层级 1] products 堆页直接恢复 (245 条)                      │
│         -> 来源:目标表物理数据,最高置信度                │
├─────────────────────────────────────────────────────────────┤
│ [层级 2] workflow_nodes / node_runs 的 output_json (快照提取)│
│         -> 来源:工作流执行历史中的 JSON 业务快照           │
├─────────────────────────────────────────────────────────────┤
│ [层级 3] creative_briefs 定位信息推断 (9 条)                │
│         -> 来源:推断商品品类,在 source_note 标记"待确认"  │
├─────────────────────────────────────────────────────────────┤
│ [层级 4] 全盘 8KB 二进制页正则暴力挖掘 (兜底方案)           │
│         -> 来源:在 654,672 个候选页中按 UUID 匹配 Unicode  │
└─────────────────────────────────────────────────────────────┘

补全结果:260 个商品中,254 个成功恢复真实名称;9 个为推断并标注 source_note;剩余 6 个经全盘搜索确认未残存任何文本信息,维持占位。

7.2 磁盘物理资产反向重建封面(source_assets

针对部分商品缺失封面图的问题,通过 rebuild_source_assets.py 对宿主机 /storage/products/<id>/ 目录进行反向扫描:

  • 扫描 source/ 目录中的原始图片,重建 kind = 'original_image' 记录。
  • 提取文件物理修改时间(mtime)作为 created_at 时间戳。
  • 过滤 .variants/.thumbnail 等衍生临时图,生成新 UUID 作为主键。

结果:有效封面商品数从 199 提升至 250 / 260


8. 双向审计与完整性闭环

为了向系统维护者提供确定性的恢复边界,实施了正向与负向双向审计:

8.1 正向审计:全盘页属性分布统计

对全部 654,672 个命中页进行 natts 属性数全量统计:

  • 已知表结构页:456,561 页成功被现有 Schema 字典解析并消费。
  • 未知属性页:发现 attr=14(33,878 页)、attr=17(181 页)、attr=21(169 页)。分析确认为未收录进字典的新版实验表或 TOAST 扩展结构。按照策略 3(从严原则),未对无字典支持的页进行盲目强解,保留原始页归档。

8.2 负向证明:确证缺失目标物理消亡

针对剩余 6 个无名称占位商品 ID,编写正则穿透脚本在全部 654,672 个 8KB 二进制页中执行全局扫描。结果证实磁盘中仅存在零星的路径引用,确实不存在商品名称字符串。

审计结论:该 6 个实体的文本信息已在物理扇区层面被覆写,当前恢复结果已达到物理介质允许的最大恢复极限。


9. 最终恢复结果核对

入库完成后,数据库各表行数与恢复去重行数完全对齐,后端服务健康检查全部通过:

表名恢复去重行数当前库总行数校验说明
workflow_edges2,9232,935全部恢复 ID 存在,拓扑关系闭环
workflow_nodes2,4722,490节点结构完整
workflow_node_runs2,4302,430运行历史 100% 精确匹配
image_session_assets1,6851,704关联关系完整
source_assets1,1951,401基础数据 + 磁盘反向重建封面
image_session_rounds1,1271,127Prompt 文本完整无缺失
workflow_runs522522运行时实例精确匹配
image_sessions367368会话数据恢复上线
creative_briefs302302创意提案精确匹配
products245260254 个拥有真实名称,封面达 250/260
product_workflows146149工作流定义完整恢复
image_session_generation_tasks137137任务生成上下文完整
poster_variants / gallery51 / 5151 / 51衍生图与画廊元数据精确匹配
合计恢复去重行13,653单事务全量入库,零语法与约束报错

10. 根因剖析与工程防范

10.1 事故根因

本次事故暴露了多层防护机制的缺失:

  1. 操作审核缺失:包含破坏性开关 -v 的运维命令未经逐参数审核即在生产环境执行。
  2. 单点存储缺陷:数据唯一副本存放在 Docker 命名卷中,未建立定时逻辑备份(pg_dump)。Docker 卷属于编排设施,不具备容灾备份属性。
  3. 快照防御缺失:云服务器未配置定时磁盘快照,丧失了分钟级系统回滚的能力。

10.2 改进措施

  1. 高危命令防护:在生产环境中针对 docker compose downrmdrop 等命令建立拦截与二次确认机制;禁止自动化运维脚本携带 -v 参数。
  2. 多层备份体系
    • 建立每日自动 pg_dump,备份数据异机同步至独立对象存储。
    • 开启云主机每日滚动快照。
  3. 人机协作边界:明确 AI 工具在系统运维中的角色定位——AI 仅承担脚本生成与执行建议,最终执行权与参数审查必须由人工工程师把关。

附录:核心 Python 实现源码

附录 A:varlena 变长字段解析实现(decode_varlena

import struct

def decode_varlena(data: bytes, off: int):
    """
    解析 PostgreSQL 16 varlena 变长字段:
    - 短头(1 字节):bit0=1,总长度 = b0 >> 1(含头自身),无对齐要求。
    - 长头(4 字节):bit0=0,需 4 字节对齐,低 2 位为 flags,总长度 = hdr >> 2。
    - TOAST 指针:0x01 开头,整体跳过 18 字节。
    """
    if off >= len(data):
        return None, off
    b0 = data[off]
    
    # 短头格式
    if b0 & 1:
        if b0 == 0x01:  # TOAST 外部指针(1 字节 tag + 17 字节指针)
            return None, off + 18
        total = b0 >> 1
        end = off + total
        if end > len(data):
            return None, off
        return data[off + 1:end], end
        
    # 长头格式(需 4 字节对齐)
    off4 = (off + 3) & ~3
    if off4 + 4 > len(data):
        return None, off4 + 4
    hdr = struct.unpack_from("<I", data, off4)[0]
    flags = hdr & 3
    total = hdr >> 2
    
    if flags:  # TOAST 压缩或外部存储指针
        return None, off4 + total
        
    ln = total - 4
    if ln < 0 or off4 + 4 + ln > len(data):
        return None, off4 + 4
    return data[off4 + 4:off4 + 4 + ln], off4 + 4 + ln

附录 B:严格元组解析实现(parse_tuple

def parse_tuple(tup: bytes, cols: list, enums: dict):
    """
    严格按照 Schema 定义解析单个 HeapTuple。
    遇到任何非法字节或约束不符,立即返回 None, False。
    """
    if len(tup) < 24:
        return None, False
    infomask = struct.unpack_from("<H", tup, 20)[0]
    t_hoff = tup[22]
    if t_hoff < 24 or t_hoff > len(tup):
        return None, False
        
    hasnull = bool(infomask & 0x0001)
    off = t_hoff
    vals = {}
    
    for i, c in enumerate(cols):
        # NULL 位图解析(LSB-first: bit=1 为存在,bit=0 为 NULL)
        if hasnull:
            bi = 23 + i // 8
            if bi >= len(tup):
                return None, False
            if not ((tup[bi] >> (i % 8)) & 1):
                vals[c["name"]] = None
                continue
                
        typ = c["type"]
        if typ in enums:
            off = t_hoff + align_up(off - t_hoff, ALIGN.get("i", 4))
            if off + 4 > len(tup):
                return None, False
            try:
                v = struct.unpack_from("<i", tup, off)[0]
            except struct.error:
                return None, False
            labels = enums[typ]
            if typ == "jobstatus" and v in JOBSTATUS_OID:
                vals[c["name"]] = JOBSTATUS_OID[v]
            else:
                vals[c["name"]] = labels[v - 1] if 0 < v <= len(labels) else f"{typ}#{v}"
            off += 4
        elif typ in FIXED_BYVAL:
            a = ALIGN.get(c["align"], 4)
            sz = FIXED_BYVAL[typ]
            off = t_hoff + align_up(off - t_hoff, a)
            if off + sz > len(tup):
                return None, False
            try:
                if typ == "bool":
                    b = tup[off]
                    if b not in (0, 1):  # 严格校验布尔值
                        return None, False
                    vals[c["name"]] = bool(b)
                elif typ == "uuid":
                    vals[c["name"]] = str(bytes(tup[off:off + 16]).hex())
                elif typ in ("timestamptz", "int8", "time"):
                    vals[c["name"]] = struct.unpack_from("<q", tup, off)[0]
                else:
                    vals[c["name"]] = struct.unpack_from("<i", tup, off)[0]
            except struct.error:
                return None, False
            off += sz
        elif typ in VARLENA:
            raw, off2 = decode_varlena(tup, off)
            if raw is None:
                vals[c["name"]] = None
                off = off2
                continue
            if typ == "jsonb" and raw and raw[0] == 1:
                raw = raw[1:]
            try:
                s = raw.decode("utf-8", "strict")
                if typ in ("json", "jsonb"):
                    json.loads(s)
                vals[c["name"]] = s
            except Exception:
                vals[c["name"]] = None
            off = off2
            
    # 严格校验:尾部填充必须全零且不超过 7 字节
    pad = len(tup) - off
    if pad < 0 or pad > 7 or (pad and any(b != 0 for b in tup[off:])):
        return None, False
        
    # NOT NULL 约束校验
    for c in cols:
        if c["notnull"] and vals.get(c["name"]) is None:
            return None, False
            
    return vals, True

附录 C:多版本去重与 SQL 批量生成主循环(make_sql3.py 片段)

def main():
    inputs = ["full_rows_v5.jsonl", "singles_rows_v5.jsonl"]
    best = {}
    
    for fn in inputs:
        with open(fn, encoding="utf-8") as f:
            for line in f:
                if not line.strip(): continue
                obj = json.loads(line)
                t, r = obj.get("t"), obj.get("r")
                if not t or not r or t not in CURRENT_COLUMNS: continue
                
                kc = "key" if t == "app_settings" else "id"
                kv = r.get(kc)
                if not kv or not str(kv).strip(): continue
                if kc == "id" and not (isinstance(kv, str) and UUID_RE.match(kv)): continue
                
                key = (t, kc, str(kv))
                rt = row_time(r, t)
                nonnull = sum(1 for v in r.values() if v is not None)
                
                cur = best.get(key)
                # 仲裁规则:时间戳更新 > 非空字段更多
                if cur is None or rt > cur[0] or (rt == cur[0] and nonnull > cur[2]):
                    best[key] = (rt, r, nonnull, fn)
                    
    groups = collections.defaultdict(list)
    for (t, kc, kv), (rt, r, nonnull, fn) in best.items():
        groups[t].append(r)
        
    with open("insert_v5.sql", "w", encoding="utf-8") as out:
        out.write("SET session_replication_role = 'replica';\n")
        for t in TABLE_INSERT_ORDER:
            if t not in groups: continue
            rows = groups[t]
            cols = [c for c in CURRENT_COLUMNS[t] if any(c in r for r in rows)]
            for r in rows:
                vals = [sql_literal(clean_value(t, c, r.get(c), r)) for c in cols]
                out.write(f"INSERT INTO {t} ({', '.join(cols)}) VALUES ({', '.join(vals)}) ON CONFLICT DO NOTHING;\n")
        out.write("SET session_replication_role = 'origin';\n")

附录 D:磁盘媒体资产反向重建脚本(rebuild_source_assets.py 片段)

def rebuild_assets():
    out = open("rebuild_source_assets.sql", "w", encoding="utf-8")
    out.write("SET session_replication_role = 'replica';\n")
    
    for prod in sorted(os.listdir(os.path.join(ROOT, "products"))):
        sdir = os.path.join(ROOT, "products", prod, "source")
        if not os.path.isdir(sdir): continue
        
        files = [
            os.path.join(sdir, fn) for fn in sorted(os.listdir(sdir))
            if os.path.isfile(os.path.join(sdir, fn)) and not fn.startswith(".") and ".variants" not in fn
        ]
        if not files: continue
        
        # 选取 mtime 最早的文件作为原始封面图
        chosen = min(files, key=lambda f: os.path.getmtime(f))
        rel_path = f"products/{prod}/source/{os.path.basename(chosen)}"
        
        if prod not in original_products and rel_path not in existing_paths:
            mt = datetime.datetime.fromtimestamp(os.path.getmtime(chosen), tz=datetime.timezone.utc).isoformat()
            oid = str(uuid.uuid4())
            fn = os.path.basename(chosen)
            out.write(
                f"INSERT INTO source_assets (id, product_id, kind, original_filename, mime_type, storage_path, created_at) "
                f"VALUES ('{oid}', '{prod}', 'original_image', '{fn}', 'image/jpeg', '{rel_path}', '{mt}') "
                f"ON CONFLICT DO NOTHING;\n"
            )
            original_products.add(prod)
            
    out.write("SET session_replication_role = 'origin';\n")
    out.close()

一天是牛马, 一辈子是牛马