qinghuan
← 回到知识探索

后端现场 / 动手实验

内层已经 RELEASE,为什么整批数据还是没了?

用真实 SQLite 看清保存点、部分撤销和最终提交;记录每一步还能查到什么,再重新连接核验。

展开本文目录
  1. 下载完整练习包
  2. 先把一整批操作和中间标记分开
  3. 内层 RELEASE 后,先别把查得到当作提交
  4. ROLLBACK TO 撤销后面的写入,目标标记还在
  5. 另做一组没有外层 BEGIN 的对照
  6. 运行单文件,核对三组最后留下的记录
  7. 重新连接扩大了证据,但没有做断电试验
  8. 能部分撤销,不等于业务应该接受一半结果
  9. 记住这些要点
  10. 别把例子用过头
  11. 自己试一小步
  12. 查原始资料
开始前,会这些就够了
  • 能读懂 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;版本号由程序打印,换电脑后应保存自己的运行记录。

展开收起代码 · 小实验python · 107 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

小实验 · python
"""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 都不提交。
  • 把部分撤销能力当成业务允许部分成功,或期待它撤销外部副作用。

自己试一小步

为一批可选资料导入设计三个保存点,怎样让别人只看时间线就判断最终保留哪些记录?

  1. 自拟五个不同名称和三个保存点,不沿用本文名称及操作顺序;先写出每一步的记录预测。
  2. 加入一次回退到中间保存点、一次继续写入,以及一次外层最终决定;说明哪些部分业务上允许舍弃。
  3. 在脚本副本的临时数据库中执行,逐步保存名称列表与事务状态,再关闭重连。
  4. 换掉外层的最终决定再做一组对照,保留两次记录;不要访问真实业务库。

做到这些,就算完成

  • 预测与查询分别保存,没有只提交最后一行 PASS。
  • 能解释哪个保存点仍可使用、哪个已经不存在。
  • 最终列表保留了完整名称,清楚区分部分回退和整批结束。
  • 没有把正常重连结果写成并发、断电或框架传播已经验证。

查原始资料

想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。