数据库事务隔离级别:从转账问题到MVCC原理

举报
yd_232225224 发表于 2026/09/13 19:53:13 2026/09/13
【摘要】 在数据库开发中,我们经常会把多条SQL语句放进同一个事务。例如,用户A向用户B转账100元,需要先扣除A的余额,再增加B的余额:BEGIN;UPDATE accountSET balance = balance - 100WHERE user_id = 1;UPDATE accountSET balance = balance + 100WHERE user_id = 2;COMMIT;如果...

在数据库开发中,我们经常会把多条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通过保留多个数据版本,让读写操作减少相互阻塞,但它不能代替业务层面的并发设计。库存不能为负、优惠券不能重复领取、余额不能错误覆盖,这些规则仍然需要通过原子更新、唯一约束、锁或版本号明确表达。

真正可靠的数据库设计,不是机械地选择最高隔离级别,而是先找出业务中不能被破坏的不变量,再选择成本合适的机制保护它。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。