qinghuan
← 回到知识探索

后端现场 / 动手实验

事务已经提交,另一个窗口为什么还看不到?

用两个连接读同一条数据,观察事务除了回滚还决定了“能看到哪一版”。

展开本文目录
  1. 回滚、可见性和业务正确是三件事
  2. 只用本地文件观察一次快照
  3. 换一个数据库,先核对规则
  4. 排查时把事务边界写出来
  5. 记住这三点
  6. 别把例子用过头
  7. 自己试一小步
  8. 查原始资料
  9. 找一份资料继续练
开始前,会这些就够了
  • 知道提交和回滚
  • 会执行简单 SELECT
遇到不熟悉的词,先看这 2 个解释
事务
把一组数据库操作放进同一个提交范围,让它们作为一件事被确认或撤销,并按数据库规则控制其他事务看到的变化;它帮助处理做到一半时的失败。
隔离级别
多个事务交错运行时,数据库规定它们何时能看到彼此的修改、允许出现哪些相互影响;隔离级别就是这些规则的选择,影响读取结果与并发处理方式。
打开完整术语小抄 ↗

假设你开两个数据库窗口:甲读到库存十件,乙改成二十件并提交,甲再查仍然是十件。这不一定是缓存,也不一定是提交失败。甲可能仍处于一个读取旧快照的事务里。要解释它,除了问“有没有提交”,还要问“谁在什么时候开始读”。

回滚、可见性和业务正确是三件事

回滚关注本次修改失败后是否撤回;隔离关注同时进行的事务彼此能看到什么;业务正确还取决于约束和操作方式。把几句 SQL 放进事务,只解决了一部分问题,不能顺势推断它们会自动避开所有并发冲突。

快照是一套读取某个时间点数据版本的规则,不必真的复制整张表。读者可以继续看到旧版本,写者可以完成新版本;两者不矛盾。具体什么时候确定快照、下一条语句会不会换一份,要看数据库、隔离级别和语句类型。

只用本地文件观察一次快照

下面限定为 SQLite 的 WAL 模式,用两个连接访问同一个临时数据库。甲先开始事务并读取,乙更新后提交,甲在原事务里仍看到十;甲结束事务后再次读取,才看到二十。程序使用临时目录,退出时删除本次创建的练习文件,不接触已有数据库。

这个实验故意顺序安排两条连接的动作,不用线程制造随机结果。它展示的是 SQLite WAL 的已定义读取规则,不是 PostgreSQL 或 MySQL 所有隔离级别的模拟。观察点都放在提交前后,便于把结果与事务范围对应起来。

小实验 · python
import sqlite3
from pathlib import Path
from tempfile import TemporaryDirectory

with TemporaryDirectory(prefix="snapshot-demo-", dir=".") as folder:
    path = Path(folder) / "demo.db"
    a = sqlite3.connect(path, isolation_level=None)
    b = sqlite3.connect(path, isolation_level=None)
    try:
        assert a.execute("PRAGMA journal_mode=WAL").fetchone()[0] == "wal"
        a.execute("CREATE TABLE stock (id INTEGER PRIMARY KEY, qty INTEGER)")
        a.execute("INSERT INTO stock VALUES (1, 10)")
        a.execute("BEGIN")
        print(a.execute("SELECT qty FROM stock WHERE id=1").fetchone()[0])
        b.execute("BEGIN")
        b.execute("UPDATE stock SET qty=20 WHERE id=1")
        b.execute("COMMIT")
        print(a.execute("SELECT qty FROM stock WHERE id=1").fetchone()[0])
        a.execute("COMMIT")
        print(a.execute("SELECT qty FROM stock WHERE id=1").fetchone()[0])
    finally:
        b.close()
        a.close()

怎样运行:Python 3.10+ 标准库、SQLite 3.7.0+;在自己的空练习目录保存为 snapshot_demo.py,运行 python snapshot_demo.py。

运行后对照结果
10
10
20

换一个数据库,先核对规则

PostgreSQL 17 的 READ COMMITTED 会让普通 SELECT 在每条语句开始时取得快照,因此同一事务中的两次查询可能看到不同的已提交值。REPEATABLE READ 则让后续查询沿用事务中的那份快照,并且可能要求整个事务因并发冲突而重试。

MySQL 8.4 InnoDB 的 REPEATABLE READ 普通一致性读取,通常沿用第一次这类读取建立的快照;READ COMMITTED 每次一致性读取使用新的快照。自身已经做出的修改还存在可见性规则,UPDATE 和带锁查询也不能简单套用普通 SELECT 的结论。

排查时把事务边界写出来

遇到“看不到新值”,先记录连接身份、自动提交设置、隔离级别、第一次读取时间,以及另一方确实完成提交的证据。如果连接来自连接池,还要确认事务有没有在返回池前结束。仅截图最后一条 SELECT,通常不足以还原这段历史。连续点击两次查询按钮,也不一定处于同一事务:工具可能每次自动提交,也可能一直保留手动开启的事务,必须先确认它实际发了哪些语句。

需要立即根据库存做决定时,不要仅靠“先读一个数、在程序里减一、再写回”。应选择能表达业务条件的数据库操作,并检查受影响行数、约束或必要的锁。旧快照能解释读取结果,但并不自动替你保证扣库存规则。

记住这三点

  • 事务同时影响修改能否撤回和读取能看见哪一版。
  • 快照何时建立,必须结合产品、版本、级别和语句类型判断。
  • 提交成功不代表所有仍在执行的旧事务立刻看见新值。

别把例子用过头

  • SQLite WAL 实验不能代替 MySQL 或 PostgreSQL 的并发测试。
  • 普通读取、带锁读取和更新语句不一定使用相同的可见性规则。
  • 读取稳定不等于业务规则自动安全,冲突处理仍要设计。

自己试一小步

如果甲在乙提交之前只执行 BEGIN、还没有读取,第一次读取结果应该怎样验证?

  1. 改变实验的动作顺序,只移动甲的第一次 SELECT。
  2. 保留每个 BEGIN、SELECT、UPDATE、COMMIT 的编号。
  3. 查所用数据库的固定版本文档,说明快照建立在哪一步。

做到这些,就算完成

  • 结论注明数据库和模式。
  • 不把事务开始时间一律当成快照建立时间。
  • 把实际运行观察与对其他数据库的推断分开。

查原始资料

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

  • Isolation In SQLite (新标签页)↗SQLite · 英文

    支持 WAL 的快照隔离、两个连接的读取示例、结束旧事务后读取新值和模式差别。

    公开英文文档;读取规则需区分 rollback journal 与 WAL 模式,本文实验使用 WAL。

  • PostgreSQL 17: Transaction Isolation (新标签页)↗PostgreSQL Global Development Group · 英文

    支持 READ COMMITTED 每语句快照、REPEATABLE READ 的快照与冲突重试。

    公开英文固定版本文档;适用 PostgreSQL 17,隔离级别不同,读取规则也不同。

  • MySQL 8.4: Consistent Nonlocking Reads (新标签页)↗Oracle MySQL · 英文

    支持 InnoDB 一致性读取快照、自身修改的例外,以及普通读取与 DML 不同的条件。

    公开英文固定版本文档;说明 InnoDB 普通一致性读取,不能直接套到所有带锁语句。

找一份资料继续练

先选一项,按下面的范围继续学;不必从头读完整门课程。