对账时发现:同一个事务里两次查询的结果不一样
上周做月底对账,写了个校验脚本:先统计账户表的总余额,再逐条核对流水,最后再统计一次总余额,看两次是否一致。结果两次统计的数字差了 3800 元。
我一开始以为是脚本 bug,查了半天才发现——脚本跑的过程中,有交易在实时发生,我第二次统计时读到了新提交的数据。
这个是我在 MySQL 事务隔离上补的第一课。以前只知道"MySQL 默认是可重复读",但从没真正理解这四个隔离级别到底差在哪。
先搞清楚三种"读"异常
隔离级别就是为解决这三个问题而存在的。
脏读:读到了别人没提交的数据
-- 会话 A
START TRANSACTION;
UPDATE t_account SET balance = balance - 100 WHERE id = 1; -- 900 → 800
-- 还没提交
-- 会话 B
SELECT balance FROM t_account WHERE id = 1; -- 读到 800
-- 会话 A 回滚了
ROLLBACK; -- 余额变回 900
-- 会话 B 拿着读到的 800 去做业务判断,数据是错的
B 读到了一个从未真正存在过的状态。这就是脏读,最严重的一种。
不可重复读:同一行,两次读的值不同
-- 会话 B
START TRANSACTION;
SELECT balance FROM t_account WHERE id = 1; -- 900
-- 此时会话 A 提交了:UPDATE ... SET balance = 800 WHERE id = 1; COMMIT;
SELECT balance FROM t_account WHERE id = 1; -- 800,同一个事务内两次结果不同
注意区分脏读和不可重复读:脏读读到的是未提交的数据,不可重复读读到的是已提交的新数据。我开头遇到的就是不可重复读。
幻读:同一个范围,两次读的行数不同
-- 会话 B
START TRANSACTION;
SELECT COUNT(*) FROM t_order WHERE amount > 1000; -- 15 条
-- 会话 A 插入一条并提交
INSERT INTO t_order (amount) VALUES (2000); COMMIT;
SELECT COUNT(*) FROM t_order WHERE amount > 1000; -- 16 条,多了一行
不可重复读针对某一行的值,幻读针对结果集的行数。这个区别我一开始老记混,后来记成"一个改的是格子里的数,一个是多了格子"。
四种隔离级别
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED(读未提交) | 可能 | 可能 | 可能 |
| READ COMMITTED(读已提交) | 不会 | 可能 | 可能 |
| REPEATABLE READ(可重复读) | 不会 | 不会 | InnoDB 下基本不会 |
| SERIALIZABLE(串行化) | 不会 | 不会 | 不会 |
查询和设置:
-- MySQL 5.7 默认是 REPEATABLE READ
SELECT @@tx_isolation;
-- 或者 5.7.20 之后的写法
SELECT @@transaction_isolation;
-- 设置当前会话
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 全局设置(新连接生效)
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;
Spring 里用注解指定:
@Transactional(isolation = Isolation.READ_COMMITTED)
public void reconcile() { ... }
核心:RC 和 RR 的差异到底在哪
这两个级别最大的区别,在于什么时候生成一致性视图(ReadView)。
MVCC 是怎么工作的
InnoDB 的 MVCC(多版本并发控制)靠三样东西实现:
- 隐藏字段:每行记录除了我们定义的列,还有
DB_TRX_ID(最后修改这行的事务 ID)和DB_ROLL_PTR(指向 undo log 里上一个版本的指针)。 - undo log:每次修改都会把旧版本记到 undo log,通过
DB_ROLL_PTR串成一条版本链。 - ReadView:快照,记录"当前有哪些事务还在活跃"。
版本链示意(同一行被改了 3 次):
当前行 (balance=800, trx_id=103)
↑ roll_ptr
undo (balance=900, trx_id=101)
↑ roll_ptr
undo (balance=1000, trx_id=99)
当事务读取某一行时,InnoDB 从最新版本开始,顺着版本链往回找,直到找到一个"在我这个 ReadView 下可见"的版本。判断可见的规则大致是:版本的 trx_id 小于 ReadView 里最小的活跃事务 ID → 可见;trx_id 是我自己 → 可见;否则不可见,继续往前找。
差异就一句话
- RR(可重复读):ReadView 在事务中第一次 SELECT 时生成,之后一直复用。 所以整个事务看到的是同一份快照,不管别人怎么改、怎么提交。
- RC(读已提交):每条 SELECT 语句都重新生成一个 ReadView。 所以别人提交了什么,我下一条语句就能看到。
用实际数据验证。开两个会话,B 用 RR:
-- 会话 B(RR)
START TRANSACTION;
SELECT balance FROM t_account WHERE id = 1; -- 900,此刻生成 ReadView
-- 会话 A: UPDATE ... SET balance = 800; COMMIT;
SELECT balance FROM t_account WHERE id = 1; -- 依然是 900 !
COMMIT;
SELECT balance FROM t_account WHERE id = 1; -- 800,事务结束后才看到新的
换成 RC 再跑一遍,第二次 SELECT 就会读到 800。
另一个重要差异:锁的范围
除了 MVCC,RC 和 RR 在加锁行为上也不一样,这是更容易踩坑的地方。
先说明 InnoDB 里的三种锁形态:
- Record Lock:锁单条索引记录。
- Gap Lock:锁两条记录之间的间隙,防止往里插数据。
- Next-Key Lock:Record Lock + Gap Lock,锁一个左开右闭的区间。
RR 下默认用 Next-Key Lock,这是 InnoDB 能"基本避免幻读"的原因——它把可能被插入的间隙也锁住了。RC 下只有 Record Lock,没有 Gap Lock。
我做过一个实验。表里有 id 为 1、5、10、20 四条记录,两个会话都开启事务:
-- 会话 B(RR)
START TRANSACTION;
SELECT * FROM t WHERE id > 5 FOR UPDATE;
-- RR 下会锁住 (5, 10]、(10, 20]、(20, +∞) 这些区间
-- 会话 A 尝试插入
INSERT INTO t (id) VALUES (7); -- 阻塞!被 Gap Lock 挡住
INSERT INTO t (id) VALUES (15); -- 阻塞
INSERT INTO t (id) VALUES (25); -- 阻塞
-- 会话 B(改成 RC)
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT * FROM t WHERE id > 5 FOR UPDATE;
-- RC 下只锁住命中的 id=10、id=20 这两条记录
-- 会话 A 尝试插入
INSERT INTO t (id) VALUES (7); -- 成功!间隙没被锁
INSERT INTO t (id) VALUES (25); -- 成功
这个差异的实际影响很大。我们线上用的是 RR,遇到过一次死锁:两个事务都用 SELECT ... FOR UPDATE 查一个范围,互相等待对方持有的 Gap Lock。改成 RC 之后这类死锁消失了,因为 Gap Lock 没了。
顺便说下,互联网公司(比如阿里)的 MySQL 规范里推荐用 RC,主要就是看中"没有 Gap Lock → 并发度高、死锁少"。代价是要接受不可重复读,业务上通过"先查后更新时用乐观锁(版本号)或者 SELECT ... FOR UPDATE 显式加锁"来补。
当前读和快照读
还有个容易混淆的点:即使在 RR 下,也不是所有读都走 MVCC 快照。
- 快照读:普通
SELECT,走 ReadView,不加锁。 - 当前读:
SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、以及所有的INSERT/UPDATE/DELETE。这些操作读的是最新版本,而且要加锁。
我在 RR 下验证过这个陷阱:
-- 会话 B(RR)
START TRANSACTION;
SELECT balance FROM t_account WHERE id = 1; -- 快照读,900
-- 会话 A 提交:UPDATE ... SET balance = 800; COMMIT;
SELECT balance FROM t_account WHERE id = 1; -- 快照读,还是 900
UPDATE t_account SET balance = balance - 50 WHERE id = 1; -- 当前读!
SELECT balance FROM t_account WHERE id = 1; -- 750,不是 850!
最后一步很反直觉:balance 从 800 减 50 变成 750,而不是从事务"看到的" 900 减 50 变成 850。因为 UPDATE 走的是当前读,它必须基于最新的值修改,否则就会覆盖掉会话 A 的改动(这叫丢失更新)。
所以在 RR 下,如果事务里既有快照读又有更新操作,要格外小心。需要一致的读,就在事务开始时加 SELECT ... FOR UPDATE 锁住要改的行。
我的实际选择
我们项目最终保持 MySQL 默认的 RR 没动。原因很实际:改动隔离级别影响面太大,我们没有足够的压测数据支撑。但对于几个特定的高并发场景(库存扣减),我用的是显式加锁而不是依赖隔离级别:
@Transactional
public boolean deductStock(String skuId, int quantity) {
// 当前读,加行锁,同一时刻只有一个事务能改这一行
Sku sku = skuMapper.selectBySkuIdForUpdate(skuId);
if (sku.getStock() < quantity) {
return false;
}
return skuMapper.deduct(skuId, quantity) > 0;
}
<select id="selectBySkuIdForUpdate" resultType="Sku">
SELECT * FROM t_sku WHERE sku_id = #{skuId} FOR UPDATE
</select>
注意 FOR UPDATE 要生效,sku_id 上必须有索引。没索引的话 InnoDB 会退化成锁全表(实际上是锁所有行和间隙),并发直接归零。我们测试环境就因为漏建索引,压测时 QPS 只有个位数。
就写到这。如果哪天你也被《事务隔离级别与脏读、不可重复读、幻读》里同一个坑绊住,回来翻这篇,能省半小时。