事务不是「一种技术」,而是「几条语句捆绑成一个单位」这件事本身。
没提交的改动只有自己看得见,另一个连接读到的还是旧值——这是整章的地基。
SAVEPOINT 能只撤一半;但它的寿命就是那个事务,跨不过 COMMIT。
第 16 章开讲事务。前 15 章讲的都是「一条语句怎么执行」,这一章讲「几条语句怎么当一个用」。 和第 13 章的数据完整性约束是一路的:约束管「这一行合不合法」, 事务管「这一批要不要一起生效」。 一个管行内,一个管行间。 教材 16.1 有七个小节:概念、必要性、关闭自动提交、回滚、提交、MySQL 中的事务、回滚点。 这一篇把它们并成七节,全部真跑——而且必须开两个连接,单连接下什么都看不出来。
一、事务的概念:几条语句当一个用
转账在数据库里是两条 UPDATE:给甲扣 300、给乙加 300。
两条之间如果断了——进程崩了、网络断了、余额不足报了错——就会只做了一半。
先把「只做一半」的样子摆出来:三条账户各 1000,只给甲扣了 300。
| 账户 | 金额 |
|---|---|
| 甲 | 700 |
| 乙 | 1000 |
| 丙 | 1000 |
| 合计 | 2700 |
合计算出来 2700,原本是 3000。 凭空少了 300 元——钱没有消失,它只是「扣了但没加」,停在半路。 同样只做完第一步,但把它回滚掉,合计立刻回到 3000,一行都没变。 这就是事务的全部意义:把几条语句捆成一个「要么全做、要么全不做」的单位。 它保证的不是「不出错」,而是「出错之后不留下半成品」。 这两句话有区别:前者做不到(硬盘坏了谁也没辙),后者才是工程上真正拿得到的东西。
二、事务机制的必要性
为什么非得有这个东西?因为「业务上的一件事」和「数据库上的一条语句」不对等。
| 业务上的一件事 | 数据库上的几条语句 |
|---|---|
| 转账 | 扣款、加款,两条 UPDATE |
| 下单 | 插订单、扣库存、加积分,三条 |
| 入职 | 插员工、建账号、分配部门,三条 |
每多一条语句,就多一个「执行到一半」的窗口。 没有事务的话,这个窗口只能靠应用层自己兜:写日志、补偿、定时对账—— 每一样都比事务本身难写得多,而且要在每个业务点上重复一遍。 还有一层不那么直观的用处:并发。 两个人同时改同一行时,事务保证「一个人改的时候,另一个人看不到中间状态」。 这一条不是靠「小心一点」能解决的,只能靠数据库自己拿锁来保证。下一篇细讲。
三、关闭自动提交:先看清默认是什么
SQLite 和 MySQL 的默认都是自动提交:每条语句自己开事务、自己提交。
所以平时写一条 UPDATE 不用管事务,它自己就生效了。
验证的办法很简单:改一行,然后让另一个连接去读。
两条读到的都是 111,说明这条 UPDATE 已经落到磁盘上,别人立刻看得见。
| 谁在看 | 甲的余额 |
|---|---|
| 改它的连接 | 111 |
| 另一个连接 | 111 |
没写 BEGIN、也没写 COMMIT,因为它自动提交了。
教材在这一节讲的是 SET autocommit = 0 关掉自动提交。
这个语句在沙箱里报 near "SET": syntax error——SQLite 没有这个开关。
SQLite 的做法是「显式写 BEGIN」,效果一样:一旦开了事务,后面的改动就攒着不提交。
Python 的 sqlite3 模块里还有一层:连接参数 isolation_level 默认不是 None,
它会在写语句前偷偷发一个 BEGIN。
这一层最容易搞混——明明没写 BEGIN,rollback() 却有东西可回滚,就是它在起作用。
四、事务回滚:撤掉从 BEGIN 起的所有改动
在一个事务里连改三行,然后回滚。
| 账户 | 回滚前 | 回滚后 |
|---|---|---|
| 甲 | 900 | 1000 |
| 乙 | 900 | 1000 |
| 丙 | 900 | 1000 |
三条 UPDATE 一起撤掉,不是只撤最后一条。
ROLLBACK 撤的是「从 BEGIN 起的所有改动」,这一点没有例外。
所以「回滚」这个词要理解准:它不是「撤销上一步」,是「撤销这一段」。
想要「撤销上一步」那种粒度,才需要下一篇讲的回滚点。
还有一条容易忽略:回滚之后,那些行并没有「变回旧值」,而是「从头就没改过」。
这两个说法在结果上一样,在代价上不一样——后者不需要把旧值写回去,只是丢掉新值。
五、事务提交:别人什么时候才看得见
这一节是整章最关键的一组数字。 a 连接开事务转一笔账,b 连接在中间读两次。
| 时机 | 另一个连接看到 甲/乙 | 合计 |
|---|---|---|
| 提交之前 | 1000 / 1000 | 2000 |
| 提交之后 | 700 / 1300 | 2000 |
提交之前,另一个连接看到的是「原样」——两条 UPDATE 一条都没生效。
提交之后两条同时生效,合计还是 2000。
所以「原子性」在别人眼里长这样:要么看见转账前,要么看见转账后,看不到中间。
注意第二行的两个数变了、合计没变——这正是「钱没多也没少,只是换了口袋」。
再补一个自己看自己的视角:
| 时刻 | 自己这个连接 | 另一个连接 |
|---|---|---|
BEGIN 之后 |
1000 | 1000 |
| 改完、没提交 | 900 | 1000 |
ROLLBACK 之后 |
1000 | 1000 |
第二行是整章最关键的两个数字:自己看到 900,别人还看到 1000。 「没提交的改动只有自己看得见」——这一条是所有事务特性里最先要记住的。 记住它,后面讲锁、讲隔离级别、讲脏读,全都能推出来。
六、MySQL 中的事务:写法差在哪
MySQL 的日常写法是这三种:START TRANSACTION、SET autocommit = 0、BEGIN。
START TRANSACTION;
UPDATE tb_acct SET money = money - 300 WHERE id = 1;
UPDATE tb_acct SET money = money + 300 WHERE id = 2;
COMMIT;
-- 也可以先关掉自动提交,再手动提交
SET autocommit = 0;
UPDATE tb_acct SET money = money - 300 WHERE id = 1;
COMMIT;
SET autocommit = 1;
沙箱只认 BEGIN / COMMIT / ROLLBACK 这一套。教材里的九种写法挨个试,全是语法错:
| MySQL 的写法 | 沙箱的反应 |
|---|---|
START TRANSACTION |
near "START": syntax error |
SET autocommit = 0 |
near "SET": syntax error |
BEGIN WORK |
near "WORK": syntax error |
COMMIT WORK |
near "WORK": syntax error |
ROLLBACK WORK |
near "WORK": syntax error |
LOCK TABLES ... READ |
near "LOCK": syntax error |
UNLOCK TABLES |
near "UNLOCK": syntax error |
BEGIN WORK 那一行最有用:报的是 near "WORK",前半截 BEGIN 它是认的。
所以两个引擎的差别往往不是「有没有这个功能」,而是「叫什么名字」。
这决定了迁移时的正确做法:不是找「替代方案」,而是找「同义词」。
七、回滚点:撤一半,留一半
SAVEPOINT 在事务中间打一个标记,ROLLBACK TO 只撤到标记处,前面那段保留。
教材叫「回滚点」。MySQL 写 ROLLBACK TO SAVEPOINT 名,SQLite 少一个 SAVEPOINT 词。
BEGIN;
UPDATE tb_acct SET money = money - 100 WHERE id = 1;
SAVEPOINT sp1; -- 打个点
UPDATE tb_acct SET money = money + 100 WHERE id = 2;
ROLLBACK TO sp1; -- 只撤到点这儿
COMMIT;
| 时刻 | 甲 | 乙 |
|---|---|---|
| 扣甲、加乙之后 | 900 | 1100 |
ROLLBACK TO sp1 之后 |
900 | 1000 |
COMMIT 之后 |
900 | 1000 |
第二行:只撤掉了「加乙」那一步,扣甲的 100 还在。
第三行整个事务提交,扣甲的 100 就落定了——这叫「部分回滚」。
用过之后这个点就没了:事务一 COMMIT,再 ROLLBACK TO sp1 报 no such savepoint: sp1。
回滚点的寿命就是那个事务,跨不过 COMMIT;要用它就得在事务里用。
工程上这正好够用:一个大批量导入,每 1000 行打一个点,出错就退到上一个点,不用整批重来。
这个用法比「整个事务回滚」实用得多——十万行的导入,不该因为最后一万行里有一行脏数据就全废。
八、动手 Demo:两个连接看事务,没提交的别人看不见
这一段全程开两个连接:一个改、一个读,把「没提交的改动只有自己看得见」这件事量出来。
顺序是:转账只做一半的后果、自动提交的样子、BEGIN/COMMIT/ROLLBACK 三态对照、
九种 MySQL 写法的报错、最后是 SAVEPOINT 的部分回滚。
import sqlite3
p = u"demo.db"
c = sqlite3.connect(p, isolation_level=None) # 自动提交
c.execute(u"CREATE TABLE tb_acct (id INTEGER PRIMARY KEY, "
u"name TEXT, money REAL)")
c.executemany(u"INSERT INTO tb_acct VALUES (?,?,?)",
[(1, u"甲", 1000), (2, u"乙", 1000), (3, u"丙", 1000)])
o = sqlite3.connect(p, isolation_level=None) # 第二个连接,只读来看
def bal(conn, i):
return conn.execute(u"SELECT money FROM tb_acct WHERE id=?", (i,)).fetchone()[0]
# ① 只做一半:合计少了 300
c.execute(u"BEGIN")
c.execute(u"UPDATE tb_acct SET money = money - 300 WHERE id = 1")
c.execute(u"COMMIT")
print(u"合计", c.execute(u"SELECT sum(money) FROM tb_acct").fetchone()[0])
# ② 显式事务:自己看 900,别人看 1000
c.execute(u"BEGIN")
c.execute(u"UPDATE tb_acct SET money = 900 WHERE id = 1")
print(u"自己", bal(c, 1), u"别人", bal(o, 1))
c.execute(u"ROLLBACK")
print(u"回滚后", bal(c, 1), bal(o, 1))
# ③ 提交之后两边才一致
c.execute(u"BEGIN")
c.execute(u"UPDATE tb_acct SET money = 700 WHERE id = 1")
c.execute(u"UPDATE tb_acct SET money = 1300 WHERE id = 2")
print(u"提交前", bal(o, 1), bal(o, 2))
c.execute(u"COMMIT")
print(u"提交后", bal(o, 1), bal(o, 2))
# ④ 回滚点:只撤一半
c.execute(u"BEGIN")
c.execute(u"UPDATE tb_acct SET money = money - 100 WHERE id = 1")
c.execute(u"SAVEPOINT sp1")
c.execute(u"UPDATE tb_acct SET money = money + 100 WHERE id = 2")
c.execute(u"ROLLBACK TO sp1")
c.execute(u"COMMIT")
print(u"甲", bal(c, 1), u"乙", bal(c, 2))
# ⑤ MySQL 的写法:九条全报语法错
for q in (u"START TRANSACTION", u"SET autocommit = 0", u"BEGIN WORK",
u"LOCK TABLES tb_acct READ", u"UNLOCK TABLES"):
try:
c.execute(q)
except sqlite3.Error as e:
print(q, u"->", e)
实跑输出:
--------------------------------------------------------------------
事务机制:四条语句一起成,或者一起败
--------------------------------------------------------------------
为什么需要事务:一次转账分成两步
--------------------------------------------------------------------
**转账在数据库里是两条 `UPDATE`:给甲扣 300、给乙加 300。**
**两条之间如果断了——进程崩了、网络断了、余额不足报了错——就会只做了一半。**
**先看没有事务保护、只做完第一步的样子:**
账户 金额
----------
甲 700
乙 1000
丙 1000
合计 2700
**合计算出来是 2700,原本是 3000。**
**凭空少了 300 元——钱没有消失,它只是「扣了但没加」,停在半路。**
**同样只做完第一步,但这次把它回滚掉:**
账户 金额
----------
甲 1000
乙 1000
丙 1000
合计 3000
**合计回到 3000,一行都没变。**
**这就是事务的全部意义:把几条语句捆成一个「要么全做、要么全不做」的单位。**
**它保证的不是「不出错」,而是「出错之后不留下半成品」。**
默认就是自动提交:一句一提交
--------------------------------------------------------------------
**SQLite 的默认行为是「每条语句自动开事务、自动提交」,这叫自动提交模式。**
**MySQL 的默认也是 `autocommit = 1`,两边一样。**
**所以平时写一条 `UPDATE` 不用管事务,它自己就提交了。**
谁在看 甲的余额
--------------------
改它的连接 111
另一个连接 111
**两条一样,说明这条 `UPDATE` 已经落到磁盘上了,另一个连接立刻看得见。**
**没有 `BEGIN`、也没有 `COMMIT`,因为它自动提交了。**
显式开事务:BEGIN / COMMIT / ROLLBACK
--------------------------------------------------------------------
**要「几条语句一起算」,就得自己开事务:`BEGIN` 开头,`COMMIT` 或 `ROLLBACK` 收尾。**
时刻 自己这个连接 另一个连接
-----------------------------------------
`BEGIN` 之后 1000 1000
改完、没提交 900 1000
`ROLLBACK` 之后 1000 1000
**第二行是整章最关键的两个数字:自己看到 900,别人还看到 1000。**
**「没提交的改动只有自己看得见」——这一条是所有事务特性里最先要记住的。**
**第三行 `ROLLBACK` 之后,自己也回到 1000,两边重新一致。**
回滚:撤掉从 BEGIN 起的所有改动
--------------------------------------------------------------------
账户 回滚前 回滚后
--------------------
甲 900 1000
乙 900 1000
丙 900 1000
**三条 `UPDATE` 一起撤掉,不是只撤最后一条。**
**`ROLLBACK` 撤的是「从 `BEGIN` 起的所有改动」,这一点没有例外。**
提交:确认之后别人才能看见
--------------------------------------------------------------------
时机 另一个连接看到 甲 / 乙 合计
--------------------------------------
提交之前 1000 / 1000 2000
提交之后 700 / 1300 2000
**提交之前,另一个连接看到的是「原样」——两条 `UPDATE` 一条都没生效。**
**提交之后两条同时生效,合计还是 2000。**
**所以「原子性」在别人眼里是这样的:要么看见转账前,要么看见转账后,看不到中间。**
MySQL 里的写法,以及沙箱认不认
--------------------------------------------------------------------
[教材原码 · 本沙箱不执行]
| START TRANSACTION;
| UPDATE tb_acct SET money = money - 300 WHERE id = 1;
| UPDATE tb_acct SET money = money + 300 WHERE id = 2;
| COMMIT;
|
| -- 也可以先关掉自动提交,再手动提交
| SET autocommit = 0;
| UPDATE tb_acct SET money = money - 300 WHERE id = 1;
| COMMIT;
| SET autocommit = 1;
这两组写法是 MySQL 的日常;沙箱只认 BEGIN / COMMIT / ROLLBACK。
MySQL 的写法 沙箱的反应
------------------------------------------------------------------------------
`START TRANSACTION` near "START": syntax error
`SET autocommit = 0` near "SET": syntax error
`SET autocommit = 1` near "SET": syntax error
`BEGIN WORK` near "WORK": syntax error
`COMMIT WORK` near "WORK": syntax error
`ROLLBACK WORK` near "WORK": syntax error
`SET TRANSACTION ISOLATION LEVEL REPEATABLE READ` near "SET": syntax error
`LOCK TABLES tb_acct READ` near "LOCK": syntax error
`UNLOCK TABLES` near "UNLOCK": syntax error
**九条全是语法错,报错位置都落在不该出现的那个词上。**
**`BEGIN WORK` 报的是 `near "WORK"`——前半截 `BEGIN` 它认,多出来的 `WORK` 不认。**
**这一行最有用:两个引擎的差别往往不是「有没有这个功能」,而是「叫什么名字」。**
**沙箱自己的一套是 `BEGIN` 开头、`COMMIT`/`END`/`ROLLBACK` 收尾,三种都认。**
**`BEGIN` 后面还可以加 `DEFERRED`/`IMMEDIATE`/`EXCLUSIVE`,那是锁的粒度,下一篇讲。**
回滚点:撤一半,留一半
--------------------------------------------------------------------
**`SAVEPOINT` 在事务中间打一个标记,`ROLLBACK TO` 只撤到标记处,前面那段保留。**
**教材叫「回滚点」。MySQL 写 `ROLLBACK TO SAVEPOINT 名`,SQLite 少一个 `SAVEPOINT` 词。**
时刻 甲 乙
---------------------------------
扣甲、加乙之后 900 1100
`ROLLBACK TO sp1` 之后 900 1000
`COMMIT` 之后 900 1000
**第二行:只撤掉了「加乙」那一步,扣甲的 100 还在。**
**第三行整个事务提交,扣甲的 100 就落定了——这叫「部分回滚」。**
事务已经提交了,再回头退这个点:
`ROLLBACK TO sp1`(事务已提交)-> no such savepoint: sp1
**报的是 `no such savepoint: sp1`——事务一结束,点就跟着没了。**
**回滚点的生命周期就是那个事务,跨不过 `COMMIT`;要用它就得在事务里用。**
**工程上这正好够用了:一个大批量导入,每 1000 行打一个点,出错就退到上一个点,不用整批重来。**
第一节那两行 2700 和 3000 是同一个操作的两个结局。
差的那 300 不是「丢了」,是停在半路——数据库里没有「丢了」这个状态,只有「一半」。
第二节三行表是全篇的核心:自己 900、别人 1000。
这一行能看懂,后面讲锁、讲隔离级别全是它的推论。
第五节九条报错里,注意 BEGIN WORK 报的是 near "WORK"。
前半截它认、后半截不认,这个细节说明两边的差别在「词」上,不在「功能」上。
迁移时该查同义词表,不该重新设计。
30 秒实验
- 把「转账」写成两条
UPDATE,中间故意raise一个异常, 看有事务和没事务两种写法下表里的合计分别是多少。 - 在
SAVEPOINT之后再来一个SAVEPOINT(嵌套两层),从里往外退, 看清楚「退到内层」和「退到外层」分别撤掉了哪些改动。 - 用一个连接开事务改一行但不提交,另一个连接同时
SELECT, 把读到的值和提交后再读到的值都打出来——这就是 READ COMMITTED 和 REPEATABLE READ 的分界线之一。
常见误区
- 把
ROLLBACK理解成「撤销上一步」。它撤的是从BEGIN起的所有改动。 要「撤销上一步」,得在每一步之前打SAVEPOINT。 - 以为「没提交别人就完全不知道我在改」。别人确实读不到你的新值, 但你的写锁是实实在在的——他连写都写不进去。这是下一篇的主题。
- 以为 Python 里没写
BEGIN就没有事务。sqlite3的isolation_level默认会在写语句前偷偷BEGIN,所以rollback()才有东西可撤。
自测三问(点开对答案)
1. 为什么「转账只做一半」在数据库里表现为「合计少了 300」,而不是「报错」? —— 因为两条 `UPDATE` 各自都是合法语句,数据库没有理由报错。 它只能保证「单条语句完整」,管不了「两条语句之间的业务含义」——那正是事务要做的事。 2. `BEGIN` 之后改了一行,还没 `COMMIT`,另一个连接读这一行会读到什么? —— 读到改之前的旧值。没提交的改动只存在于自己的事务里,别人看不见; 而且因为写锁被持有,别人连写都写不进去。 3. `SAVEPOINT` 打完之后 `COMMIT` 了,还能 `ROLLBACK TO` 那个点吗? —— 不能,报 `no such savepoint`。回滚点的寿命就是那个事务,事务一结束点就消失。 它不能跨事务用,这也是它和「备份」最重要的区别。下一篇讲锁:一个连接写的时候,另一个连接到底被挡住了什么、没被挡住什么。
- ☐ (本篇完,下一篇 16.2 锁机制)