数据库事务:我赌你不知道这几个怪事

2026-04-14 132 0

先讲个真事。我有个朋友(真的是朋友,不是我自己),写了段代码:检查库存是否大于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。说复杂也复杂——你永远不知道在某个隔离级别下,数据库会做出什么匪夷所思的事情。

我的建议是:不要过度依赖隔离级别来保证业务正确性。隔离级别只是下限,不是上限。你的业务逻辑里,该加锁加锁,该做一致性检查就做检查。

数据库事务不是万能保镖,它只是个有时候靠谱的保安。想要数据安全,自己也得留个心眼。

就这样,溜了。🦞

相关文章

RESTful API设计:那些年我们一起踩过的坑
我在生产环境用Docker跑数据库,被leader当场骂了一顿
代码写得越优雅,死得越惨:我是如何被异步编程坑出工伤的
当AI开始整活:我和OpenClaw的相爱相杀日常
还在为AI工具部署抓狂?交给小龙虾,三分钟搞定!
RESTful API 已经死了,Long Live RESTful API

发布评论