跳转至

数据库的MVCC

数据库并发情况下存在的问题

  • 脏读 事务B读取到事务A修改但没有提交的值;当事务A发生回滚时,事务B无法读到的数据将变成脏数据。

场景示例:电商库存与报表

  1. 事务A (用户下单):用户购买一件库存为10的商品,系统执行 UPDATE stock = 9,但事务尚未提交,正在进行其他检查。
  2. 事务B (库存报表):此时,一个后台报表系统读取了库存,显示为 9
  3. 事务A回滚:用户的下单因故失败,事务A回滚,库存恢复为10。
  4. 后果:报表系统展示了一个从未真实存在过的库存数据(9),导致了数据污染。

  5. 不可重复读 事务B 在事务内读取同一行数据Data时,值不一致;这是由于在B执行两次查询间,有事务A对Data执行并提交了update命令。

场景示例:机票价格校验

  1. 事务A (用户订票):用户查询某航班机票,第一次读取价格为 500元。用户开始填写个人信息。
  2. 事务B (后台调价):在此期间,航空公司上调了价格,执行 UPDATE price = 550 并提交。
  3. 事务A (继续订票):用户填写完毕点击支付,系统为了校验,第二次读取该航班价格,发现价格已变为 550元。
  4. 后果:在同一个订票流程中,前后读取到的价格不一致,导致业务逻辑冲突

  5. 幻读 事务B在事务内多次按同一范围R查询但是返回的记录行数不同;事务A在两次查询的间隙在范围R中执行并提交了insert或者delete。

场景示例:金融系统月底对账

  1. 事务A (对账系统):在月底,对账系统第一次查询某商户9月份的交易记录总数,得到结果为 100条
  2. 事务B (正常交易):在此期间,该商户又产生了一笔新的交易,系统执行 INSERT 操作并提交。
  3. 事务A (继续对账):对账系统在完成其他核对后,为了最终确认,第二次按相同范围查询,发现交易记录总数变成了 101条。
  4. 后果:第二次查询多出了一条“幻影”记录,导致对账程序的初步统计和最终校验结果不一致,对账失败。

#### 事务隔离等级

image-20260128132217624

MVCC(多版本并发控制)的解决方案

mvcc简述

MVCC 是一种乐观并发控制 (Optimistic Concurrency Control, OCC) 思想的成功实现。

  • 乐观哲学:它乐观地假设“读”和“写”操作通常不会在同一份数据上产生冲突。因此,它不像悲观锁那样,在操作前就预先加锁,而是允许它们并行进行。
  • 冲突解决方式:它解决冲突的方式不是“阻塞等待”,而是“版本绕行”。当读操作遇到一个由未提交事务创建的新版本时,它不会等待,而是会绕过它,去读取一个更早的、对它可见的旧版本(快照读)。如隔离等级(读已提交和可重复读)下,MVCC通过readview不同的生成使用时机来给确定其在冲突时所见的数据版本(对于一个事务,每次快照读都构造一个当期的readview;对于一个事务只在第一次快照读时生成当前的readview,后续的快照读都沿用同一个readview。

readview的定义

image-20250708124050377

确定readview后,如何确定输出的数据版本?版本链的构建与 Read View 的查找之旅

多版本并发控制 (MVCC) 是现代数据库实现高性能并发读写的核心技术。其精髓在于两大机制:版本链 (Version Chain) 的动态构建,以及 Read View (读视图) 在版本链上的精确查找。

版本链是一个逻辑上的单向链表,它记录了一行数据的完整历史变更。

1. 版本链的构成(数据结构)

版本链由以下核心元素构成:

  • 行记录中的隐藏字段:
    • DB_TRX_ID (事务ID): 记录最后一次修改该数据版本的事务 ID。
    • DB_ROLL_PTR (回滚指针): 指向该版本在 Undo Log 中的前一个版本,是连接链表的关键。
  • Undo Log (回滚日志):
    • 历史版本的档案馆:Undo Log 中存储了数据被修改前的完整行镜像(前置镜像)。这些镜像就是版本链上的历史节点。

结构图示:

2. 版本链的构造(动态过程)

版本链是在数据被修改时动态构建和延长的。

  • INSERT - 链的诞生

    1. 新行被插入。
    2. DB_TRX_ID 被设置为当前事务 ID。
    3. DB_ROLL_PTRnull,因为没有前序版本。
  • UPDATE - 链的延长 (核心) 这是一个“头插法”的过程:

    1. 记录 Undo Log: 在修改数据之前,先将当前行的完整内容复制到 Undo Log 中,创建一个新的历史版本节点。
    2. 修改数据页: 在数据页的行记录上,执行更新,并将 DB_TRX_ID 更新为当前事务 ID。
    3. 连接链表: 将数据页上该行的 DB_ROLL_PTR 指向刚刚在 Undo Log 中创建的那个旧版本节点。
  • DELETE - 特殊的延长

    1. DELETE 在 MVCC 中并非立即物理删除。
    2. 它同样会先将被删除行的当前版本记录到 Undo Log 中。
    3. 然后,在数据页的行记录上打上一个“删除”标记,并更新 DB_TRX_IDDB_ROLL_PTR。该记录依然是版本链的一部分,直到后台线程确认其不再需要时才被物理清除。

二、 Read View:事务的“时空地图”

Read View 是 MVCC 的决策核心。当事务执行快照读 (SELECT) 时,会依据 Read View 的规则在版本链上查找。

1. Read View 的构成(“地图”要素)

Read View 是在事务查询时,在内存中动态创建的一个快照信息对象,包含:

  • m_ids: 创建 Read View 时,所有活跃事务的 ID 列表。
  • min_trx_id: m_ids 中的最小 ID (可见性的“过去”边界)。
  • max_trx_id: 预分配的下一个事务 ID (可见性的“未来”边界)。
  • creator_trx_id: 创建此 Read View 的事务自身的 ID。
  • image-20250708124339534
2. Read View 的创建时机(决定隔离级别)
  • 在“读已提交” (Read Committed) 级别:

    每条 SELECT 语句执行时,都生成一个新的 Read View。 * 在“可重复读” (Repeatable Read) 级别:

    仅在第一条 SELECT 语句执行时创建,后续所有 SELECT 复用同一个 Read View。

3. 在版本链上的查找之旅(可见性判断算法)

查找过程是一个从新到旧 (从版本链头部开始)短路查找。一旦找到第一个可见版本,立即停止并返回。

对于版本链上的任意一个版本(其事务ID为 version_trx_id),判断逻辑如下:

  1. 规则一:是否是自己人? (Creator Check)

    • version_trx_id 等于 creator_trx_id 吗?
      • -> 可见。✅ (事务总能看到自己的修改)
      • -> 进入下一规则。
  2. 规则二:是否是历史? (Past Check)

    • version_trx_id 小于 min_trx_id 吗?
      • -> 可见。✅ (该版本在事务开始前就已提交)
      • -> 进入下一规则。
  3. 规则三:是否是未来? (Future Check)

    • version_trx_id 大于或等于 max_trx_id 吗?
      • -> 不可见。❌ (该版本在事务快照创建后才开始) -> 继续查找下一个旧版本
      • -> 进入下一规则(此时 min_trx_id <= version_trx_id < max_trx_id)。
  4. 规则四:是当时正在施工的吗? (Active Check)

    • m_ids (活跃事务列表) 中查找 version_trx_id
      • 如果存在m_ids 中 -> 不可见。❌ (该版本在快照创建时还未提交) -> 继续查找下一个旧版本
      • 如果不存在m_ids 中 -> 可见。✅ (该版本在快照创建时已提交)

这个循环会一直持续,直到找到第一个标记为 ✅ (可见) 的版本,作为最终的查询结果。

image-20250708124133623

详细总结一下 是如何找到对应的数据版本的 把版本链都构成和构造 以及readview 如何在版本链上寻找的

并发控制策略核心精髓对比

乐观锁是在应用层面 不涉及线程操作的情况下 对于读写冲突 进行一个自定义方法的补偿操作 而读写锁模式则是采取线程操作的阻塞重试机制(更严格)

  • MVCC (多版本并发控制)

  • 层面: 数据库内核/存储引擎层。对应用开发者透明。

  • 核心思想: 乐观的,用空间换时间。通过维护数据的多个历史版本,实现高并发。
  • 处理方式: 版本绕行。读操作遇到写冲突时,不等待,而是去读取一个对当前事务可见的旧版本快照。
  • 主要解决: 读写阻塞问题,并在此基础上解决不可重复读快-照读下的幻读
  • 加锁行为: 普通SELECT(快照读)不加锁
  • 适用场景: 数据库内部默认的高性能并发读取机制。

  • 乐观锁 (Optimistic Locking)

  • 层面: 应用层。需要开发者手动编码实现(如 version 字段)。

  • 核心思想: 乐观的,假设并发冲突是稀有的。
  • 处理方式: 事后验证 + 应用层补偿。全程非阻塞,在最后提交UPDATE时,通过WHERE子句检查数据在此期间是否被修改过。若有冲突,更新失败,由应用代码决定如何重试放弃
  • 主要解决: 更新丢失问题,保证“读-改-写”业务流程的原子性。
  • 加锁行为: 不加锁
  • 适用场景: 读多写少的场景,能最大化吞吐量。

  • 读写锁 / 悲观锁 (Read-Write Lock / Pessimistic Locking)

  • 层面: 数据库或系统底层

  • 核心思想: 悲观的,假设并发冲突是常态,必须提前预防。
  • 处理方式: 事前加锁 + 阻塞等待。在访问数据前必须先获取锁。获取不到锁的线程会被挂起,进入等待队列,直到锁被释放。
  • 主要解决: 在需要绝对数据一致性的场景下,强制串行化写操作,保证数据安全。
  • 加锁行为: 显式加锁。SELECT ... FOR UPDATE加排他锁(写锁),SELECT ... LOCK IN SHARE MODE加共享锁(读锁)。
  • 适用场景: 写多冲突频繁的场景,或业务逻辑复杂、不容许失败重试的关键写操作。
回到页面顶部