🏺 第13章 数据库控制
📄 一句话概括

事务就是「一组要么全成、要么全不成的语句」。 COMMIT 提交、ROLLBACK 回滚、SAVEPOINT 设存档点,三件套管住它。 最容易踩的坑是 DDL 隐式提交—— 一句 CREATE TABLE 会把你前面没提交的改动先提交掉,之后回滚已经晚了。

📝 本章和前面几章的关系

4.6 讲的是「怎么改数据」。这一篇讲「改到一半不要了怎么办」。 从这一篇开始进第 13 章:数据库控制。 前面十二章基本是「一个人用库」,这一章开始讲「好几个人同时用会出什么事」。 事务是所有并发问题的地基,所以放在最前面。

一、ACID 四条,各管一段

字母 名字 一句话说清 靠什么实现
A 原子性 要么全成,要么全不成 UNDO 回滚段
C 一致性 约束、外键不会破 约束本身
I 隔离性 互相看不见中间状态 UNDO 快照 + 锁
D 持久性 提交了就不会丢 重做日志 + LGWR

四条里最直观的是原子性:转账两条 UPDATE,不能只成功一条。 第二节说的 ROLLBACK 就是在实现它。 持久性靠的是重做日志——COMMIT 返回的那一刻, 改动已经写进日志文件了,而数据块本身可能还没落盘。 所以「断电了数据还在」靠的不是数据文件,是日志文件。 一致性其实不全是数据库的事。 数据库保证声明过的约束(主键、外键、CHECK)不被破坏。 但「总共 100 块,A 少了 30、B 就得多 30」这种业务规则,只有事务包住两条语句才能保证。

二、事务从哪开始,到哪结束

开始:第一条 DML 语句(INSERT/UPDATE/DELETE)自动开一个事务, 也可以显式写 SET TRANSACTION。 注意 SELECT 不开事务——只读不需要事务保护。

什么动作 事务怎么了
COMMIT 结束,改动生效
ROLLBACK 结束,改动全部撤销
执行 DDL(建表、改表、TRUNCATE、GRANT) 先隐式提交,再执行
正常退出 SQL*Plus(EXIT) 自动提交
会话异常终止、机器断电 自动回滚

表格里第四、五行常被忽略。 正常退出会提交,所以「跑完脚本忘了 COMMIT」通常没事—— 但如果是程序崩溃或者被人 KILL,就变成回滚了。 这两种结局完全相反,所以别指望「退出时会帮你保存」。

三、三件套:COMMIT、ROLLBACK、SAVEPOINT

COMMIT:把改动定下来。 内部做三件事:写重做日志、释放锁、给其他会话发通知。 其中「释放锁」是并发能不能跑起来的关键——一个长事务占着行锁不放,别人全在排队。 ROLLBACK:把改动撤掉。 做法是拿 UNDO 里的旧值反向应用一遍。 所以回滚一个大事务,可能比执行它本身还慢。 SAVEPOINT:在事务里打一个存档点。 ROLLBACK TO s1 只退到 s1,事务不结束,后面的语句还能继续跑。 典型用法:一个事务里处理 100 条记录,每条之前打个存档点,某条失败就退到它之前, 跳过这条继续,最后统一 COMMIT。 注意 ROLLBACK TO 和 ROLLBACK 是两件事。 前者是「退一半」,后者是「全不要」。 写错一个 TO,就是「以为只撤了一条,其实全撤了」。

四、隔离级别:Oracle 只给你两档

级别 Oracle 支持吗 能防什么
READ UNCOMMITTED 不支持 Oracle 没有脏读这回事
READ COMMITTED 默认 脏读
SERIALIZABLE 支持 脏读、不可重复读、幻读
READ ONLY 支持(只读事务) 整个事务看同一个快照

第一行是关键:Oracle 根本没有「脏读」这个级别。 别的数据库要显式设成 READ COMMITTED 才能避免读到别人没提交的数据,Oracle 默认就是。 这件事常被当成「Oracle 的怪癖」,其实是它的多版本机制决定的—— 读的时候根本不看当前数据,看的是 UNDO 里的快照。 READ COMMITTED 的一致性只在【一条语句】内部有效。 一条语句跑 10 秒,这 10 秒里看到的是同一个快照; 但语句结束、下一条语句开始,就能看到别人新提交的数据了。 所以同一个事务里查两次,结果可能不一样——这不叫 bug,叫语句级一致性。 SERIALIZABLE 是事务级:整个事务一个快照。 代价是冲突时会报 ORA-08177(cannot serialize access),要整个事务重来。 所以只有「报表要对齐、账要对平」这种场景才用它,普通业务不用。

五、DDL 隐式提交:最常见的一个坑

下面这些语句执行前,Oracle 会先把当前事务提交掉: CREATE/ALTER/DROP/TRUNCATE/RENAME/COMMENT, 以及 GRANT/REVOKE/ANALYZE/AUDIT。 后果很具体: 你写了一个脚本,先 INSERT 十万行,再 CREATE INDEX,最后 ROLLBACK。 你以为十万行都撤了,其实在 CREATE INDEX 那一刻就已经提交了。 再 ROLLBACK 什么也撤不掉。 这个坑最阴的地方是「没有报错」。 脚本一路绿灯跑完,数据却和你想的不一样。 所以规矩是:DDL 单独一个脚本,DML 单独一个脚本,中间不要混。 混着写省不了几行代码,排查要花一整天。

六、动手 Demo:转账、回滚、存档点,六个小实验

沙箱里没有 Oracle,但事务这件事 SQLite 做得和 Oracle 一样:开事务、提交、回滚、存档点, 语义完全对得上。 两个连接模拟两个人,能直接看出「未提交的数据别人看不见」。 唯一对不上的是 DDL:SQLite 的 CREATE TABLE 可以回滚,Oracle 的不行。 这一处会在输出里标出来,不拿它当共同结论。

# 事务:COMMIT / ROLLBACK / SAVEPOINT
import os, sqlite3, tempfile
p = os.path.join(tempfile.gettempdir(), "t58.db")
if os.path.exists(p):
    os.remove(p)
a = sqlite3.connect(p, timeout=0)
a.isolation_level = None          # 自己管事务
b = sqlite3.connect(p, timeout=0)  # 第二个连接,模拟另一个人
b.isolation_level = None
a.execute("create table acct (id int primary key, bal real)")
a.executemany("insert into acct values (?,?)", [(1, 100.0), (2, 200.0)])

a.execute("begin")
a.execute("update acct set bal = bal - 30 where id = 1")
a.execute("update acct set bal = bal + 30 where id = 2")
print("A 自己看:", a.execute("select id, bal from acct").fetchall())
print("B 看到:", b.execute("select id, bal from acct").fetchall())
a.execute("commit")
print("提交后 B 看到:", b.execute("select id, bal from acct").fetchall())

a.execute("begin")
a.execute("savepoint s1")
a.execute("update acct set bal = bal - 10 where id = 1")
a.execute("savepoint s2")
a.execute("update acct set bal = bal - 10 where id = 2")
a.execute("rollback to s2")   # 只退到 s2,s1 之后的保留
print("退到 s2 后:", a.execute("select id, bal from acct").fetchall())
a.execute("commit")

实跑输出:

--------------------------------------------------------------------
第五十八课:事务——提交、回滚、保存点
--------------------------------------------------------------------

  **一条 UPDATE 要么全改,要么一行都不改,这一点靠事务保证。**
  **这一课把 COMMIT/ROLLBACK/SAVEPOINT 三件套跑一遍。**
  **再看两个「以为是这样、其实不是」:未提交的数据别人看不见,DDL 会隐式提交。**

  一、转账:两条 UPDATE 必须同进退
  --------------------------------------------------------------------

  [Oracle 原码 · 本沙箱不执行]
  | BEGIN
  |   UPDATE acct SET bal = bal - 30 WHERE id = 1;
  |   UPDATE acct SET bal = bal + 30 WHERE id = 2;
  | COMMIT;

  **A 自己看(事务里,还没提交):**

  SQL> select id, name, bal from acct order by id
  ID NAME BAL
  -----------
  1  张三 70
  2  李四 230
  已选择 2 行
  **B 在另一个连接上看同一张表:**

  SQL> select id, name, bal from acct order by id
  ID NAME BAL
  -----------
  1  张三 100
  2  李四 200
  已选择 2 行
  **B 看到的是 100 和 200,A 改的 30 块它一点都不知道。**
  **这就是【读一致性】:没提交的修改只有自己看得见。**
  **A 提交之后,B 再看:**

  SQL> select id, name, bal from acct order by id
  ID NAME BAL
  -----------
  1  张三 70
  2  李四 230
  已选择 2 行

  二、ROLLBACK:改到一半不要了
  --------------------------------------------------------------------

  [Oracle 原码 · 本沙箱不执行]
  | UPDATE acct SET bal = 0 WHERE id = 1;
  | ROLLBACK;

  **事务里改成 0,回滚之后再查,还是 70,一行没变:**

  SQL> select id, bal from acct order by id
  ID BAL
  ------
  1  70
  2  230
  已选择 2 行

  三、SAVEPOINT:只退一半
  --------------------------------------------------------------------

  [Oracle 原码 · 本沙箱不执行]
  | SAVEPOINT s1;
  |   UPDATE acct SET bal = bal - 10 WHERE id = 1;
  |   SAVEPOINT s2;
  |   UPDATE acct SET bal = bal - 10 WHERE id = 2;
  |   ROLLBACK TO s2;
  | COMMIT;

  **退到 s2 之前:1 号 60、2 号 220:**

  SQL> select id, bal from acct order by id
  ID BAL
  ------
  1  60
  2  220
  已选择 2 行
  **ROLLBACK TO s2 之后:1 号的变化留着,2 号退回去了:**

  SQL> select id, bal from acct order by id
  ID BAL
  ------
  1  60
  2  230
  已选择 2 行
  **注意 ROLLBACK TO 不结束事务**,事务还开着,**后面还能接着改,最后仍要 COMMIT 或 ROLLBACK。**

  四、DDL 会隐式提交:这条最容易埋雷
  --------------------------------------------------------------------

  [Oracle 原码 · 本沙箱不执行]
  | INSERT INTO acct VALUES (3, '王五', 500);
  | CREATE TABLE tmp1 (id INT);   -- 这一句先把上面那句提交了
  | ROLLBACK;                      -- 撤不掉 3 号了
  教材原码,本沙箱不执行:SQLite 的 DDL 也在事务里,回滚得掉

  **本沙箱实测:上面那段回滚之后,3 号也没了:**

  SQL> select id, bal from acct order by id
  ID BAL
  ------
  1  60
  2  230
  已选择 2 行
  **SQLite 的 CREATE TABLE 是可回滚的,Oracle 的不是。**
  **所以这条只能在 Oracle 上验:DDL 之前没提交的 DML 会被 DDL 顺手提交,回滚时已经晚了。**
  **实用结论:DDL 单独放一个事务,别和 DML 混在一起写。**
  **混着写的脚本,中途报错时你根本说不清哪些改动留下了。**

  五、一条语句本身也是原子的
  --------------------------------------------------------------------

  [Oracle 原码 · 本沙箱不执行]
  | UPDATE u58 SET id = 9;   -- 两行都改成 9,第二行撞主键
  | -- 结果:整条语句回滚,两行都还是 1 和 2

  **报错:UNIQUE constraint failed: u58.id**

  SQL> select id from u58 order by id
  ID
  --
  1
  2
  已选择 2 行
  **第一行本来已经改成 9 了,报错之后又退回 1。**
  **一条语句要么整条生效,要么整条不生效,没有「改了一半」这种状态。**

  六、提交的代价:单独提交 vs 批量提交
  --------------------------------------------------------------------
  **500 行,一次提交:0.0150 秒;一行一次提交:5.9745 秒,慢 397.6 倍。**
  **差距全在「每次提交都要把日志刷到磁盘」上。**
  **Oracle 里对应的是 LGWR 写重做日志,同一个道理:提交次数才是成本,行数不是。**
  **所以批量导入要「攒够一批提交一次」。**
  **代价是中途失败时,最后没提交的那一批会丢。**

第一段:A 看到 70 和 230,B 看到 100 和 200。 同一张表、同一时刻,两个人看到的不一样—— 这就是读一致性,也是最容易讲不清的一个概念:数据没有「唯一当前值」, 只有「你这个事务能看到的值」。 第二段:A 提交之后,B 再看就是 70 和 230 了。 中间那一瞬间发生了什么? COMMIT 返回时日志已经落盘,数据块要等后面慢慢写。 所以「提交了但数据文件里还是旧值」是正常状态,不是没生效。 第三段:存档点。 退到 s2 之前是 60 和 220,退完之后是 60 和 230——1 号的减 10 留着,2 号的减 10 没了。 这就是「只退一半」,事务还开着。 第四段是这一篇的重点:DDL 那段在 SQLite 上回滚掉了,Oracle 上回滚不掉。 所以这个实验结果【不能】拿来推断 Oracle 的行为,两边的 DDL 事务语义不一样。 要验这一点只能在真库上试,本沙箱给不出答案。 第五段:一条 UPDATE 改成两行同一个值,撞主键报错,两行都退回原样。 语句级原子性,和事务级原子性是两回事,但都在这一层保护你。 第六段:一次提交和五百次提交,差几百倍。 差的全是「日志刷盘」这一次动作。 所以「批量操作慢」往往不是 SQL 慢,是提交次数太多。

30 秒实验

  1. 把 rollback to s2 改成 rollback to s1,再跑一次。 猜猜 1 号那 10 块还在不在? 答案是退到 s1 之后一起没了。
  2. 把「500 行一次提交」改成「每 100 行提交一次」,量一下总耗时。 它应该落在 0.01 秒和 5 秒之间的某个位置——这个位置就是「提交次数」和「耗时」的比例关系。
  3. 开两个连接,一个 begin 之后只 SELECT、不写,另一个也 begin 之后写。 看两边会不会互相挡。 结果是不挡——只读不开事务、也不加锁。

常见误区

  1. 「提交之后别人立刻能看到」。 大体是,但要等对方【下一条语句】开始。 正在跑的那条语句还看着旧快照,这是 READ COMMITTED 的定义。
  2. 「ROLLBACK 能撤销任何操作」。 撤不掉已经 COMMIT 的,也撤不掉被 DDL 顺手提交过的。
  3. 「提交越频繁越安全」。 安全是对的,但慢,而且日志量翻倍。 正确做法是「攒一批提交一次」,批次大小按失败重做的成本来定。
  4. 「Oracle 有脏读级别」。 没有。 Oracle 不支持 READ UNCOMMITTED,任何隔离级别下都读不到未提交的数据。
自测三问(点开对答案) 1. `ROLLBACK TO s1` 和 `ROLLBACK` 差在哪? —— **前者只退到存档点 `s1`,事务还开着;后者整个事务结束,全部撤销。** 2. 为什么说「持久性靠日志不靠数据文件」? —— **`COMMIT` 时只保证日志落盘,数据块是延后写的。** **断电后重启,Oracle 拿日志把没写完的数据补上(前滚)。** 3. Oracle 的默认隔离级别是什么?它防住了什么? —— **`READ COMMITTED`。** 防住脏读,一致性范围是【一条语句】。**事务里查两次可能不一样。** 4. 为什么 DDL 要单独放一个脚本? —— **DDL 会隐式提交前面的 DML。** 混在一起写,中途失败时你说不清哪些改动留下了。

下一篇讲锁。事务保证「要么全成要么全不成」,锁保证「两个人别同时改同一行」。 这两件事合起来,才是完整的并发控制。 重点是 Oracle 的锁粒度是【行】,而且读不加锁—— 这一条决定了它的并发能力比其他库高一个档次。

相关笔记