上周三凌晨两点,正在床上玩手机的老婆突然听到一声惨叫——我从床上弹起来冲向电脑。线上出大事了。
事情是这样的:用户下单后,订单状态一直卡在"处理中",资金被冻结了,库存也释放了,但订单就是完不成。客服后台一堆投诉,都是说钱扣了但订单没生效。
我赶紧登上服务器看日志,好家伙,MySQL死锁了。上一次看到这种场景还是三年前,数据量小的时候不觉得有什么,这次并发一上来,原形毕露。
这个问题说起来其实是老生常谈,但偏偏就是有人踩坑。让我来好好聊聊事务隔离级别这个事儿。
先说基本概念,别急
事务隔离级别这玩意儿,教科书上写的都是四个:读未提交、读已提交、可重复读、串行化。听着挺明白的对吧?但你真的理解每个级别背后发生了什么吗?
先说个暴论:大部分程序员对事务隔离级别的理解仅限于面试八股文,一到生产环境就抓瞎。我当年也是这么过来的,直到踩了坑才知道这水有多深。
读未提交:没人用的理由
读未提交(Read Uncommitted)是最弱的隔离级别,事务能读到其他事务未提交的数据。听起来很没用对吧?确实很没用,基本没人用。
但你知道吗,在某些高并发场景下,有人居然把它当宝贝。理由是"性能好"。性能你个头啊,你能看到脏数据,脏数据!万一另一个事务回滚了,你读到的就是不存在的数据,然后你按这个数据做业务决策,boom,数据不一致。
这种隔离级别只适合那种完全不在乎数据准确性、只需要个大概数字的场景。比如统计报表,差个百分之零点几无所谓。但我实在想不出什么生产场景适合这个。
读已提交:这个坑最深
读已提交(Read Committed)要求事务只能读到已经提交的数据。这是很多数据库的默认级别,包括Oracle和SQL Server。
问题来了:这个级别能防止脏读,但会遇到不可重复读。什么意思?
举个例子:你开启一个事务,读取了一条订单,金额是100块。然后另一个事务把这条订单的金额改成200块并提交了。这时候你再读,会发现金额变成200了。同一个事务内,两次读取结果不一样,这就是不可重复读。
在订单处理的场景下,如果你先读订单金额判断是否够支付,然后扣款,中间有另一个事务改了这个金额,你就可能做出错误的决策。比如原来金额1000,够支付,扣了800,但扣款期间有人把金额改成了500,结果你扣成了负数。
有人会说这不就是并发问题吗,加个锁不就完了?行,那你加锁,但你知道该锁哪一行吗?你知道订单金额字段上有没有索引吗?你知道这条SQL会不会走全表扫描吗?不知道?那你就等着踩坑吧。
可重复读:MySQL的默认选择
可重复读(Repeatable Read)是MySQL默认的隔离级别。它保证在同一个事务内,多次读取同一批数据,结果是一致的。
听起来很完美对吧?但它有个致命问题:幻读。
幻读是什么?就是你在一个事务内,第一次读的时候没看到某条记录,但第二次读的时候突然出现了。听起来很诡异对吧?
举个例子:事务A开启,读取所有状态为"处理中"的订单,第一次读出来10条。事务B这时候插入了一条新的"处理中"订单并提交了。事务A再读,发现变成了11条。多出来的这条就是幻读。
在订单处理场景下,如果你用可重复读,然后先查一遍"处理中"的订单列表,遍历处理,处理完了再查一遍,你以为你处理完了所有订单,结果漏了一条——新插入的那条。然后这条订单就永远没人处理了,用户的钱就这么挂着。
我这次遇到的问题就是这玩意儿导致的。并发情况下,两个事务同时处理订单列表,都先查询,都看到了相同的订单,都去更新,更新的时候 InnoDB 说不行你们死锁了。一个解决方法是按照订单ID排序后逐个处理,另一个是用主键作为查询条件确保锁的是单行。但这些都是事后补救,真正的解决方案是从一开始就设计好隔离级别。
串行化:性能杀手
串行化(Serializable)是最强的隔离级别,事务完全串行执行,性能最差。一般没人用,除非你在做某些需要强一致性的金融业务。
但即使是金融业务,我也不建议直接用串行化。为啥?因为它的实现方式是通过锁表来完成的。高并发场景下,串行化会让你的系统变成单线程,数据库CPU跑满,响应时间变成龟速。
真正做金融业务的,都是在应用层做幂等控制,加上分布式事务,或者用消息队列来保证最终一致性。你要是真用串行化,等着被DBA骂死吧。
标准是标准,实现是实现
这里有个巨坑:SQL标准定义了四个隔离级别,但不同的数据库实现不一样。
MySQL的InnoDB在可重复读级别下,使用MVCC+next-key锁来实现。MVCC是多版本并发控制,它让读不阻塞写,写也不阻塞读。但next-key锁会把扫描到的范围都锁住,这就会导致间隙锁的问题。
比如你有个订单表,ID从1到100,你执行 SELECT * FROM orders WHERE id > 50,InnoDB 会锁住50之后的所有记录,包括那些根本不存在的ID区间。这就是间隙锁。
如果有另一个事务想插入一条ID在50到100之间的订单,就会被阻塞。如果并发高,两个事务互相等待,boom,死锁来了。
Oracle的实现又不一样,它用的是回滚段来保存数据的老版本,读已提交级别下每次读取都重新获取快照。而MySQL的可重复读是事务开始时就确定好快照,后续读都是读这个快照。
所以你写的代码,在这个数据库上好好的,迁移到另一个数据库上可能就出问题。这不是玄学,这是隔离级别的实现差异。
实战建议
说了这么多,给点实在的建议:
第一,不要迷信默认级别。MySQL默认是可重复读,但不代表它适合你的业务。如果你做的是订单处理,并且需要保证每次读取的金额是一致的,那就把隔离级别调高,或者在事务里用 SELECT ... FOR UPDATE 来锁定你要处理的数据。
第二,查询条件要精确。避免全表扫描,避免范围查询,尤其是高并发场景下。给经常查询的字段加上索引,让InnoDB能精确定位到行,而不是锁一片区间。
第三,大事务是万恶之源。能把一个大事务拆成多个小事务,就拆。事务持续时间越长,持有锁的时间越长,死的概率越高。处理完一批就提交,别拖。
第四,了解你的数据库。MySQL、PostgreSQL、Oracle,相同SQL在不同数据库上的行为可能不一样。想当然地写代码,迟早要还的。
最后
凌晨四点,我终于把那个死锁问题解决了。解决方案其实很简单:查询的时候按主键排序,然后逐条处理,避免并发锁冲突。加上重试机制,死锁了就回滚重试,最终都能成功。
但真正的问题是,代码从一开始就没考虑过并发场景。事务隔离级别不是万能药,它只是给你提供了一个工具,用不用、怎么用,还是看人。
下次再有人问你事务隔离级别,别只背概念了,讲讲你在生产环境里踩过的坑吧。那才是真正有价值的东西。
好了,天都快亮了,我去补觉了。明天还有一堆需求等着我呢,人生就是这样,痛并快乐着。