第 7 章 · 事务与并发控制

用两个真实的数据库会话,演出本章全部悲剧的缩影。账户余额 1000,两个会话几乎同时各取 800:

会话 A                               会话 B
SELECT balance FROM account WHERE id=1;
→ 1000
                                     SELECT balance FROM account WHERE id=1;
                                     → 1000
UPDATE account SET balance=1000-800
  WHERE id=1;
COMMIT;
                                     UPDATE account SET balance=1000-800
                                       WHERE id=1;
                                     COMMIT;

最后余额 200。取走了 1600,库里只少了 800,凭空蒸发的 800 足够上一条社会新闻。两个会话各自单看都没错,错的是它们交错的方式:会话 B 读到的是会话 A 更新前的旧值。这就是并发要解决的全部问题——多个事务同时跑,结果必须和「排队逐个跑」一样,否则钱、库存、票数都会凭空变化。本章讲清楚:数据库承诺什么(ACID)、不隔离会发生什么(四种异常)、用什么手段保证(锁、隔离级别、MVCC)。

7.1 ACID:数据库的四个承诺

性质一句话谁来兑现
原子性 A事务内的操作全成或全不成undo 日志回滚(第 8 章)
一致性 C承诺执行前后,库都处于合法状态约束 + A/I/D 共同支撑
隔离性 I并发事务互不感知,等效于串行本章主角
持久性 D提交了就永不丢redo 日志(第 8 章)

先破一个误会:一致性 C 不是某个独立开关,它是 A、I、D 加上你声明的约束(第 2 章)共同的结果。「转账前后总额不变」这条业务不变量,靠原子性保证「不执行一半」,靠隔离性保证「不被并发打断」,靠约束保证「存款不为负」。教材爱把 C 单独拎出来考概念,工程上要记住:你的约束声明得越全,数据库替你守住的一致性越多。

7.2 四种并发异常:交错时间的罪证

所有并发异常都是「读写交错」的不同形状。设余额 1000,用时间轴看每一种:

脏读:读到别人还没提交的数据。会话 B 在 A 更新后、提交前读走 200;A 万一回滚,B 拿着的是从未存在过的数字。做决策依据的脏数据,比没有数据更危险。

不可重复读:同一事务内,同一行两次读,值变了。T1 读余额 1000,期间 T2 扣款提交,T1 再读变 200。对「多步计算基于同一份数据」的场景(对账、报表)是灾难。

幻读:同一查询跑两次,满足条件的行集合变了(T1 统计「余额大于 500 的账户」得 10 行,T2 插入一个新账户提交,T1 再查得 11 行)。注意它和不可重复读的分工:不可重复读盯已存在的行的值,幻读盯集合的成员。

丢失更新:开头的取款事故。两个事务都基于旧值做「读-改-写」,后提交者把前者的修改整个覆盖。

这四种异常按严重度递减(越往下越难察觉),隔离级别就是按「容忍哪些」分的档位。

7.3 封锁:加锁换串行

最直接的武器:锁。两种:

  • 共享锁 S(读锁):别人也能加 S,大家一起读;有 S 在,谁都别想写。
  • 排他锁 X(写锁):独占,别人读写全禁。

兼容矩阵一眼记住:两个 S 兼容,其余全冲突。读多写少的应用靠 S 锁并发读,写时独占,秩序井然。但锁加多容易出事(第 5 章的死锁),加少又不安全,数据库用**两段锁协议(2PL)**定规矩:事务分两段,增长段只加不放,缩减段只放不加。只要所有事务守 2PL,调度结果必然等价于某种串行执行(可串行化),这是理论保证。代价照旧:2PL 不防死锁,靠死锁检测兜底(第 5 章讲过 InnoDB 杀事务重试)。

7.4 隔离级别:四档套餐

全上最严的锁,安全但慢;全裸奔,快但丢钱。折中方案是给应用提供四档套餐:

级别脏读不可重复读幻读默认归属
READ UNCOMMITTED有有有几乎没人用
READ COMMITTED无有有Oracle、PG 默认
REPEATABLE READ无无基本*MySQL InnoDB 默认
SERIALIZABLE无无无强一致性需求

*InnoDB 在 RR 下用间隙锁(锁住「范围本身」而不只是行)把幻读也基本堵死了,所以 MySQL 用户常觉得幻读是传说,换到 PG 的 RC 级别就会遇见。

选档位的实用判断:大多数互联网业务用默认档(RC 或 RR)就够了,配合「该锁的时候显式锁」;资金、库存这类「读-改-写」热点行,光靠隔离级别不够(见下),要显式 SELECT ... FOR UPDATE(给选中的行直接上 X 锁,把「读」和「后续写」绑成原子)或乐观重试。反例是图省事直接上 SERIALIZABLE:吞吐掉一个量级,除非确实需要对全表串行,别交这份税。

7.5 MVCC:用一个巧思换来「读不挡写」

锁方案有个天然瓶颈:读者和写者互相等。报表跑一分钟,期间写全堵死,现代业务扛不住。主流数据库(InnoDB、PG、Oracle)的真身是 MVCC(多版本并发控制):每行数据带隐藏字段(最后修改的事务 ID、指向旧版本的回滚指针),修改时不覆盖,而是留下一串历史版本;每个事务按规则读到「自己视角应该看到的那个版本」。

效果:读操作永远读某个快照版本,不需要任何锁;写照常加锁,但只挡住别的写。读不挡写、写不挡读,报表再慢也堵不住下单。隔离级别在 MVCC 下重新解释:READ COMMITTED 是「每条语句开工时取一次快照」(所以两次读可能看到不同版本,不可重复读复现),REPEATABLE READ 是「事务开工时取一次快照,全程用到底」(所以可重复读天然成立,快照一以贯之)。

代价也要认识:旧版本堆在 undo 区,长事务(开几小时不提交)会让版本链越拖越长,undo 膨胀、磁盘吃紧,这是 MySQL 运维的经典事故源(查长事务:SELECT * FROM information_schema.innodb_trx);另外 MVCC 只解决「读和写的相安无事」,两个事务同时写同一行,仍靠锁裁决,丢失更新的防住靠 FOR UPDATE 或乐观锁(版本号比对重试)。

常见错误

  • 依赖自动提交,以为单条语句天然安全。 autocommit 下每条语句一个事务,「先查再改」之间照样插得进别人的事务,开头的取款事故在 autocommit 下完整复现。
  • 把隔离级别当万能保险。RR 挡的是「读到的旧数据」,挡不住「两个事务基于旧值并发写」,热点行的丢失更新要显式锁或乐观重试。
  • 长事务不管理。MVCC 的代价全记在长事务头上,报表、批处理错峰跑、分批提交,别让 undo 无限膨胀。

练习

  1. 演出:用两个 sqlite3/mysql 客户端复现丢失更新(建 account 表,两个会话按 7.1 的时间轴操作),然后在 UPDATE 里改成 SET balance = balance - 800 再试,解释为什么这次对了(提示:原子自增把「读」挪进了数据库内部)。
  2. 手算:画出「T1: R(x) W(x) ; T2: R(x) W(x)」的所有交错序列,指出哪些产生丢失更新;若加 2PL 锁,写出唯一合法的交错。
  3. 动手:开两个会话,RR 级别下会话 A 开事务读某行,会话 B 修改并提交,会话 A 再读(应看到旧值),然后 A 里 UPDATE 同一行再读(惊喜:看到了新值,这是 InnoDB 当前读)。查一下「快照读 vs 当前读」,解释这个现象。
  4. 思考:优惠券「限量 100 张」,高并发领取,给出两种防超发的实现(悲观 FOR UPDATE、乐观版本号重试),各自的适用并发量和代价。