这次案例的难点不在“数据库坏了”,而在 PostgreSQL 的系统目录也不可用了:表、字段、类型、索引这些元信息都无法从生产库里读出来。现场能依赖的只剩测试环境 DDL,以及被勒索软件加密后的数据库文件。救援思路因此从传统的 pg_dump、WAL 回放、物理备份恢复,切换到更底层的“按表文件识别结构,再把关键数据导出”。
难点:没有系统目录,PostgreSQL 就失去了地图
PostgreSQL 的普通表数据通常落在数据目录下的 relation 文件里,但文件名不是表名,而是 relfilenode。正常情况下,我们通过系统目录把 relfilenode -> 表 -> 字段定义 串起来:
pg_class告诉你表和 relfilenode 的关系。pg_attribute告诉你字段顺序、类型、是否可空。pg_type告诉你类型解释方式。- TOAST 表还会把大字段拆到另一组文件里。
一旦系统目录不可读,就像拿着一堆没有标签的零件。即使某些数据页还能被扫描出来,也不知道这些字节属于哪张表、哪一列、该按 int8、text 还是 timestamp 解码。
这份现场报告的关键点在于:测试环境里有 DDL。虽然它不等于生产库的实时目录,但至少提供了“已知表结构”。于是 PDU dropscan 被改造成一种匹配器:拿每个疑似表文件,尝试套用已知表结构,看哪种结构能稳定解出合理数据,再导出核心表。
救援路线:从“恢复数据库”改成“恢复数据集”
面对全文件加密后的 PostgreSQL,目标要收缩。不要一开始就试图恢复完整实例,尤其在系统目录损坏时,这通常成本极高。更可行的路线是:
- 冻结现场,复制数据目录镜像,不在原盘上实验。
- 收集同版本 PostgreSQL、扩展清单、测试环境 DDL、业务主键规则、关键表清单。
- 对数据文件做页级扫描,识别还能解析的 heap tuple。
- 用已知 DDL 生成候选结构,逐个文件尝试匹配。
- 先导出最关键的业务表,再处理外键、字典表、日志表和 TOAST 数据。
这里的“匹配”不是猜表名那么简单,而是多维度打分:字段数量是否对得上、固定长度字段是否能落在合理边界、时间戳是否在业务时间范围内、枚举/状态字段是否符合常见取值、主键是否递增或分布合理。
可以这样实践:用测试库 DDL 做一次离线盘点
下面这个脚本不是 PDU dropscan 的替代品,而是一个可改造的“小工具”:它从测试环境导出的 DDL 里提取表和字段,生成一份候选结构清单。真实救援时,可以把这份 JSON 交给自研扫描器、恢复工具或人工分析流程使用。
运行前准备:把测试环境 DDL 保存为 schema.sql。脚本只覆盖常见 CREATE TABLE 语句,复杂语法需要按你的库调整。
#!/usr/bin/env python3
import json
import re
from pathlib import Path
DDL_FILE = "schema.sql"
TYPE_HINTS = {
"bigint": "int8",
"integer": "int4",
"int": "int4",
"smallint": "int2",
"text": "varlena",
"varchar": "varlena",
"character varying": "varlena",
"timestamp": "timestamp",
"timestamp without time zone": "timestamp",
"boolean": "bool",
"uuid": "uuid",
"numeric": "numeric",
}
def split_columns(block: str):
columns = []
depth = 0
current = []
for char in block:
if char == "(":
depth += 1
elif char == ")":
depth -= 1
if char == "," and depth == 0:
columns.append("".join(current).strip())
current = []
else:
current.append(char)
tail = "".join(current).strip()
if tail:
columns.append(tail)
return columns
def normalize_type(raw: str):
value = re.sub(r"\s+", " ", raw.lower()).strip()
value = re.sub(r"\(.*\)", "", value).strip()
return TYPE_HINTS.get(value, value)
def parse_schema(sql: str):
tables = []
pattern = re.compile(
r"CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([\w.\"]+)\s*\((.*?)\);",
re.IGNORECASE | re.DOTALL,
)
for match in pattern.finditer(sql):
table_name = match.group(1).replace('"', '')
column_block = match.group(2)
fields = []
for item in split_columns(column_block):
head = item.strip().split()
if not head:
continue
if head[0].upper() in {"PRIMARY", "UNIQUE", "CONSTRAINT", "CHECK", "FOREIGN"}:
continue
column_name = head[0].replace('"', '')
raw_type = " ".join(head[1:4])
fields.append({"name": column_name, "type_hint": normalize_type(raw_type)})
tables.append({"table": table_name, "columns": fields, "column_count": len(fields)})
return tables
if __name__ == "__main__":
schema = Path(DDL_FILE).read_text(encoding="utf-8")
print(json.dumps(parse_schema(schema), ensure_ascii=False, indent=2))
执行:
python3 ddl_to_candidates.py > candidates.json
jq '.[] | {table, column_count}' candidates.json
如果你有一个 PostgreSQL 数据目录副本,可以先做只读盘点,找出可能的 relation 文件。以下命令只做文件层面的候选收集,不会修改数据:
PGDATA_COPY=/recovery/pgdata-copy
find "$PGDATA_COPY/base" -type f \
-regextype posix-extended \
-regex '.*/[0-9]+(\.[0-9]+)?' \
-printf '%s %p\n' \
| sort -nr \
| head -100
这些文件还不能直接等同于表。它们只是后续 dropscan、页解析、结构匹配的输入。真正导出前,需要在隔离环境中验证每个文件的解码结果。
匹配表文件时,看哪些信号
在系统目录不可用时,救援人员要把数据库知识和业务知识合在一起用。常见信号包括:
- 字段形态:定长字段、变长字段、NULL bitmap 是否符合候选表结构。
- 时间范围:
created_at、updated_at解出来是否落在系统上线之后、事故之前。 - 主键规律:订单表、用户表常见自增 ID 或 UUID 分布,不应大面积乱码。
- 状态枚举:支付状态、订单状态、删除标记通常有有限取值。
- 行宽估算:候选表的平均行宽和文件大小、业务量是否大致一致。
这也是为什么测试环境 DDL 很重要。没有它,工具只能盲扫;有了它,扫描器可以把“可读字节”变成“可能属于某张表的行”。
风险边界:能抢回数据,不代表能恢复一致性
这种救援方式更接近数字取证,不是标准数据库恢复。它有几个边界必须提前说清楚:
- 事务一致性可能丢失,尤其是没有可用 WAL 和检查点上下文时。
- 二级索引通常不是优先恢复目标,核心是 heap 数据本身。
- TOAST、大对象、压缩字段会显著增加难度。
- DDL 如果和生产不一致,字段解释可能偏移,导出结果需要业务校验。
- 勒索软件如果覆盖了原始页,而不是只做可逆加密,恢复率会下降。
采用建议:把这次事故前置成演练
最好的恢复,是不用走到 dropscan 这一步。团队可以把下面几件事纳入 PostgreSQL 运维基线:
- 定期做物理备份和逻辑备份恢复演练,而不是只检查备份文件存在。
- 把生产 DDL、扩展版本、关键参数纳入版本管理或每日快照。
- 对
PGDATA、备份仓库、对象存储使用分权账号,避免一把密钥全盘沦陷。 - 为关键表维护数据字典,包括主键、时间字段、枚举含义和业务校验规则。
- 在隔离环境中准备页级扫描和应急导出工具链,事故时不要从零编译。
这次案例最有价值的地方,是把 PostgreSQL 救援从“目录还在”的舒适区推进到了“只剩文件和 DDL”的极限场景。真正救回来的不是一个完美数据库实例,而是业务继续运转所需的核心数据。