PostgreSQL 的 NOT VALID 外键会跳过对现有行的初始检查,同时强制检查新的引用。较早的不匹配行会保留,直到修复并完成验证。PostgreSQL 17:ALTER TABLE

本指南面向在 Mac 的终端中工作的开发者,介绍如何为已有数据的表添加外键并理解操作结果。完整的 node-postgres 示例只使用合成的文章数据,并追踪一个不匹配的引用,从创建约束到修复再到成功验证的整个过程。

现有行和新写入会发生什么?

假设有两个表:articles 包含文章 ID 和标题,article_links 包含对这些 ID 的引用。如果某个链接的非空 article_id 没有对应的文章,该链接就是不匹配的。使用 NOT VALID 添加外键,可以让较早存在的不匹配继续保留,同时为之后的写入建立约束。PostgreSQL 17:ALTER TABLE

添加约束后的操作 此示例中的结果
不改动较早的不匹配链接 它仍保留在数据库中
插入一个引用现有文章的链接 成功
插入一个引用缺失文章的链接 失败
将链接的文章 ID 改为另一个不存在的 ID 失败
不匹配链接仍存在时进行验证 验证失败
修复所有不匹配的引用后再验证 验证可以成功

这些结果符合文档所述的添加约束与验证约束之间的区别。PostgreSQL 17:ALTER TABLE

一个重要例外是更新后外键值保持不变的情况。在 PostgreSQL 18 中,如果新旧键比较后相等,实现可以跳过外键检查。因此,只修改较早存在的不匹配链接的标签,可能无需修复其引用就能成功。实现还对当前事务插入的行设有特殊处理,因此不应将此结论推广到所有更新序列。PostgreSQL 18 外键实现

实际影响是,标签修改成功并不能有力证明清理工作已完成。应直接检查不匹配的引用,并通过验证确认该约束对整个表都成立。

需要哪个 PostgreSQL 版本和表结构?

以下示例使用普通的非分区表,适用于 PostgreSQL 17 和 18。分区表存在重要的版本差异:PostgreSQL 17 明确不允许将分区表上的外键声明为 NOT VALID;PostgreSQL 18 则新增了此功能。应检查服务器版本,而不要根据已安装的 Node 软件包推断是否支持。PostgreSQL 17:ALTER TABLE,PostgreSQL 18 发行说明

对于 PostgreSQL 17 上的分区引用表,受支持的直接做法是先更正引用,再正常添加外键,并执行初始验证扫描。如果必须在分区父表上分阶段验证,PostgreSQL 18 提供了文档所述的功能。本教程采用简单的表布局,以免分区层级细节掩盖行级行为。

被引用的键也需要适当的唯一性保证。这里,articles.article_id 是主键。引用列有意允许 NULL:在默认的 MATCH SIMPLE 规则下,空引用不需要匹配任何文章。如果每个链接都必须指向一篇文章,请另外添加 NOT NULL。PostgreSQL 18:CREATE TABLE

在 Mac 上运行完整的合成数据示例

你需要一个正在运行的 PostgreSQL 数据库、已有且可用的本地连接配置,以及 node-postgres 支持的 Node.js 版本。在终端中进入项目目录,运行以下命令安装软件包,然后将完整程序保存为 not-valid-demo.mjs。node-postgres 官方入门文档介绍了软件包的安装方法。node-postgres 文档

npm install pg

程序使用 new pg.Client(),它会读取 PostgreSQL 连接环境变量,例如 PGHOST、PGPORT、PGDATABASE 和 PGUSER。使用已有的本地数据库连接设置。node-postgres:连接

两个表都是临时表,仅在当前连接的会话期间存在。PostgreSQL 要求临时表的外键引用其他临时表,因此程序会同时创建关系两端的表。PostgreSQL 18:CREATE TABLE

每个操作都会使用单独的 await 查询。值通过 $1 和 $2 等 node-postgres 参数传入;表名和约束名则固定在 SQL 中。node-postgres:查询

import pg from 'pg';

const client = new pg.Client({ connectionTimeoutMillis: 5000 });
const validate = `ALTER TABLE pg_temp.article_links
  VALIDATE CONSTRAINT article_links_article_fk`;

async function expectForeignKeyError(label, sql, values = []) {
  try {
    await client.query(sql, values);
  } catch (error) {
    if (error.code !== '23503' ||
        error.constraint !== 'article_links_article_fk') throw error;
    console.log(label + ': 23503 (expected)');
    return;
  }
  throw new Error(label + ': unexpectedly succeeded');
}

async function showStatus(label) {
  const result = await client.query(`
    SELECT conname, convalidated
    FROM pg_constraint
    WHERE conrelid = 'pg_temp.article_links'::regclass
      AND conname = 'article_links_article_fk'
      AND contype = 'f'
  `);
  console.log(label, result.rows);
}

try {
  await client.connect();
  console.log((await client.query('SHOW server_version')).rows);
  await client.query(`CREATE TEMP TABLE articles (
    article_id integer PRIMARY KEY,
    title text NOT NULL
  )`);
  await client.query(`CREATE TEMP TABLE article_links (
    link_id integer PRIMARY KEY,
    article_id integer,
    label text NOT NULL
  )`);
  await client.query(
    'INSERT INTO pg_temp.articles VALUES ($1, $2)',
    [1, 'Desk arrangement']
  );
  await client.query(`INSERT INTO pg_temp.article_links VALUES
    (10, 1, 'Matching article'),
    (11, 99, 'Older unmatched reference')`);
  await client.query(`ALTER TABLE pg_temp.article_links
    ADD CONSTRAINT article_links_article_fk
    FOREIGN KEY (article_id)
    REFERENCES pg_temp.articles (article_id) NOT VALID`);
  await showStatus('After adding');

  await expectForeignKeyError(
    'New unmatched reference',
    'INSERT INTO pg_temp.article_links VALUES ($1, $2, $3)',
    [12, 99, 'New reference']
  );
  await expectForeignKeyError(
    'Changed unmatched reference',
    'UPDATE pg_temp.article_links SET article_id = $1 WHERE link_id = $2',
    [98, 11]
  );
  await client.query(
    'UPDATE pg_temp.article_links SET label = $1 WHERE link_id = $2',
    ['Revised label', 11]
  );
  console.log('Label-only update succeeded');

  const unmatched = await client.query(`
    SELECT l.link_id, l.article_id, l.label
    FROM pg_temp.article_links AS l
    WHERE l.article_id IS NOT NULL
      AND NOT EXISTS (
        SELECT 1 FROM pg_temp.articles AS a
        WHERE a.article_id = l.article_id
      )
    ORDER BY l.link_id
  `);
  console.log('Unmatched references', unmatched.rows);
  await expectForeignKeyError('Validation before repair', validate);
  await showStatus('After failed validation');

  await client.query(
    'UPDATE pg_temp.article_links SET article_id = $1 WHERE link_id = $2',
    [1, 11]
  );
  await client.query(validate);
  await showStatus('After successful validation');
} finally {
  await client.end();
}

在同一个项目目录中运行已保存的程序:

node not-valid-demo.mjs

准备期间,JavaScript 通过了 Node 语法检查。研究环境无法执行数据库操作;以下是根据文档行为推断的预期结果,并非实际执行日志。

应如何解读结果?

首先,约束应显示 convalidated 的值为 false。系统目录将该字段定义为约束是否已验证。查询同时按表名和约束名筛选,因为约束名在数据库中不一定唯一。PostgreSQL:pg_constraint

接下来,插入新的不匹配引用以及更改为不匹配引用的操作都应分别产生 23503。PostgreSQL 将该 SQLSTATE 标识为 foreign_key_violation,并建议检查错误代码,而不是匹配本地化的错误消息文本。辅助函数只接受预期的代码和约束;遇到意外错误时,程序会停止。PostgreSQL 错误代码

仅修改标签的更新会使链接 11 仍然指向文章 99。因此,诊断查询应只返回这个链接,其标签现为 Revised label。显式的 IS NOT NULL 条件会排除可选的空引用,而 NOT EXISTS 用于找出在 articles 中不存在的非空 ID。这些只是根据提供的合成行数据推导出的示例结果。

此时验证应当失败。修复操作将链接 11 改为引用文章 1 后,验证应成功,且 convalidated 的值应变为 true。VALIDATE CONSTRAINT 会扫描表以检查此前未经验证的约束;它本身不会修复数据。PostgreSQL:ALTER TABLE

在这个演示中,修复方式是经过明确编辑选择的:两个链接都应指向 Desk arrangement。处理你自己的文章数据时,应逐一检查每个不匹配的引用,再决定是更正 ID、补上缺失的文章,还是删除已过时的链接。自动将所有不匹配的 ID 重定向到同一篇文章,会掩盖数据库无法判断的差异。

如何将这一流程用于已有数据的表?

分三个独立阶段进行:添加并提交 NOT VALID 约束,检查并修复不匹配的引用,然后进行验证。添加外键时,两个表都会取得 SHARE ROW EXCLUSIVE 锁。验证时,引用表会取得 SHARE UPDATE EXCLUSIVE 锁,被引用表会取得 ROW SHARE 锁;验证扫描期间,普通更新仍可并发进行。不过,获取锁仍可能需要等待。PostgreSQL:ALTER TABLE

将初始添加约束与之后的验证放在不同事务中,可以保留分阶段处理的意义:初始事务可以在较长的扫描开始之前结束。应将添加约束安排为短时操作,并将诊断查询作为清理辅助工具,以成功验证作为完成步骤。

该演示有意避免显式事务块,以便逐条处理预期的语句失败。如果要将此流程用于 node-postgres 事务,请自行执行 BEGIN、COMMIT 和 ROLLBACK,并确保事务中的所有语句都使用同一个客户端。node-postgres 文档明确指出,事务语句不要使用 pool.query()。node-postgres:事务

结合使用示例中的三项观察:不匹配的行显示需要修复的内容,被拒绝的新引用证明约束仍在生效,而 convalidated = true 则记录了验证成功。这样,在已有文章表中添加关系时,就有了明确的完成标准。

来源