SQLite 插入报错,前面的写入就一定撤销了吗?
一个导入脚本连续插入几条记录,第二条撞上重复键,程序抛出了异常。界面显示“导入失败”,我们很容易认为数据库已经恢复原样。但如果第一条记录还在,下一次重试就会面对一份导入了一半的数据。SQLite 插入失败时,到底会撤销哪一部分?怎样让一批记录真正一起成功或一起失败?
答案取决于三件事:前面的操作是否已经提交,冲突处理撤销的是当前语句还是整个事务,以及应用捕获异常后如何结束事务。异常告诉我们一次操作遇到了问题,却不自动证明数据库里没有留下任何变化。这篇文章用临时数据库把这些状态分开观察,再给出一个边界明确的批量写入函数。
1. 先区分语句、事务与业务上的“一批”
SQL 语句是一次 INSERT、UPDATE 等操作;事务把若干操作放进同一个提交或回滚的范围;“一批导入”则是应用对业务完整性的要求。这三个范围可能重合,也可能完全不同。把两次插入写在同一个函数里,并没有自动把它们变成一个数据库事务。
SQLite 在没有活动事务时,会为需要访问数据库的语句自动启动事务;自动启动的事务在相关语句结束后提交。显式 BEGIN 则把事务边界延伸到后续的 COMMIT 或 ROLLBACK。因此,已经提交的第一条写入,不会因为下一条插入报错就自动撤销。SQLite:Transaction
另一方面,事务已经开始也不等于所有错误都会把整批清空。SQLite 默认的约束冲突策略是 ABORT:撤销当前失败语句的变化,保留同一事务里先前语句的变化,事务继续存在。名字里的“终止”是语句范围,不是你在界面上称为“导入”的整个业务过程。SQLite:ON CONFLICT
如果业务允许逐条成功、逐条报错,保留先前记录可以是正确设计。如果业务要求配置项或关联记录必须成套出现,就需要整批回滚。关键不是见到错误就一律选择某种策略,而是先定义哪些写入属于不可拆开的工作单元。
2. 同时看当前连接、另一条连接和最终结果
我在 2026 年 10 月 3 日使用 macOS、Python 3.9.6、运行时 SQLite 3.54.0 执行了完整实验脚本。这里的 SQLite 版本来自 sqlite3.sqlite_version,不能仅凭 Python 版本猜测。所有数据库都新建在临时目录里,实验完成后删除;没有连接谷米服务器或既有业务数据库。
脚本固定使用 journal_mode=DELETE,连接设置 isolation_level=None,再用 SQL 明确发出事务命令。在本次 Python 接法下,这个设置关闭了驱动的隐式事务管理,方便直接观察 SQLite 的行为。Python 3.9:Controlling Transactions 表结构如下:
CREATE TABLE items (
code TEXT NOT NULL PRIMARY KEY,
label TEXT NOT NULL
);
每个案例都从空表开始。普通案例先插入键 a,再插入同一个键触发 IntegrityError。脚本在错误发生时记录事务是否活动、写入连接看到哪些键,并新开独立连接查询;随后按案例选择提交或回滚,再从新连接读取最终结果。[] 表示空表,字母是键值,不是行数。
| 案例 | 错误时事务活动 | 本连接可见 | 另一连接可见 | 按案例结束后的结果 |
|---|---|---|---|---|
autocommit:未显式开始整批事务 | False | [a] | [a] | [a] |
abort_then_commit:默认冲突,随后提交 | True | [a] | [] | [a] |
abort_then_rollback:默认冲突,随后回滚 | True | [a] | [] | [] |
or_rollback:冲突时回滚整个事务 | False | [] | [] | [] |
batch_abort:多行语句使用 ABORT,随后提交 | True | [seed] | [] | [seed] |
batch_fail:多行语句使用 FAIL,随后提交 | True | [b, seed] | [] | [b, seed] |
with_exception_out:异常离开 with 块 | True | [a] | [] | [] |
with_exception_caught:在 with 块内吞掉异常 | True | [a] | [] | [a] |
第二行尤其值得看:错误之后,当前连接还能查到 a,另一连接却查不到。它仍是未提交变化,不能把“自己查到了”解释为已经提交。等代码真的提交,新的读取连接才看到了 a。第三行错误瞬间的状态完全相同,但应用随后回滚,最终就是空表。
这也解释了为什么只截图异常,或只在原连接上执行一次查询,无法验证整批回滚。你至少要知道后续走了什么结束路径,并检查最终提交状态。
3. 多行 INSERT 失败,也要看冲突策略
为了观察语句内部的边界,两个案例先在同一事务中插入 seed,再执行一次包含两个相同键的多行插入:
INSERT OR ABORT INTO items (code, label)
VALUES ('b', 'first in statement'), ('b', 'duplicate in statement');
实际结果是这条语句的第一个 b 也被撤销,但前一条语句写入的 seed 仍留在活动事务里。随后提交,就只留下 seed。这是“当前语句没有部分成功”,却不是“整个事务没有部分成功”。
把 ABORT 换成 FAIL 后,同一个输入触发错误时,当前连接看到了 b 和 seed;随后提交,两者都留下了。官方定义正是如此:适用的 FAIL 冲突会保留失败语句中已经做出的变化,ROLLBACK 冲突则结束并回滚当前事务。SQLite:冲突算法定义
不要把这个例子扩大成“所有 SQL 错误都能留下半条语句”,也不要认为加上 OR ROLLBACK 就能覆盖所有故障。这里实际触发的是主键唯一性冲突,结果依赖明确的冲突策略。不同约束与不同错误有各自的规则,需要查对应文档。
从应用的角度看,重要的是提交前仍然检查整批的结果。把循环、批量 API 或多行 SQL 称为“批量操作”,都不足以说明业务上的原子性;真正需要检查的是它所在的事务边界和失败处理。
4. Python 的 with 块为什么也可能提交“失败的一批”
连接的上下文管理器根据退出路径决定提交或回滚。异常从 with connection: 内传出去时,它回滚;正常退出时,它提交活动事务。它也不会因为离开块就自动关闭连接。Python 3.9:连接上下文管理器
本实验在两个 with 案例内部都显式执行了 BEGIN。这很重要:我们已把隐式事务管理关掉,不能只增加一个 with 就假定事务被启动。随后第一条插入成功、第二条重复键失败,两个案例在错误瞬间都仍有未提交的 a。
差异出在异常是否继续传播。with_exception_out 先记录状态,再重新抛出异常,在块外捕获;退出时回滚,最终为空。with_exception_caught 在块内捕获后不再抛出,块正常结束;上下文管理器于是提交,最终留下 a。
在这里,打印错误或写日志并不等于回滚。上下文管理器看不到一个已经被吞掉的异常,它看到的是正常退出。如果需求是整批失败,就应让失败进入相应的回滚路径,而不是只改变控制台上的提示。若需求本来就是跳过坏记录、保存其他记录,也应明确报告哪些记录成功,避免界面把它描述为整批未发生。
5. 给“整批成功或失败”一个明确的实现
所附脚本里的 insert_batch 只接管一个顶层事务,要求连接采用与实验相同的设置,并且调用时没有活动事务。下面是它的关键逻辑;INSERT 使用参数占位符传入键和标签:
INSERT = "INSERT INTO items (code, label) VALUES (?, ?)"
def insert_batch(connection, batch):
if connection.in_transaction:
raise ValueError("insert_batch needs a connection with no active transaction")
connection.execute("BEGIN")
try:
connection.executemany(INSERT, batch)
connection.execute("COMMIT")
except BaseException:
if connection.in_transaction:
connection.execute("ROLLBACK")
raise
写入和提交都在同一个错误处理范围里;遇到失败时,如果事务仍活动,就回滚,再把异常交回调用者。这里捕获后重新抛出,是为了让上层得到失败结果,而不是把“清理完成”误当作“导入成功”。事务已经被数据库回滚的情况下,状态检查也避免再次发出一个没有活动事务的回滚命令。
函数先拒绝已有事务,是因为它不能擅自回滚调用者在此前写入的其他数据。已有事务里的局部工作单元需要另外设计,例如使用保存点;本文这个函数没有实现嵌套管理,也不应直接塞进一个已有的事务包装器。
我实际验证了四个边界:空批次不改变表;两个合法键一起提交,独立连接看到 [a, b];含重复键的批次抛出异常后,表为空且没有活动事务;调用者已有事务时,函数拒绝运行,调用者先前写入的键和事务状态保持原样。它们验证的是这段代码对本次输入的行为,不是对所有数据库故障的保证。
下载脚本后可直接运行,只有 Python 标准库依赖:
python3 sqlite-rollback-lab.py --json sqlite-rollback-results.json
这次输出的结尾为 All 8 cases and 4 batch-helper checks passed.。也可以下载本次实际运行的 JSON 记录,逐项查看环境、错误瞬间状态和最终结果。每个案例都使用单独的新数据库,避免上一组残留数据影响下一组。
6. 本次实验没有覆盖哪些故障
唯一性冲突便于复现,却不是数据库故障的全部。SQLite 文档指出,磁盘满、I/O 错误、中断和内存不足等错误可能只撤销语句,也可能迫使整个事务自动回滚。因此,不能从今天的 ABORT 结果推导出“任何错误后事务必定还在”。SQLite:事务内错误的响应
提交也不是必然成功的最后一行。官方文档说明,因其他连接的读取等原因,COMMIT 可能返回忙碌错误,事务仍保持活动。本实验没有制造锁竞争,没有测量忙碌重试策略,也没有注入磁盘满或回滚失败。生产代码还需要针对这些情况决定如何报告、恢复及处理连接,不能把本例的四项检查当作完整故障测试。
另一个边界是持久性。独立连接读到提交结果,说明本次普通执行下数据已提交并可见;它没有模拟掉电、系统崩溃或损坏的文件系统。我们也没有在 WAL 模式、共享缓存、外键错误或触发器组合下复跑。明确这些范围,是为了让读者知道实验解决的是事务控制问题,而不是宣称数据库在任何环境下都不会丢数据。
7. 排查半批数据,先查结束路径
面对“显示失败但数据库有记录”,先确认连接设置和事务起点,再核对实际 SQL 的冲突策略。随后跟踪异常在哪一层被捕获、有没有重新抛出,以及最终是否执行提交或回滚。检查当前连接的事务状态可以帮助理解过程,但最终结果还应从适当的新连接验证,并与这批业务记录的预期集合核对。
不要把错误日志的数量当作回滚证据,也不要等连接关闭时才猜测数据会怎样。若整批是业务要求,就把“成功时一起提交、失败时撤销整批”写进同一个可审查的控制流程;如果允许部分成功,就把成功与失败的记录列表作为结果的一部分。
回到开头的问题:默认约束错误通常撤销当前语句,并不自动撤销先前写入。能否恢复原样,要看那些写入是否仍处在同一个事务里,以及失败后这个事务如何结束。把这两个边界说清、实际查验,比一句“已经加了事务”更能解释数据最后会是什么状态。
文档与链接核对日期:2026 年 10 月 3 日。表格与 JSON 来自所附脚本的实际运行;临时数据只用于教学,没有访问任何真实业务数据库。