ACID 四条:原子性(全成或全不成)、一致性(约束始终满足)、
隔离性(并发事务互不可见中间状态)、持久性(提交了就一定在)。
原子性和持久性靠日志,一致性靠约束,隔离性靠锁和 MVCC。
隔离级别有四种,MySQL 默认 repeatable read;
本沙箱只有一种行为,大致相当于 serializable。 越长的事务危害越大。
上一篇讲「怎么用事务」,这一篇讲「它凭什么敢承诺」。 ACID 这四个字母是数据库的立身之本,理解了它们,很多「奇怪的行为」就不奇怪了: 为什么长事务看到的是旧数据、为什么并发写会报死锁、为什么批量插入要包成一个事务。
一、A:原子性,要么全成要么全不成
Demo 里转账转到一半,第二条故意把表名写错(acctt),
报 no such table,回滚之后两边都是原值。
第一条明明执行成功了,但因为它没被提交,回滚就一起没了。
这就是原子性:事务是一个不可分割的单位。
它不是「尽量做到」,是「数据库保证的」——
哪怕中途断电,重启之后也是「要么都在、要么都不在」。
靠的是日志:先写日志再改数据,重启时按日志决定重做还是撤销。
二、C:一致性,约束在事务里也守得住
给 balance 加一条 check (balance >= 0),
第二条扣 200 就被拦下,整个事务回滚,两边都没动。
一致性说的是「事务开始前数据满足约束,结束后也满足」。
中间那一瞬间可以违反,但对外永远看不到那一瞬间。
因为别人看到的是「提交后的样子」,不是中间态。
这一条其实最微妙:
数据库能保证的是「约束不被破坏」,不能保证「业务上对」——
转账金额写错成 600 也是「一致」的,因为约束没被破坏。 业务正确性还得靠应用层校验和测试。
三、I:隔离性,别人看不到你还没提交的东西
Demo 里两个连接同时查同一张表: A 写了没提交,A 看到 40、B 看到 100。 A 一提交,B 立刻看到 40。 这就是隔离性:并发的事务之间互相看不见对方的中间状态。 没有它的话,B 可能基于一个「后来被回滚的数据」做出决策,那就是脏读。 隔离是靠锁和 MVCC 实现的: 读的时候拿快照、写的时候拿锁。 两套机制的代价就是「隔离级别」这个旋钮—— 锁得越紧越安全,也越慢。
四、写锁是库级的,MySQL 是行级的
Demo 里 B 想写另一行,直接报 database is locked。 本沙箱的写锁是「整个库一把」——
A 在写,B 连另一行都改不了。
MySQL 的 InnoDB 有行锁:改第 1 行不挡第 2 行,
所以并发写入的能力强很多。 但代价是锁管理更复杂——
死锁、锁等待、间隙锁这些问题都是行锁带来的。
所以「换个数据库就快了」这件事要看场景:读写都少的嵌入式场景,库级锁够用而且简单;
高并发写入的场景,行级锁是必需的。 选型时这一条比「语法好不好看」重要得多。
五、D:持久性,提交了就一定在
Demo 里 commit 之后关连接、重开,999 还在。 这就是持久性。
它靠「先写日志、再改数据」实现:
commit 时先把「我要改什么」写进日志并刷盘,然后才慢慢改数据文件。
即使改到一半断电,重启时按日志重做一遍就行。
代价是每次 commit 都要等一次磁盘。
这就是为什么「批量插入一万行」时,把一万行包进一个事务会快几十倍——
不包的话就是一万次刷盘。 这条在导入数据时特别实用。
六、隔离级别:四种,越严越慢
从松到严:read uncommitted 能读到别人没提交的;read committed 只读已提交的;
repeatable read 同一事务里反复读结果一样(MySQL 默认);serializable 完全串行。
严的级别解决三种「读得不对」:脏读(读到别人没提交、后来又被回滚的数据)、
不可重复读(同一事务里两次读同一行,值不一样)、幻读(同一事务里两次查同一个条件,
行数不一样)。
本沙箱只有一种行为,大致相当于 serializable:
读的时候看不到别人未提交的改动,写的时候整库加锁。 所以这里没法演示「脏读」——
想看脏读得去 MySQL 上把级别调到 read uncommitted。
MySQL 的默认是 repeatable read,靠 MVCC 实现:
每个事务开始的时候拍一张快照,之后一直读那张快照。
所以「同一事务里读两次结果一样」是天然的,不用加锁。
这一条也解释了「为什么长事务看到的数据是旧的」——它一直在读自己那张老快照。
七、长事务:最该避免的用法
一是锁占用时间长。 你拿着锁不放,别人就等着——
一个开了十分钟的事务,能让整条业务线卡十分钟。
二是旧版本数据堆着删不掉。 MVCC 要给老事务保留它能看见的旧版本,
长事务一直不结束,这些旧版本就一直留着——MySQL 里叫「undo log 膨胀」,磁盘会涨得很快。
三是回滚代价大。 改了十万行才发现要回滚,撤销本身也要时间,期间还占着锁。
四是容易撞上事务超时。 MySQL 的 innodb_lock_wait_timeout 默认 50 秒,等不到就报错;
应用那边如果没处理这个错,就是一次失败。
所以原则是「事务要短」:只把「必须一起成功」的语句包进去,
查数据、算逻辑、调接口都放到事务外面。 一个事务里最好别超过十几条语句。
这一条比任何调优参数都管用。
八、动手 Demo:ACID 四条各验一次,再看两个连接怎么互相看不见
Demo 六段:原子性(写到一半报错)、一致性(check 拦下)、隔离性(两个连接看到不同的值)、
库级写锁、持久性(重开连接还在)、隔离级别与长事务的说明。
第三段那个「A 看 40、B 看 100」是全篇最直观的一处——同一个时刻、同一张表、两个不同的答案。
import os
import sqlite3
import tempfile
db = os.path.join(tempfile.gettempdir(), "acid.db")
c = sqlite3.connect(db)
c.execute("CREATE TABLE acct (id INTEGER PRIMARY KEY, name TEXT, "
"balance INTEGER CHECK (balance >= 0))")
c.executemany("INSERT INTO acct VALUES (?,?,?)",
[(1, '账户1', 100), (2, '账户2', 50)])
c.commit()
# C:约束守得住——第二条被 check 拦下,整个事务回滚
c.execute("BEGIN")
c.execute("UPDATE acct SET balance = balance - 60 WHERE id = 1")
try:
c.execute("UPDATE acct SET balance = balance - 200 WHERE id = 2")
except sqlite3.Error as e:
print("第二条被拦:", e)
c.rollback()
# I:两个连接,一个写了没提交,另一个看不到
a = sqlite3.connect(db)
b = sqlite3.connect(db)
a.execute("BEGIN IMMEDIATE")
a.execute("UPDATE acct SET balance = balance - 60 WHERE id = 1")
print("A 看到", a.execute("SELECT balance FROM acct").fetchall())
print("B 看到", b.execute("SELECT balance FROM acct").fetchall())
# 写锁是库级的:B 想写直接报 locked
try:
b.execute("UPDATE acct SET balance = balance + 1 WHERE id = 2")
except sqlite3.Error as e:
print("B 想写:", e)
a.commit() # 提交后 B 立刻看到
print("提交后 B 看到", b.execute("SELECT balance FROM acct").fetchall())
# D:commit 之后重开连接,数据还在
c.execute("UPDATE acct SET balance = 999 WHERE id = 1")
c.commit()
c.close()
c = sqlite3.connect(db)
Q.show(c, "SELECT id, balance FROM acct WHERE id = 1")
实跑输出:
--------------------------------------------------------------------
第七十四课:事务的属性——ACID 四条,逐条验一遍
--------------------------------------------------------------------
A:原子性,要么全成要么全不成
**转账转到一半,第二条写错了**(故意把表名写错):
SQL> BEGIN
完成
SQL> UPDATE acct SET balance = balance - 60 WHERE id = 1
影响 1 行
SQL> UPDATE acctt SET balance = balance + 60 WHERE id = 2
报错:no such table: acctt
SQL> ROLLBACK
完成
SQL> SELECT id, name, balance FROM acct
id name balance
-------------------------
1 账户1 100
2 账户2 50
(2 行)
**两边都是原值。** **第一条明明执行成功了,但因为它没被提交,回滚就一起没了。**
**这就是原子性:事务是一个不可分割的单位。** **它不是「尽量做到」,是「数据库保证的」**——
**哪怕断电,重启之后也是「要么都在、要么都不在」。**
**靠的是日志:先写日志再改数据,重启时按日志决定重做还是撤销。**
C:一致性,约束在事务里也守得住
**`acct` 表没加约束,所以「余额扣成负数」这句话本身不会报错。**
**要守住「余额不能为负」,得靠一条 `check` 约束**:
SQL> BEGIN
完成
SQL> UPDATE acct2 SET balance = balance - 60 WHERE id = 1
影响 1 行
SQL> UPDATE acct2 SET balance = balance - 200 WHERE id = 2
报错:CHECK constraint failed: balance >= 0
SQL> ROLLBACK
完成
SQL> SELECT id, name, balance FROM acct2
id name balance
-------------------------
1 账户1 100
2 账户2 50
(2 行)
**第二条被 `check` 拦下,整个事务回滚,两边都没动。**
**一致性说的是「事务开始前数据满足约束,结束后也满足」**——
**中间那一瞬间可以违反,但对外永远看不到那一瞬间。**
I:隔离性,别人看不到你还没提交的东西
SQL> SELECT id, balance FROM acct
id balance
---------------
1 40
2 50
(2 行)
SQL> SELECT id, balance FROM acct
id balance
---------------
1 100
2 50
(2 行)
**同一个时刻,A 看到 40,B 看到 100。** **B 看到的是「A 开始之前的样子」。**
**这就是隔离性:并发的事务之间互相看不见对方的中间状态。**
SQL> SELECT id, balance FROM acct
id balance
---------------
1 40
2 50
(2 行)
**A 一提交,B 立刻看到 40。** **注意 B 这句是「重新查的」,所以看到了新值**——
**如果 B 是在一个事务里连查两次、中间 A 提交了,结果就不一定了**——
**那正是「不可重复读」,后面讲隔离级别时会说。**
写锁是库级的:B 想写会直接报 locked
SQL> UPDATE acct SET balance = balance + 1 WHERE id = 2
报错:database is locked
**报 `database is locked`。** **本沙箱的写锁是「整个库一把」**——**A 在写,B 连另一行都改不了。**
**MySQL 的锁粒度细得多**:**InnoDB 有行锁**(改第 1 行不挡第 2 行),
**所以并发写入的能力强很多。** **但代价是「锁管理更复杂」**——
**死锁、锁等待、间隙锁这些问题都是行锁带来的。**
D:持久性,提交了就一定在
SQL> BEGIN
完成
SQL> UPDATE acct SET balance = 999 WHERE id = 1
影响 1 行
SQL> COMMIT
完成
SQL> SELECT id, balance FROM acct WHERE id = 1
id balance
---------------
1 999
(1 行)
**连接关掉、重新打开,999 还在。**
**这就是持久性:`commit` 返回成功的那一刻,数据就已经落到磁盘上了。**
**这一条是靠「先写日志、再改数据」实现的**:
**`commit` 时先把「我要改什么」写进日志并刷盘,然后才慢慢改数据文件。**
**所以即使改到一半断电,重启时按日志重做一遍就行。** **代价是每次 `commit` 都要等一次磁盘**——
**这就是为什么「批量插入一万行」时,把一万行包进一个事务会快几十倍。**
隔离级别:四种,越严越慢
MySQL 原码(本沙箱不执行):
| SELECT @@transaction_isolation; -- 8.0 之前叫 tx_isolation
| SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
**四种级别,从松到严**:**`read uncommitted`** 能读到别人没提交的;
**`read committed`** 只读已提交的;
**`repeatable read`** 同一事务里反复读结果一样(MySQL 默认);**`serializable`** 完全串行。
**严的级别解决三种「读得不对」**:**脏读**(读到别人没提交、后来又被回滚的数据)、
**不可重复读**(同一事务里两次读同一行,值不一样)、**幻读**(同一事务里两次查同一个条件,
行数不一样)。
**本沙箱只有一种行为,大致相当于 `serializable`**:
**读的时候看不到别人未提交的改动,写的时候整库加锁。** **所以这里没法演示「脏读」——**
**想看脏读得去 MySQL 上把级别调到 `read uncommitted`。**
**MySQL 的默认是 `repeatable read`,靠 MVCC 实现**:
**每个事务开始的时候拍一张快照,之后一直读那张快照。**
**所以「同一事务里读两次结果一样」是天然的,不用加锁。**
**这一条也解释了「为什么长事务看到的数据是旧的」**——**它一直在读自己那张老快照。**
长事务:最该避免的用法
**事务开得越久,危害越大**,四个理由:
**一是锁占用时间长。** **你拿着锁不放,别人就等着**——
**一个开了十分钟的事务,能让整条业务线卡十分钟。**
**二是旧版本数据堆着删不掉。** **MVCC 要给老事务保留它能看见的旧版本**,
**长事务一直不结束,这些旧版本就一直留着**——**MySQL 里叫「undo log 膨胀」,磁盘会涨得很快。**
**三是回滚代价大。** **改了十万行才发现要回滚,撤销本身也要时间**,**期间还占着锁。**
**四是容易撞上「事务超时」。** **MySQL 的 `innodb_lock_wait_timeout` 默认 50 秒**,
**等不到就报错**;**应用那边如果没处理这个错,就是一次失败。**
**所以原则是「事务要短」**:**只把「必须一起成功」的语句包进去**,
**查数据、算逻辑、调接口都放到事务外面。** **一个事务里最好别超过十几条语句。**
原子性:第二条报 no such table: acctt,回滚后两边都是原值。
一致性:第二条报 CHECK constraint failed: balance >= 0,回滚后 100/50 没动。
隔离性:A 看到 40(自己事务里的新值),B 看到 100(旧值)。 A 一提交,B 立刻看到 40。
写锁:B 想改另一行报 database is locked。
持久性:commit 后关连接重开,999 还在。
30 秒实验
- 把
check (balance >= 0)去掉再做一致性那段,会发生什么? - 两个连接都是「读」,会不会互相锁住?为什么不?
- 为什么「一万行包一个事务」比「一万行各自提交」快几十倍?
常见误区
- 以为一致性等于业务正确。 数据库只保证约束不被破坏,金额算错它不管。
- 开一个长事务跑一整天。 锁、undo log、超时三重代价,事务要短。
- 在事务里调外部接口。 接口一慢,锁就握着不放,要放到事务外面。
自测三问(点开对答案)
1. ACID 四条分别靠什么实现? —— 原子性和持久性靠日志(先写日志再改数据),一致性靠约束和触发器,隔离性靠锁和 MVCC。 2. MySQL 默认的隔离级别是什么?它解决了哪个问题? —— `repeatable read`,靠 MVCC 拍快照,解决了不可重复读;同一事务里反复读结果一致。 3. 为什么长事务危害大? —— 锁占用久(挡住别人)、undo log 不能清理(磁盘涨)、回滚代价大、还容易撞上锁等待超时。第 15 章完了: 事务的用法和它凭什么敢承诺,都过了。 下一篇进第 16 章——安全管理,讲账号和权限。
- ☐ (本篇完,下一篇 16.1 安全管理简介)