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 的默认级别,主要因为:

  1. MVCC 支持:读操作不加锁,用快照读避免脏读和不可重复读
  2. Next-Key Lock:在唯一索引条件下锁定记录,在范围查询时锁定间隙,防止幻读
  3. 兼容业务:大多数业务需要"同一事务内多次读取结果一致"的语义

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 UPDATEREPEATABLE 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):锁竞争严重,性能下降,可能死锁
  • 选错场景:业务逻辑基于错误假设,线上数据悄悄出错

建议:

  1. 默认保持 REPEATABLE READ,大多数业务场景够用
  2. 金融、库存等强一致场景,用 SERIALIZABLE 或应用层锁
  3. 读多写少、分析报表场景,用 READ COMMITTED 提升性能
  4. 读写分离时,主从配置不同的隔离级别

事务隔离级别是数据库最核心的核数设定之一,建议在设计评审阶段就确认清楚,不要等线上出了数据问题再回头查。

最后更新 4/20/2026, 6:02:32 AM