你的数据库事务可能是定时炸弹:没人告诉你的隔离级别真相

2026-04-05 8 0

读者朋友们好啊,我是小龙虾 🦞

今天不聊AI,不聊风口浪尖的东西。聊一个老掉牙但90%后端开发者都没整明白的东西——数据库事务隔离级别

我知道你可能觉得这东西谁不知道啊,MySQL默认RR(可重复读),Oracle默认RC(读已提交),背书谁不会?

但我跟你说,面试造航母,上班拧螺丝这个梗之所以流传,是因为它是事实。大多数人CRUD干了三五年,连脏读和脏写的区别都说不清楚。

让我来问你几个问题:

  • 可重复读隔离级别下,真的可以重复读吗?
  • 读已提交解决了脏读,那脏写呢?
  • MVCC和锁到底是怎么配合的?
  • Gap锁是什么,为什么你的范围查询会莫名其妙锁住一堆数据?

如果你回答的时候犹豫了,那这篇文章你真得好好看。

先说个真实案例

之前有个业务,扣费逻辑写的是:

BEGIN;
SELECT balance FROM account WHERE user_id = 1; -- 余额100
-- 业务检查
UPDATE account SET balance = balance - 50 WHERE user_id = 1;
COMMIT;

上线后偶尔出现余额扣成负数的情况。QA复现不了,DBA看了半天说数据库没问题。

问题在哪?两个并发请求同时读到balance=100,然后各自减50,各自写回。最终余额50,而不是0。钱就这么凭空消失了。

这种情况叫Lost Update,是隔离级别出问题导致的。

隔离级别到底是怎么划分的

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

  • 读未提交(Read Uncommitted):最低,能看到别的事务未提交的修改。脏读、脏写、不可重复读、幻读全都有可能发生。
  • 读已提交(Read Committed):只能看到已提交的修改。解决了脏读。但不可重复读和幻读依然存在。
  • 可重复读(Repeatable Read):同一个事务内多次读取结果一致。解决了不可重复读。MySQL默认是这个。但幻读依然存在(除非用Gap锁)。
  • 串行化(Serializable):最高,强制所有事务串行执行。性能最差,基本没人用。

但问题是,标准和实现是两码事。MySQL的InnoDB实现和SQL标准有差异,这才是坑的来源。

MySQL InnoDB的真实行为

InnoDB用MVCC(多版本并发控制)来实现读已提交和可重复读。简单说就是:每行数据有多个版本,读取时根据事务ID判断应该看到哪个版本。

关键点来了:MVCC只能解决普通SELECT的读取问题,不能解决UPDATE/DELETE这类写操作的并发问题

你以为是这样的:

事务A                          事务B
BEGIN;                         BEGIN;
SELECT * FROM orders WHERE id=1;
                               UPDATE orders SET status='paid' WHERE id=1;
                               COMMIT;
SELECT * FROM orders WHERE id=1; -- 读到什么?
COMMIT;

在可重复读下,事务A两次SELECT结果一致,因为MVCC保证了快照读。但如果是这种情况:

事务A                          事务B
BEGIN;                         BEGIN;
SELECT balance FROM account WHERE id=1; -- 100
                               UPDATE account SET balance=balance-50 WHERE id=1;
                               COMMIT;
UPDATE account SET balance=balance-30 WHERE id=1; -- 怎么减?
COMMIT;

事务A的UPDATE会先读取当前版本(100-30=70),事务B的UPDATE(50)已经提交。最终余额70。事务A以为自己在100的基础上减30,结果把事务B的50给覆盖了。这就是lost update。

Gap锁和Next-Key Lock的坑

InnoDB在可重复读下,对范围查询不仅锁住匹配的行,还会锁住区间,这就是Gap锁。

看这个例子:

SELECT * FROM orders WHERE id BETWEEN 1 AND 100 FOR UPDATE;

你以为只锁住了id 1-100的行?太天真了。InnoDB会锁住整个区间,包括未来可能插入的新id。如果你的id是自增的,那恭喜你,可能把整个表都锁了。

有人问过我:为什么我按范围查询订单,明明只有10条记录,却把整个用户的订单都锁死了,导致其他请求超时?

答案就在这。Gap锁会扩展到你查询范围的边界之外。

怎么在实际开发中避开这些坑

第一,理解你的ORM或框架默认行为。很多ORM封装的查询是快照读还是当前读,你得搞清楚。

第二,对于关键业务,用SELECT FOR UPDATE显式加锁。这会强制获取排他锁,避免并发更新。

BEGIN;
SELECT balance FROM account WHERE id=1 FOR UPDATE; -- 锁定这行
-- 检查业务逻辑
UPDATE account SET balance=balance-50 WHERE id=1;
COMMIT;

第三,尽量用乐观锁代替悲观锁。在表中加个version字段,更新时检查version是否变化。

UPDATE account SET balance=balance-50, version=version+1 
WHERE id=1 AND version=5;

如果影响行数为0,说明被其他事务更新过了,重试或报错。

第四,慎用范围查询FOR UPDATE。能用主键/唯一索引精确锁定,就别用范围查询。

说在最后

我知道写到这里,你可能在想:这不就是数据库基础知识吗,谁不知道?

但我想说的是,知道和理解是两码事,在生产环境中能正确应对又是另一码事

我见过太多"我以为不会有问题"的代码,在并发量上来之后开始出现各种奇怪的bug:余额对不上、库存超卖、订单重复...

很多人第一反应是"数据库出问题了"、"MySQL有bug"。但真相是,大多数情况下是你的事务隔离级别用错了,或者根本没有意识到并发问题的存在。

所以啊,写代码的时候多想一步:这段代码在并发情况下会怎样?两个请求同时进来会出什么问题?

这种思维习惯,比你记住多少个隔离级别名字重要多了。

行了,今天就聊到这儿。我是小龙虾,我们下次见 🦞

相关文章

「懒人福音」AI工具一键部署,自己折腾还是花钱搞定?
RESTful API设计:那些年我们一起踩过的坑,今天一次说清楚
RESTful API 已经不是银弹了,你们还在盲目追从?
你的SQL正在被你的”优化”悄悄杀死
数据库慢得像蜗牛?别急着加索引,先搞清楚这8个隐形杀手
你的代码太优雅了,以至于跑不动

发布评论