订单明细表已经写了 REFERENCES orders(id),导入程序却能插入不存在的订单号。更容易误判的情况是:初始化脚本设置过 PRAGMA foreign_keys = ON,接口仍然产生孤儿明细。
这时先确认写入连接的状态。SQLite 的外键执行开关属于连接;迁移连接开启,不代表接口、后台任务和临时导入脚本的连接也开启。下面用订单与明细两个表复现问题,再把检查放进连接创建入口。适用于 Python 标准库 sqlite3 和本地 SQLite 数据库,不涉及 PostgreSQL、MySQL 的配置。
1. 在故障连接上取证
不要为了检查而另开一个客户端连接。应通过真正执行故障写入的连接执行:
PRAGMA database_list;
PRAGMA foreign_keys;
PRAGMA foreign_key_list(order_lines);
PRAGMA foreign_key_check;
依次确认数据库文件路径、当前执行开关、表的外键声明和存量违规记录。foreign_keys 返回 1 才说明当前连接开启;没有返回行时应检查 SQLite 版本与编译支持,不应当作开启。
foreign_key_check 每条结果包含子表名、子表 rowid、父表名、外键序号;最后一列可与 foreign_key_list 的 id 对应。没有结果表示这次检查没有发现外键违规;它不是全库业务正确性检查。WITHOUT ROWID 表的违规行不会提供普通 rowid。字段定义见官方 PRAGMA 文档。
先排除连错文件,再判断开关问题。相对路径、工作目录变化和同名测试库,都可能让取证与故障发生在不同文件。
2. 复现两个容易漏掉的入口
下面只在 SQLite 临时内存库执行:
PRAGMA foreign_keys = OFF;
CREATE TABLE orders (id INTEGER PRIMARY KEY);
CREATE TABLE order_lines (
id INTEGER PRIMARY KEY,
order_id INTEGER NOT NULL REFERENCES orders(id)
);
INSERT INTO order_lines VALUES (1, 999);
PRAGMA foreign_key_check;
BEGIN;
PRAGMA foreign_keys = ON;
PRAGMA foreign_keys;
ROLLBACK;
PRAGMA foreign_keys = ON;
PRAGMA foreign_keys;
PRAGMA foreign_key_check;
第一条明细能够插入,违规检查返回 order_lines|1|orders|0。事务中的开关读取仍为 0;结束事务后设置才变为 1,但已存在的违规行仍然能被检查出来。
复现主动关闭开关,是为了让结果不依赖发行版的默认设置。真实程序应显式开启并读取确认。另一个关键点是设置时机:在已经开始的事务中执行开关设置不会报错,却不会生效。SQLite 官方外键说明明确要求逐连接启用,并说明了事务中的限制。
3. 在连接创建后、事务开始前完成初始化
将下面代码保存为 test_sqlite_foreign_keys.py。它只使用标准库;后面测试代码追加到同一文件。
import sqlite3
import unittest
from contextlib import closing
from pathlib import Path
from tempfile import TemporaryDirectory
SCHEMA = """
CREATE TABLE orders (id INTEGER PRIMARY KEY);
CREATE TABLE order_lines (
id INTEGER PRIMARY KEY,
order_id INTEGER NOT NULL REFERENCES orders(id)
);
CREATE INDEX order_lines_order_id_idx ON order_lines(order_id);
"""
def open_database(path: str | Path) -> sqlite3.Connection:
"""在交付连接前验证外键执行状态,避免调用方带着错误配置写入。"""
connection = sqlite3.connect(path, isolation_level=None, timeout=5.0)
try:
connection.execute("PRAGMA foreign_keys = ON")
state = connection.execute("PRAGMA foreign_keys").fetchone()
if state != (1,):
raise RuntimeError("SQLite 连接未启用外键约束")
except (sqlite3.Error, RuntimeError):
connection.close()
raise
return connection
这里显式使用 isolation_level=None,让此示例通过 SQL 的 BEGIN、COMMIT、ROLLBACK 管理事务;不要照搬到已有框架后再与框架事务管理混用。Python 3.12 以上若改用 autocommit=False,需要重新安排初始化时机,避免连接处于持续开启的事务中。事务模式见 Python 官方说明。
NOT NULL 表达“每条明细必须有订单”;仅有外键不能代替这个业务约束。子表索引用于降低按父键定位明细的成本,不决定外键是否开启。
调用方负责关闭连接;连接上下文管理器处理事务,不能替代关闭动作。这里用 closing 明确回收。生产中应让接口、批处理、迁移和运维工具使用同一连接工厂;使用连接池时,将初始化放在每个物理连接建立的钩子中。
4. 用回归测试锁住故障边界
将以下代码追加到文件末尾,执行 python -m unittest -v test_sqlite_foreign_keys.py。
class ForeignKeyTests(unittest.TestCase):
"""覆盖正常关联、失败写入、连接隔离和历史违规检查。"""
def setUp(self) -> None:
self.connection = open_database(":memory:")
self.addCleanup(self.connection.close)
self.connection.executescript(SCHEMA)
def test_valid_reference(self) -> None:
self.connection.execute("INSERT INTO orders VALUES (?)", (1,))
self.connection.execute("INSERT INTO order_lines VALUES (?, ?)", (1, 1))
self.assertEqual(
self.connection.execute("SELECT order_id FROM order_lines").fetchall(),
[(1,)],
)
def test_orphan_is_rejected(self) -> None:
with self.assertRaises(sqlite3.IntegrityError):
self.connection.execute(
"INSERT INTO order_lines VALUES (?, ?)", (1, 999)
)
def test_null_is_rejected(self) -> None:
with self.assertRaises(sqlite3.IntegrityError):
self.connection.execute(
"INSERT INTO order_lines VALUES (?, ?)", (1, None)
)
def test_referenced_parent_cannot_be_deleted(self) -> None:
self.connection.execute("INSERT INTO orders VALUES (?)", (1,))
self.connection.execute("INSERT INTO order_lines VALUES (?, ?)", (1, 1))
with self.assertRaises(sqlite3.IntegrityError):
self.connection.execute("DELETE FROM orders WHERE id = ?", (1,))
def test_setting_inside_transaction_has_no_effect(self) -> None:
with closing(sqlite3.connect(":memory:", isolation_level=None)) as raw:
raw.execute("PRAGMA foreign_keys = OFF")
raw.execute("BEGIN")
raw.execute("PRAGMA foreign_keys = ON")
self.assertEqual(raw.execute("PRAGMA foreign_keys").fetchone(), (0,))
raw.execute("ROLLBACK")
raw.execute("PRAGMA foreign_keys = ON")
self.assertEqual(raw.execute("PRAGMA foreign_keys").fetchone(), (1,))
def test_setting_is_per_connection(self) -> None:
with TemporaryDirectory() as directory:
path = Path(directory) / "orders.db"
with closing(open_database(path)) as first:
first.executescript(SCHEMA)
with closing(open_database(path)) as second:
second.execute("PRAGMA foreign_keys = OFF")
self.assertEqual(
first.execute("PRAGMA foreign_keys").fetchone(), (1,)
)
second.execute(
"INSERT INTO order_lines VALUES (?, ?)", (1, 999)
)
self.assertEqual(
first.execute("PRAGMA foreign_key_check").fetchall(),
[("order_lines", 1, "orders", 0)],
)
def test_enabling_does_not_repair_existing_data(self) -> None:
self.connection.execute("PRAGMA foreign_keys = OFF")
self.connection.execute("INSERT INTO order_lines VALUES (?, ?)", (1, 999))
self.connection.execute("PRAGMA foreign_keys = ON")
self.assertEqual(
self.connection.execute("PRAGMA foreign_key_check").fetchall(),
[("order_lines", 1, "orders", 0)],
)
self.connection.execute("INSERT INTO orders VALUES (?)", (999,))
self.assertEqual(
self.connection.execute("PRAGMA foreign_key_check").fetchall(), []
)
def test_failed_batch_can_be_rolled_back(self) -> None:
self.connection.execute("BEGIN")
try:
self.connection.execute("INSERT INTO orders VALUES (?)", (1,))
with self.assertRaises(sqlite3.IntegrityError):
self.connection.execute(
"INSERT INTO order_lines VALUES (?, ?)", (1, 999)
)
finally:
self.connection.execute("ROLLBACK")
self.assertEqual(
self.connection.execute("SELECT COUNT(*) FROM orders").fetchone(),
(0,),
)
第六项使用同一个真实临时文件的两个连接,验证其中一个关闭外键后,另一个仍然开启;单纯在两个独立内存库中检查开关无法覆盖这个使用场景。最后一项验证调用方执行整批回滚;默认即时外键失败不应被理解成整个事务已经自动撤销。
5. 处理已存在的孤儿明细
上线连接工厂只阻止新的违规写入。对于历史数据,先备份数据库并暂停相关写入口,再执行检查,按业务主键取证,例如:
SELECT line.id, line.order_id
FROM order_lines AS line
LEFT JOIN orders AS parent ON parent.id = line.order_id
WHERE parent.id IS NULL;
这条查询只针对本文 order_id NOT NULL、整数主键的两表结构。不要把它直接推广为所有复合外键的检查器。
对每条违规记录核对导入批次和原始订单:确有订单遗漏时补齐真实订单;明细订单号写错时修正关联;确认无效且允许删除时才隔离或删除。不能为了让检查变绿而创建没有业务依据的父记录。修复应在事务中完成,提交前再次运行 foreign_key_check,仍有违规就回滚并保留待处理清单。
若开启后出现 foreign key mismatch,检查父键是否确为主键或匹配的唯一键,以及列组合、排序规则是否匹配。这属于模型声明问题,不能通过关闭检查解决。大库完整检查的耗时需要在副本评估,再安排维护窗口。
6. 验收与预防
本地已执行本文八项标准库测试,以及上面的 SQL 故障复现。它们验证 SQLite 实际行为,不依赖外部数据库服务。未接入真实 ORM、连接池、多进程写入或生产数据文件,这些集成场景需要上线前补验。
验收时至少检查三条路径:接口创建的新连接、导入脚本创建的新连接、服务重启后重建的连接。每条路径都应读取到 foreign_keys=1,并拒绝一条不存在父键的写入;这些负向测试在隔离测试库运行。存量库另需得到可解释的违规清单或空检查结果。
不要把 integrity_check 返回正常当作外键验收;外键违规应由 foreign_key_check 检查。部署时保留数据库备份和写入失败记录;新约束暴露旧调用缺陷时,应修复或暂停该入口,避免回退成全局关闭外键。
这类故障的排查顺序是:确认实际文件,读取故障连接的开关,核对声明,再检查历史数据。将初始化前移到每次物理连接创建,并保留失败写入测试,才能防止“迁移时配置正确,运行时约束失效”再次出现。
Discussion
评论