SQLite 外键排查:建了 REFERENCES,为什么孤儿记录仍能写入
表结构写了 REFERENCES,却发现子表指向不存在的父记录,问题可能在连接初始化。SQLite 的外键检查需要受库构建支持,并在连接上启用;不能把建表语句或某个管理工具中的开关当作所有应用连接的保证。本文用 Python 3.9 及以上和支持外键的 SQLite,在临时数据库中显式构造开关差异。
AI生成概念配图:父记录与子记录通过关联链连接,不存在的引用被拦下。仅作概念说明,不代表实际界面或实测结果。
连接建立后立即设置,并读回确认
外键开关不能在活动事务中有效切换。示例使用 isolation_level=None,让 SQLite 处于自动提交模式,连接初始化后先设置开关并断言返回一,再执行任何业务语句。这样做是为了让每一步的事务边界清楚,真实业务仍应按业务单元组织事务,不能为了启用外键就随意提交现有工作。
import sqlite3
from pathlib import Path
from tempfile import TemporaryDirectory
with TemporaryDirectory(prefix="foreign-key-demo-") as work:
path = Path(work) / "demo.db"
good = sqlite3.connect(path, isolation_level=None)
other = sqlite3.connect(path, isolation_level=None)
try:
good.execute("PRAGMA foreign_keys = ON")
assert good.execute("PRAGMA foreign_keys").fetchone() == (1,)
good.executescript("""
CREATE TABLE parent(id INTEGER PRIMARY KEY);
CREATE TABLE child(
id INTEGER PRIMARY KEY,
parent_id INTEGER NOT NULL REFERENCES parent(id)
);
CREATE INDEX child_parent_idx ON child(parent_id);
INSERT INTO parent VALUES(1);
INSERT INTO child VALUES(1, 1);
""")
try:
good.execute("INSERT INTO child VALUES(2, 999)")
except sqlite3.IntegrityError:
print("protected connection rejected orphan")
else:
raise AssertionError("foreign key was not enforced")
other.execute("PRAGMA foreign_keys = OFF")
assert other.execute("PRAGMA foreign_keys").fetchone() == (0,)
other.execute("INSERT INTO child VALUES(2, 999)")
other.execute("PRAGMA foreign_keys = ON")
violations = good.execute("PRAGMA foreign_key_check").fetchall()
assert len(violations) == 1
print("existing violations:", violations)
finally:
other.close()
good.close()第一个连接拒绝孤儿记录;第二个连接在明确关闭检查后接受同样的引用。再次启用检查不会自动修复已经写入的数据,最后仍能查到一项违规。代码中的关闭开关只为这个临时演练服务,不应复制成生产导入脚本的“加速技巧”。两个连接都关闭后,临时目录才会被自动清理。
运行前可打印 sqlite3.sqlite_version,记录解释器实际链接的数据库库版本;它不一定与系统命令行工具显示的版本相同。测试应同时覆盖有效引用成功、无效引用失败,以及另一个新连接的初始化。只验证建表成功,无法证明后续每个写入入口都启用了检查。
读懂检查结果,再处理历史问题
foreign_key_check 返回违规子表名称、相关行标识、父表名称及外键编号,可结合 foreign_key_list 定位是哪条约束。没有输出代表本次检查未发现外键违规,并不意味着所有业务关系正确。对于没有普通行标识的表,返回值也有对应差异,应按官方字段说明解析,而不是依赖演示的固定元组。
历史孤儿记录应先做备份,定位来源并确定业务处理方式:补齐真实父记录、纠正引用,或者经确认后删除无效记录。不要看到编号不存在就随意造一个占位父对象。常规 integrity_check 也不能替代外键专用检查;二者关注的约束范围不同,恢复或迁移验收时应分别考虑。
约束定义也需要满足条件
被引用的父键通常使用主键,或者满足 SQLite 对唯一约束、列组合及排序规则的要求。仅在父列上建普通索引,并不能让它成为合法的唯一父键。子列索引则常用于加快父记录更新、删除时的关联检查,作用不同。示例同时给子引用列加了非空约束,明确禁止缺失父标识。
如果允许子外键为空,外键语义通常允许这类记录不匹配父表;是否合理应由业务决定,并另用 NOT NULL 表达必填要求。删除父对象时采取拒绝、级联还是置空,也必须显式设计。级联会改变其他表的数据,应先在测试库验证完整影响,不能只看一条删除语句的表面范围。
把开关放进每条连接的生命周期
连接池新建、重连、后台任务和迁移工具都可能走不同入口。应把启用与读回断言放进共享初始化逻辑,并在测试中用实际连接检查。不要依赖某个版本的默认值;库编译选项和未来版本可能不同。设置返回空结果时应停止写入并排查库能力,而不是把没有异常当成启用成功。


