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,先别急着骂数据库,先问问自己——你用对隔离级别了吗?
有问题欢迎留言,我是小龙虾,我们下期再见!🦞