数据库事务隔离级别:从转账问题到MVCC原理
在数据库开发中,我们经常会把多条SQL语句放进同一个事务。例如,用户A向用户B转账100元,需要先扣除A的余额,再增加B的余额:
BEGIN;
UPDATE account
SET balance = balance - 100
WHERE user_id = 1;
UPDATE account
SET balance = balance + 100
WHERE user_id = 2;
COMMIT;
如果两条语句全部执行成功,转账完成;如果中途发生异常,则需要回滚所有修改。
单个事务内部的逻辑并不难,真正复杂的是:当大量事务同时执行时,它们应该如何相互影响?
如果完全不进行控制,两个并发事务可能读取到彼此尚未提交的数据,也可能覆盖对方的修改。数据库事务隔离级别,就是为了在并发性能和数据一致性之间取得平衡。
一、事务的四个基本特性
数据库事务通常使用ACID概括四个基本特性。
1. 原子性
一个事务中的操作要么全部成功,要么全部失败。
转账时不能只扣除付款人的余额,却没有增加收款人的余额。如果第二条SQL执行失败,第一条SQL也必须回滚。
2. 一致性
事务执行前后,数据都应满足业务规则和数据库约束。
例如:
- 账户余额不能违反系统定义的限制;
- 订单金额必须等于各项商品金额之和;
- 外键不能指向不存在的数据;
- 库存不能因为异常操作变成无效状态。
一致性并不完全由数据库自动保证。数据库可以提供主键、唯一键、外键和检查约束,但复杂业务规则仍需要应用程序正确实现。
3. 隔离性
多个事务同时执行时,每个事务都不应该随意看到其他事务尚未完成的中间状态。
隔离性越强,并发事务之间的影响越小,但通常也会带来更高的锁竞争、等待和回滚成本。
4. 持久性
事务提交后,修改结果应被可靠保存。即使数据库随后发生崩溃,已经提交的数据也不应无故丢失。
数据库通常通过预写日志、刷盘和恢复机制实现持久性。
二、为什么并发事务会产生问题
假设某商品库存为1,两名用户同时购买。
事务A执行:
SELECT stock
FROM product
WHERE id = 100;
事务B也执行相同查询。
两个事务都读到:
stock = 1
随后,它们都认为库存充足,并分别创建订单。如果没有进一步控制,最终就可能出现一个库存被卖给两个人的超卖问题。
并发问题的核心在于:单独看每个事务的逻辑都没有错误,但事务交错执行后,整体结果可能不正确。
常见的并发现象包括:
- 脏读;
- 不可重复读;
- 幻读;
- 丢失更新。
三、脏读是什么
脏读是指一个事务读取到了另一个事务尚未提交的数据。
假设账户余额原本为1000元。
事务A执行:
BEGIN;
UPDATE account
SET balance = 0
WHERE user_id = 1;
但事务A暂时没有提交。
此时事务B读取余额,如果得到0元,就发生了脏读。
随后事务A发现操作有误并执行回滚:
ROLLBACK;
账户余额重新变成1000元。事务B之前读取到的0元实际上从未成为正式数据,因此被称为“脏数据”。
脏读的危险在于,事务B可能根据一个最终不存在的状态继续执行业务。例如,它可能因为读取到余额为0而拒绝交易、触发告警或冻结账户。
四、不可重复读是什么
不可重复读是指同一个事务内,使用相同条件两次读取同一行,却得到不同结果。
事务A第一次查询账户余额:
BEGIN;
SELECT balance
FROM account
WHERE user_id = 1;
返回:
1000
此时事务B修改余额并提交:
UPDATE account
SET balance = 800
WHERE user_id = 1;
COMMIT;
事务A再次执行同样的查询:
SELECT balance
FROM account
WHERE user_id = 1;
这次得到800。
对事务A来说,同一行数据在事务期间发生了变化,因此两次读取结果不同。
不可重复读不一定永远是错误。有些业务希望读取最新数据,就可以接受这种现象;但如果程序需要基于同一份稳定数据完成多步计算,它就可能造成逻辑不一致。
五、幻读是什么
幻读是指同一个事务使用相同范围条件进行多次查询,却发现结果集中新增或减少了某些行。
事务A第一次查询所有待处理订单:
BEGIN;
SELECT *
FROM orders
WHERE status = 'pending';
假设返回10条记录。
随后事务B插入一条新的待处理订单并提交:
INSERT INTO orders(id, status)
VALUES (101, 'pending');
COMMIT;
事务A再次执行相同查询,结果变成11条。多出来的那条记录就像突然出现的“幻影”,因此被称为幻读。
不可重复读关注的是同一行数据内容发生变化,幻读关注的是满足查询条件的行数或行集合发生变化。
六、丢失更新是什么
丢失更新是实际开发中非常重要的问题,但它并不完全等同于脏读、不可重复读和幻读。
假设文章点赞数原本为100。
事务A读取点赞数:
100
事务B也读取到:
100
事务A在程序中计算:
100 + 1 = 101
然后写回数据库。
事务B也计算:
100 + 1 = 101
并再次写回数据库。
两次点赞后,正确结果应该是102,最终却只有101。事务B的写入覆盖了事务A的结果,其中一次更新丢失了。
容易出现问题的写法是:
先读取当前值
→ 在应用程序中计算新值
→ 将新值写回数据库
更安全的方式是让数据库原子地完成自增:
UPDATE article
SET likes = likes + 1
WHERE id = 100;
数据库会在更新过程中处理并发控制,避免两个请求都基于同一个旧值写回。
七、四种标准事务隔离级别
SQL标准定义了四种常见隔离级别。
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| 读未提交 | 可能发生 | 可能发生 | 可能发生 |
| 读已提交 | 不会发生 | 可能发生 | 可能发生 |
| 可重复读 | 不会发生 | 不会发生 | 按标准仍可能发生 |
| 串行化 | 不会发生 | 不会发生 | 不会发生 |
这张表描述的是标准意义上的最低保证。不同数据库的具体实现可能更强,因此不能仅凭隔离级别名称推断全部行为。
八、读未提交
读未提交是隔离程度最低的级别。
在该级别下,一个事务可能读取到其他事务尚未提交的数据,因此脏读、不可重复读和幻读都可能发生。
它的并发限制较少,但数据稳定性也最弱。对于订单、支付、库存和账户等业务,通常不应使用这种隔离级别。
所谓“性能更高”也不能作为随意使用它的理由。错误数据带来的业务成本,往往远高于减少的少量并发控制开销。
九、读已提交
读已提交保证事务只能读取其他事务已经提交的数据,因此不会发生脏读。
不过,同一事务中的两次查询可能看到不同的已提交状态。
例如,事务A第一次查询时,事务B尚未提交,所以A看到旧值;第二次查询前,事务B完成提交,A就可能看到新值。
可以把读已提交理解为:
每条查询语句开始时,读取数据库当时已经提交的结果。
这种隔离级别适合希望及时看到其他事务最新提交结果的场景,也是许多数据库中常见的默认选择。
但如果业务需要在一个事务中多次读取完全一致的数据,就需要额外考虑锁、版本检查或更高隔离级别。
十、可重复读
可重复读保证事务内多次读取同一数据时,通常能够得到一致结果。
可以把它理解为:
事务在开始读取时获得了一份数据快照,后续普通查询继续从符合规则的快照中读取。
即使其他事务已经提交修改,当前事务的普通一致性读取也可能继续看到原来的数据版本。
不过,具体行为取决于数据库实现以及使用的是普通查询还是加锁查询。某些数据库还会使用范围锁或间隙相关的锁机制,进一步防止特定场景中的幻读。
因此,讨论可重复读是否会发生幻读时,需要同时说明:
- 使用的数据库;
- 存储引擎;
- 查询类型;
- 是否显式加锁;
- 是否执行了写入操作。
只说“可重复读一定有幻读”或“一定没有幻读”,都可能过于简单。
十一、串行化
串行化是隔离程度最高的标准级别。
它要求并发事务产生的结果等价于按照某种顺序逐个执行。例如,事务A和事务B可以同时开始,但最终结果必须与“先执行A再执行B”或“先执行B再执行A”一致。
串行化并不一定意味着数据库真的每次只能运行一个事务。数据库可以通过锁、版本检测和冲突检查等机制允许部分操作并发执行。
不过,当事务之间存在冲突时,数据库可能:
- 让其中一个事务等待;
- 终止并回滚其中一个事务;
- 要求应用程序重新执行事务。
串行化可以提供很强的一致性,但也会增加阻塞和重试概率,因此通常只用于一致性要求极高且并发冲突可控的场景。
十二、MVCC是什么
MVCC的全称是多版本并发控制。
它的核心思想是:数据被修改时,不一定立即覆盖唯一的一份旧数据,而是保留能够支持并发读取的多个版本。
假设某行数据经历了以下变化:
版本1:余额1000
版本2:余额800
版本3:余额600
不同事务可以根据各自的可见性规则读取不同版本:
- 较早开始的事务可能继续看到1000;
- 后开始的事务可能看到800;
- 最新事务可能看到600。
这样,读操作不必总是阻塞写操作,写操作也不必总是阻塞普通读取,从而提高并发性能。
但MVCC不是简单地永久保存每一个完整副本。数据库通常会结合事务编号、版本信息、撤销记录和后台清理机制,重建事务所需的数据版本。
十三、MVCC如何判断一个版本是否可见
不同数据库的实现细节不同,但基本思路相似。
每个事务都会获得某种事务标识或快照信息。读取数据时,数据库需要判断:
- 这个版本由哪个事务创建;
- 创建它的事务是否已经提交;
- 创建时间是否对当前快照可见;
- 这个版本是否已经被其他事务删除或替换;
- 删除或替换操作是否对当前事务可见。
如果最新版本不可见,数据库就沿着版本链寻找更早的可见版本。
因此,MVCC解决的重点不是“数据有没有被修改”,而是“当前事务应该看到哪个版本”。
十四、快照读和当前读
理解MVCC时,需要区分快照读与当前读。
快照读
普通查询通常属于快照读:
SELECT balance
FROM account
WHERE user_id = 1;
它根据事务快照读取符合可见性规则的数据版本,一般不会为了读取而长期锁住目标记录。
当前读
加锁查询和更新操作通常需要读取当前最新版本:
SELECT balance
FROM account
WHERE user_id = 1
FOR UPDATE;
以及:
UPDATE account
SET balance = balance - 100
WHERE user_id = 1;
这类操作不能只读取历史快照,因为它们准备修改真实数据,需要确认当前状态并与其他写操作协调。
因此,在同一个事务中,普通查询和加锁查询有时会表现出不同的数据可见性。不能把所有SELECT都视为完全相同的读取。
十五、MVCC不能解决所有并发问题
MVCC提高了读取并发能力,但不能自动保证所有业务逻辑正确。
例如下面的购票流程:
查询剩余票数
→ 程序判断票数大于0
→ 创建订单
→ 更新剩余票数
即使每个事务都能够稳定读取自己的快照,两个事务仍可能同时判断“有票”,从而引发业务冲突。
解决方式可能包括:
- 使用原子条件更新;
- 使用悲观锁;
- 使用乐观锁;
- 设置唯一约束;
- 提高事务隔离级别;
- 重新设计业务流程。
数据库隔离性解决的是通用并发控制问题,而业务不变量仍然需要开发者明确表达。
十六、悲观锁如何控制并发
悲观锁假设数据很可能发生冲突,因此在读取时就锁定目标记录:
BEGIN;
SELECT stock
FROM product
WHERE id = 100
FOR UPDATE;
获得锁后,其他事务如果也想修改同一行,通常需要等待当前事务提交或回滚。
然后当前事务再执行:
UPDATE product
SET stock = stock - 1
WHERE id = 100;
COMMIT;
悲观锁适合冲突概率较高、事务执行时间较短的场景。
但使用时需要注意:
- 尽量按照固定顺序加锁;
- 缩短事务持续时间;
- 不要持锁等待用户输入;
- 不要在持锁期间执行耗时的外部调用;
- 确保查询条件能够有效命中索引。
否则可能出现长时间阻塞或死锁。
十七、乐观锁如何控制并发
乐观锁假设大部分事务不会发生冲突,因此读取时不加排他锁,而是在更新时检查数据是否已经变化。
可以给数据表增加版本号:
SELECT stock, version
FROM product
WHERE id = 100;
假设读取到:
stock = 10
version = 5
更新时加入版本条件:
UPDATE product
SET stock = 9,
version = version + 1
WHERE id = 100
AND version = 5;
如果受影响行数为1,说明更新成功。
如果受影响行数为0,说明版本已经被其他事务修改,当前操作需要重新读取、重试或返回冲突提示。
乐观锁不会让事务长时间持有数据库锁,适合读多写少、冲突概率较低的场景。但冲突非常频繁时,大量失败重试也会造成额外负担。
十八、条件更新往往比先查后改更可靠
库存扣减可以直接写成:
UPDATE product
SET stock = stock - 1
WHERE id = 100
AND stock > 0;
程序根据受影响行数判断结果:
- 受影响1行:扣减成功;
- 受影响0行:库存不足或商品不存在。
这种方式将“检查库存”和“扣减库存”合并为一个原子操作,避免两个事务分别读取旧库存后再写回。
很多并发问题并不需要复杂的分布式锁。合理使用数据库原子更新、唯一约束和条件判断,往往更加直接可靠。
十九、唯一约束也是并发控制工具
假设系统规定每个用户只能领取一次某种优惠券。
如果应用程序先查询:
SELECT COUNT(*)
FROM coupon_record
WHERE user_id = 1
AND coupon_id = 10;
确认不存在后再插入,并不能完全阻止并发重复领取。两个事务可能同时查询到0,然后同时插入。
更可靠的方法是建立联合唯一约束:
UNIQUE(user_id, coupon_id)
这样,即使两个请求同时到达,数据库也只允许其中一个插入成功。
应用层检查可以提供友好提示,数据库约束则负责守住最终一致性底线。
二十、死锁是如何产生的
假设事务A先锁定账户1,再尝试锁定账户2:
事务A:锁定账户1
事务A:等待账户2
事务B却先锁定账户2,再尝试锁定账户1:
事务B:锁定账户2
事务B:等待账户1
此时:
- 事务A等待事务B释放账户2;
- 事务B等待事务A释放账户1。
双方互相等待,形成死锁。
数据库通常能够检测死锁,并主动回滚其中一个事务,让另一个事务继续执行。因此,应用程序需要能够识别死锁错误,并在合适情况下重新执行事务。
降低死锁概率的方法包括:
- 多个事务按照相同顺序访问资源;
- 缩短事务时间;
- 减少一次事务涉及的数据量;
- 为查询建立合适索引;
- 避免在事务内执行无关操作;
- 对重试次数设置上限。
二十一、隔离级别应该如何选择
选择隔离级别时,需要考虑业务需要看到什么数据,以及能够承受多大的并发控制成本。
适合读已提交的情况
- 每条查询希望看到最新提交的数据;
- 事务内不要求多次读取完全一致;
- 应用已经使用原子更新、唯一约束或显式锁处理关键冲突;
- 希望降低长事务持有旧版本带来的压力。
适合可重复读的情况
- 一个事务中需要基于相对稳定的快照进行多次查询;
- 报表或批量计算要求读取期间的数据视图保持一致;
- 能够接受在事务期间看不到其他事务的新提交结果。
适合串行化的情况
- 业务一致性要求极高;
- 事务冲突不算频繁;
- 可以接受阻塞或事务重试;
- 很难通过简单约束表达业务不变量。
无论选择哪种隔离级别,都不应该让事务长期处于未提交状态。事务持续时间越长,锁等待、版本保留和资源占用问题通常越明显。
二十二、数据库并发设计检查清单
开发涉及余额、库存、订单和配额的功能时,可以检查以下问题:
- 多个事务是否可能同时修改同一数据;
- 是否存在先查询、在程序中计算、再写回的流程;
- 能否改成数据库原子更新;
- 是否需要唯一约束保证业务唯一性;
- 是否需要悲观锁或乐观锁;
- 事务隔离级别是否符合业务读取要求;
- 普通查询和加锁查询的语义是否被混淆;
- 事务中是否包含耗时外部调用;
- 多个资源是否按照固定顺序加锁;
- 应用是否能够处理死锁和序列化失败;
- 重试操作是否具备幂等性;
- 是否存在长期未提交事务。
结语
数据库事务隔离并不是为了让所有操作绝对互不影响,而是在正确性和并发性能之间建立一套可控规则。
脏读意味着读取了尚未提交的数据;不可重复读意味着同一行在事务内发生变化;幻读意味着相同条件查询得到不同的行集合;丢失更新则意味着一个事务的修改被另一个事务覆盖。
MVCC通过保留多个数据版本,让读写操作减少相互阻塞,但它不能代替业务层面的并发设计。库存不能为负、优惠券不能重复领取、余额不能错误覆盖,这些规则仍然需要通过原子更新、唯一约束、锁或版本号明确表达。
真正可靠的数据库设计,不是机械地选择最高隔离级别,而是先找出业务中不能被破坏的不变量,再选择成本合适的机制保护它。
- 点赞
- 收藏
- 关注作者
评论(0)