用两个真实的数据库会话,演出本章全部悲剧的缩影。账户余额 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)。
| 性质 | 一句话 | 谁来兑现 |
|---|---|---|
| 原子性 A | 事务内的操作全成或全不成 | undo 日志回滚(第 8 章) |
| 一致性 C | 承诺执行前后,库都处于合法状态 | 约束 + A/I/D 共同支撑 |
| 隔离性 I | 并发事务互不感知,等效于串行 | 本章主角 |
| 持久性 D | 提交了就永不丢 | redo 日志(第 8 章) |
先破一个误会:一致性 C 不是某个独立开关,它是 A、I、D 加上你声明的约束(第 2 章)共同的结果。「转账前后总额不变」这条业务不变量,靠原子性保证「不执行一半」,靠隔离性保证「不被并发打断」,靠约束保证「存款不为负」。教材爱把 C 单独拎出来考概念,工程上要记住:你的约束声明得越全,数据库替你守住的一致性越多。
所有并发异常都是「读写交错」的不同形状。设余额 1000,用时间轴看每一种:
脏读:读到别人还没提交的数据。会话 B 在 A 更新后、提交前读走 200;A 万一回滚,B 拿着的是从未存在过的数字。做决策依据的脏数据,比没有数据更危险。
不可重复读:同一事务内,同一行两次读,值变了。T1 读余额 1000,期间 T2 扣款提交,T1 再读变 200。对「多步计算基于同一份数据」的场景(对账、报表)是灾难。
幻读:同一查询跑两次,满足条件的行集合变了(T1 统计「余额大于 500 的账户」得 10 行,T2 插入一个新账户提交,T1 再查得 11 行)。注意它和不可重复读的分工:不可重复读盯已存在的行的值,幻读盯集合的成员。
丢失更新:开头的取款事故。两个事务都基于旧值做「读-改-写」,后提交者把前者的修改整个覆盖。
这四种异常按严重度递减(越往下越难察觉),隔离级别就是按「容忍哪些」分的档位。
最直接的武器:锁。两种:
兼容矩阵一眼记住:两个 S 兼容,其余全冲突。读多写少的应用靠 S 锁并发读,写时独占,秩序井然。但锁加多容易出事(第 5 章的死锁),加少又不安全,数据库用**两段锁协议(2PL)**定规矩:事务分两段,增长段只加不放,缩减段只放不加。只要所有事务守 2PL,调度结果必然等价于某种串行执行(可串行化),这是理论保证。代价照旧:2PL 不防死锁,靠死锁检测兜底(第 5 章讲过 InnoDB 杀事务重试)。
全上最严的锁,安全但慢;全裸奔,快但丢钱。折中方案是给应用提供四档套餐:
| 级别 | 脏读 | 不可重复读 | 幻读 | 默认归属 |
|---|---|---|---|---|
| READ UNCOMMITTED | 有 | 有 | 有 | 几乎没人用 |
| READ COMMITTED | 无 | 有 | 有 | Oracle、PG 默认 |
| REPEATABLE READ | 无 | 无 | 基本* | MySQL InnoDB 默认 |
| SERIALIZABLE | 无 | 无 | 无 | 强一致性需求 |
*InnoDB 在 RR 下用间隙锁(锁住「范围本身」而不只是行)把幻读也基本堵死了,所以 MySQL 用户常觉得幻读是传说,换到 PG 的 RC 级别就会遇见。
选档位的实用判断:大多数互联网业务用默认档(RC 或 RR)就够了,配合「该锁的时候显式锁」;资金、库存这类「读-改-写」热点行,光靠隔离级别不够(见下),要显式 SELECT ... FOR UPDATE(给选中的行直接上 X 锁,把「读」和「后续写」绑成原子)或乐观重试。反例是图省事直接上 SERIALIZABLE:吞吐掉一个量级,除非确实需要对全表串行,别交这份税。
锁方案有个天然瓶颈:读者和写者互相等。报表跑一分钟,期间写全堵死,现代业务扛不住。主流数据库(InnoDB、PG、Oracle)的真身是 MVCC(多版本并发控制):每行数据带隐藏字段(最后修改的事务 ID、指向旧版本的回滚指针),修改时不覆盖,而是留下一串历史版本;每个事务按规则读到「自己视角应该看到的那个版本」。
效果:读操作永远读某个快照版本,不需要任何锁;写照常加锁,但只挡住别的写。读不挡写、写不挡读,报表再慢也堵不住下单。隔离级别在 MVCC 下重新解释:READ COMMITTED 是「每条语句开工时取一次快照」(所以两次读可能看到不同版本,不可重复读复现),REPEATABLE READ 是「事务开工时取一次快照,全程用到底」(所以可重复读天然成立,快照一以贯之)。
代价也要认识:旧版本堆在 undo 区,长事务(开几小时不提交)会让版本链越拖越长,undo 膨胀、磁盘吃紧,这是 MySQL 运维的经典事故源(查长事务:SELECT * FROM information_schema.innodb_trx);另外 MVCC 只解决「读和写的相安无事」,两个事务同时写同一行,仍靠锁裁决,丢失更新的防住靠 FOR UPDATE 或乐观锁(版本号比对重试)。
SET balance = balance - 800 再试,解释为什么这次对了(提示:原子自增把「读」挪进了数据库内部)。FOR UPDATE、乐观版本号重试),各自的适用并发量和代价。