数据的契约 —— 事务与隔离级别
你在ATM取钱:卡里扣了500元,但吐钞口卡住了没出钱。如果银行系统没有事务保护,你这500元就凭空消失了。事务就是数据库对你的承诺——要么全做,要么全不做。今天我们来彻底搞懂事务。
📋 开篇自测:你已经知道多少?
- ACID四个字母分别代表什么?用你自己的话解释每一个。
- MySQL默认的事务隔离级别是什么?它能防止哪些并发问题?
- MVCC是什么?它和锁有什么关系?
一、什么是事务——生活中的原子操作
银行转账是最经典的事务场景:张三给李四转100元。
-- 张三余额减100
UPDATE accounts SET balance = balance - 100 WHERE name = '张三';
-- 李四余额加100
UPDATE accounts SET balance = balance + 100 WHERE name = '李四';
这两条SQL要么都成功,要么都不做。如果第一条执行了、第二条失败了,张三少了100但李四没收到——钱凭空蒸发了。
事务(Transaction)就是一组操作的集合,它们被当作一个不可分割的整体来执行。
-- 开启事务
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE name = '张三';
UPDATE accounts SET balance = balance + 100 WHERE name = '李四';
-- 提交事务(两条操作都成功)
COMMIT;
-- 或者回滚事务(撤销所有操作)
-- ROLLBACK;
二、ACID——事务的四大承诺
事务有四个基本特性,合称ACID:
A - Atomicity(原子性)
事务中的所有操作要么全部完成,要么全部不做。没有”完成一半”的状态。
实现机制:undo日志。InnoDB在执行每个修改操作之前,先把”怎么撤销这个操作”记录到undo日志中。如果事务需要回滚,InnoDB就按照undo日志逆序执行撤销操作。
START TRANSACTION;
UPDATE accounts SET balance = 900 WHERE name = '张三'; -- undo日志记录:balance原来是1000
UPDATE accounts SET balance = 1100 WHERE name = '李四'; -- undo日志记录:balance原来是1000
-- 如果此时ROLLBACK,InnoDB会:
-- 1. 把李四的balance恢复为1000
-- 2. 把张三的balance恢复为1000
ROLLBACK;
C - Consistency(一致性)
事务执行前后,数据库从一个一致的状态转变为另一个一致的状态。所谓”一致”是指数据满足所有约束和业务规则。
比如转账前后,两个账户的余额总和应该不变。张三+李四=2000元,无论怎么转,总额还是2000元。
一致性是事务的最终目标,由原子性、隔离性、持久性共同保障,同时也需要应用层配合(比如业务逻辑检查余额不能为负)。
I - Isolation(隔离性)
多个事务并发执行时,每个事务感觉不到其他事务的存在。一个事务的中间状态不应该被其他事务看到。
实现机制:锁 + MVCC(后面详细讲)。
D - Durability(持久性)
一旦事务提交成功,它对数据的修改就是永久的,即使系统崩溃也不会丢失。
实现机制:redo日志。事务提交时,修改操作会先写入redo日志并刷新到磁盘。即使Buffer Pool中的脏页还没写回磁盘,系统崩溃后也能通过redo日志恢复数据。
ACID特性与实现机制的对应关系:
原子性(A) ← undo日志
一致性(C) ← A + I + D + 应用层逻辑
隔离性(I) ← 锁 + MVCC
持久性(D) ← redo日志
🤔 想一想 如果没有持久性保障,提交了事务但数据丢了,会怎样?如果没有隔离性,两个事务同时修改同一行数据,会怎样?
三、并发事务带来的问题
如果每次只有一个事务在执行,什么问题都没有。但现实中,数据库要同时服务成百上千个并发请求。多个事务并发执行时,会出现以下问题:
脏读(Dirty Read)
事务A修改了一行数据但还没提交,事务B读到了这个未提交的修改。如果事务A随后回滚了,事务B读到的就是一个”不存在的幽灵值”。
事务A 事务B
─────── ───────
UPDATE accounts SET
balance=900 WHERE name='张三';
SELECT balance FROM accounts
WHERE name='张三';
→ 读到900(脏数据!A还没提交)
ROLLBACK;
(张三余额恢复为1000) B以为张三只有900元...
不可重复读(Non-Repeatable Read)
事务B在同一个事务中两次读取同一行数据,两次的结果不同。因为事务A在中间修改并提交了这行数据。
事务A 事务B
─────── ───────
SELECT balance FROM accounts
WHERE name='张三';
→ 读到1000
UPDATE accounts SET
balance=900 WHERE name='张三';
COMMIT;
SELECT balance FROM accounts
WHERE name='张三';
→ 读到900(同一个事务,两次读取不一样!)
幻读(Phantom Read)
事务B在同一个事务中两次执行相同的范围查询,第二次多出了一些行。因为事务A在中间插入了新的行并提交。
事务A 事务B
─────── ───────
SELECT * FROM accounts
WHERE balance > 500;
→ 返回3行
INSERT INTO accounts VALUES
('赵六', 800);
COMMIT;
SELECT * FROM accounts
WHERE balance > 500;
→ 返回4行(多了"赵六"这个幻影行!)
🤔 想一想 脏读、不可重复读、幻读这三个问题的严重程度排序是什么?哪个问题你觉得在实际业务中最不能容忍?
四、四种隔离级别——你想要多安全
SQL标准定义了四种事务隔离级别,从低到高提供不同程度的保护:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED(读未提交) | 可能 | 可能 | 可能 |
| READ COMMITTED(读已提交) | 不会 | 可能 | 可能 |
| REPEATABLE READ(可重复读) | 不会 | 不会 | 可能* |
| SERIALIZABLE(串行化) | 不会 | 不会 | 不会 |
*注:InnoDB的REPEATABLE READ通过间隙锁在很大程度上也避免了幻读。具体来说,快照读(普通SELECT)通过MVCC机制完全避免幻读——因为ReadView在事务首次读取时创建后不再更新,后续其他事务插入的新行对当前事务不可见;当前读(SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、以及INSERT/UPDATE/DELETE)则通过间隙锁(Gap Lock)和临键锁(Next-Key Lock)来阻止其他事务在查询范围内插入新行,从而避免幻读。两者配合,使得InnoDB在RR级别下对幻读有了近乎完整的防护。
MySQL InnoDB的默认隔离级别是REPEATABLE READ。
-- 查看当前隔离级别
SELECT @@transaction_isolation;
-- 或
SHOW VARIABLES LIKE 'transaction_isolation';
-- 设置会话级隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 设置全局隔离级别(新连接生效)
SET GLOBAL TRANSACTION ISOLATION LEVEL REPEATABLE READ;
每种级别的取舍
- READ UNCOMMITTED:几乎没有保护,性能最好但风险最大。几乎不用。
- READ COMMITTED:能防脏读,每次读取都能看到已提交的最新数据。Oracle和PostgreSQL的默认级别。适合大多数OLTP业务。
- REPEATABLE READ:在同一事务中读到的数据始终一致。MySQL的默认级别。对一致性要求高的场景适用。
- SERIALIZABLE:最安全但最慢。读加共享锁,写加排他锁,所有事务串行执行。除非有极端一致性要求,否则不用。
⚠️ 常见误区 误区一:隔离级别越高越好。 隔离级别越高,并发性能越差。SERIALIZABLE级别下,事务几乎要排队执行,吞吐量极低。在实际项目中,应该根据业务需求选择最低够用的隔离级别。
误区二:REPEATABLE READ不能防幻读。 SQL标准中确实如此,但MySQL InnoDB通过**间隙锁(Gap Lock)和临键锁(Next-Key Lock)**机制,在REPEATABLE READ级别下也能很大程度上防止幻读。这是InnoDB超越SQL标准的地方。
五、MVCC——不加锁也能隔离
如果完全靠加锁来实现隔离性,读操作和写操作会互相阻塞,并发性能很差。InnoDB的杀手锏是MVCC(Multi-Version Concurrency Control,多版本并发控制)。
MVCC的核心思想:为每一行数据维护多个版本。读操作读取某个历史版本,写操作创建新版本。读写之间互不阻塞。
MVCC的实现基础
还记得第三章讲的隐藏列吗?每行数据都有两个隐藏字段:
- DB_TRX_ID:最后修改这行数据的事务ID
- DB_ROLL_PTR:回滚指针,指向undo日志中这行的上一个版本
通过回滚指针,同一行数据的多个历史版本形成了一条版本链:
当前版本 历史版本1 历史版本2
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ name='李四' │ │ name='张三' │ │ name='王五' │
│ TRX_ID=200 │──────→│ TRX_ID=150 │──────→│ TRX_ID=100 │
│ ROLL_PTR │ │ ROLL_PTR │ │ ROLL_PTR=NULL │
└──────────────┘ └──────────────┘ └──────────────┘
(最新版本) (undo日志中) (undo日志中)
ReadView——事务的”快照相机”
每个事务在执行读操作时,需要判断版本链中的哪个版本对自己可见。这个判断机制就是ReadView(读视图)。
ReadView中包含四个关键信息:
- m_ids:创建ReadView时,当前系统中所有活跃(未提交)事务的ID列表
- min_trx_id:m_ids中的最小值
- max_trx_id:系统下一个要分配的事务ID(当前最大事务ID + 1)
- creator_trx_id:创建这个ReadView的事务自己的ID
可见性判断规则
对于版本链中的某个版本,其DB_TRX_ID记为trx_id,判断规则如下:
如果 trx_id == creator_trx_id
→ 可见(这是我自己修改的)
如果 trx_id < min_trx_id
→ 可见(修改者在我创建ReadView之前就已提交)
如果 trx_id >= max_trx_id
→ 不可见(修改者在我创建ReadView之后才开始)
如果 min_trx_id <= trx_id < max_trx_id
→ 检查trx_id是否在m_ids中
在m_ids中 → 不可见(修改者还没提交)
不在m_ids中 → 可见(修改者已经提交了)
从当前版本开始,沿着版本链逐个检查,找到第一个可见的版本,那就是这个事务应该看到的数据。
READ COMMITTED vs REPEATABLE READ的区别
两者的核心区别就在于ReadView的创建时机:
- READ COMMITTED:事务中每次读操作都创建新的ReadView。所以能看到其他事务在此期间提交的修改(不可重复读)
- REPEATABLE READ:事务中只在第一次读操作时创建ReadView,后续读操作复用同一个ReadView。所以整个事务期间看到的数据是一致的
READ COMMITTED的行为:
事务B开始
第1次SELECT → 创建ReadView_1 → 看到版本X
(事务A提交了修改)
第2次SELECT → 创建ReadView_2 → 看到版本Y(新的!)
REPEATABLE READ的行为:
事务B开始
第1次SELECT → 创建ReadView_1 → 看到版本X
(事务A提交了修改)
第2次SELECT → 复用ReadView_1 → 仍然看到版本X(一致!)
🤔 想一想 MVCC解决的是”读-写”冲突,那”写-写”冲突(两个事务同时修改同一行)呢?MVCC能解决吗?
六、事务的使用实践
自动提交
MySQL默认开启了自动提交(autocommit=1)。这意味着你执行的每一条SQL都会被隐式地包装在一个事务中并自动提交。
-- 查看自动提交状态
SHOW VARIABLES LIKE 'autocommit';
-- 关闭自动提交(需要手动COMMIT或ROLLBACK)
SET autocommit = 0;
显式事务
-- 方式1:START TRANSACTION
START TRANSACTION;
-- 或者
BEGIN;
-- 执行SQL操作
INSERT INTO orders (user_id, amount) VALUES (1, 99.00);
UPDATE accounts SET balance = balance - 99 WHERE user_id = 1;
-- 提交
COMMIT;
-- 或者回滚
-- ROLLBACK;
保存点(Savepoint)
你可以在事务中设置保存点,回滚时只回滚到保存点,而不是撤销整个事务。
START TRANSACTION;
INSERT INTO orders VALUES (1, '商品A', 100);
SAVEPOINT sp1;
INSERT INTO orders VALUES (2, '商品B', 200);
SAVEPOINT sp2;
INSERT INTO orders VALUES (3, '商品C', 300);
-- 哎,第三条不要了
ROLLBACK TO sp2;
-- 此时只有商品A和商品B的插入保留
COMMIT;
长事务的危害
长事务是MySQL性能的大敌:
- undo日志膨胀:事务未提交,对应的undo日志不能被清理。长事务会导致undo日志越来越大
- 锁持有时间长:长事务占用锁的时间长,阻塞其他事务
- MVCC版本链过长:其他事务需要沿着更长的版本链来查找可见版本
-- 查看当前正在执行的事务
SELECT * FROM information_schema.INNODB_TRX;
-- 查看运行时间超过60秒的事务
SELECT trx_id, trx_state, trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS running_seconds,
trx_query
FROM information_schema.INNODB_TRX
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60;
⚠️ 常见误区 误区:只读查询不需要事务。 在REPEATABLE READ级别下,如果你需要在一个业务流程中多次查询并且要求数据一致性(比如先查余额再查订单),这些查询应该放在同一个事务中。否则两次查询之间数据可能被其他事务修改,导致业务逻辑出错。
📝 掌握度自测
- ACID四个特性分别是什么?每个特性主要由什么机制来保障?
- 脏读、不可重复读、幻读各是什么问题?请用具体的事务执行序列来举例说明。
- MySQL的默认隔离级别是什么?四种隔离级别的区别是什么?
- MVCC是如何实现的?ReadView的可见性判断规则是什么?
- READ COMMITTED和REPEATABLE READ在MVCC层面的核心区别是什么?
💡 自我评估
- 答对5题:事务知识体系完整,对MySQL的并发控制有深入理解
- 答对3-4题:核心概念到位,建议重点理解MVCC的ReadView机制
- 答对0-2题:事务是数据库最核心的概念之一,建议结合转账场景反复理解ACID和隔离级别
购买课程解锁全部内容
让查询飞起来:MySQL 从索引到主从高可用
¥29.90