SQLite INSERT OR IGNORE:非法记录被跳过,为什么外键错误仍会中断

昨天 4阅读

把INSERT改成INSERT OR IGNORE以后,重复键、不合格数量和缺失字段似乎都被安静跳过,于是有人把它当成“尽量导入,遇到什么都继续”。一旦出现外键错误,这个预期就会失效,而且同一条多行INSERT里较早写入的记录也可能一起消失。要判断哪些数据留下,需要同时看错误类别、SQL语句边界和事务边界,单看IGNORE这个名字不够。

本文在Linux、CPython 3.12.14与运行时SQLite 3.53.1上实跑。数据库均为独立的:memory:连接,测试结束显式关闭,不读取或修改业务数据库。Python版本与SQLite库版本分别记录,因为相同Python版本可能链接不同SQLite构建。 程序使用isolation_level=None并显式BEGIN与COMMIT,避免隐式事务开关遮住本例的语句回滚范围。

SQLite INSERT OR IGNORE:非法记录被跳过,为什么外键错误仍会中断

AI生成的概念示意图:一条通道把局部不合格票据分流,另一条带断链标志的通道被整体拦住,表示两类约束的不同处理方式;不是真实软件界面或运行截图。

完整程序与实际输出

保存为demo.py,执行python3 demo.py。程序只使用固定测试输入,代码和本次输出分别列出。

import sqlite3

con = sqlite3.connect(':memory:', isolation_level=None)
try:
    con.execute('PRAGMA foreign_keys = ON')
    assert con.execute('PRAGMA foreign_keys').fetchone()[0] == 1
    con.executescript('''
        CREATE TABLE parent(id INTEGER PRIMARY KEY);
        CREATE TABLE item(id INTEGER PRIMARY KEY, label TEXT NOT NULL UNIQUE,
            qty INTEGER NOT NULL CHECK(qty > 0), parent_id INTEGER REFERENCES parent(id));
        INSERT INTO parent VALUES(1);
    ''')
    con.execute('BEGIN')
    con.execute("INSERT INTO item VALUES(1, 'kept', 1, 1)")
    con.execute('''INSERT OR IGNORE INTO item VALUES
        (2, NULL, 1, 1), (3, 'bad-qty', 0, 1),
        (4, 'kept', 2, 1), (5, 'good', 2, 1)''')
    print('after local constraints:', con.execute('SELECT id FROM item ORDER BY id').fetchall())
    try:
        con.execute('''INSERT OR IGNORE INTO item VALUES
            (6, 'before-fk', 1, 1), (7, 'bad-fk', 1, 99), (8, 'after-fk', 1, 1)''')
    except sqlite3.IntegrityError as exc:
        print('foreign key:', exc.sqlite_errorname)
    print('after failed statement:', con.execute('SELECT id FROM item ORDER BY id').fetchall())
    print('transaction active:', con.in_transaction)
    con.execute('COMMIT')
finally:
    con.close()

本次实际标准输出:

after local constraints: [(1,), (5,)]
foreign key: SQLITE_CONSTRAINT_FOREIGNKEY
after failed statement: [(1,), (5,)]
transaction active: True

第一条导入为什么只增加一个编号

item表有主键、非空标签、唯一标签、正数量CHECK与父编号外键。事务开始后先用普通INSERT写入编号一。第一条多行INSERT OR IGNORE依次提供空标签、零数量、重复标签和完全合法的编号五。前三行违反适用的局部约束,被跳过;编号五被插入,查询结果是编号一与五。

这里“没有抛出异常”不表示所有输入都成功。IGNORE使某些拒绝变成静默跳过,调用者必须另行核对接受数量与业务键。如果原本只希望跳过重复标签,却顺便吞掉了缺失字段和错误数量,那么导入表面顺利,数据质量问题反而更难追踪。

样本把各类违规拆在不同输入行里,避免一行同时违反多个规则而让约束检查顺序干扰判断。真实数据可能同时缺字段、重复并引用不存在的父项,最终观察到哪个错误或是否跳过,不能仅凭此处单一违规样本推断。设计测试时应先验证各类基本情况,再覆盖组合错误。

外键错误会终止当前语句

第二条INSERT OR IGNORE是一个包含三行的SQL语句:编号六合法,编号七引用不存在的父编号九十九,编号八合法。实际运行抛出SQLITE_CONSTRAINT_FOREIGNKEY。SQLite的IGNORE冲突算法不把外键错误当作普通可跳过行处理;在本例立即外键下,它按ABORT效果终止当前语句。

查询仍然只得到编号一与五,编号六没有留下。虽然六在多行VALUES列表中排在违规行之前,但它属于同一条失败语句,这条语句的修改被撤销。编号八也没有成为最终记录。若把这些行换成多次独立execute或executemany,语句边界已经不同,不能直接套用本例的保留集合。

这解释了为什么“前面有一条好数据”不意味着它一定保存。SQL语句内部已经尝试过的修改,与语句成功完成后的修改不是同一个层次。排查导入缺行时,应先查日志里究竟发送了一条多行语句还是多条单行语句,再讨论冲突算法,而不是只看应用层调用了一次批处理函数。

语句回滚也不等于整个事务回滚

外键异常之后,con.in_transaction仍为True。早先独立语句写入的一和五继续保留在当前事务中,程序随后显式COMMIT提交它们。ABORT在这里撤销的是失败语句,不会自动抹掉此前所有语句,也不会替应用决定整个导入事务应该结束。

是否继续提交应该由业务策略决定。本文为了展示边界,明确保留之前成功的记录;全批原子导入通常应该在失败后回滚整个事务。不要把示例最后的COMMIT直接当成生产建议。真正需要保证的是结果与事先声明的全有或部分成功策略一致,而不是尽量让程序不报错。

延迟外键还会改变错误出现的时间:某些违规可能直到提交时才被检查。本文使用默认立即外键,因此可以在第二条INSERT处捕获。若迁移了约束模式,应用的异常处理必须覆盖COMMIT本身,不能只包住插入循环。事务入口、语句执行和提交出口都可能是需要观察的边界。

把忽略策略收窄到真正允许的情况

如果业务只接受某个唯一键冲突被忽略,可以评估带明确冲突目标的UPSERT DO NOTHING,而不是笼统使用OR IGNORE。这样更容易把其他格式错误保留为可见失败。但选择哪种语法仍要结合SQLite版本、唯一约束与业务去重键验证,不能只替换关键词就认为完成了质量控制。

对需要完整审计的导入,先写入隔离的暂存表或逐条记录拒绝原因,再将验证通过的数据转入正式表,往往比静默跳过更清楚。至少应记录输入总数、接受数、按原因分类的拒绝数以及可追溯业务键。只记录“SQL执行成功”无法说明丢弃了多少数据,更无法支持用户重试失败部分。

外键检查必须真正开启,本例在任何事务前执行PRAGMA并回读断言。若检查关闭,编号九十九也可能写入,这并不能证明IGNORE支持外键跳过,只说明这一约束未被执行。复制实验时应保留该断言,否则最关键的反例会因为连接配置不同而悄悄失效。

最后把测试的两个集合记下来:第一阶段保留一和五,失败语句结束后仍然是一和五,事务保持活动。验收同时核对集合、异常类别与事务状态,才能确认边界。输入参数、SQL语句和提交策略三者都应留在测试记录中,避免后续为了性能把单条导入合并成多行语句时,意外改变失败后的保留范围。

重试还需要考虑先前保留的一和五。若应用只知道这批调用失败而不记录已提交前缀,直接再次发送完整批次可能造成新的重复或静默跳过。为每条记录提供稳定业务键,并把重试结果与原批次关联,才能让部分成功模式可解释。IGNORE减少了异常数量,却不会自动提供这一层幂等性和审计能力。

参考资料与验证记录

官方资料核验于2026年10月3日。本次完整程序退出码为0,标准错误为空;例子验证的是上述固定输入与运行环境,不表示所有平台和版本的输出细节完全一致。


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