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 行核心业务数据。
恢复策略框架
在缺乏文件系统元数据的情况下,直接从裸块设备恢复二进制数据存在高误报率与数据污染风险。为了保证恢复的准确性与工程效率,整个恢复过程基于以下五条核心策略:
- 成本递增与机理排查:优先尝试操作成本较低的文件系统级工具;一旦失败,必须查明其底层失效机理,再决定是否升级到块设备级雕刻,避免盲目尝试同质工具。
- 数据获取与数据解释解耦:磁盘 I/O 扫描与元组数据解析严格分离。全盘扫描仅执行一次,将命中页固化为
.bin镜像;解析器与清洗脚本在离线副本上迭代调试,避免重复扫盘的时间与 I/O 损耗。 - 解析校验从严原则:解析器对任何存在越界、对齐异常、非法布尔值或违背非空约束的元组一律执行丢弃。在灾难恢复中,“漏恢复”可通过后续业务审计补全,而“误恢复”(将噪声误解析为看似合法的伪数据)会污染数据库且极难排查。
- 完整性双向证明:通过正向统计“存在但无法被当前 Schema 解释的结构”,以及负向穷尽搜索“确证缺失的目标 ID”,共同确立恢复的置信边界。
- 数据溯源全程留痕:在恢复数据中明确区分精确解析、上下文推断与占位状态(记录于
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 extundelete 与 ext4magic 失效
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] |
+-------------------------------------------------------------------+
页面校验约束规则:
pd_pagesize_version:版本标识需匹配4或0x2004。- 偏移量关系:24 \le pd\_lower \le pd\_upper \le pd\_special \le 8192。
- 行指针合法性:遍历 ItemIdData 数组,若 lp\_flags = 1(有效元组),其偏移 lp\_off 与长度 lp\_len 必须满足 lp\_off \ge pd\_upper 且 lp\_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)采用紧凑二进制存储,不包含列名、数据类型、存储长度与非空约束等元数据。对于同一段二进制字节,若按 int4、int8 或 text 等不同类型解析,会得到完全不同的结果;且变长列的解码直接影响后续所有列的起始偏移计算。
因此,必须构建一份与事故前数据库结构严格一致的物理布局字典。
4.2 字典来源与物理属性建模
字典通过整合以下两处信息构建,输出为 schema.json、cols.txt 和 enums.txt:
- Alembic 迁移脚本:提供表结构演进历史,包含字段添加、删除与类型修改的精确顺序,用于适配历史旧版本页。
- 本地开发库 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 堆页只存储单一数据表的元组。单行元组匹配具有偶然性,必须依赖整页一致性进行强约束判定。
- 解决方案:引入三级递进判定机制:
- Level 1(属性数预筛):读取元组头
t_infomask2低 11 位获取natts,将候选表范围缩小至列数匹配的子集。 - Level 2(整页一致消费):要求页面内所有有效行指针指向的元组,必须全部能够无缝按同一表结构完成解析。堆页不会混合存放不同表的元组,错误的表结构几乎不可能使整页所有元组全部通过。
- Level 3(行可信度打分
row_score):在少数表结构极其相近的场景下,通过行特征加权投票(UUID 格式匹配加 4 分,合理时间戳区间加 1~3 分),得分最高的表胜出。
- Level 1(属性数预筛):读取元组头
5.2 暗坑二:NULL 位图极性反转(静默数据错位)
- 问题现象:解析不含 NULL 的记录完全正确;但当元组包含 NULL 字段时,后续字段(外键 ID、字符串、时间戳)发生整体偏移错位,但解析器不触发任何报错。
- 底层机理:
PostgreSQL 的 NULL 位图采用 LSB-first(最低有效位优先) 编码:bit = 1:表示该字段非空(有物理数据),需从数据区读取对应字节。bit = 0:表示该字段为 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 的变长类型(
text、varchar、json、jsonb)统一采用varlena结构,包含三种形态:- 1 字节短头(1-byte header):首字节
bit0 = 1,数据总长度(含头部自身)为b0 >> 1,紧凑排列,无内存对齐要求。 - 4 字节长头(4-byte header):首字节
bit0 = 0,需按 4 字节边界对齐。低 2 位为 flags,总长度为header_uint32 >> 2。 - TOAST 外部指针:首字节为
0x01,后跟 17 字节指针(整体占用 18 字节),指示数据存储在 TOAST 扩展表中。
- 1 字节短头(1-byte header):首字节
- 修复方案:必须先判定首字节标志位,再决定长度提取与对齐策略(详见附录 A)。
5.4 暗坑四:enum 类型的 OID 物理存储
- 问题现象:解析枚举类型字段时,得到的不是标签文本,也不是 1..N 的序号,而是
16394、16790等离散的大整数。 - 底层机理:PostgreSQL 在堆页中存储枚举值时,写入的是系统表
pg_enum中全局分配的oid(4 字节无符号整型)。由于系统库已重建,原有的pg_enum映射已丢失。 - 修复方案:通过将提取到的 OID 与
workflow_node_runs、image_session_rounds中的业务上下文、文件路径及错误日志进行交叉印证,反推还原了枚举映射关系:#16394\rightarrowreference_upload#16396\rightarrowgenerated_image#16772\rightarrowproduct_context#16790\rightarrowsucceeded
5.5 其他关键校验约束
- MAXALIGN 尾部清零校验:元组数据读取完毕后,末尾填充字节数必须在 [0, 7] 区间内,且填充字节必须全部为
0x00。出现非零值说明元组长度或字段偏移解析错误,整行丢弃。 - JSONB 版本头剥离:
jsonb物理存储的首字节为版本号(1),解析时需剥离首字节后再执行 UTF-8 解码与 JSON 合法性校验。 - 严格值域过滤:布尔值仅允许
0x00与0x01;timestamptz(基于 2000-01-01 UTC 的 int64 微秒数)必须落在合理年份区间内;主键必须符合 UUID 正则。
6. 全量扫描、去重与 SQL 灌库
6.1 两阶段流水线与中间格式
数据处理分为清晰的两个阶段:
- 第一阶段(扫描解析):
full_scan2.py(处理链页)与scan_singles.py(处理单页),输出统一格式的 JSONL 文件(每行为{t: 表名, r: 行数据字典})。 - 第二阶段(清洗去重与 SQL 生成):
make_sql3.py流式读取 JSONL,执行业务级清洗、去重与 SQL 生成。
选择 JSONL 作为中间格式,使得处理过程具备流式可重入性,且便于使用 grep / jq 随时进行人工断点抽查。
6.2 MVCC 多版本去重规则
PostgreSQL 在执行 UPDATE 时会生成新元组并保留旧版本(直至 VACUUM 清理)。裸盘雕刻会同时捞出历史版本与最新版本。去重规则定义如下:
- 去重键:
(table_name, primary_key)。 - 版本仲裁策略:
- 优先选取时间戳(
updated_at/created_at)最新的元组。 - 若时间戳完全一致,选取非空(Non-null)字段数量最多的元组(更完整的快照)。
- 优先选取时间戳(
6.3 灌库过程中的问题排查
在生成 insert_v5.sql 并执行灌库时,排查并解决了以下两个关键问题:
- 多行文本换行符被转为 NULL 导致事务回滚:
- 原因:数据清洗函数将不可打印字符统一视作坏数据,误将
\n、\t、\r也归入非法字符并转为 NULL,导致image_session_rounds.prompt触发 NOT NULL 约束。 - 修复:调整字符过滤逻辑,白名单保留合法换行与制表符,仅过滤真正的
0x00(NUL)等无法存入 PostgreSQL 文本类型的字节。
- 原因:数据清洗函数将不可打印字符统一视作坏数据,误将
ON CONFLICT DO NOTHING静默吞掉真实数据:- 原因:在恢复初期,为提供前端骨架曾人工插入过一批带有占位名称的商品记录。随后使用
ON CONFLICT DO NOTHING灌入恢复出的真实商品行时,主键冲突导致真实数据被静默跳过,前端仍显示占位名称。 - 修复:将商品表插入逻辑切换为显式
UPDATE products SET ...,按主键覆盖占位记录。
- 原因:在恢复初期,为提供前端骨架曾人工插入过一批带有占位名称的商品记录。随后使用
- 绕过外键依赖约束:
- 在事务开头设置
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_edges | 2,923 | 2,935 | 全部恢复 ID 存在,拓扑关系闭环 |
workflow_nodes | 2,472 | 2,490 | 节点结构完整 |
workflow_node_runs | 2,430 | 2,430 | 运行历史 100% 精确匹配 |
image_session_assets | 1,685 | 1,704 | 关联关系完整 |
source_assets | 1,195 | 1,401 | 基础数据 + 磁盘反向重建封面 |
image_session_rounds | 1,127 | 1,127 | Prompt 文本完整无缺失 |
workflow_runs | 522 | 522 | 运行时实例精确匹配 |
image_sessions | 367 | 368 | 会话数据恢复上线 |
creative_briefs | 302 | 302 | 创意提案精确匹配 |
products | 245 | 260 | 254 个拥有真实名称,封面达 250/260 |
product_workflows | 146 | 149 | 工作流定义完整恢复 |
image_session_generation_tasks | 137 | 137 | 任务生成上下文完整 |
poster_variants / gallery | 51 / 51 | 51 / 51 | 衍生图与画廊元数据精确匹配 |
| 合计恢复去重行 | 13,653 | — | 单事务全量入库,零语法与约束报错 |
10. 根因剖析与工程防范
10.1 事故根因
本次事故暴露了多层防护机制的缺失:
- 操作审核缺失:包含破坏性开关
-v的运维命令未经逐参数审核即在生产环境执行。 - 单点存储缺陷:数据唯一副本存放在 Docker 命名卷中,未建立定时逻辑备份(
pg_dump)。Docker 卷属于编排设施,不具备容灾备份属性。 - 快照防御缺失:云服务器未配置定时磁盘快照,丧失了分钟级系统回滚的能力。
10.2 改进措施
- 高危命令防护:在生产环境中针对
docker compose down、rm、drop等命令建立拦截与二次确认机制;禁止自动化运维脚本携带-v参数。 - 多层备份体系:
- 建立每日自动
pg_dump,备份数据异机同步至独立对象存储。 - 开启云主机每日滚动快照。
- 建立每日自动
- 人机协作边界:明确 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()