🦏 第15章 事务
📄 一句话概括

事务是「一组要么全生效、要么全不生效」的操作: BEGIN 开始、COMMIT 提交、ROLLBACK 撤销。 没有事务时,转账的两条 update 任意一条失败,钱就凭空消失了。 事务就是防这个的。 没 commit 就关连接,那些改动静默消失—— 这是新手最常见的数据丢失原因,而且测试时看不出来。

📝 本章和前面几章的关系

第 13、14 章讲的都是「怎么写得更花」, 这一章讲的是「怎么保证不出错」——事务是数据库最核心的一个承诺,比索引还重要。 前面其实已经用过它很多次了: 每次 commit() 都是在结束一个事务,只是没显式写过 begin。 Python 的驱动是「自动 begin、手动 commit」,所以这一章要把那个隐式的东西摊开看。

一、先看一个坏结果:钱转丢了

从账户 1 转 60 给账户 2,要两条 update:一条减、一条加。 第一条执行完,账户 1 变成 40。 这时候如果程序崩了、第二条写错了、或者网络断了呢? 账户 1 少了 60,账户 2 没多——这 60 块凭空消失了。 这就是「没有事务」的世界:每条语句各自算数,做完就收不回来了。 数据库不知道「这两条是一件事」,它只看见两条互不相干的 update。 这里要说清一件事:MySQL 默认 autocommit = 1,每条语句一执行就提交, 所以上面那种「半截」状态会真的落盘; 本沙箱的 Python 驱动默认要手动 commit, 「半截」暂时还留在内存里——但只要你调了 commit,一样救不回来。

二、把两条包进一个事务

begin 开始,两条 update,然后 rollback。 事务里看到的是 40 和 110,回滚之后又回到 100 和 50。 关键是数据库现在知道「这三条是一件事」了。 它会把中间状态记在日志里,随时能撤销—— 这正是事务能做到「全不生效」的原因。 一个事务的三种结局: commit(全生效)、rollback(全撤销)、连接断了(自动撤销)。 没有第四种。 「只生效一半」在事务里是不存在的。

三、commit 才是真正落盘

commit 之后才是永久的。 再想撤销就只能「反向再写一遍」,没法回滚了。 这个「不可逆」很重要:很多人以为「数据库有日志,随时能回滚」, 其实日志是给「崩溃恢复」用的,不是给「人反悔」用的。 提交了就是提交了。 所以真实项目里「改错了想撤销」只能靠备份(第 17 章), 或者靠业务上再写一条反向操作。 事务不等于「后悔药」。

四、不 commit 就关连接:数据真的没了

Demo 里插一行、不 commit、直接 close(),再打开一看——那行不在。 它不是「没写成功」,是「根本没算数」:它只在那个连接里存在过,连接一断就蒸发了。 这是新手最常见的数据丢失原因:代码里漏了一句 commit, 测试的时候因为有连接池、连接一直没关,看起来一切正常; 上线重启一次,数据全没了——而且日志里没有任何报错,因为它压根没出错。 所以「写完必须提交」要变成本能: insert/update/delete 之后立刻想一句「我 commit 了吗」。 只读的 select 不用管。

五、保存点:事务里的存档点

savepoint 名字 打一个标记,rollback to 名字 回到那个标记, release 名字 把标记删掉。 Demo 里插账户 3、打保存点、插账户 4、回滚到保存点—— 结果账户 3 还在、账户 4 没了。 保存点之前的改动不受影响。 保存点的用处是「局部重试」: 一批数据里有几行可能失败,失败的回到保存点重来,成功的不受影响。 不用为了三行脏数据把整批全撤销。 批量导入、批量更新时特别实用。 release 有个容易误解的地方: 它是「删掉保存点、改动留着」,不等于 commit。 最终还是要靠最外层那个 commit 才算数。

六、DDL 也能回滚,这一点和 MySQL 不同

Demo 里 begin → create table newt → rollback,结果 newt 不存在。 本沙箱的 DDL 是事务的一部分。 MySQL 8.0 之前不行: create table、alter table 这类 DDL 会「隐式提交」—— 你正在跑的事务被强制结束,DDL 之前的改动一起落盘。 所以「在 MySQL 事务里建表、出错就回滚」这个想法,在老版本上不成立。 MySQL 8.0 有了「原子 DDL」,但也不是所有语句都支持, 而且隐式提交的行为还在很多语句上保留着(比如 grant、lock tables)。 写迁移脚本时这条特别重要:别指望「DDL 失败能整体回滚」。

七、事务不能嵌套:第二个 BEGIN 直接报错

cannot start a transaction within a transaction。 事务是平的,不能套。 MySQL 上更微妙:再 begin 一次会「隐式提交」前面那个事务, 不报错,但行为不是你想要的。 要「嵌套」只能用保存点: 外层一个事务,里层用 savepoint/rollback to/release 划段。 这是标准做法,两种数据库都支持。 还有一个容易踩的地方:很多驱动和框架自己会开事务(Django 的 atomic、 Spring 的 @Transactional), 你在里面再手动 begin 一次就撞了。 所以「谁在管事务」要先弄清楚。

八、动手 Demo:转账丢钱、回滚、忘记 commit、保存点、DDL 回滚、嵌套 BEGIN

Demo 六段,每一段一个结论:先看「没有事务会怎样」、再看回滚、再看 commit 之后、 然后是忘记 commit 的后果、保存点、DDL 回滚,最后撞一次嵌套 begin。 第四段(关连接丢数据)是全篇最该亲手跑一遍的——它没有报错、没有警告,数据就是不见了。

import os
import sqlite3
import tempfile

# 事务要跨连接才看得出生效,所以用文件库
db = os.path.join(tempfile.gettempdir(), "tx.db")
c = sqlite3.connect(db)
c.execute("CREATE TABLE acct (id INTEGER PRIMARY KEY, name TEXT, "
          "balance INTEGER)")
c.executemany("INSERT INTO acct VALUES (?,?,?)",
               [(1, '账户1', 100), (2, '账户2', 50)])
c.commit()

# 1) 两条 update 包进一个事务,然后撤销
c.execute("BEGIN")
c.execute("UPDATE acct SET balance = balance - 60 WHERE id = 1")
c.execute("UPDATE acct SET balance = balance + 60 WHERE id = 2")
print(c.execute("SELECT balance FROM acct").fetchall())   # 40 / 110
c.rollback()
print(c.execute("SELECT balance FROM acct").fetchall())   # 100 / 50

# 2) 再来一次,这回提交
c.execute("BEGIN")
c.execute("UPDATE acct SET balance = balance - 60 WHERE id = 1")
c.execute("UPDATE acct SET balance = balance + 60 WHERE id = 2")
c.commit()

# 3) 不 commit 就关连接,那行数据会没了
c.execute("INSERT INTO acct VALUES (3, '账户3', 0)")
c.close()                      # 没 commit
c = sqlite3.connect(db)
Q.show(c, "SELECT id, name FROM acct ORDER BY id")

# 4) 保存点:只回滚一段
c.execute("BEGIN")
c.execute("INSERT INTO acct VALUES (4, '账户4', 0)")
c.execute("SAVEPOINT sp1")
c.execute("INSERT INTO acct VALUES (5, '账户5', 0)")
c.execute("ROLLBACK TO sp1")
c.execute("COMMIT")

# 5) DDL 也能回滚
c.execute("BEGIN")
c.execute("CREATE TABLE newt (x INT)")
c.rollback()
Q.show(c, "SELECT name FROM sqlite_master WHERE name = 'newt'")

# 6) 事务不能嵌套
c.execute("BEGIN")
c.execute("BEGIN")            # cannot start a transaction within
c.rollback()

实跑输出:

--------------------------------------------------------------------
第七十三课:事务是什么——一组要么全成、要么全不成的操作
--------------------------------------------------------------------

  先看一个坏结果:钱转丢了
  SQL> SELECT id, name, balance FROM acct
  id     name      balance
  -------------------------
  1      账户1     100
  2      账户2     50
  (2 行)
  **现在要从账户 1 转 60 给账户 2。** **这件事得两条 `update`:一条减、一条加。**
  SQL> UPDATE acct SET balance = balance - 60 WHERE id = 1
  影响 1 行
  **第一条执行完了,账户 1 变成 40。** **这时候如果程序崩了、或者第二条写错了、或者网络断了呢?**
  SQL> SELECT id, name, balance FROM acct
  id     name      balance
  -------------------------
  1      账户1     40
  2      账户2     50
  (2 行)
  **账户 1 少了 60,账户 2 没多**——
  **这 60 块凭空消失了。** **这就是「没有事务」的世界:每条语句各自算数,做完就收不回来了。**
  **这里要说清一件事**:**MySQL 默认 `autocommit = 1`,每条语句一执行就提交**,
  **所以上面那种「半截」状态会真的落盘**;
  **本沙箱的 Python 驱动默认要手动 `commit`**,
  **「半截」暂时还留在内存里——但只要你调了 `commit`,一样救不回来。**

  把两条包进一个事务
  SQL> BEGIN
  完成
  SQL> UPDATE acct SET balance = balance - 60 WHERE id = 1
  影响 1 行
  SQL> UPDATE acct SET balance = balance + 60 WHERE id = 2
  影响 1 行
  SQL> SELECT id, name, balance FROM acct
  id     name      balance
  -------------------------
  1      账户1     40
  2      账户2     110
  (2 行)
  **事务里看到的是「已经改成 40 和 110」**——**因为改动已经在连接里生效了。**
  SQL> ROLLBACK
  完成
  SQL> SELECT id, name, balance FROM acct
  id     name      balance
  -------------------------
  1      账户1     100
  2      账户2     50
  (2 行)
  **`rollback` 之后又回到 100 和 50。** **两条 `update` 一起被撤销了。**
  **这就是事务的核心承诺:一组操作要么全生效、要么全不生效。**

  commit 才是真正落盘
  SQL> BEGIN
  完成
  SQL> UPDATE acct SET balance = balance - 60 WHERE id = 1
  影响 1 行
  SQL> UPDATE acct SET balance = balance + 60 WHERE id = 2
  影响 1 行
  SQL> COMMIT
  完成
  SQL> SELECT id, name, balance FROM acct
  id     name      balance
  -------------------------
  1      账户1     40
  2      账户2     110
  (2 行)
  **`commit` 之后才是永久的。** **再想撤销就只能「反向再写一遍」,没法回滚了。**
  **所以一个事务的三种结局**:
  **`commit`(全生效)、`rollback`(全撤销)、连接断了(自动撤销)。** **没有第四种。**

  不 commit 就关连接:数据真的没了
  **插了一行 `b`,没 `commit`,直接把连接关掉。**
  SQL> SELECT id, v FROM t
  id     v
  ----------
  1      a
  (1 行)
  **只剩 `a`。** **`b` 不是「没写成功」,是「根本没算数」**——
  **它只在那个连接里存在过,连接一断就蒸发了。**
  **这是新手最常见的数据丢失原因**:**代码里漏了一句 `commit`**,
  **测试的时候因为有连接池、连接一直没关,看起来一切正常**,**上线重启一次,数据全没了。**

  保存点:事务里的「存档点」
  SQL> BEGIN
  完成
  SQL> INSERT INTO acct (id, name, balance) VALUES (3, '账户3', 0)
  影响 1 行
  SQL> SELECT COUNT(*) AS 行数 FROM acct
  行数
  -----
  3
  (1 行)
  SQL> SAVEPOINT sp1
  完成
  SQL> INSERT INTO acct (id, name, balance) VALUES (4, '账户4', 0)
  影响 1 行
  SQL> SELECT COUNT(*) AS 行数 FROM acct
  行数
  -----
  4
  (1 行)
  SQL> ROLLBACK TO sp1
  完成
  SQL> SELECT COUNT(*) AS 行数 FROM acct
  行数
  -----
  3
  (1 行)
  **`rollback to sp1` 只回滚到那个保存点,之前的不动。** **所以账户 3 还在、账户 4 没了。**
  SQL> COMMIT
  完成
  SQL> SELECT id, name FROM acct ORDER BY id
  id     name
  -------------
  1      账户1
  2      账户2
  3      账户3
  (3 行)
  **保存点的用处是「局部重试」**:
  **一批数据里有几行可能失败,失败的回到保存点重来,成功的不受影响。**
  **不用为了三行脏数据把整批全撤销。**

  DDL 也能回滚,这一点和 MySQL 不同
  SQL> BEGIN
  完成
  SQL> CREATE TABLE newt (x INT)
  完成
  SQL> INSERT INTO newt VALUES (1)
  影响 1 行
  SQL> ROLLBACK
  完成
  SQL> SELECT name FROM sqlite_master WHERE name = 'newt'
  name
  -----
  (0 行)
  **0 行——建表被撤销了,`newt` 根本不存在。** **本沙箱的 DDL 是事务的一部分,能回滚。**
  **MySQL 不一样**:
  **8.0 之前,`create table` 这类 DDL 会「隐式提交」**——
  **你正在跑的事务被强制结束了,DDL 之前的改动一起落盘。**
  **所以「在 MySQL 事务里建表、出错就回滚」这个想法,在老版本上是不成立的。**
  **MySQL 8.0 有了原子 DDL,但也不是所有语句都支持。**

  事务不能嵌套:第二个 BEGIN 直接报错
  SQL> BEGIN
  完成
  SQL> BEGIN
  报错:cannot start a transaction within a transaction
  **报 `cannot start a transaction within a transaction`。** **事务是平的,不能套。**
  **要「嵌套」只能用保存点**:
  **外层一个事务,里层用 `savepoint` / `rollback to` / `release` 划段。**
  **`release sp1` 是「把保存点删掉,改动留着」**——
  **注意它不等于 `commit`,最终还是要靠最外层那个 `commit`。**
  SQL> ROLLBACK
  完成

  MySQL 那边怎么写
  MySQL 原码(本沙箱不执行):
  | START TRANSACTION;      -- 或者写 BEGIN
  | UPDATE acct SET balance = balance - 60 WHERE id = 1;
  | UPDATE acct SET balance = balance + 60 WHERE id = 2;
  | COMMIT;                 -- 或者 ROLLBACK;
  | 
  | SELECT @@autocommit;    -- 默认是 1
  **保存点:`SAVEPOINT sp1;` / `ROLLBACK TO sp1;` / `RELEASE SAVEPOINT sp1;`**
  **`start transaction` 和 `begin` 是一回事**,
  **`begin` 更短,但注意它和 `begin … end` 那个「代码块」的 `begin` 不是一个东西。**
  **`delimiter` 里出现的 `begin` 是代码块,单独一句 `begin` 才是开事务。**
  **`@@autocommit` 是那个开关**:**等于 1 时每条语句自动提交**(等于没有事务),
  **等于 0 时每条语句都不提交,等你手动 `commit`。**
  **本沙箱的 Python 驱动相当于「`autocommit = 0` 且自动 `begin`」**——
  **所以 `insert` 之后一定要记得 `commit`。**

第一段:事务里是 40 和 110,rollback 后回到 100 和 50。 第二段 commit 之后,再查还是 40 和 110。 第三段插了账户 3 没 commit 就关连接,重开只剩账户 1、2。 第四段保存点:账户 4 留下、账户 5 被回滚掉。 第五段 DDL 回滚,sqlite_master 里 0 行,newt 不存在。 第六段第二个 begin 报 cannot start a transaction within a transaction。

30 秒实验

  1. 把第四段的 commit 去掉,再关连接重开,acct 里还剩几行?
  2. release sp1 之后直接关连接、不 commit,账户 4 还在吗?
  3. 为什么 MySQL 老版本上「事务里建表然后回滚」行不通?

常见误区

  1. 写完不 commit。 数据静默丢失,测试时还看不出来,这是最常见的事故来源。
  2. 以为事务是后悔药。 commit 之后就回不去了,反悔只能靠备份或反向操作。
  3. 事务里套事务。 第二个 begin 报错,MySQL 上还会隐式提交前一个,要用 savepoint。
自测三问(点开对答案) 1. 一个事务有哪三种结局? —— `commit` 全生效、`rollback` 全撤销、连接断开自动撤销。没有「只生效一半」。 2. 保存点 `rollback to sp1` 和 `rollback` 有什么区别? —— 前者只撤销到 `sp1` 之后的部分,之前的改动还在;后者撤销整个事务。 3. 为什么 MySQL 老版本的事务里不能靠回滚撤销建表? —— DDL 会隐式提交前面的事务,建表之前那些改动一起落盘了,回滚已经没东西可回。

会用了,下一篇把它的四条承诺拆开:15.2 事务的属性——ACID 逐条验。

相关笔记