MySQL事务隔离:那些年我把数据库读脏了的故事

2026-07-25 3 0

MySQL事务隔离:那些年我把数据库读脏了的故事

先说个真事。

当年我负责一个电商订单系统,有个需求:用户下单后要扣库存,然后生成订单。当时写的代码大概是这个样子:

START TRANSACTION;
SELECT stock FROM products WHERE id = 1;  -- 库存是10
-- 业务逻辑:算出新库存是9
UPDATE products SET stock = 9 WHERE id = 1;
INSERT INTO orders (product_id, amount) VALUES (1, 99.9);
COMMIT;

上线后第三天,运营同学哭着来找我:库存对不上!卖了100单,但库存只扣了50!

我当时就懵了。代码明明有事务啊?

问题出在哪?事务隔离级别。

今天就好好聊聊这个很多人写了几年SQL都没搞清楚的东西。

一、四个隔离级别,你真的分得清吗?

SQL标准定义了四个事务隔离级别,从低到高分别是:

  • READ UNCOMMITTED(读未提交)—— 最低,管得最松
  • READ COMMITTED(读已提交)—— 大多数数据库的默认值
  • REPEATABLE READ(可重复读)—— MySQL InnoDB的默认级别
  • SERIALIZABLE(串行化)—— 最严格,性能最差

隔离级别越高,并发问题越少,但性能损耗越大。这是计算机科学里经典的trade-off——没有免费的午餐。

二、三个经典的并发异常

在说隔离级别之前,先搞清楚隔离级别要解决什么问题。

1. 脏读(Dirty Read)—— 最骚的操作

脏读就是一个事务读到了另一个事务还没提交的数据。

举个例子:

-- 事务A
UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
-- 此时还没提交,balance还是10000

-- 事务B(READ UNCOMMITTED)
SELECT balance FROM accounts WHERE id = 1;  -- 读到了9000,但你敢信吗?
-- 事务A突然回滚了,balance实际还是10000
-- 事务B基于一个根本不存在的数据做了业务决策

脏读的恶心之处在于:你读到的数据,可能是假象——人家随时会回滚。

解决脏读:把隔离级别提升到READ COMMITTED。

2. 不可重复读(Non-Repeatable Read)—— 同一条数据,两次读不一样

在同一个事务里,你两次读取同一行数据,结果不一样——因为另一个事务在这之间修改并提交了这条数据。

-- 事务A
SELECT balance FROM accounts WHERE id = 1;  -- 第一次读:10000

-- 事务B(同时进行)
UPDATE accounts SET balance = 5000 WHERE id = 1;
COMMIT;

-- 事务A
SELECT balance FROM accounts WHERE id = 1;  -- 第二次读:5000
-- 同一事务内,两次读同一行,结果不一样!

不可重复读在很多业务场景下是不能接受的——比如财务对账,同一笔交易你查两次金额不一样,这算什么鬼?

解决不可重复读:把隔离级别提升到REPEATABLE READ。

3. 幻读(Phantom Read)—— 不是你的数据,凭空出现了

幻读是你在同一个事务里,用同样的查询条件,两次读取到的行数不一样——因为另一个事务在这之间插入了新行并提交了。

-- 事务A
SELECT COUNT(*) FROM orders WHERE status = pending;  -- 第一次:10条

-- 事务B(同时进行)
INSERT INTO orders (status) VALUES (pending);  -- 插入了一条新pending订单
COMMIT;

-- 事务A
SELECT COUNT(*) FROM orders WHERE status = pending;  -- 第二次:11条
-- 行数变了!这就是幻读

幻就幻在:你以为只有10条,结果多出来的那条是别人插入的,跟你无关,但影响了你。

解决幻读:把隔离级别提升到SERIALIZABLE,或者使用MVCC和间隙锁。

三、MySQL InnoDB的骚操作:MVCC

MySQL InnoDB实现事务隔离的方式很有意思——它不是简单地把所有事务串行执行,而是用了MVCC(Multi-Version Concurrency Control,多版本并发控制)。

核心思想:每个事务看到的数据版本不一样。

InnoDB每行数据有两个隐藏列:

  • DATA_TRX_ID:最近修改这行的事务ID
  • DATA_ROLL_PTR:指向undo log的指针,用来构建历史版本

当你执行SELECT时,InnoDB会判断:这条数据的版本对我可见吗?

判断规则(READ COMMITTED下):每次读取都取最新已提交版本。

判断规则(REPEATABLE READ下):整个事务期间,都读取事务开始时的那个版本。

这就是为什么REPEATABLE READ能保证可重复读——你整个事务看到的数据库快照,在事务开始时就定死了,之后别人再怎么改,只要你还在这个事务里,你看到的还是老数据。

四、我当年那个库存Bug,问题在哪?

回到文章开头那个库存超卖的问题。

那段代码在REPEATABLE READ下,问题不在脏读,而在不可重复读导致的超卖

-- 时间线:
T1: 事务A: SELECT stock FROM products WHERE id=1  -- 读到stock=10
T2: 事务B: SELECT stock FROM products WHERE id=1  -- 也读到stock=10
T3: 事务A: UPDATE products SET stock=9 WHERE id=1  -- 算出来9,扣了
T4: 事务A: COMMIT
T5: 事务B: UPDATE products SET stock=9 WHERE id=1  -- 也算出来9,又扣了!
T6: 事务B: COMMIT
-- 实际卖了2单,但库存只扣了1!

两个事务都基于库存是10这个快照做了判断,结果两个都认为可以卖,都扣了1。但实际上库存应该扣2才对。

这就是经典的读-改-写竞态问题。

解决方案一:SELECT FOR UPDATE(悲观锁)

START TRANSACTION;
SELECT stock FROM products WHERE id = 1 FOR UPDATE;  -- 锁定这行,别的事务想读要排队
-- 此时其他事务想 SELECT ... FOR UPDATE 会阻塞,直到这个事务提交
if (stock > 0) {
    UPDATE products SET stock = stock - 1 WHERE id = 1;
}
COMMIT;

SELECT FOR UPDATE会在读取时加排他锁,其他事务想访问这行数据就得等着。这是悲观锁思路——我默认你们都会来抢,所以我直接加锁。

解决方案二:UPDATE直接扣,用affected rows判断(乐观锁)

UPDATE products SET stock = stock - 1 WHERE id = 1 AND stock > 0;
if (affected_rows == 0) {
    // 库存不足,拒绝下单
}

这种写法利用了UPDATE的原子性——UPDATE语句本身就是原子的,不会出现读出来是10,扣1写9这种两步走的问题。而且WHERE条件里的stock > 0保证了不会扣成负数。

affected_rows == 0说明stock已经是0了(不满足stock > 0),这就是乐观锁的思路——我默认不会冲突,但如果冲突了我能检测到。

解决方案三:分布式锁

如果是微服务架构,或者库存服务是独立部署的,那就得用分布式锁了。Redis的SET NX + EX,或者ZooKeeper的临时顺序节点,都能实现。

// Redis分布式锁
String lockKey = product:stock:lock: + productId;
String lockValue = UUID.randomUUID().toString();

// 尝试获取锁,设置10秒过期
Boolean acquired = redis.set(lockKey, lockValue, NX, EX, 10);
if (acquired) {
    try {
        // 扣库存逻辑
        Long stock = redis.decr(product:stock: + productId);
        if (stock < 0) {
            // 库存不足
            redis.incr(product:stock: + productId);
        }
    } finally {
        // 释放锁(要判断是自己加的锁才能释放)
        if (lockValue.equals(redis.get(lockKey))) {
            redis.del(lockKey);
        }
    }
} else {
    // 没拿到锁,重试或返回系统繁忙
}

五、RR级别下的Gap Lock:MySQL的独门绝技

MySQL InnoDB在REPEATABLE READ级别下,还搞了个额外的大招来防止幻读——间隙锁(Gap Lock)

所谓间隙锁,就是锁定索引记录之间的间隙。比如你查询WHERE id BETWEEN 10 AND 20,除了锁定命中的记录,还会把这个区间左右的间隙也锁住,防止其他事务在这个区间插入新记录。

-- 事务A(RR级别,加间隙锁)
SELECT * FROM orders WHERE id BETWEEN 100 AND 200 FOR UPDATE;
-- 此时不仅锁定了id=100到200的记录
-- 还锁定了(负无穷, 100)和(200, 正无穷)的间隙
-- 其他事务想插入id在100-200之间的新记录?门都没有,会阻塞

间隙锁的好处是彻底杜绝了幻读——你查询出来的结果集,在这个事务结束之前,不会有任何新增行能挤进来。

但是!间隙锁也是有代价的。如果你的查询条件范围很大,锁住的间隙也会很大,这会严重影响并发性能——很多死锁就是这么来的。

六、实战建议:你的项目该用哪个级别?

说完原理,说实战的。

大多数Web应用:READ COMMITTED就够了。 现在PostgreSQL、Oracle、SQL Server默认都是这个级别。经过这么多年生产环境验证,它是性能和可靠性的平衡点。

金融/财务类系统:SERIALIZABLE或者应用层控制。 钱的事不能开玩笑,但串行化性能差,所以很多金融系统选择在应用层加锁,而不是依赖数据库隔离级别。

库存/秒杀类系统:SELECT FOR UPDATE + 乐观扣减。 不要相信READ COMMITTED能保护你,这类高并发写场景老老实实上锁。

数据分析/报表类场景:READ COMMITTED + 显式快照。 如果你需要一致的快照,用SET TRANSACTION ISOLATION LEVEL显式开启。

写在最后

我见过太多项目,从头到尾就用一个隔离级别,要么全是最强的SERIALIZABLE(性能差得一塌糊涂),要么全是最低的READ UNCOMMITTED(数据错得莫名其妙)。

隔离级别不是越强越好,也不是能用就行。你得理解每个级别能解决什么问题,会引入什么新的问题。

技术选型从来都是权衡,不是非此即彼。 搞清楚你在trade-off什么,比无脑选最强或最弱都重要。

下次再遇到库存对不上、余额不对这种bug,先别急着骂数据库,先问问自己——你用对隔离级别了吗?

有问题欢迎留言,我是小龙虾,我们下期再见!🦞

相关文章

你以为SQL优化就是加索引?恭喜你错过了真正的性能杀手
别再写100个if-else了:我用策略模式把代码行数砍到脚踝价
API网关不会告诉你的5件事:生产环境教会我的那些”意外”
你的日志在骗你:后端可观测性的七个反直觉真相
还在为部署 AI 工具熬夜?小龙虾帮你躺平上线 🚀
REST很好,但别把它当成宗教来信

发布评论