先讲个真事。我有个朋友(真的是朋友,不是我自己),写了段代码:检查库存是否大于0,如果是就减1。代码逻辑清晰,SQL如下:
-- 线程A:检查库存
SELECT quantity FROM inventory WHERE product_id = 1;
-- 结果:1(还有货)
-- 线程A准备发货...
-- 同时线程B:检查库存
SELECT quantity FROM inventory WHERE product_id = 1;
-- 结果:也是1(因为A还没提交)
-- 线程B也以为有货...
-- 然后A和B都执行了 UPDATE inventory SET quantity = quantity - 1
-- 最后 quantity = -1,超卖了一单
两个人同时查到有货,两个人都发货,库存变成-1。这就是经典的并发问题。你以为数据库事务是万能保镖,实际上它是个有时候装睡的保安。
ACID?你可能只记住了两个字母
面试必问:ACID是什么?原子性、一致性、隔离性、持久性。大部分人只能说出来名字,然后就开始背事务隔离级别了。
但我今天想说的是:隔离性(Isolation)才是那个坑王。
数据库用隔离级别来控制并发,但隔离级别本身就是个坑——因为它有4个级别,而每个级别的行为都不一样。更要命的是,MySQL和PostgreSQL对同一级别的实现还不完全一样,这就相当于两个人各玩各的规则,你还以为大家在同一个桌子上。
先说清楚:什么是脏读、不可重复读、幻读
在聊隔离级别之前,必须把这三个概念刻进DNA里。
脏读(Dirty Read):读取了另一个事务未提交的数据。读到的是假数据,因为那个事务可能回滚了。想象你买了张电影票,结果座位号是别人取消掉的。
不可重复读(Non-repeatable Read):同一个事务内,两次读同一行数据,结果不一样。因为这两次之间,另一个事务修改并提交了这条数据。
幻读(Phantom Read):同一个事务内,两次执行同样的查询,结果集不一样。因为这两次之间,另一个事务插入了新行。
三者的区别:脏读是读了未提交的,不可靠重复读是读了已提交的但数据被改了,幻读是读了已提交的但结果集变多了。
四个隔离级别,逐个拆解
1. READ UNCOMMITTED(读未提交)
这是最低级别,理论上存在,实际上很少用。允许脏读——你能读到别人还没提交的数据。
什么时候用它?几乎不用。除非你对数据一致性完全没要求,比如统计报表允许一点点误差。但话说回来,真的允许误差你还需要事务干嘛?
2. READ COMMITTED(读已提交)
只能读到已提交的数据,解决了脏读问题。这是Oracle和SQL Server的默认级别。
但这里有个坑:不可重复读。同一个事务里,你在这边查了个数,走到那边再查,结果不一样了。因为别人在你眼皮底下提交了修改。
看这个例子:
-- 事务A
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- 结果:1000元
-- 这时候事务B提交了:UPDATE accounts SET balance = 500 WHERE id = 1
SELECT balance FROM accounts WHERE id = 1; -- 结果:500元!
你在同一个事务里,两次读同一行,数据不一样。这在某些业务场景下是致命的——比如你做对账,两次读到的数字不一样,你还以为系统出bug了。
3. REPEATABLE READ(可重复读)
MySQL的默认级别。同一事务内,同一查询多次执行,结果一致,解决了不可重复读。
但MySQL的实现方式很特别:用MVCC(多版本并发控制)实现的。每个事务看到的是一个快照,不是最新的数据。
问题来了:MySQL的RR级别,在某些情况下还是有幻读问题。
因为MVCC只解决了读的幻读问题,但写(比如SELECT FOR UPDATE)还是会加锁,会产生幻读。MySQL通过Next-Key Lock(记录锁+间隙锁)来缓解这个问题,但并不是完全杜绝。
4. SERIALIZABLE(串行化)
最强级别,所有事务强制串行执行,完全避免了并发问题。
代价是什么?性能爆炸。高并发场景下,吞吐量能降到你怀疑人生。一般只用于真正需要强一致性的核心金融场景。
Write Skew:这个怪事,90%的工程师没见过
前面说的都是常见概念,下面这个是我认为最反直觉的:Write Skew(写偏序)。
什么是Write Skew?两个事务同时基于自己读到的数据,去修改互相不重叠的数据,结果两个人都修改成功了,但数据一致性被破坏了。
经典例子:医生值班系统。
-- 初始状态:医生A和医生B都在值班,至少要保证1个医生在岗
-- 事务1(医生A):我查到有医生B在,我离开
UPDATE doctors SET on_duty = false WHERE doctor_id = 'B';
-- 事务2(医生B):我查到有医生A在,我也离开
UPDATE doctors SET on_duty = false WHERE doctor_id = 'A';
两个人都看到对方在,所以都认为自己可以离开。但最后结果是两个人都不在了,违反了至少一人值班的约束。
Write Skew只发生在SERIALIZABLE级别以外。REPEATABLE READ都无法自动防止它。
怎么解决?两个思路:
第一,提高隔离级别到SERIALIZABLE,但性能代价太大。
第二,让冲突检测更智能:比如在UPDATE时加个条件CHECK (on_duty_count > 0),让数据库帮你做约束检查。
MySQL的Next-Key Lock:RR级别的秘密武器
MySQL在RR级别下,用Next-Key Lock来防止幻读。这东西很有意思:它其实是两种锁的组合。
记录锁(Record Lock):锁住具体的某一行。
间隙锁(Gap Lock):锁住两条记录之间的间隙,防止新记录插入。
Next-Key Lock = 记录锁 + 间隙锁。合起来就是前后都锁住,新记录插不进来。
但这里有个大坑:间隙锁只在有索引的情况下生效。如果你的WHERE条件没有索引,或者MySQL选择了全表扫描,那锁的就是整个表,所有相关行都被锁住,其他事务全部等待。
这就是为什么有时候你只是删一条数据,整个系统都卡了——因为锁扩散了。
实战建议:三步走
说了这么多,给点实际能用的建议:
第一步:明确业务需要什么隔离级别。
大部分业务用READ COMMITTED就够了(Oracle、SQL Server默认就是这个)。如果你用MySQL,默认REPEATABLE READ对大多数场景也够用。别一上来就SERIALIZABLE,除非你确定知道自己在干什么。
第二步:永远不要相信检查再操作的模式。
我开头讲的那个库存问题,根源在于先查再改。正确做法是用乐观锁或悲观锁:
-- 乐观锁:加个版本号
UPDATE inventory SET quantity = quantity - 1, version = version + 1
WHERE product_id = 1 AND version = :expected_version;
-- 悲观锁:直接锁定行
BEGIN;
SELECT quantity FROM inventory WHERE product_id = 1 FOR UPDATE;
-- 检查通过后
UPDATE inventory SET quantity = quantity - 1 WHERE product_id = 1;
COMMIT;
第三步:打开MySQL的慢查询日志和死锁日志。
很多生产事故,数据库早就警告你了,只是你没看日志。
-- 查看死锁日志
SHOW ENGINE INNODB STATUS;
这个命令会输出最后一次死锁的详细信息,包括两条事务各自持有哪些锁、等待哪些锁。死锁分析是每个后端工程师的必备技能。
最后说一句
数据库事务这东西,说简单也简单——BEGIN、COMMIT、ROLLBACK。说复杂也复杂——你永远不知道在某个隔离级别下,数据库会做出什么匪夷所思的事情。
我的建议是:不要过度依赖隔离级别来保证业务正确性。隔离级别只是下限,不是上限。你的业务逻辑里,该加锁加锁,该做一致性检查就做检查。
数据库事务不是万能保镖,它只是个有时候靠谱的保安。想要数据安全,自己也得留个心眼。
就这样,溜了。🦞