事务把「一批语句」当成一件事:要么全成,要么全败。四个特性合称 ACID。 实测转账:扣了甲 200、第二步故意崩掉,回滚之后甲一分没少——这就是原子性。 默认模式是自动提交:每条语句自己一个事务。所以平时感觉不到事务的存在。
第 14 章讲索引和完整性——那些约束管的是「单条语句」级别的事。
这一章补上另一层:多条语句之间的一致性。
教材 15.1 分三小节:事务的四个特性、事务的四种模式、事务与批处理的关系。
四个特性最值得花时间——后面 15.2 到 15.5 全是在补它的细节。
事务是标准 SQL 行为,SQLite 能真跑,所以这一章的演示大部分是实测数字,
只有 @@TRANCOUNT 和分布式协调器是复刻的,涉及处会说明。
多连接并发必须用文件库——:memory: 的各连接互相看不见。
一、什么是事务:一组语句,当成一件事
最典型的例子是转账。甲给乙转 200,要做两件事:
UPDATE acc SET balance = balance - 200 WHERE id = 1; -- 甲扣 200
UPDATE acc SET balance = balance + 200 WHERE id = 2; -- 乙加 200
两件事必须一起成、一起败。 只做了第一件会怎样?甲少了 200,乙没收到,钱凭空消失。 事务就是把这两条包起来,让数据库把它们当成一件事。
| 时刻 | 甲的余额 | 乙的余额 |
|---|---|---|
| 转账前 | 1000 | 500 |
| 扣款后、入账前(事务里) | 800 | 500 |
第二步崩了,ROLLBACK 之后 |
1000 | 500 |
跑出来就是这样:事务里能看到甲变成 800,一回滚又变回 1000。 中间那个 800 只在这一批语句内部可见——第 ③ 段会验证「别人看不到」。
二、原子性(Atomicity):全成或全败
原子性说的就是上面这件事:事务里的语句要么全部生效,要么全部不生效。 没有中间状态——不会出现「扣了款但没入账」。 实现靠的是「日志 + 回滚」。 数据库改数据之前先写日志,事务失败时按日志把改过的页恢复原样。 这也是为什么大事务回滚很慢——它得一条一条往回撤。 要注意一个常被误解的点: 原子性保证的是「这一批」, 不是「整个程序」。程序里两个事务之间崩了,前一个照样是提交了的。 所以事务的边界要自己划对——该在一个事务里的,别拆成两个。
三、一致性(Consistency):约束在事务里一样管
从甲的账户扣 5000(余额只有 1000),结果被 CHECK 挡住:
消息 102,级别 15,状态 1
CHECK constraint failed: balance >= 0
余额没变,还是 1000。 约束不会因为「在事务里」就放松——
这正是「一致性」的意思:事务开始前数据满足所有规则,结束后也必须满足。
注意区分「一致性」和「原子性」: 原子性管「做不做」,一致性管「做完合不合规」。
扣 5000 那条语句本身就是原子的(要么扣要么不扣),但它破坏了规则——
所以被拒的不是「原子性」,是「一致性」。
换句话说:一致性靠的是约束(主键、CHECK、外键,14.4 讲的那五类),
事务只负责「一旦违规就整体撤掉」。 没有约束,事务保证不了任何一致性。
四、隔离性(Isolation):别人看不到你没提交的东西
在事务里把甲的余额改成 100、还没提交,另开一个连接去读:
| 谁在看 | 看到甲的余额 |
|---|---|
| 自己(事务里) | 100 |
| 另一个连接 | 1000 |
另一个连接看到的还是 1000。 未提交的修改在别人眼里根本不存在—— 这就是隔离性,也是「脏读」被挡住的原因。 如果两个连接能同时改同一行,谁赢? 数据库的答案是排队: 写锁是互斥的,后到的等前面提交或回滚。 15.4 会实测这个等待。 隔离不是免费的: 挡得越严,并发越差。 四种隔离级别就是在「准不准」和「快不快」之间选一个位置(15.4 详讲)。
五、持久性(Durability):提交了就跑不掉
把甲的余额改成 777 并提交,再开一个全新的连接来读:777 在。 「提交」这个动作的含义就是「这件事定了」——之后哪怕断电、进程被杀、 数据库重启,这个 777 都必须在。 怎么做到的?先写日志,再改数据页。 提交时把「我要改什么」写进日志并刷到磁盘, 改数据页可以慢慢来;断电重启后按日志重放,结果一样。 代价也在这里:每次提交都要等磁盘确认。 实测 400 行逐条提交 4657 毫秒、包成一个事务 26 毫秒(15.3 会详细算这笔账)—— 差的 178 倍全是「等磁盘」的钱。
六、四种事务模式:自动提交是默认的
| 模式 | 谁开的 | 什么时候结束 | SQL Server 里的样子 |
|---|---|---|---|
| 显式事务 | 你写 BEGIN TRAN |
你写 COMMIT 或 ROLLBACK |
最常见,也最好懂 |
| 隐式事务 | 上一条语句自己开 | 到下一条语句或你提交 | SET IMPLICIT_TRANSACTIONS ON |
| 自动提交 | 每条语句自己一个事务 | 语句结束就提交 | 默认模式 |
| 批范围事务 | 一个批里的一组语句 | 批结束(GO) |
用得非常少 |
默认是自动提交。 你写一条 UPDATE,数据库自己给它包一个事务,
语句结束就提交——所以平时根本感觉不到事务的存在。
要「多条一起成、一起败」,才需要显式写。
这就是这一章的重点:不是「怎么用事务」,而是「什么时候值得用」。
隐式事务(SET IMPLICIT_TRANSACTIONS ON)是另一回事:
它让每条语句自动开一个新事务,然后等你提交。
忘了提交就锁一直挂着——15.3 会实测这个坑。
七、事务和批处理:两件不同的事
GO 是客户端的分隔符,把脚本切成一个个「批」。但 GO 不是事务边界。
一个事务可以跨好几个批,一个批里也可以开好几个事务。
| 概念 | 管什么 | 边界在哪 |
|---|---|---|
| 事务 | 数据改动的「全成或全败」 | BEGIN 到 COMMIT/ROLLBACK |
| 批 | 一次发到服务端的语句包 | GO(客户端切) |
最常踩的坑: BEGIN TRAN 写在一个批里,COMMIT 写在下一个批里——语法上没问题,
但中途报错就很难说清哪个事务还开着,而 @@TRANCOUNT 又没打印出来,只能靠猜。
所以规矩是:一个事务尽量写在一个批里,BEGIN 和 COMMIT 隔得越近越好。
这不只是风格——是「出问题时能一眼看懂」的运维要求。
八、动手 Demo:ACID 一条一条验:转账、越界、并发、重启
这一段把四个特性各验一次:先演一次「扣了款、入账失败」的转账,
再撞一次 CHECK 约束,然后开第二个连接看「看不到未提交」,最后提交完再读一次。
用了三个连接——一个当事务、一个当「别人」、一个当「重启后的新会话」。
库必须是文件,:memory: 的各连接互相看不见。
import sqlite3
# 文件库:多连接才看得见彼此
c = sqlite3.connect(u"tx.db", isolation_level=None)
d = sqlite3.connect(u"tx.db", isolation_level=None) # 另一个连接
# ① 原子性:扣款后故意抛错
try:
c.execute(u"BEGIN")
c.execute(u"UPDATE acc SET balance = balance - 200 WHERE id = 1")
raise RuntimeError(u"模拟程序在第 2 步崩了")
except RuntimeError:
c.execute(u"ROLLBACK")
# ③ 隔离性:改完不提交,另一个连接读
c.execute(u"BEGIN")
c.execute(u"UPDATE acc SET balance = 100 WHERE id = 1")
print(u"自己", bal(c, 1), u"别人", bal(d, 1)) # 100 / 1000
c.execute(u"ROLLBACK")
# ④ 持久性:提交后新连接读
c.execute(u"BEGIN")
c.execute(u"UPDATE acc SET balance = 777 WHERE id = 1")
c.execute(u"COMMIT")
print(bal(sqlite3.connect(u"tx.db"), 1)) # 777
实跑输出:
--------------------------------------------------------------------
事务的四个特性:ACID 一条一条验
--------------------------------------------------------------------
① 原子性:扣了款,入账失败了
--------------------------------------------------------------------
甲给乙转 200:先扣甲,再给乙加。中间那一步故意抛个错,模拟程序崩了。
转账前:甲 1000,乙 500
扣款后(事务里):甲 800
抛出来的错:模拟程序在第 2 步崩了
回滚后:甲 1000,乙 500
甲的钱一分没少。 这就是原子性:整批当成一件事,失败就全撤。
不写事务会怎样? 扣款那条一执行就生效了——甲少了 200、乙没收到,钱凭空消失。
② 一致性:约束在事务里也一样管用
--------------------------------------------------------------------
1> UPDATE acc SET balance = balance - 5000 WHERE id = 1
消息 102,级别 15,状态 1
CHECK constraint failed: balance >= 0
余额:甲 1000
约束不会因为「在事务里」就放松。 一致性说的是「事务前后数据都满足所有规则」——
这里规则是余额不许为负,扣 5000 会变 -4000,所以整笔被拒、原样退回。
③ 隔离性:别人看不到你没提交的东西
--------------------------------------------------------------------
事务里改完、还没提交:
| 谁在看 | 看到甲的余额 |
|---|---:|
| 自己(事务里) | 100 |
| 另一个连接 | 1000 |
另一个连接看到的还是 1000。 未提交的修改在别人眼里根本不存在——
这就是隔离性,也是「脏读」被挡住的原因(15.4 专门测)。
④ 持久性:提交了就跑不掉
--------------------------------------------------------------------
提交之后,再开一个全新的连接来读:甲 777
777 在。 新连接看到的是提交之后的状态——这就是持久性。
真数据库靠写日志保证这件事:先写日志、再改数据页,断电也能重放。
⑤ 事务的四种模式
--------------------------------------------------------------------
| 模式 | 谁开的 | 什么时候结束 | SQL Server 里的样子 |
|---|---|---|---|
| 显式事务 | 你写 BEGIN TRAN | 你写 COMMIT 或 ROLLBACK | 最常见,也最好懂 |
| 隐式事务 | 上一条语句自己开 | 到下一条语句或你提交 | SET IMPLICIT_TRANSACTIONS ON |
| 自动提交 | 每条语句自己一个事务 | 语句结束就提交 | 默认模式 |
| 批范围事务 | 一个批里的一组语句 | 批结束(GO) | 用得很很少 |
默认是自动提交:写一条 UPDATE,它自己包在一个事务里,语句结束就提交——
这就是平时感觉不到事务存在的原因。 要「多条一起成、一起败」,才需要显式写。
⑥ 事务和批处理:两件不同的事
--------------------------------------------------------------------
GO 是客户端的分隔符,把脚本切成一个个「批」。但 GO 不是事务边界——
一个事务可以跨好几个批,一个批里也可以开好几个事务。两者管的是不同的东西:
| 概念 | 管什么 | 边界在哪 |
|---|---|---|
| 事务 | 数据改动的「全成或全败」 | BEGIN 到 COMMIT/ROLLBACK |
| 批 | 一次发到服务端的语句包 | GO(客户端切) |
最常踩的坑: BEGIN TRAN 写在一个批里,COMMIT 写在下一个批里——
语法上没问题,但中途报错就很难说清哪个事务还开着。
所以规矩是:一个事务尽量写在一个批里,BEGIN 和 COMMIT 隔得越近越好。
第一段的关键是那个 raise。 它模拟「第一条语句成功了、
第二条没成功」——真实场景里可能是网络断了、程序抛异常、进程被 kill,效果一样。
ROLLBACK 之后甲还是 1000,钱没丢。
第二段的报错是 CHECK constraint failed,不是「事务失败」。
这条语句自己被拒了,事务里其他语句不受影响——
想全撤得自己 ROLLBACK(演示里就是这么做的)。
第三段两个数(100 / 1000)是这一节最该记住的一对:
同一条记录,事务里看到 100、事务外看到 1000。
中间没有任何「延迟一秒」之类的玄机,就是隔离性。
第四段的 777 是持久性的证据。 注意这里新开了一个连接——
不是「同一个连接自己看到」,是「另一个会话也看到」,这才叫持久化。
30 秒实验
- 把
try里那个raise删掉、把ROLLBACK改成COMMIT,重跑。 甲变成 800 且不再变回来——这就是「成功提交」的样子,跟失败回滚正好相反。 - 在 ② 那段里把
ROLLBACK去掉、直接COMMIT,看会怎样。 提交会失败并报约束错——因为CHECK在语句执行时就拦住了,事务里根本没脏数据可提交。 - 想一想:一个程序要做「下订单 + 扣库存 + 写日志」三件事,哪些必须在一个事务里? 订单和库存必须(不然会出现「有订单没扣库存」); 写日志可以不在——日志丢了不影响业务正确性,把它拉进事务只会让事务更长、锁更久。
常见误区
- 「事务能保证业务逻辑正确」。事务只保证「这一批要么全成要么全败」。
规则本身对不对,得靠约束和代码——
CHECK写错了,事务照样把错数据提交得干干净净。 - 「提交了就等于写进磁盘了」。要看
synchronous设置。 关掉同步能快很多,但断电就真的可能丢——这是性能与可靠性的取舍,不是「数据库的 bug」。 - 「
GO就是事务结束」。GO是客户端的批分隔符,跟事务一点关系都没有。 事务只认BEGIN/COMMIT/ROLLBACK。
自测三问(点开对答案)
1. ACID 四个特性各管什么? —— **原子性管「全成或全败」,一致性管「前后都满足规则」,隔离性管「别人看不到未提交」,** **持久性管「提交了就丢不了」。** 2. 「别人看不到我没提交的修改」属于哪个特性? —— **隔离性。** 同一行数据,事务里看到 100、事务外看到 1000——**未提交的修改在别人眼里不存在。** 3. 为什么 400 行逐条提交比包成一个事务慢 178 倍? —— **因为每次提交都要等一次磁盘确认。** 400 次提交就是 400 次等盘, **包成一个事务只等一次**——省的是刷盘次数,不是计算量。下一篇 15.2 讲显式事务的三个动作:BEGIN、COMMIT、ROLLBACK,
外加一个「只回滚一半」的保存点——批量导入时它比整批重来省太多。
- ☐ (本篇完,下一篇 15.2 显式事务)