MySQL 事务隔离级别选错,轻则数据不准重则数据库崩溃
MySQL 事务隔离级别选错,轻则数据不准重则数据库崩溃
电商系统里有个库存查询接口,上线前测试数据准确无误,线上跑了一周,对账时发现库存流水对不上。查日志、查代码、查 MySQL 配置,都没发现问题。
最后定位到原因:两个事务并发读到了不同的库存值,导致业务层逻辑判断出现分歧。
这不是 MySQL 的 bug,是隔离级别没选对。
四种隔离级别
MySQL InnoDB 提供了四种事务隔离级别,由低到高:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 |
| READ COMMITTED | 不可能 | 可能 | 可能 |
| REPEATABLE READ | 不可能 | 不可能 | 可能 |
| SERIALIZABLE | 不可能 | 不可能 | 不可能 |
MySQL 默认是 REPEATABLE READ,Oracle 是 READ COMMITTED。
脏读:读到未提交的数据
两个事务并发:
-- 事务 A
BEGIN;
UPDATE account SET balance = balance - 1000 WHERE user_id = 1;
-- 还没提交
-- 事务 B
SELECT balance FROM account WHERE user_id = 1;
-- 读到了 9000,实际上事务 A 还没提交,可能回滚
-- 事务 A
ROLLBACK; -- 回滚了
在 READ UNCOMMITTED 下,事务 B 会读到事务 A 未提交的修改。如果 A 最终回滚,B 读到的就是脏数据。
危害:业务基于脏数据做了判断和后续操作,可能导致资金损失或状态不一致。
不可重复读:同一事务两次读取结果不同
-- 事务 A
BEGIN;
SELECT balance FROM account WHERE user_id = 1; -- 第一次读到 10000
-- 事务 B
BEGIN;
UPDATE account SET balance = 8000 WHERE user_id = 1;
COMMIT;
-- 事务 A
SELECT balance FROM account WHERE user_id = 1; -- 第二次读到 8000
COMMIT;
在 READ COMMITTED 下,同一事务内两次读取同一行,结果可能不同。
危害:如果业务逻辑依赖"事务开始时的数据快照",就会出问题。比如验权时读一次余额,扣款时再读一次校验,一致性无法保证。
幻读:同一事务两次查询结果条数不同
-- 事务 A
BEGIN;
SELECT COUNT(*) FROM orders WHERE user_id = 1; -- 第一次查到 10 条
-- 事务 B
BEGIN;
INSERT INTO orders (user_id, ...) VALUES (1, ...); -- 插入一条
INSERT INTO orders (user_id, ...) VALUES (1, ...); -- 再插入一条
COMMIT;
-- 事务 A
SELECT COUNT(*) FROM orders WHERE user_id = 1; -- 第二次查到 12 条
COMMIT;
这就是"幻读"——同一事务内,两次执行同样的查询,结果集的行数不一样,像出现了"幻影"。
在 REPEATABLE READ 下,普通查询有 MVCC 快照保护,只有当前读(带 FOR UPDATE/LOCK IN SHARE MODE)才受最新影响。
为什么 MySQL 默认选 REPEATABLE READ
REPEATABLE READ 是 MySQL 的默认级别,主要因为:
- MVCC 支持:读操作不加锁,用快照读避免脏读和不可重复读
- Next-Key Lock:在唯一索引条件下锁定记录,在范围查询时锁定间隙,防止幻读
- 兼容业务:大多数业务需要"同一事务内多次读取结果一致"的语义
但 REPEATABLE READ 不是银弹,它有性能开销,也有它解决不了的问题。
隔离级别对性能的影响
一般来说,隔离级别越高,性能开销越大:
READ UNCOMMITTED:最少保护,最高性能READ COMMITTED:每次读取生成新的快照REPEATABLE READ:事务开始时生成快照,事务内复用SERIALIALIZABLE:所有读操作都加锁,串行执行
高并发写密集场景下,REPEATABLE READ 的 Next-Key Lock 可能导致严重的锁等待和死锁。
实战:不同场景怎么选
场景一:金融转账(强一致性)
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
SELECT balance FROM account WHERE user_id = 1 FOR UPDATE;
-- 检查余额是否足够
UPDATE account SET balance = balance - 1000 WHERE user_id = 1;
UPDATE account SET balance = balance + 1000 WHERE user_id = 2;
COMMIT;
金融场景选 SERIALIZABLE 或应用层加分布式锁。不要依赖业务逻辑的"二次校验",数据库层面的强一致性才是底线。
场景二:库存扣减(防超卖)
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
-- 先锁定库存记录
SELECT * FROM inventory WHERE sku_id = ? FOR UPDATE;
-- 检查库存是否足够
IF available < quantity THEN
ROLLBACK;
RETURN '库存不足';
END IF;
-- 扣减库存
UPDATE inventory SET available = available - ? WHERE sku_id = ?;
COMMIT;
FOR UPDATE 在 REPEATABLE READ 下会加 Next-Key Lock,锁住查询命中的记录和前后间隙,防止其他事务在这个范围内插入新记录。
场景三:报表查询(可接受幻读)
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
-- 统计数据,允许少量不一致
SELECT COUNT(*), SUM(amount) FROM orders
WHERE created_at >= '2026-01-01' AND created_at < '2026-02-01';
COMMIT;
报表类场景对实时性要求不高,READ COMMITTED 可以减少锁竞争,提升并发度。
场景四:配置读取(几乎只读)
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
SELECT * FROM config WHERE app_id = ?;
COMMIT;
如果确认是只读场景,可以降低隔离级别换取性能。
隔离级别配置的坑
Spring 中设置隔离级别
// 方法级别
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void transfer(Long fromUserId, Long toUserId, BigDecimal amount) {
// ...
}
注意:Spring 默认不会改事务隔离级别,除非你显式指定。如果你在代码里设置了 SET SESSION TRANSACTION ISOLATION LEVEL,但方法上加了 @Transactional,可能产生冲突。
Docker / Kubernetes 环境
容器化部署时注意 MySQL 配置文件是否被正确挂载。有些团队的 my.cnf 挂载是只读的,导致 SET GLOBAL 失效。
# 检查当前隔离级别
SELECT @@transaction_isolation;
SHOW VARIABLES LIKE 'transaction_isolation';
读写分离架构
主库用高隔离级别保证写一致性,从库用低隔离级别提升读性能。确保你的中间件能识别事务类型(读事务 vs 写事务)路由到不同节点。
定位隔离级别相关的问题
如果系统出现数据不一致问题,排查步骤:
-- 1. 查看当前隔离级别
SELECT @@transaction_isolation;
-- 2. 查看当前锁状态
SHOW ENGINE INNODB STATUS;
-- 3. 查看长时间运行的事务
SELECT * FROM information_schema.INNODB_TRX
WHERE trx_state = 'LOCK WAIT' OR trx_started < NOW() - INTERVAL 10 SECOND;
-- 4. 查看锁等待关系
SELECT
r.trx_id AS waiting_trx_id,
r.trx_mysql_thread_id AS waiting_thread,
r.trx_query AS waiting_query,
b.trx_id AS blocking_trx_id,
b.trx_mysql_thread_id AS blocking_thread,
b.trx_query AS blocking_query
FROM information_schema.INNODB_LOCK_WAITS w
JOIN information_schema.INNODB_TRX b ON b.trx_id = w.blocking_trx_id
JOIN information_schema.INNODB_TRX r ON r.trx_id = w.requesting_trx_id;
总结
隔离级别选错的后果:
- 选太低(READ UNCOMMITTED):脏读、不可重复读、幻读都可能发生,数据一致性无保证
- 选太高(SERIALIZABLE):锁竞争严重,性能下降,可能死锁
- 选错场景:业务逻辑基于错误假设,线上数据悄悄出错
建议:
- 默认保持
REPEATABLE READ,大多数业务场景够用 - 金融、库存等强一致场景,用
SERIALIZABLE或应用层锁 - 读多写少、分析报表场景,用
READ COMMITTED提升性能 - 读写分离时,主从配置不同的隔离级别
事务隔离级别是数据库最核心的核数设定之一,建议在设计评审阶段就确认清楚,不要等线上出了数据问题再回头查。
