事务是「一组要么全生效、要么全不生效」的操作:
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 秒实验
- 把第四段的
commit去掉,再关连接重开,acct里还剩几行? release sp1之后直接关连接、不commit,账户 4 还在吗?- 为什么 MySQL 老版本上「事务里建表然后回滚」行不通?
常见误区
- 写完不
commit。 数据静默丢失,测试时还看不出来,这是最常见的事故来源。 - 以为事务是后悔药。
commit之后就回不去了,反悔只能靠备份或反向操作。 - 事务里套事务。 第二个
begin报错,MySQL 上还会隐式提交前一个,要用savepoint。
自测三问(点开对答案)
1. 一个事务有哪三种结局? —— `commit` 全生效、`rollback` 全撤销、连接断开自动撤销。没有「只生效一半」。 2. 保存点 `rollback to sp1` 和 `rollback` 有什么区别? —— 前者只撤销到 `sp1` 之后的部分,之前的改动还在;后者撤销整个事务。 3. 为什么 MySQL 老版本的事务里不能靠回滚撤销建表? —— DDL 会隐式提交前面的事务,建表之前那些改动一起落盘了,回滚已经没东西可回。会用了,下一篇把它的四条承诺拆开:15.2 事务的属性——ACID 逐条验。
- ☐ (本篇完,下一篇 15.2 事务的属性)