开始前,会这些就够了
- 能读懂 INSERT、SELECT 与简单 Python 函数。
- 知道事务可整体提交或回滚;无需安装独立数据库服务。
- Python 3.10 或更新版本,且发行版包含标准库 sqlite3。
批量导入时,主记录已经写入,随后尝试补充一段可选信息。你希望这段失败时只撤销它,仍保留前面已经做好的部分。保存点可以帮忙,但它也带来一个容易误读的词:内层 RELEASE 成功,是否代表这部分已经独立保存?
本篇用三份临时数据库留下可核对的过程。每一步同时看当前记录和事务状态,最后关闭连接再打开。学习产物不是背四个命令,而是一张能回答“哪些写入还能被谁撤销”的时间线。
把例子带到自己的电脑上
下载完整练习包
需要 Python 3.10 或更新版本;制作时实测 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- savepoint_demo.py:完整示例
- README.md:环境、范围与独立练习
- observed.stdout.txt:制作时的观察记录
- verification.json:对应源码的验证范围
- SHA256SUMS.txt:文件内容摘要
在这个文件夹打开 PowerShell,逐行运行
python --version
python -B savepoint_demo.py解压后在含示例文件的文件夹打开 PowerShell,按命令运行。先预测结果,再与当次输出对照。 真实临时 SQLite 文件与正常断开后重连,验证保存点和事务规则;不模拟崩溃、掉电或其他数据库。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
先把一整批操作和中间标记分开
把一次导入想成一张尚未交出的工作单:先写基础内容,在补充信息前做个标记;补充出错时,可以退回标记处继续填写。这里的标记就是保存点,它不等于已经交出一份独立成品。这个比喻只帮助定位撤销范围,真实结果仍以下面的数据库查询为准。
已有事务文章比较共享事务与独立事务,本实验增加的是同一事务内部的部分撤销。所有命令顺序执行,每个场景同一时刻只使用一个连接;没有创建第二个并行写事务,也没有模拟 Spring 的传播注解。
表里只有一个名称字段,outer、inner 等值用来标明写入位置。查询固定按名称排序,因此输出顺序也不是提交先后。要判断边界,应同时读 stage、names 和 in_transaction 三个字段。
内层 RELEASE 后,先别把查得到当作提交
第一组先 BEGIN,写入 outer,再建立 part 保存点并写入 inner。执行 RELEASE part 后,两条记录都能由当前连接查到,事务状态仍为 true。接着执行外层 ROLLBACK,两条记录一起消失;关闭并重开数据库,结果仍为空。
这个对照把两个问题拆开了:当前事务看见哪些写入,以及事务最后留下哪些写入。第一步查询只回答前者。内层释放标记并没有使 inner 脱离外层事务,所以后面的整体撤销仍包括它。
排查实际日志时也应寻找最终提交的位置。只看到一个子步骤成功返回、一个 SQL 没抛异常,或者当前连接刚查到了记录,都不足以证明整个业务操作已经完成。
ROLLBACK TO 撤销后面的写入,目标标记还在
第二组先写 base,然后依次建立 part、deeper 两个标记,并在它们后面各写一条待舍弃记录。回退到 part 后,base 留下,后面两条消失。程序尝试释放 deeper,会得到标记不存在的错误;这次错误没有改变已有记录。
接着写一条 discard_retry,再次回退到同一个 part。仍然只剩 base,说明目标标记没有被第一次回退删除。最后写 replacement,释放 part,再尝试回退到 part 时才失败。这里同时检查记录不变和事务仍活动,避免把预期报错当成整体回滚。
这组步骤不包含失败语句的自动恢复。撤销命令都是程序明确发出的;我们有意制造的错误只有引用不存在的保存点。其他错误是否结束事务,必须按数据库和错误类型另行判断,不能靠这一组结果概括。
另做一组没有外层 BEGIN 的对照
不能把第一组结论缩成“RELEASE 永远不提交”。第三组在没有活动事务时直接 SAVEPOINT whole,写入 standalone,然后 RELEASE whole。事务状态变成 false,关闭重开后仍能读到记录。
差别来自释放的位置。第一组的 part 位于外层 BEGIN 内部;第三组的 whole 自己就是最外层。SQLite 中释放最外层保存点会完成提交,所以看命令名还不够,必须知道当前还包着哪一层。
本例为每个标记使用不同的固定名称,便于在纸上追踪。没有演示同名保存点的匹配规则,也没有把其他数据库的嵌套事务行为当成相同规则。
运行单文件,核对三组最后留下的记录
下载后解压,在包含 savepoint_demo.py 的目录运行下面的命令。脚本只使用 Python 标准库,在系统临时目录创建三份独立 SQLite 文件;每次连接都会关闭,所有场景完成后删除自己的临时目录。它不访问已有业务数据库,也不在当前目录留下数据库文件。
代码明确关闭 Python 自动开启事务的旧式行为,让 BEGIN 等 SQL 决定边界;在 Python 3.12 及以上也明确选择对应的事务控制模式。不要删除这些设置后只凭相同代码外观比较结果。
本次实跑为 Python 3.14.0、SQLite 3.50.4,得到十一条状态记录和末行 PASS。先预测三次 reopened 会读到什么,再核对 JSON;版本号由程序打印,换电脑后应保存自己的运行记录。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""SQLite savepoints on temporary databases; Python 3.10+, standard library only."""
from contextlib import closing
from pathlib import Path
from tempfile import TemporaryDirectory
import json
import sqlite3
import sys
def connect(path):
options = {"isolation_level": None}
if sys.version_info >= (3, 12):
options["autocommit"] = sqlite3.LEGACY_TRANSACTION_CONTROL
return sqlite3.connect(path, **options)
def names(db):
return [row[0] for row in db.execute("SELECT name FROM item ORDER BY name")]
def put(db, name):
db.execute("INSERT INTO item(name) VALUES (?)", (name,))
def observe(trace, case, stage, db, expected, active):
row = {"case": case, "stage": stage, "names": names(db), "in_transaction": db.in_transaction}
assert row["names"] == expected, row
assert row["in_transaction"] is active, row
trace.append(row)
def missing_mark(db, statement):
before = names(db)
try:
db.execute(statement)
except sqlite3.OperationalError as error:
assert "no such savepoint" in str(error), error
else:
raise AssertionError("missing savepoint was accepted")
assert names(db) == before and db.in_transaction
def make_table(db):
db.execute("CREATE TABLE item(name TEXT PRIMARY KEY)")
assert not db.in_transaction
def main():
trace = []
with TemporaryDirectory(prefix="sqlite-savepoint-") as directory:
root = Path(directory)
rollback_path = root / "outer-rollback.db"
with closing(connect(rollback_path)) as db:
make_table(db)
db.execute("BEGIN")
put(db, "outer")
db.execute("SAVEPOINT part")
put(db, "inner")
db.execute("RELEASE part")
observe(trace, "outer_rollback", "after_inner_release", db, ["inner", "outer"], True)
db.execute("ROLLBACK")
observe(trace, "outer_rollback", "after_outer_rollback", db, [], False)
with closing(connect(rollback_path)) as db:
observe(trace, "outer_rollback", "reopened", db, [], False)
partial_path = root / "partial-retry.db"
with closing(connect(partial_path)) as db:
make_table(db)
db.execute("BEGIN")
put(db, "base")
db.execute("SAVEPOINT part")
put(db, "discard_first")
db.execute("SAVEPOINT deeper")
put(db, "discard_second")
db.execute("ROLLBACK TO part")
observe(trace, "partial_retry", "after_rollback_to", db, ["base"], True)
missing_mark(db, "RELEASE deeper")
put(db, "discard_retry")
db.execute("ROLLBACK TO part") # The target mark remains usable.
observe(trace, "partial_retry", "after_second_rollback_to", db, ["base"], True)
put(db, "replacement")
db.execute("RELEASE part")
missing_mark(db, "ROLLBACK TO part")
observe(trace, "partial_retry", "after_release_before_commit", db, ["base", "replacement"], True)
db.execute("COMMIT")
observe(trace, "partial_retry", "after_commit", db, ["base", "replacement"], False)
with closing(connect(partial_path)) as db:
observe(trace, "partial_retry", "reopened", db, ["base", "replacement"], False)
standalone_path = root / "outermost-release.db"
with closing(connect(standalone_path)) as db:
make_table(db)
db.execute("SAVEPOINT whole") # No surrounding BEGIN in this control.
put(db, "standalone")
observe(trace, "outermost_release", "before_release", db, ["standalone"], True)
db.execute("RELEASE whole")
observe(trace, "outermost_release", "after_release", db, ["standalone"], False)
with closing(connect(standalone_path)) as db:
observe(trace, "outermost_release", "reopened", db, ["standalone"], False)
assert not root.exists()
print(json.dumps({"python": sys.version.split()[0], "sqlite": sqlite3.sqlite_version,
"trace": trace, "temporary_directory_removed": True}, indent=2))
print("PASS: inner release, partial rollback, retained/removed marks, outer commit, reopen and cleanup")
if __name__ == "__main__":
main()
怎样运行:python -B savepoint_demo.py 2026-10-15 已用 3.14.0 执行这份示例并通过自带断言;下方为当次输出。真实临时 SQLite 文件与正常断开后重连,验证保存点和事务规则;不模拟崩溃、掉电或其他数据库。
运行后对照结果
{
"python": "3.14.0",
"sqlite": "3.50.4",
"trace": [
{
"case": "outer_rollback",
"stage": "after_inner_release",
"names": [
"inner",
"outer"
],
"in_transaction": true
},
{
"case": "outer_rollback",
"stage": "after_outer_rollback",
"names": [],
"in_transaction": false
},
{
"case": "outer_rollback",
"stage": "reopened",
"names": [],
"in_transaction": false
},
{
"case": "partial_retry",
"stage": "after_rollback_to",
"names": [
"base"
],
"in_transaction": true
},
{
"case": "partial_retry",
"stage": "after_second_rollback_to",
"names": [
"base"
],
"in_transaction": true
},
{
"case": "partial_retry",
"stage": "after_release_before_commit",
"names": [
"base",
"replacement"
],
"in_transaction": true
},
{
"case": "partial_retry",
"stage": "after_commit",
"names": [
"base",
"replacement"
],
"in_transaction": false
},
{
"case": "partial_retry",
"stage": "reopened",
"names": [
"base",
"replacement"
],
"in_transaction": false
},
{
"case": "outermost_release",
"stage": "before_release",
"names": [
"standalone"
],
"in_transaction": true
},
{
"case": "outermost_release",
"stage": "after_release",
"names": [
"standalone"
],
"in_transaction": false
},
{
"case": "outermost_release",
"stage": "reopened",
"names": [
"standalone"
],
"in_transaction": false
}
],
"temporary_directory_removed": true
}
PASS: inner release, partial rollback, retained/removed marks, outer commit, reopen and cleanup重新连接扩大了证据,但没有做断电试验
同一连接中的查询帮助观察工作状态,关闭后重连帮助区分本次最终提交与撤销结果。第二组最后显式 COMMIT,base 和 replacement 才成为这组重连看到的记录;被回退掉的名称不会因为之后提交又回来。
这仍是一台机器上正常结束的顺序实验,没有杀进程、断电、并发冲突或磁盘故障。我们没有更改日志和同步设置,也没有据此测量各种配置的持久性或性能。记录已经重连读到,比只写“成功”更具体,但不能扩写成所有故障都已验证。
能部分撤销,不等于业务应该接受一半结果
保存点只提供一个撤销范围,不能替产品决定哪些部分允许失败。可选描述导入失败后继续,与转账只保留扣款一边,是完全不同的业务结果。开始编码前先列出必须一起成功的记录,以及允许重做或省略的步骤。
还要明确它只影响当前数据库事务里的变化。程序已经打印的日志、发送的邮件或另一个系统完成的写入,不会因为这里 ROLLBACK TO 就倒退。本文没有执行这些外部操作,练习时也不要为了模仿业务而加入真实发送。
最后把时间线交给另一位读者:每条写入在哪个标记之前或之后,哪次动作只撤销一段,哪次动作结束整批,以及重连读到了什么。若只能说“里面成功、外面失败”,信息仍不足以判断留下了哪些结果。
记住这些要点
- 内层 RELEASE 移除保存点,外层仍能撤销这批写入。
- ROLLBACK TO 保留目标标记,撤销其后的变化并移除更深标记。
- 没有外层事务时,释放最外层保存点会提交;先判断位置再解释命令。
- 事务内查询、最终提交与故障持久性是不同层次的证据。
别把例子用过头
- 把子步骤成功、当前连接可见直接写成整批已提交。
- 以为 ROLLBACK TO 自动结束整个事务或删除目标标记。
- 从内层 RELEASE 推出任何 RELEASE 都不提交。
- 把部分撤销能力当成业务允许部分成功,或期待它撤销外部副作用。
自己试一小步
为一批可选资料导入设计三个保存点,怎样让别人只看时间线就判断最终保留哪些记录?
- 自拟五个不同名称和三个保存点,不沿用本文名称及操作顺序;先写出每一步的记录预测。
- 加入一次回退到中间保存点、一次继续写入,以及一次外层最终决定;说明哪些部分业务上允许舍弃。
- 在脚本副本的临时数据库中执行,逐步保存名称列表与事务状态,再关闭重连。
- 换掉外层的最终决定再做一组对照,保留两次记录;不要访问真实业务库。
做到这些,就算完成
- 预测与查询分别保存,没有只提交最后一行 PASS。
- 能解释哪个保存点仍可使用、哪个已经不存在。
- 最终列表保留了完整名称,清楚区分部分回退和整批结束。
- 没有把正常重连结果写成并发、断电或框架传播已经验证。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- SQLite:SAVEPOINT、ROLLBACK TO 与 RELEASE (新标签页)↗
阅读第 2、3 节,核对目标保存点保留、内层释放、最外层释放和标记栈规则。三组输入和断言为原创实验。
- SQLite:事务控制与事务中的错误 (新标签页)↗
阅读第 2 节和第 3 节,核对 BEGIN 的事务边界,以及错误并不统一对应同一种自动回滚结果。
- Python 3.14:sqlite3 的显式事务控制 (新标签页)↗
阅读
isolation_level、LEGACY_TRANSACTION_CONTROL、in_transaction和连接上下文管理器;核对本例显式 SQL 控制及连接关闭责任。