快照读不加锁、读历史版本;当前读加锁、读最新已提交版本——区别不在“读什么”,而在“怎么读”和“是否阻塞”。

快照读不加锁、读历史版本;当前读加锁、读最新已提交版本——区别不在“读什么”,而在“怎么读”和“是否阻塞”。
快照读只在特定隔离级别下稳定生效
MySQL 的 SELECT 默认是快照读,但它的行为高度依赖事务隔离级别:
-
REPEATABLE READ下:事务启动时生成一个ReadView,整个事务内所有快照读都复用这个视图,看到的是“事务开始那一刻”的一致快照 -
READ COMMITTED下:每次执行SELECT都生成新ReadView,只能看到语句执行前已提交的数据,两次查询可能结果不同 -
READ UNCOMMITTED下:根本不算快照读,直接读最新行数据(含未提交的脏数据) -
SERIALIZABLE下:所有SELECT自动转为SELECT LOCK IN SHARE MODE,强制当前读
容易踩的坑:SELECT 看似轻量,但在 READ COMMITTED 下无法保证事务内一致性;而只要加了 FOR UPDATE 或 LOCK IN SHARE MODE,哪怕在 REPEATABLE READ 下也立刻退化为当前读。
当前读必然触发行锁或临键锁
当前读不是“读得更及时”,而是“读得更霸道”——它必须拿到锁才能继续:
-
SELECT ... FOR UPDATE→ 加X 锁(排他锁),阻塞其他事务的读写 -
SELECT ... LOCK IN SHARE MODE→ 加S 锁(共享锁),允许并发读,但阻塞写 -
UPDATE/DELETE→ 先做一次当前读(加锁),再修改;INSERT虽不读旧行,但会检查唯一约束、间隙等,也可能触发临键锁
关键点:当前读读取的是“最新已提交版本”,但不是靠“查得快”,而是靠“锁得住”。如果锁冲突,事务会等待甚至超时报错 Lock wait timeout exceeded。
幻读问题其实取决于读类型混用
很多人以为 REPEATABLE READ 能彻底杜绝幻读,结果线上还是出现两次 SELECT COUNT(*) 结果不一致。真相是:
- 纯快照读(如两次普通
SELECT)在REPEATABLE READ下不会幻读——MVCC 保证视图不变 - 但若第一次是快照读,第二次改成
SELECT ... FOR UPDATE,就变成当前读,此时会重新评估索引范围,可能扫到新插入的行(尤其没主键/没索引时) - 更隐蔽的是:快照读不锁间隙,当前读才用临键锁(Next-Key Lock)封住间隙;混用二者等于主动打开幻读窗口
典型误操作:BEGIN; SELECT * FROM t WHERE id > 100; -- 快照读,无锁;然后另一事务 INSERT INTO t VALUES (105, ...); COMMIT;,接着本事务执行 UPDATE t SET x=1 WHERE id > 100; —— 这个 UPDATE 是当前读,会命中新插入的 105 行,造成逻辑错乱。
如何判断一条 SQL 到底是快照读还是当前读
看 SQL 本身有没有“锁提示”或“写意图”,而不是看隔离级别或表结构:
- 明确带
FOR UPDATE、LOCK IN SHARE MODE、FOR SHARE→ 当前读 - 任何
INSERT、UPDATE、DELETE→ 当前读(即使UPDATE的WHERE条件没匹配到行,也会走当前读流程) - 纯
SELECT(不含上述关键词)→ 快照读,但受隔离级别影响其“快照”时效性 -
SELECT ... INTO OUTFILE、SELECT ... INTO DUMPFILE→ 也是快照读,不加锁
最易被忽略的一点:很多 ORM 框架(如 Django ORM 的 .select_for_update()、MyBatis 的 标签配 forUpdate="true")会在背后自动加锁,你以为在发普通 SELECT,实际早已进入当前读轨道。











