一个资料整理Agent把切片登记为属于doc_id=999。工具执行没有报错,数据库里却根本没有这份资料。开发者打开建表语句,发现REFERENCES docs(id)明明就在那儿。
模型不一定选对了ID,但这里还有一个更基础的问题:数据库是否真的在这条写入连接上执行外键约束?
本文聚焦SQLite。它很适合存放本地Agent的任务清单、资料登记和实验状态,却有一道容易漏掉的执行边界:表里声明了关系,不等于每个连接都启用了关系检查。这个开关放错时点,甚至可能没有报错、也没有生效。
AI原创概念插画:雪山暮色的微缩铁路工坊里,红色书车与青绿盆景车之间的黄铜挂钩留着间隙,前景摆着两节车厢的设计图。画面只作“纸上关系与实际连接不同”的艺术隐喻,不是数据库结构图或实测证据。
一、先分清“写了什么”和“正在执行什么”
假设有两张表:docs记录原始资料,chunks记录资料切片。chunks.doc_id引用docs.id,目的是阻止切片指向不存在的父记录。
至少要区分四件事:
| 要问的问题 | 检查对象 |
|---|---|
| 表定义里有没有外键? | 建表语句、PRAGMA foreign_key_list(chunks) |
| 这条连接有没有执行外键约束? | PRAGMA foreign_keys的实际返回值 |
| 已有数据有没有违反外键? | PRAGMA foreign_key_check |
| 这次操作是否满足业务与权限要求? | 当前授权、资料状态、提交结果和相关外部资源 |
它们不能互相代替。外键列表非空,只说明声明存在;foreign_keys返回1,说明当前连接开启执行,不说明老数据已经被修好,更不说明这份资料当前允许交给用户。
SQLite官方文档说明,外键执行需要相应编译支持,并由应用在运行时按连接启用;默认值还可能受到编译选项影响。因此工程上应明确设置、读取确认,而不是赌默认值。[1][2]
二、最隐蔽的坑:在事务里面才打开开关
SQLite对PRAGMA foreign_keys=ON有一条关键限制:有待结束的BEGIN或SAVEPOINT时,切换没有效果,而且不会因此报错。[1][2]
这就可能形成下面的故障链:
- 工具打开数据库连接;
- 事务已经开始;
- 初始化代码执行“开启外键”;
- SQL调用正常返回,于是程序认为保护已生效;
- 不存在的资料ID仍被写入。
下面用真实SQLite执行这个差别。**这是本地确定性教学实验,不是模型、Agent或线上数据库实测。**本次环境为Python 3.13.5、SQLite 3.53.4;示例需要Python 3.12及以上,以使用显式autocommit参数。
代码先主动设为OFF,只是为了固定反例起点,避免把某台机器的默认配置冒充所有环境。生产初始化不应照抄这个关闭步骤。
import sqlite3
for label, late in [('inside-transaction', True), ('before-transaction', False)]:
db = sqlite3.connect(':memory:', autocommit=True)
try:
db.execute('PRAGMA foreign_keys=OFF') # 固定教学起点,不依赖默认值
db.execute('CREATE TABLE docs(id INTEGER PRIMARY KEY)')
db.execute('CREATE TABLE chunks(id INTEGER PRIMARY KEY, '
'doc_id INTEGER NOT NULL REFERENCES docs(id))')
if late:
db.execute('BEGIN')
db.execute('PRAGMA foreign_keys=ON')
enabled = db.execute('PRAGMA foreign_keys').fetchone()[0]
if not late:
db.execute('BEGIN')
try:
db.execute('INSERT INTO chunks VALUES(1, 999)')
except sqlite3.IntegrityError:
outcome = 'rejected'
else:
outcome = 'inserted'
db.execute('COMMIT')
count = db.execute('SELECT count(*) FROM chunks').fetchone()[0]
print(label, 'enabled=', enabled, outcome, 'rows=', count)
finally:
db.close()本次独立执行正文代码,实际输出:
inside-transaction enabled= 0 inserted rows= 1
before-transaction enabled= 1 rejected rows= 0两组表定义相同,插入的不存在ID也相同。区别只有启用时点:
- 第一组已经
BEGIN,所以开启请求没有改变开关,孤儿切片被插入; - 第二组先启用并读回
1,再开始事务,非法插入被拒绝,切片数量保持为零。
代码显式使用SQL的BEGIN与COMMIT。这里的autocommit=True不妨碍显式开启事务,但此模式下Python连接对象的commit()、rollback()方法不负责这些显式事务的结算,不能随手替换示例里的SQL。[3]
实验中的异常捕获只为输出对照结果。真实的多步工具不能接住一个约束错误,就把之前的其他修改当作整单成功提交;失败应怎样触发回滚,是另一道必须设计的事务边界。
三、为什么“我刚提交过了”仍可能没有解决问题?
Python的sqlite3有多种事务控制模式,不能把所有连接都想成示例中的显式SQL模式。
特别是autocommit=False:Python文档说明,它会保持事务始终打开;connect()会打开事务,commit()或rollback()结束旧事务后又立即打开新事务。[3]
因此,下面这个补丁思路并不可靠:
先
commit()一下,再执行开启外键。
如果驱动已经马上开了下一笔事务,开启请求仍然在事务内。本次扩展实验实际验证了这一点:在固定关闭状态下切到autocommit=False,连接处于事务中;提交以后仍处于事务中,再发PRAGMA foreign_keys=ON,读回依然是0。
一种可审查的初始化组织方式是:
- 在新建、尚未接收业务写入的连接上,先选用允许事务外初始化的模式,例如先以
autocommit=True建立连接; - 设置
PRAGMA foreign_keys=ON,立即读取并要求结果恰为(1,);没有返回、仍为零或读取失败,都不把连接交给业务; - 再按服务约定切到
autocommit=False,或者继续采用严格管理的显式SQL事务; - 由连接工厂统一处理,覆盖连接池新建、后台工作线程和迁移脚本等真实入口。
这是基于所读契约的应用组织建议,不是完整连接池实现。不要在已经承载未完成业务的连接上随意切换模式:例如,把Python的autocommit改为True会提交待处理事务。[3] 为了补开关而意外提交半单,比初始化失败更难排查。
四、同一个数据库文件,另一条连接仍可能漏检
外键执行开关不是“这份数据库文件已经永久开启”的标记。
本次扩展实验让两条连接同时打开同一个专用临时磁盘数据库:
| 实际连接 | 明确设置的开关 | 插入不存在父ID的结果 |
|---|---|---|
| A | 开启 | 被拒绝 |
| B | 关闭 | 插入成功 |
B仍然能查询到同一份外键声明。说明表结构没有丢,丢的是这条执行路径上的约束执行。
随后在B上开启外键,读回确认成功,再运行foreign_key_check。实际发现一条违规记录;关闭全部连接、重新打开后,违规记录仍在。
**启用开关不会自动删除、补齐或重新归属已有孤儿行。**旧数据需要另行核查,并按可信业务规则修复。不能让Agent“为了通过检查”随便伪造一份父资料,或者未经授权批量删除切片。
还有一个容易误读的信号:同一份有孤儿行的实验数据库,PRAGMA integrity_check实际返回了ok,但foreign_key_check发现了违规。这符合官方文档:前者不检查外键错误,外键需要后者检查。[2]
所以,“数据库完整性检查通过”这句话必须说明具体检查了什么,不能只保留一个漂亮的ok。
五、开启外键,也不是“所有关系都安全了”
外键做的是声明范围内的参照完整性,下面几件事仍要独立决定。
1. 允许空关联,还是每份切片必须有来源?
SQLite允许可空外键列为NULL,此时不要求有对应父行。本次扩展实验也实际插入成功。[1]
若业务要求每条切片必须有资料ID,就应独立使用合适的NOT NULL约束。REFERENCES并不自动表达“必填”。正文实验因此明确写了NOT NULL。
2. 删除父资料,是拒绝,还是连子记录一起删?
默认的NO ACTION不等于“随便删除”。在本文默认即时约束的夹具里,只要仍有依赖切片,删除父资料就被拒绝。改成ON DELETE CASCADE后,删除父行会连带删除对应子行。[1]
扩展实验得到的实际结果是:默认动作拒绝删除并保留一条切片;级联动作允许删除,切片数量归零。
这不是建议所有资料库一律加级联。保留历史引用、撤回资料和永久删除是不同业务动作。级联关系必须来自可信的数据生命周期设计,不能为了让工具“不再报错”临时加上。
3. 当前插入成功,是否等于已经能报告提交成功?
本文示例采用默认即时外键;SQLite也支持延迟外键,其违规可以到提交时才报告。[1] 因而INSERT正常返回不是通用的最终成功收据,仍要确认真正负责结算的边界。
4. 父记录存在,是否代表资料可读、内容就绪?
不代表。外键不会自动验证当前用户有权读取、资料已经审核、版本已激活,或对象存储里的原文件可以下载。
即使SQL里级联删除了切片登记,另一个向量服务中的向量、答案缓存和对象文件也不因此自动删除。跨资源对账仍是Agent执行支撑层的责任。对于多租户资料,即使ID确实存在,也还要验证它属于获准范围;单纯“有这个父行”不足以证明授权。
六、可复用的连接与工具验收清单
- 每条真正写入的连接,都在事务外显式启用外键并读回
1了吗? - 连接工厂、池内新连接、后台任务和迁移入口是否全部覆盖?
- 当前Python事务模式会不会提前或自动重新开启事务?
- 是否实际测试了“父ID不存在会被拒绝”,而不只检查建表文本?
- 旧库是否单独运行
foreign_key_check,并为违规保留获授权的修复方案? - 必填关联是否另有
NOT NULL?删除、更新与延迟检查动作是否符合业务? - 工具是否等最终提交确认后才报告成功,失败不会被误转换成正常提交?
- 用户授权、资料状态、向量索引、缓存和原文件是否有独立验收?
本次完整实验还覆盖了新连接回读、两条连接的不同开关、既有孤儿行、可空外键、默认删除与级联删除,以及autocommit=False自动重开事务。它们只验证本地机制,不是并发压力、崩溃恢复、连接池或生产数据清理测试。
**模型给出的ID需要核实,数据库宣称的关系也需要落实到真正执行的连接。**把“声明存在”“开关生效”“历史数据合格”和“业务允许”逐层分开,才能知道这一次保护究竟守住了哪一段。
来源与适用范围
本文由AI创作。实际检索截止:2026年10月27日23:16 UTC。以下为机制文档,不是当日新品或性能公告。SQLite两页属于同一项目文档,不是独立复现;本文另据Python驱动文档核对事务模式,并执行了上面的本地实验。
- SQLite:Foreign Key Support。页面标注最后更新为2026年3月20日;外键执行支持自3.6.19(2009年10月14日)引入。实际阅读启用条件、NULL例外、延迟检查及删除/更新动作相关章节;不将历史引入日期当作本机版本。
- SQLite:PRAGMA Statements。页面标注最后更新为2026年10月4日。实际阅读
foreign_keys、foreign_key_list、foreign_key_check及integrity_check的相关完整条目;用于区分声明、连接执行与已有数据检查,不声称通读全部PRAGMA。 - Python 3.13:sqlite3。本轮页面显示Python 3.13.18文档,动态维护,无独立章节发布日期。实际阅读
autocommit、in_transaction及事务控制相关章节;本文本地解释器为3.13.5,不把文档补丁版本冒充安装版本。


留下你的想法 0
欢迎提问、补充和不同观点。评论通过审核后会公开显示。