从逻辑复制到“有身份的 NULL”:爱丁堡 PostgreSQL 聚会带来的两道数据库难题

2026-09-18 18 预计阅读时间: 1 分钟
来源: postgr.es AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:11 分钟

苏格兰的 PostgreSQL 活动现在有了一个稳定入口:postgres.scot。页面刻意保持精简,目前汇总 PostgreSQL Edinburgh User Group(PostgresEDI)的报名、日历订阅和 RSS 信息,未来也会收录苏格兰其他 PostgreSQL 活动。相比每次跟着活动平台迁移,一个长期可收藏、可分享的地址对技术社区更有价值。

这次爱丁堡聚会的两场分享,恰好触及数据库系统中两个经常被低估的问题:托管数据库里的数据究竟有多“属于你”,以及 SQL 的一个 NULL 是否足以表达现实世界中的不同未知值。

把恢复能力从云服务中拆出来

Torsten Förtsch 的分享从迁移到 Aurora 后产生的疑问开始:当数据库运行在他人的服务中时,“拥有数据”到底意味着什么?仅仅能够执行查询或导出表,并不等于掌握完整、可验证的恢复路径。

他的思路是在逻辑层重新构建时间点恢复能力:

  1. 使用 pg_dump 生成基础副本;
  2. 通过 wal2json 捕获后续变更流,而不是依赖物理 WAL 归档;
  3. 将 JSON 变更翻译为 SQL;
  4. 在自己控制的 PostgreSQL 实例中重放,目标端可以采用不同操作系统或 PostgreSQL 版本。

这种方法的吸引力在于可移植性。物理备份通常和 PostgreSQL 大版本、平台以及底层存储布局紧密相关,而逻辑事件描述的是插入、更新和删除,理论上更容易跨环境重建数据。

但真正困难的部分不是把 JSON 变成 SQL。分享中,捕获与重放的核心转换只有约 35 行 jq;更棘手的是确定基础 pg_dump 对应变更流中的哪个位置。如果转储与变更流没有精确对齐,就会出现两类错误:

  • 重放位置过早,同一修改被执行两次;
  • 重放位置过晚,转储完成前后的部分修改永久丢失。

这也是为什么“做一次 dump,再保存后续事件”不能直接等同于时间点恢复。工程实现必须保存一致性边界,并证明恢复过程能够从该边界无缝继续。

设计逻辑恢复链时要补齐的细节

如果准备实践类似方案,可以把下面几项当作最低检查表:

  • 事务边界:同一事务中的事件必须按顺序、原子地重放;
  • 稳定行标识UPDATEDELETE 最好通过主键或唯一键定位,否则回放可能退化为全表扫描,甚至修改错误的行;
  • SQL 参数化:不要直接拼接文本值,特别是引号、反斜线、二进制数据和 NULL
  • DDL 与序列:表结构变化、序列当前值和扩展对象不会自动包含在普通行事件中;
  • 复制槽生命周期:消费端停止后,逻辑复制槽可能让源端持续保留 WAL,最终耗尽磁盘;
  • 幂等与断点:保存最后成功提交的位置,而不是只记录最后看到的事件;
  • 恢复演练:备份是否有效,只能通过定期恢复和校验来证明。

逻辑恢复可以提高云迁移和跨版本恢复的自由度,但它不是物理 PITR 的无条件替代品。对于大型数据库,高事件吞吐量、DDL 密集型系统或严格的恢复时间目标,通常需要把逻辑方案与云快照、物理备份和归档策略组合起来。

为什么 Age = Age 也会丢行

Paolo Guagliardo 的分享从一条看似不可能出错的 SQL 开始:

SELECT Name
FROM Person
WHERE Age = Age;

如果 Jane 和 John 的年龄是 NULL,Mary 和 Carl 的年龄分别是 30 和 23,那么查询只会返回 Mary 和 Carl。原因不是 PostgreSQL 的特殊行为,而是 SQL 的三值逻辑:

  • 30 = 30TRUE
  • NULL = NULL 不是 TRUE,而是 UNKNOWN
  • WHERE 只保留结果为 TRUE 的行。

下面这段 SQL 可以直接复制到 psql 中运行:

DROP TABLE IF EXISTS person;

CREATE TABLE person (
    name text PRIMARY KEY,
    age  integer
);

INSERT INTO person (name, age) VALUES
    ('Jane', NULL),
    ('John', NULL),
    ('Mary', 30),
    ('Carl', 23);

-- 只返回 Mary 和 Carl
SELECT name
FROM person
WHERE age = age
ORDER BY name;

-- 展示三值逻辑:NULL 行的 self_equal 仍然是 NULL
SELECT name, age, age = age AS self_equal
FROM person
ORDER BY name;

-- 如果需求只是让 NULL 与 NULL 在比较中视为相等,可以使用:
SELECT name
FROM person
WHERE age IS NOT DISTINCT FROM age
ORDER BY name;

IS NOT DISTINCT FROM 能解决“把两个 NULL 当作相等值比较”的问题,但它没有解决分享中更深的一层语义:两个未知年龄是否代表同一个未知事实

标准 SQL 只有一种 NULL。Jane 的年龄未知,John 的年龄也未知,但数据库无法表达这两个未知值来自同一份缺失记录,还是两个完全无关的未知事实。数据库理论中的 marked nulls 会为未知值附加标记,从而保留这种身份信息。

在不修改数据库内核的情况下,可以这样近似建模。注意:这是应用层实践示例,不等同于分享中的 PostgreSQL 内核实现。

DROP TABLE IF EXISTS person_age;
DROP TABLE IF EXISTS unknown_value;

CREATE TABLE unknown_value (
    marker_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    reason text NOT NULL
);

CREATE TABLE person_age (
    name text PRIMARY KEY,
    known_age integer,
    age_marker bigint REFERENCES unknown_value(marker_id),
    CHECK (
        (known_age IS NOT NULL AND age_marker IS NULL)
        OR
        (known_age IS NULL AND age_marker IS NOT NULL)
    )
);

INSERT INTO unknown_value (reason)
VALUES ('Age missing from the same historical document');

-- Jane 和 John 指向同一个未知事实;Mary 有已知年龄。
INSERT INTO person_age (name, known_age, age_marker) VALUES
    ('Jane', NULL, 1),
    ('John', NULL, 1),
    ('Mary', 30, NULL);

SELECT p1.name AS person_a, p2.name AS person_b,
       p1.age_marker = p2.age_marker AS same_unknown
FROM person_age AS p1
JOIN person_age AS p2 ON p1.name < p2.name
WHERE p1.age_marker IS NOT NULL
  AND p2.age_marker IS NOT NULL;

这种建模增加了表、外键和查询复杂度,却能区分“值未知”和“未知值的身份”。是否值得采用,取决于业务是否真的需要追踪缺失数据的来源与关联。Paolo 的实现则进一步探索了如何把 marked nulls 放进 PostgreSQL,并使用 TPC-H、TPC-DS 数据评估存储开销;在没有具体测量结果的情况下,不应假定这种表达能力没有成本。

一个小社区页面连接的却是大问题

postgres.scot 的意义不在页面功能多,而在于提供稳定的社区坐标。当前页面把用户带到 PostgresEDI 的报名、日历和 RSS,后续还会容纳苏格兰其他 PostgreSQL 活动;它是志愿者维护的网站,并不代表 PostgreSQL 项目或 PostgreSQL Community Association。

八月聚会在爱丁堡大学 Paterson's Land 举行,两场技术分享之外还有披萨、饮料和会后交流。下一场活动计划于 9 月 24 日回到 Paterson's Land,18:00 开门,已公布的主题是 ColdFront:把冷 PostgreSQL 数据分层迁移到对象存储上的 Apache Iceberg,同时让这些行继续能够从 PostgreSQL 读取。社区也仍在征集第二场分享及后续讲者,第一次做技术演讲的人同样受欢迎。

采用这些思路前,先问四个问题

无论是构建逻辑恢复链,还是为缺失值增加身份,都不应只看功能是否“能跑”:

  1. 语义是否明确? 恢复位置、事务顺序和未知值身份都必须有可验证定义。
  2. 故障是否可见? 监控复制延迟、WAL 积压、回放失败和约束冲突。
  3. 成本是否测量? 测试恢复耗时、事件吞吐量、额外存储及查询复杂度。
  4. 是否定期演练? 对备份做完整恢复,对数据模型用真实查询验证,而不是只检查文件或表是否存在。

两场分享讨论的对象不同,却指向同一个工程原则:数据库里的控制权和表达能力,都不能靠假设获得。它们需要明确的模型、可重复的实验,以及真正执行过的恢复或查询证明。


相关推荐