AI 智能体直接改数据库,约束谁来守?PostgreSQL 约束索引让跨表校验提速最高 2.41 倍

举报
Eveline 发表于 2026/11/01 14:42:44 2026/11/01
【摘要】 大模型写 SQL 时,不提约束则 60 次全无校验,写明约束也常在并发下出错。马里兰大学论文主张把完整性约束收回数据库,并以 PostgreSQL 扩展实现约束索引:跨表唯一性、基数上限、函数依赖的校验吞吐比现有正确方案最高提升 2.41 倍。本文为论文节译,附落地建议。

原文信息:本文译自 arXiv 论文《When the Application Layer Is Disposable: Putting Integrity Constraints Back in the Database》,作者 Hasara Kalumin、Amol Deshpande(美国马里兰大学帕克分校),2026 年 10 月 26 日提交(arXiv:2610.36418,cs.DB)。原文链接:https://arxiv.org/abs/2610.36418 。原文采用 CC BY 4.0 许可。本文为翻译(节译),省略了参考文献与部分相关工作综述,文中图表仅保留表 1,其余配图请见原文;译者在文末补充了译者注。

译者导语:唯一性、基数上限、函数依赖这类跨表约束,大多数团队习惯写在应用层代码里。可如果写库的是一个"用完即弃"的 AI 智能体呢?这篇论文做了个小实验:让两款大模型写 60 次 SQL,在不告知约束时,60 次全都没有任何校验;把约束写进提示词后,依然有大量代码在并发下会出错。作者据此主张把约束收回数据库,并在 PostgreSQL 扩展里实现了"约束索引",跨表约束校验的吞吐最高比现有正确方案高 2.41 倍。对写 SQL、做数据库设计、或正在让 AI 接触生产库的读者,都值得一读。


摘要

完整性约束从关系模型诞生之初就是它的核心组成部分。但今天,除了主键、单行 CHECK 和参照完整性,大多数约束都由上层应用来保证。SQL-92 就已标准化的 SQL ASSERTION,至今在主流关系型数据库中仍支持有限。由于两方面的变化,这种状况越来越难以接受。

第一,由大模型驱动的应用和智能体(agent)越来越多地直接对数据库发出查询和更新语句,绕过了承载业务不变式(invariant)的应用层。这类代码往往按需生成、用完即弃,数据库因而成了唯一能长期记录并强制执行这些不变式的组件。

第二,E/R 模型、属性图等高层概念模型里很自然的约束,在为了性能选择的关系映射之下会被拆散到多个表中。拆出来的多表约束缺乏数据库的直接支持,只有一部分能用触发器等现有机制高效实现。

这些变化促使我们主张:彻底改造数据管理系统中约束的定义、挖掘、管理和执行方式。作为第一步,我们提出约束索引(constraint index),把跨多表的约束表示为事务性的访问路径(access path),并以 PostgreSQL 扩展的形式实现了原型。约束索引可以强制执行分布式继承层次、折叠关系(folded relationship)、折叠弱实体(folded weak entity)带来的约束,以及基数上限和函数依赖,相对不做任何约束检查的开销很低。在这些负载上,它比"保证正确"的基线方案中更好的那一个高出最多 2.41 倍。我们还发现,由大模型生成的写操作会大量违反未被强制执行的约束;即使把约束清单交给它们,表现也很不稳定。

一、引言

完整性约束本应是关系含义的内在组成部分——Codd 第 10 条规则写道:"特定关系数据库的完整性约束必须能用关系数据子语言定义并存储在目录中,而不是写在应用程序里。"然而,尽管 SQL 标准自 1992 年起就通过 CREATE ASSERTION 支持任意断言,如今大多数数据库只支持有限的几种内置约束。据我们所知,主流数据库中只有 Oracle 26ai 支持 CREATE ASSERTION,而且限制不少。

支持不足的主要原因,是复杂约束(尤其是跨多表、甚至同一张表内跨多行的约束)的执行成本高且难以预估。约束执行还牵涉并发控制:在读已提交(read committed)等较低隔离级别下,更新的语义更难讲清楚。虽然可以用触发器或影子表实现复杂约束,但久而久之,这类约束的执行自然上移到了应用层,并一直留在那里。

我们认为这种分工已不再合理,数据库必须更主动地识别和执行多表约束。原因有两个:

  • 数据越来越多地通过高于关系模型的抽象来建模和访问。 常见的关系映射会把高层模型里"局部"的约束变成横跨多个关系、或嵌在折叠结构里的不变式,超出了大多数数据库直接支持的约束范围。
  • 更新不再只来自应用层。 大模型应用和自主智能体越来越多地直接生成数据库更新,往往不会留下可以维护约束的长期应用代码。我们对大模型生成 SQL 的对照研究表明,即使明确给出约束,也不能可靠地得到正确的执行逻辑,并发执行时尤甚。

因此,数据库必须既提供约束的持久化定义,又提供不依赖于具体写入方行为的强制执行。(也可以用所有写操作都必须经过的标准化中间件来承担这一职责,但这只改变组件放在哪里,不影响我们的论点。)

本文提出以约束治理器(constraint governor)为中心的架构,负责管理约束从声明或发现、校验、执行、修订到最终移除的整个生命周期。作为落地的第一步,我们实现了约束索引:把受支持的概念约束表示为特定于映射、由事务维护的访问路径,物化出高效检测违规所需的紧凑辅助状态,从而避免反复执行多表校验查询。原型以 PostgreSQL 扩展实现,辅助状态存放在共享内存中,通过细粒度逻辑锁和统一的事务级撤销日志(undo journal)与事务处理集成。约束索引的更新与对应的关系更新原子完成,在受约束取值上冲突的事务会被串行化。在 256 个并发客户端下,它比最好的基线高出最多 2.41 倍。

二、动机:两类场景

2.1 来自高层概念模型的约束(概要)

论文以一个包含两条继承层次的 E/R 模型为例:Person 层次(person、student、instructor)和 Content 层次(content、video、text、post),采用"具体表映射"——每个实例只存在于其中一张表里,子类型表重复存储父类型的属性。由此产生的约束包括:

  • 实体键与属性唯一性:person_id 必须在 3 张表间唯一,content_id 在 4 张表间唯一;若 email 在 Person 上声明唯一,也要跨三张表保证。大多数数据库无法直接声明这种约束。
  • 参照完整性:定义在层次上层的联系(如 contributes_to)没有单一目标表,FOREIGN KEY 无从下手。
  • 参与约束与基数约束:例如每位导师最多带 5 名学生。联系单独成表时,需要对多行计数,键、外键、单行 CHECK 都表达不了。
  • 键以外的函数依赖:如 role → access_profile,要求同一角色的人访问配置相同;对 role 加 UNIQUE 又太严格。
  • 弱实体的标识与归属:若弱实体以复合值数组的形式折叠进属主表,PRIMARY KEY、UNIQUE、FOREIGN KEY 都管不到数组元素。

Hibernate、Django、SQLAlchemy、Rails 等 ORM 在处理概念层约束时也能看到这些问题;属性图的 PG-Keys 等模式提案映射到关系表后,同样会产生跨表约束。

2.2 大模型写入方与约束

与此同时,应用层已不再是更新的唯一来源,甚至不是主要来源。大模型应用和自主智能体越来越多地直接向数据库发语句,往往只看表结构(schema),而且所运行的代码常常是为单次任务生成、用后即弃的。如果不变式写在这类代码里,它们既没人审查,又转瞬即逝,也不会随时间积累。因此,数据库是唯一在所有写入方之间持续存在的组件,也就成了唯一能保存"什么是合法数据"的持久定义的地方——这正是当初的设计本意——同时也是能确定性地拦下违规写入的最后一道关口。

为了获得"不能指望大模型维护未写进 schema 的约束"的具体证据,我们在上面的 schema(去掉 content 表,即完全特化)上做了一个小实验,选取了六条约束:

  1. 两条约束 post、text、video 三张内容表共享的 ID/名称空间;
  2. 两条约束 person、student、instructor 共享的 ID/email 空间;
  3. 每位导师最多带五名学生;
  4. 函数依赖 role → access_profile。

针对每条约束,我们构造一个具体、完整描述的插入或更新场景——除受该约束管辖的属性外,所有属性都给出字面值——请 Claude Sonnet 5 和 GPT-5.6-Terra 两个模型各写 5 次 SQL:先不提约束,再把六条约束全部写进提示词。随后人工检查并分类生成的 SQL;下面关于并发的结论,是根据代码采用的检查和加锁策略推断出的潜在违规。

不提约束时,结果毫无悬念:60 次试验中,返回的 SQL 没有做任何检查——连单表主键唯一性都没有(尽管 schema 本身没有显式声明,这些也是再自然、再常见不过的约束)。每个回答都是一条光秃秃的 INSERT 或 UPDATE,一旦执行,就会悄无声息地破坏预期的不变式。

在提示词里给出约束清单后,表现变得五花八门,失败方式本身就很有启发性:

(1)静默限制(40/60):最常见的做法是在写操作外面套一层 WHERE NOT EXISTS(…) 或 (SELECT COUNT(*)…) < 5 条件。条件不满足时,语句只是影响 0 行,既不报错,也不返回与约束相关的反馈,调用方必须自己检查受影响行数才能发现什么都没改。有几次,模型在输出中提到了这个隐患,却没在代码里修正。

(2)先检查再抛错(15/60):另一些回答用 DO 块包住写操作,重新执行同样的检查,违规时 RAISE EXCEPTION。在 PostgreSQL 默认的 READ COMMITTED 级别下(更一般地说,在未达到真正 SERIALIZABLE 的快照隔离下),单纯的"先查后写"是教科书式的写偏斜(write skew)竞态。60 次中只有 1 次用了 PostgreSQL 的通用解法:SERIALIZABLE 加重试。这一类中有些回答加了显式锁,正确性和并发影响各不相同:

  • LOCK TABLE … IN SHARE ROW EXCLUSIVE MODE 会阻塞该表的所有写入者,因为普通 INSERT/UPDATE 需要的 ROW EXCLUSIVE 锁与之冲突。它确实消除了竞态,代价是所有被锁表上的写操作全部串行——最极端的一次试验,为了插入一行内容锁了 schema 八张表中的七张。
  • 对代理行 SELECT … FOR UPDATE(例如为了保护在 student 表上的计数而锁住 instructor 那一行),以及 pg_advisory_xact_lock 调用,则弱得多:它们只对遵守同一套加锁约定的其他事务生效,挡不住一条普通的、没有防护的 UPDATE——而同一导师上限场景下的另一次试验恰恰生成了这样的 UPDATE。

(3)完全不设防(4/60):即使每条约束都写得清清楚楚,仍有少数回答与不提约束时毫无二致。

剩下的 1 次返回了空结果。由此可见,以文本形式提供约束并不能可靠地换来正确的约束执行。写明约束会让模型多做检查,但生成的 SQL 也更复杂、更多样,每条语句都要自己选择并正确实现一套约束执行与并发控制策略。以 person_id 约束为例,提供约束时生成的 SQL 平均 153 个词,不提约束时只有 16 个词。还有几次,生成的 SQL 里留着占位符却没有任何提示,执行后同样会悄悄破坏数据。这进一步说明:数据库必须提供持久、机器可读的约束定义,并且独立于任何写入方的行为去强制执行。

三、现有手段为什么不够(概要)

除了主键、UNIQUE、CHECK、NOT NULL 和 FOREIGN KEY,函数依赖、多对多联系的参与约束、非外键的包含依赖、否定约束(denial constraint)等都很少得到直接支持;PostgreSQL 的 CHECK 也只能作用于单行。关系系统通常能高效执行的多行条件只有唯一性和参照完整性,而它们靠的是相关属性上的 B+ 树,而不是重新执行查询。PostgreSQL 的 EXCLUDE 约束(典型用途是区间不重叠,由 GiST 索引同时提供查找和加锁)在作者看来就是"编译成访问路径的约束",但它只限单个关系。

触发器:在每张参与表上挂触发器,修改时查询其他表,发现违规就报错。问题在于并发:READ COMMITTED 下,两个事务可能同时往 student 和 instructor 插入相同的 person_id,各自的触发器查询都看不到对方未提交的行,于是双双提交——这是写偏斜异常。要保证正确,参与的事务必须一致使用 SERIALIZABLE 并在序列化失败后重试,或采用显式加锁协议。此外,触发器每次写入都要重复执行查询,是过程式而非声明式的,每条约束往往都要写定制代码,还得随 schema 和映射的演进持续维护。

影子表:建立跨多个源表的"物化视图",把多表约束变成影子表上的本地约束。例如一张 (person_id, email) 影子表,用主键和 UNIQUE 就能保证层次级唯一性。它是正确的(唯一索引完成了触发器的快照读做不到的加锁),也比较快。但每次相关插入、更新、删除都必须在同一事务内维护影子表;新增约束要改 schema 并回填数据,删除约束要找到所有维护点;只有"阻塞事务"一种执行强度,没法先试运行、观察违规频率再转正;而且只适合唯一性,基数上限、区间不相交、聚合条件都得从头设计结构。PostgreSQL 原生物化视图需要显式刷新、不支持自动增量维护,也提供不了所需的事务性维护。

四、迈向约束治理

4.1 约束治理器

我们设想,约束可以以 schema 声明的方式进入系统,也可以作为候选从应用代码、负载或数据中挖掘出来。治理器负责跟踪当前有效的约束,并为每条约束选择:执行机制;执行时机(每条语句、提交时或异步);失败动作(中止、隔离或仅告警);以及返回给写入方的反馈。治理器可以以性能为主要优化目标,但也要考虑对整体数据一致性的影响——如何权衡是一个关键的开放研究问题。编译器负责把这些决策翻译成派生状态,并生成维护它所需的操作、同步、撤销与重建逻辑。写入方(应用、大模型)照常发出普通更新,底层数据系统始终是权威。

我们提出把机制、执行时机与失败动作分开,使约束可以经历完整的生命周期:先对候选约束试运行,只告警不拒绝写入;之后再升级为阻塞式执行、降级或删除。同一约束还可以在一个分支上严格执行、在另一个分支上试运行,或对历史/分析数据异步执行。

我们设想两种部署模式。在事务型数据库中,生成的操作运行在写路径上,同步冲突事务、维护派生状态,并阻止违规事务提交。在湖仓(lakehouse)这类存算分离的系统中,可能不存在能拦截每次写入的统一事务点,系统可以把更新、提交记录或变更事件复制给约束监视器(constraint monitor),由它维护同样的派生状态并在数据到达时检测违规。由于监视器不在提交路径上,它可以告警、隔离数据、阻止下游发布或发起修复,但不一定能中止原始写入。它可以从权威表或快照重建状态,再回放后续更新。

由于执行状态被视为派生数据,系统增删约束只需生成或丢弃相应结构并从底层数据重建,不必往 schema 里加持久化的辅助关系(当然,初始化新约束仍需从现有数据推导状态)。约束也可以在监控与阻塞模式之间切换,无需重新手写实现。

4.2 约束索引

约束索引保存执行某条受支持约束所需的紧凑辅助状态。辅助对象不复制完整的关系元组,只包含执行约束所需的标识、唯一值和归属信息,也可以包含布隆过滤器(Bloom filter)这类自定义数据结构。

以 Person–Student–Instructor 层次为例:实例虽然存放在三张表里,却共享一个概念上的标识空间,而且 email 在整个层次中唯一。编译器会创建一个紧凑的辅助对象 o_Person = ⟨person_id, email⟩,以及两个指向它的索引:I_Person[person_id] → o_Person 和 U_email[email] → o_Person。

每次往 Person、Student 或 Instructor 插入数据,都要在 I_Person 中预留其标识,因此修改不同物理表的事务只要使用相同的概念标识,依然会发生冲突。若带有 email,还要在 U_email 中预留。由于两个索引指向同一个辅助对象,更新 email 只需改 email 索引项和对象中的对应字段;删除时也能直接定位并移除所有关联项。

折叠对象同理。对于属主为 Person 的弱实体 IdCard(card_id, card_no),执行所需的状态是 o_IdCard = ⟨person_id, card_id, card_no⟩。编译器会为弱实体复合键生成标识索引、为唯一的卡号生成二级索引,再加一个属主索引 O[person_id] → {o_IdCard}。前两个索引即使在 IdCard 值嵌在分布于多张表的属主元组中时,也能保证弱实体标识和 card_no 唯一;属主索引按属主分组所有从属对象,删除属主和级联维护时无需扫描、提取每个折叠值。关系表示仍是完整、持久的应用数据来源。

4.3 编译与事务维护

对每个受支持的约束域 Γ,我们设想由编译器生成五个部分:辅助状态 S(标识、唯一值和属主索引项);访问和维护该状态的操作集 Ops(预留、更新、移除等);逻辑锁规范 L(每个操作必须获取的逻辑键和锁模式,使修改不同物理表的冲突事务也能同步);撤销规范 U(事务中止时撤销推测性变更的逆操作);以及重建过程 B(启动或恢复后从持久化关系数据重建辅助状态)。当前原型针对评测的约束用模板手写了这些部分,从概念 schema 和关系映射自动编译仍是未来工作。

例如插入标识为 k、email 为 u 的人员时,锁规范要求获取两个排他逻辑锁 X(id, k) 和 X(email, u),于是往不同物理表插入的事务只要用了相同的概念标识或 email 就会冲突。若插入中止,撤销规范删除 k 和 u 的索引项并释放新建的辅助对象。

每次更新都遵循"获取锁 → 校验 → 预留 → 更新 → 提交或撤销"的流程:先对受影响的概念键获取事务级锁,校验并推测性地更新辅助状态,再执行关系更新;提交则变更生效,中止则在释放锁之前由 U 恢复原状态。正确性要求在没有事务持有临时变更的静止点上,辅助状态恰好等于从已提交数据推导出的状态;维护协议必须保证所有与约束相关的写入都走约束索引操作、每对逻辑冲突的操作都会以互斥模式获取同一个键、每个推测性变更都有逆操作。

五、PostgreSQL 原型

我们把约束索引实现为通过 shared_preload_libraries 在服务器启动时加载的 PostgreSQL 扩展,以便预留并初始化服务器级共享内存。

  • 共享内存组织:每个逻辑索引分成 P 个分区,键 k 落到分区 p = h(k) mod P;每个分区是一个独立的 PostgreSQL HTAB,配一把轻量级锁(LWLock)。不同分区的操作可以并发。分区 LWLock 只在访问物理结构时短暂持有,与下面持续到事务结束的逻辑锁是两回事。
  • 逻辑锁:用 PostgreSQL 事务级 advisory lock 实现,由概念标识和受约束取值确定性地映射而来。创建或删除实体获取实体标识上的排他锁;插入弱实体则获取属主标识上的共享锁(防止属主在此期间被删)和弱实体标识上的排他锁。一个操作需要多把锁时先去重,再按全局顺序获取,以避免死锁。
  • SQL 接口:每个约束域通过一组 SQL 可调用函数暴露。标识域提供 reserve(预留新标识,已存在则拒绝)、require(确认被引用对象存在并加共享锁)、update、remove;基数域提供计数记录的创建删除以及联系实例的预留、释放、移动;管理操作包括用于重建的 load 和 clear、用于查看的 get 和 count,以及用于内存统计的 reserved_bytes。

在停止常规写入的情况下,Person 标识域通过对层次中所有物理表做一致性扫描来重建:

CREATE EXTENSION IF NOT EXISTS inmem_indexes;
SELECT inmem_clear_person_identities();
SELECT inmem_load_person_identity(person_id, email)
FROM (
  SELECT person_id, email
  FROM relation_0
  UNION ALL
  SELECT student_id AS person_id, email
  FROM relation_1
  UNION ALL
  SELECT instructor_id AS person_id, email
  FROM relation_2
) AS person_extent;

正常执行时,预留操作与对应的关系修改在同一个 PostgreSQL 事务内完成:

BEGIN;
SELECT inmem_reserve_person_identity(
  1001, 'student1001@umd.edu'
);
INSERT INTO relation_1(student_id, email, major)
VALUES (1001, 'student1001@umd.edu',
  'Computer Science');
COMMIT;

预留操作原子地占用层次级标识以及(若有)唯一的 email。违反约束会报错并中止事务。每个临时的约束索引变更都会把逆操作追加到事务级的后进先出撤销日志中:提交时由事务回调丢弃日志,中止时在逻辑锁仍被持有的情况下逆序回放,恢复原有状态。(这里只讨论整个事务的提交和中止,保存点与子事务回滚需要额外处理。)

恢复:约束索引是派生结构,不单独写 WAL——这是它与影子表方案的一大区别。PostgreSQL 恢复持久化关系数据后,初始化过程扫描参与表、重建辅助索引;若重建时遇到违反约束的重复值则失败。正常执行因此省去了约束状态的持久化元组、缓冲管理、WAL 日志和持久化索引维护。

需要说明的是,当前原型只是执行机制的概念验证:它假设所有相关写入都在与关系更新相同的事务中调用手写的约束索引操作,并不会拦截或阻止绕过这些操作的直接 SQL 写入。

六、实验评估

实验 schema 在前述实体之外,还包含 Department 实体、属主为 Person 的弱实体 Address 和 IdCard,以及 Person 到 Department 的多对一联系 Belongs_to。约束包括:Person 的概念主键 person_id、唯一的 email、函数依赖 role → access_profile;弱实体标识 (person_id, address_id)、(person_id, card_id),IdCard.card_no 全局唯一;Belongs_to 的 index 属性唯一;每位导师最多带 5 名学生;多对多联系 contributes_to 中每人最多参与 20 个内容、每个内容最多 10 位贡献者。主映射下,Person、Student、Instructor 分表存储;Address 和 IdCard 以复合值数组折叠进属主元组;Belongs_to 和 Advisor 折叠为层次表中的列。

测试环境为 PostgreSQL 16,客户端与服务端分别部署在两台 CloudLab c220g1 节点上(10 Gbps 以太网,每台 128 GB 内存、480 GB SSD)。初始化后的数据库约有 570 万个实体实例,以及数百万个折叠弱实体和联系值。每个客户端工作线程保持一个专用连接、闭环发请求,并发数从 1 到 1024。对比六种配置:

  • 无约束:不执行约束,作为吞吐上限参考;
  • 查询校验:在执行修改的事务内用 SELECT 显式校验;
  • 查询触发器:在源表上用触发器执行基于查询的校验;
  • 影子表:维护辅助关系表,并用它们执行约束;
  • 影子表触发器:在维护的辅助表上用触发器执行约束;
  • 约束索引:本文方案。

混合负载为 40% 插入、30% 更新、20% 删除、10% 读;端到端负载中 40% 请求操作 Person、30% 操作 Belongs_to、30% 操作 IdCard。查询校验与查询触发器运行在 READ COMMITTED 下,其基于快照的检查在并发下可能放过违规。

表 1:256 个并发客户端下的吞吐(TPS)。加速比相对于两种保证正确的方案(影子表、影子表触发器)中较好者;保留率为约束索引吞吐占无约束吞吐的百分比。

负载 无约束 查询校验 查询触发器 影子表 影子表触发器 约束索引 加速比 保留率
分布式层次
Person 插入 87.7K 31.1K 61.9K 55.1K 57.8K 79.6K 1.38× 90.9%
Person 混合操作 69.6K 32.0K 44.4K 47.6K 44.4K 60.5K 1.27× 87.0%
折叠联系
Belongs_to 插入 46.2K 23.7K 34.2K 34.5K 37.6K 40.9K 1.09× 88.5%
Belongs_to 混合操作 57.1K 30.8K 39.2K 33.5K 40.8K 49.5K 1.21× 86.7%
Belongs_to 插入(单表映射) 96.6K 38.4K 64.0K 65.9K 不适用 82.7K 1.25× 85.6%
对比原生 PostgreSQL UNIQUE 96.6K 38.4K 64.0K 65.9K 不适用 68.0K 1.03× 70.4%
折叠弱实体
折叠 Address 插入 41.9K 23.7K 35.1K 30.2K 33.4K 41.3K 1.24× 98.7%
折叠 IdCard 插入 40.5K 5.3K 4.8K 24.9K 29.6K 32.9K 1.11× 81.4%
更新 IdCard.card_no 30.9K 4.3K 5.1K 19.8K 21.2K 28.5K 1.35× 92.1%
键以外的约束
带函数依赖的 Student 插入 76.7K 31.2K 48.2K 37.4K 48.6K 71.5K 1.47× 93.2%
更新导师(基数约束) 49.2K 31.6K 37.6K 18.1K 25.2K 38.7K 1.54× 78.6%
contributes_to 插入(基数约束) 91.9K 17.3K 42.8K 32.9K 36.6K 88.1K 2.41× 95.9%
端到端
Person、IdCard、Belongs_to 混合 52.6K 12.6K 15.0K 37.1K 34.9K 44.4K 1.20× 84.3%

执行约束的代价:在面向键的负载上,约束索引保留了无约束吞吐的 81.4%–98.7%。只需一条查找路径时(如折叠 Address 插入)保留率最高;一个逻辑操作要维护多个结构时最低——IdCard 操作要同时维护复合标识索引、唯一 card_no 索引和属主索引。即便如此,结果表明跨表约束的开销已经低到可以常开。

与关系型执行方式对比:在所有面向键的负载上,约束索引都比最好的触发器或影子表方案快 1.09–1.38 倍。Person 插入提升最大;更新 IdCard.card_no 提升 1.35 倍,因为关系型方案要分别维护提取出的标识和唯一值状态,而约束索引直接更新一个概念对象及其查找项;简单的 Belongs_to 插入提升最小,因为属主元组本身的更新占了大头。在单表映射下,由约束索引同时负责参照完整性和唯一性时达到 82.7K TPS;混合配置(参照完整性用约束索引、index 唯一性用原生 UNIQUE)为 68.0K TPS,即约束索引是混合配置的 1.22 倍,这一对比单独体现了唯一性实现的性能差异。在单表层次映射下的补充实验(未列出)表明,收益不仅来自避免跨表检查,也来自不必持久化物化从嵌套值中提取的键。需要注意,查询校验和查询触发器运行在 READ COMMITTED 下,在若干场景下并不正确;改用 SERIALIZABLE 加重试或显式加锁还会带来额外的同步成本,本文没有测量。

显式校验查询的性能要差得多。例如 IdCard 插入,查询校验只有 5.3K TPS,约束索引则达 32.9K TPS。而这恰恰是最接近大模型在无辅助时生成的 SQL 的做法;如果没有额外同步,查询校验和触发器校验都会受到 3.1 节所述写偏斜异常的影响。

组合与通用性:端到端负载在一个事务中组合了层次级标识与 email 唯一性、折叠联系约束、折叠弱实体的标识与唯一性,以及会更新多个约束索引的级联删除。约束索引达到 44.4K TPS,关系型方案为 37.1K,无约束为 52.6K:提升 1.20 倍,同时保留 84.3% 的无约束吞吐。对更复杂的约束:带函数依赖 role → access_profile 的 Student 插入提升 1.47 倍、保留 93.2%;双向基数约束下的 contributes_to 插入提升 2.41 倍、保留 95.9%(此时影子表基线需要为两端各维护一张计数表,并在计数列上加 CHECK)。我们把这些结果视为"编译思路可以延伸到键以外"的证据,而不是任意约束的通用解法。

最后,原型目前是 PostgreSQL 扩展而非引擎原生特性,主要是为了方便试验不同方案。每个操作都要跨越 SQL 函数调用边界、使用通用 advisory lock,而且对规划器和执行器不可见。原生集成可以消除冗余检查、把约束执行与关系更新批量或融合处理,并使用专门的锁模式。

七、结论与研究挑战

当应用代码长期存在、经过审查、由理解不变式的人编写时,把完整性约束交给应用层是合理的;但这个前提已不再对所有写入方成立。约束越来越成为"比操作它的代码活得更久"的合法数据定义,而数据库是最后一个能确定性执行约束的组件。约束索引表明,由概念模型产生的一大类约束——长期被认为执行代价太高——可以被编译成事务性访问路径,开销低到可以一直开着。我们认为,把完整性执行当作物理设计问题,让执行结构与关系 schema 一起编译和选择,是弥合剩余差距的方向。

第一个挑战,是自动化、通用化地构建约束索引,并更深地集成进数据库引擎。 以往工作大多只是把约束检查增量化,而把同步问题另行处理。我们的前提是两者应该一起解决:让检查变便宜的数据结构,同样足以管理检查带来的冲突。实验已把这一思路从键扩展到基数上限和函数依赖;应用到条件唯一性、参与约束、区间不相交以及其他聚合条件仍是开放方向。其适用范围很大程度上取决于辅助状态能否在插入和删除下增量维护。需要不受限查询求值的任意断言可能仍超出这一方法的范围,不过近期增量视图维护方面的工作(如 DBSP)或许有助于统一二者。一个完整的系统还必须在存储、维护、校验成本和争用之间权衡取舍,并找出约束之间共享结构的机会。用布隆过滤器或概要(sketch)等近似数据结构进一步提升性能,也是一个有意思的研究方向。

第二个挑战,是治理:决定每条约束如何执行,以及这个决定如何随时间变化。 这不仅包括执行结构,还包括检查发生在每条语句、提交时还是异步,违规是中止、隔离还是仅告警。有些约束天然会造成大范围争用或需要昂贵的状态,此时较弱的执行方式可能好过一个复杂而昂贵的索引。这种灵活性对约束的生命周期也至关重要:候选约束应能先在真实流量上试运行,证据充分时升级为阻塞式执行,证据不足时降级或删除。当约束是从应用代码、负载或现有数据推断出来、而非来自概念 schema 时,这一点尤其重要——推断出的约束必然带有不确定性,试运行让提出这类约束变得安全。开放问题包括:如何评估升级所需的证据、如何处理已经存在的违规,以及如何让试运行的投入与候选约束不确定的价值相称。

最后,执行边界必须为推动这项工作的写入方(比如 AI 智能体)提供支持。 写入方应能在不提交的情况下测试拟执行的事务;被拒绝时,应同时返回被违反的约束和标识冲突值的"证据"(witness),让修复循环能收敛到预期的修正。约束还应以机器可读的形式公开。但我们的实验表明,仅有描述是不够的:以文本形式拿到约束的模型,生成了五花八门、且常常不安全的代码。因此,数据库既要向写入方提供语义上下文,又要提供不依赖写入方行为的强制执行。


译者注

  1. 最值得带走的一条结论:不要指望"把约束写进提示词"就能让 AI 生成的 SQL 守规矩。实验中 2/3 的回答选择了"条件不满足就影响 0 行"的静默限制,调用方如果不检查受影响行数,根本不知道写入失败;还有 1/4 采用"先查后写",在 READ COMMITTED 下存在写偏斜竞态。如果你正在让智能体或 Text-to-SQL 工具直接写库,至少要做到:关键约束用数据库原生机制(主键、UNIQUE、外键、CHECK、EXCLUDE)表达;跨表约束优先用影子表加唯一索引,或让相关事务统一使用 SERIALIZABLE 并实现重试;给智能体只开放经过审查的存储过程或接口,而不是整库写权限。
  2. 约束索引目前还是研究原型:论文中的扩展依赖 shared_preload_libraries 加载、状态放在共享内存、只对调用了其 SQL 函数的写入生效,论文中也没有提到开源或可部署的发行版本。它的意义更多在于思路——把跨表约束"编译"成带事务锁和撤销日志的访问路径,并把约束的执行时机、失败动作做成可治理的配置。托管数据库服务通常只允许安装平台白名单内的扩展,短期内更现实的做法仍是上面的影子表或 SERIALIZABLE 方案。
  3. 在华为云上的参考做法:使用云数据库 RDS for PostgreSQL、GaussDB 等产品时,可以先梳理"只写在应用代码里"的业务规则,把能下沉的尽量下沉为数据库约束;对必须跨表保证的唯一性(例如多张子类型表共享的编号、邮箱),可以按论文 3.2 节的影子表思路实现,再配合慢 SQL 和锁等待监控评估代价。若计划让 AI 助手生成 SQL 并在生产环境执行,建议先在测试实例上用并发压测验证其约束逻辑,再上线。

相关阅读

  • 学习型基数估计如何在动态数据库里落地:SWatcher+ 的选择性监控与主动纠偏:https://bbs.huaweicloud.com/blogs/e6f5085214054f46bb2c54e52ee9fd20
【版权声明】本文为华为云社区用户翻译文章,如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容, 举报邮箱:cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。