SQLite 外键排查:建了 REFERENCES,为什么孤儿记录仍能写入

10-01 4阅读

表结构写了 REFERENCES,却发现子表指向不存在的父记录,问题可能在连接初始化。SQLite 的外键检查需要受库构建支持,并在连接上启用;不能把建表语句或某个管理工具中的开关当作所有应用连接的保证。本文用 Python 3.9 及以上和支持外键的 SQLite,在临时数据库中显式构造开关差异。

SQLite 外键排查:建了 REFERENCES,为什么孤儿记录仍能写入

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 表达必填要求。删除父对象时采取拒绝、级联还是置空,也必须显式设计。级联会改变其他表的数据,应先在测试库验证完整影响,不能只看一条删除语句的表面范围。

把开关放进每条连接的生命周期

连接池新建、重连、后台任务和迁移工具都可能走不同入口。应把启用与读回断言放进共享初始化逻辑,并在测试中用实际连接检查。不要依赖某个版本的默认值;库编译选项和未来版本可能不同。设置返回空结果时应停止写入并排查库能力,而不是把没有异常当成启用成功。

参考资料

文章版权声明:除非注明,否则均为云鹊BLOG原创文章,转载或复制请以超链接形式并注明出处。