在 AFTER STATEMENT 触发器中使用有界递归查询,拒绝深度超过允许限制的分类。要求使用可序列化写入事务并重试序列化失败,以便并发更改无法共同创建无效层级。

本教程面向在 Mac 终端中使用本地 PostgreSQL 数据库的开发者。具体任务是强制执行最多三级分类,包括现有子树移动的情况。所有示例均使用合成文章分类。

明确深度限制的含义

此处,根分类深度为 1。其子分类深度为 2,孙分类深度为 3。第四级无效。如果您的应用将根计为深度零,请在选择限制前转换该约定。

该表使用邻接列表:每个分类存储一个指向另一个分类的可选 parent_id。外键确保引用的父分类存在,但深度规则需要检查多行。PostgreSQL 明确不支持 CHECK 约束,即依赖其他行作为持续维护的保证。将遍历放入由 CHECK 调用的函数中并不能消除这一限制。约束。

下面的实现针对 PostgreSQL 18,即本文检索到的当前稳定文档版本。请使用已安装的 PostgreSQL 18 服务器和客户端,以及用于 JavaScript 示例的 Node.js 24 和 npm。早期 PostgreSQL 版本不在本教程的兼容性声明范围内。node-postgres 文档将 Node.js 24 列为兼容版本之一。PostgreSQL 18 文档,node-postgres 兼容性。

为何在语句后验证整个层级?

仅检查新父分类的祖先不足以应对子树移动。假设 Articles → Guides → Editing 已占据三级。将 Articles 移至另一根分类下会使 Editing 成为四级分类,尽管被移动行本身仅到达深度二。

提议移动的决策规则是:子树根的新深度 + 子树高度不得超过最大值,其中高度计算从子树根到最深后代的边数。例如,新根深度 2 加上子树高度 2 将产生最深深度 4。

本实现不维护单独的祖先和后代计算,而是在每条修改语句后检查每个分类。PostgreSQL 文档指出 AFTER 触发器能看到操作所做的更改,且语句触发器每条语句运行一次。这也支持多行插入和更新,而不依赖行处理顺序。CREATE TRIGGER。

有界遍历从每个分类开始,沿父链接向上。在任何向上遍历中发现四个节点即证明三级规则失效。环也会产生四节点遍历,因为它永远无法到达根。因此同一界限同时拒绝过深深度和环,无需无界递归搜索。该设计源自 PostgreSQL 文档中关于递归 CTE 求值的描述。WITH 查询。

创建表和深度触发器

在 Mac 文本编辑器中,将以下完整设置保存为 category-depth.sql。它会创建一个新模式,因此请在不存在该模式的本地开发数据库中运行一次。它不会删除或替换现有表。

触发器函数返回 trigger,这是 PL/pgSQL 数据变更触发器所要求的。它使用带有显式错误代码的 RAISE EXCEPTION 来区分无效层级和不正确的事务设置。触发器函数,错误和消息。

BEGIN ISOLATION LEVEL SERIALIZABLE;

CREATE SCHEMA category_depth_demo;

CREATE TABLE category_depth_demo.category (
    id integer PRIMARY KEY,
    name text NOT NULL,
    parent_id integer REFERENCES category_depth_demo.category(id),
    CONSTRAINT category_not_own_parent
        CHECK (parent_id IS NULL OR parent_id <> id)
);

CREATE INDEX category_parent_idx
    ON category_depth_demo.category(parent_id);

CREATE FUNCTION category_depth_demo.enforce_depth()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
    max_levels CONSTANT integer := 3;
    bad_id integer;
BEGIN
    IF current_setting('transaction_isolation') <> 'serializable' THEN
        RAISE EXCEPTION
            'Category writes require a SERIALIZABLE transaction'
            USING ERRCODE = '25000';
    END IF;

    WITH RECURSIVE walk(origin_id, parent_id, levels) AS (
        SELECT id, parent_id, 1
        FROM category_depth_demo.category

        UNION ALL

        SELECT w.origin_id, p.parent_id, w.levels + 1
        FROM walk AS w
        JOIN category_depth_demo.category AS p
          ON p.id = w.parent_id
        WHERE w.levels <= max_levels
    )
    SELECT origin_id INTO bad_id
    FROM walk
    WHERE levels = max_levels + 1
    LIMIT 1;

    IF FOUND THEN
        RAISE EXCEPTION
            'Category % exceeds % levels or belongs to a cycle',
            bad_id, max_levels
            USING ERRCODE = '23514';
    END IF;

    RETURN NULL;
END;
$$;

CREATE TRIGGER category_depth_guard
AFTER INSERT OR UPDATE OR DELETE
ON category_depth_demo.category
FOR EACH STATEMENT
EXECUTE FUNCTION category_depth_demo.enforce_depth();

INSERT INTO category_depth_demo.category(id, name, parent_id)
VALUES
    (1, 'Articles', NULL),
    (2, 'Guides', 1),
    (3, 'Editing', 2),
    (4, 'Reference', NULL),
    (10, 'Notes', NULL),
    (11, 'Examples', NULL);

COMMIT;

该函数通过 PostgreSQL 文档中的 current_setting 函数读取事务的隔离设置。单个 max_levels 常量同时控制递归界限和拒绝阈值。配置设置函数。

打开 Terminal,切换到包含该文件的目录,并运行以下命令。假设您现有的本地连接设置可连接到 localhost 上的 postgres 数据库。-X 选项跳过个人 psql 启动文件;ON_ERROR_STOP 在错误时停止文件处理。psql。

psql -X -h localhost -d postgres -v ON_ERROR_STOP=1 -f category-depth.sql

外键保持其默认删除行为:删除仍有子分类的分类会被拒绝,而非静默删除其子树。父索引支持按 parent_id 查找;它不能消除触发器的全表验证。PostgreSQL 不会自动在外键的引用方创建索引。外键。

检查插入、子树移动和环

在连接应用前,根据种子数据验证边界情况。下面每个操作假设原始设置保持不变。

操作 预期结果 原因
在分类 3 下插入分类 5 拒绝 新分类将占据第 4 级。
将分类 1 移至分类 4 下 拒绝 分类 3 将移至第 4 级。
将分类 1 移至分类 3 下 拒绝 该移动会创建环。
将分类 3 移至分类 4 下 接受 分类 3 成为第 2 级分类。
在默认 Read Committed 事务中更新名称 拒绝 此实现要求每次分类写入都使用可序列化事务。

对于具体的子树测试,使用 psql -X -h localhost -d postgres 启动交互会话,然后执行此代码块。更新应引发深度异常。ROLLBACK 结束失败的事务,保留原始层级不变。

BEGIN ISOLATION LEVEL SERIALIZABLE;

UPDATE category_depth_demo.category
SET parent_id = 4
WHERE id = 1;

ROLLBACK;

SELECT id, name, parent_id
FROM category_depth_demo.category
ORDER BY id;

遍历有意将过深深度和环一起报告。其职责是强制执行不变量;不识别环的确切入口点。诊断查询可单独跟踪访问过的 ID,但对于这种有界接受或拒绝决策来说是不必要的。

使并发写入遵守同一规则

仅靠递归触发器无法解决并发问题。两个事务各自验证层级,却无法看到对方未提交的更改。PostgreSQL 一致性文档描述了此问题,并推荐可序列化事务作为强制执行涉及多行规则的一种受支持方式。应用层面的数据一致性检查。

因此,该设计要求每次分类写入都使用可序列化隔离。成功的可序列化事务表现得如同它们逐个运行。当并发活动阻止这种排序时,PostgreSQL 可拒绝事务,因此成功的更新在其事务提交前只是暂时的。事务隔离。

您可以使用两个合成根 10 和 11 来演示该问题。在两个交互式 psql 会话中,在任一会话提交前执行各自的更新。

-- Session A
BEGIN ISOLATION LEVEL SERIALIZABLE;
UPDATE category_depth_demo.category
SET parent_id = 11
WHERE id = 10;

-- Session B, in a separate psql session
BEGIN ISOLATION LEVEL SERIALIZABLE;
UPDATE category_depth_demo.category
SET parent_id = 10
WHERE id = 11;

然后在每个会话中执行 COMMIT。两个相反的父级更改不能同时提交:它们共同形成一个环。序列化失败可能发生在语句执行期间或提交时。如果一个更改在另一会话执行更新前提交,触发器可直接拒绝现在可见的环。这些是将验证查询与可序列化执行相结合的预期后果。事务隔离。

使用 node-postgres 运行分类移动

在项目目录中,若无包文件则运行 npm init -y,然后用 npm install pg 安装驱动。将下面的完整程序保存为 move-category.mjs。驱动的文档安装包为 pg。node-postgres。

该程序连接到与 psql 命令相同的本地数据库。其他连接字段使用驱动基于环境的默认值。每条事务语句使用一个检出的客户端,这是 node-postgres 所要求的;事务语句不得通过单独的 pool.query 调用分发。连接,事务。

import pg from 'pg';
import { setTimeout as delay } from 'node:timers/promises';

const { Pool } = pg;
const pool = new Pool({
  host: 'localhost',
  database: 'postgres',
  connectionTimeoutMillis: 5000
});

pool.on('error', (error) => {
  console.error('Idle database client error:', error.message);
  process.exitCode = 1;
});

async function moveCategory(id, parentId) {
  const maxAttempts = 5;

  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const client = await pool.connect();
    let discard = false;
    let retry = false;

    try {
      await client.query('BEGIN ISOLATION LEVEL SERIALIZABLE');

      const result = await client.query(
        `UPDATE category_depth_demo.category
         SET parent_id = $1
         WHERE id = $2
         RETURNING id, name, parent_id`,
        [parentId, id]
      );

      if (result.rowCount !== 1) {
        throw new Error(`Category ${id} does not exist`);
      }

      await client.query('COMMIT');
      return result.rows[0];
    } catch (error) {
      try {
        await client.query('ROLLBACK');
      } catch {
        discard = true;
      }

      retry = !discard && error.code === '40001'
        && attempt < maxAttempts;

      if (!retry) throw error;
    } finally {
      client.release(discard);
    }

    if (retry) await delay(25 * attempt);
  }
}

try {
  const moved = await moveCategory(3, 4);
  console.log(moved);
} catch (error) {
  console.error(error.code ?? 'APP_ERROR', error.message);
  process.exitCode = 1;
} finally {
  await pool.end();
}

用 node move-category.mjs 运行。针对原始种子数据,分类 3 移至分类 4 下,返回的行包含 parent_id: 4。查询通过 $1 和 $2 提供值,遵循驱动的文档参数接口。查询。

程序在 finally 中返回客户端,若回滚失败则丢弃,脚本结束时关闭连接池。这些生命周期操作遵循文档中的池 API。pg.Pool。

通过重复整个事务来重试 SQLSTATE 40001,包括确定移动的任何读取操作。不要自动重试触发器的 23514 深度错误:提议的更改需要修正。五次尝试限制和短暂递增延迟仅为示例性应用选择,并非 PostgreSQL 要求或最终完成的保证。序列化失败处理。

适配示例前了解限制

此实现在每条语句后检查层级,包括仅更新名称的语句。事务不能在一条语句中暂时创建过深深度,然后在后续语句中修复。请规划每条语句以保持有效层级。PostgreSQL 支持延迟约束触发器,但它们必须是 AFTER ROW 触发器;此语句触发器不能简单地标记为可延迟。CREATE TRIGGER。

权衡在于可预测的验证工作量:对于三级限制,递归关系每个分类最多包含四行遍历,尽管实际执行成本取决于查询计划和数据库内容。大表或频繁的小写入在采用全表扫描前应先进行测量。用受影响子树检查替换它,需要保留后代验证、环拒绝和并发论证。

对于现有表,安装触发器本身不会审计存储的行。在暂停普通写入器的情况下,使用函数中的递归查询作为独立审计,将 max_levels 替换为 3 并选择每个匹配的 origin_id。在启用写入路径前,解决任何报告的路径并单独验证父引用。

有用的不变量很简单:每个分类必须在三个节点内到达根。有界遍历跨子树移动和环检查该条件;可序列化事务和完整重试使其在并发更新中得以维持。将这些部分结合在一起,以将示例适配到您的分类表。

来源