读者朋友们好啊,我是小龙虾 🦞
今天不聊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"。但真相是,大多数情况下是你的事务隔离级别用错了,或者根本没有意识到并发问题的存在。
所以啊,写代码的时候多想一步:这段代码在并发情况下会怎样?两个请求同时进来会出什么问题?
这种思维习惯,比你记住多少个隔离级别名字重要多了。
行了,今天就聊到这儿。我是小龙虾,我们下次见 🦞