关于回朔数据的存储

问题:历史信息是map存储吗?

结论先行:
历史数据不是类似map那样独立存储的,而是以**“版本链”的形式,串联在一种叫做undo log的特殊日志文件中。这个存储方式确实会占用额外且可能相当大**的磁盘空间。

我们来详细拆解这个存储机制。


1. 历史数据不是存在表里的

首先要明确,数据库的数据表文件(比如.ibd文件)中,聚簇索引的叶子节点上存放的永远是数据的最新版本。历史版本(旧数据)不在这里。

每一行记录,除了你定义的列(如id, name, balance)之外,InnoDB还悄悄地加了几个隐藏列:

  • DB_TRX_ID: 创建或最后修改这一行数据的事务ID
  • DB_ROLL_PTR: 回滚指针 (Roll Pointer)。这是关键!它指向undo log中记录的上一个版本的数据。
  • DB_ROW_ID: 隐藏的主键(如果表没有显式定义主键)。

2. undo log:历史版本的大本营

undo log(回滚日志)是MVCC的基石。虽然它的名字叫“回滚日志”,但它有两大作用:

  1. 事务回滚:当事务需要ROLLBACK时,根据undo log中的记录恢复到修改前的状态。
  2. 构建版本链:为MVCC提供历史数据版本。

undo log 是如何存储历史数据的?

它不是一个简单的map,而更像一个基于链表思想的**“反向操作日志”**。

我们用一个例子来模拟这个过程:

初始状态id=1的行,balance100。这是由事务ID 10 创建的。

1
2
Data Page (表里):
id: 1, balance: 100, DB_TRX_ID: 10, DB_ROLL_PTR: null

Step 1: 事务 Trx_20balance100 修改为 150

  1. 记录Undo Log: InnoDB会在undo log段中记录一条日志,内容大致是:“我要把id=1这行改了,它之前的数据是{balance: 100, DB_TRX_ID: 10, DB_ROLL_PTR: null}”。
  2. 获取Undo Log地址: 假设这条undo log记录的地址是 Undo_Addr_A
  3. 修改数据页:
    • InnoDB直接在数据页上修改数据,balance变为150
    • 更新事务ID为当前事务 20
    • 关键一步:将回滚指针 DB_ROLL_PTR 设置为刚刚那条undo log的地址 Undo_Addr_A

修改后状态:

1
2
3
4
5
Data Page (表里):
id: 1, balance: 150, DB_TRX_ID: 20, DB_ROLL_PTR: Undo_Addr_A

Undo Log Segment:
Undo_Addr_A: { original_data: {balance: 100, DB_TRX_ID: 10, DB_ROLL_PTR: null} }

Step 2: 事务 Trx_30 再次把 balance150 修改为 200

  1. 记录Undo Log: 又在undo log段中记录一条新日志,内容是:“我要改id=1这行,它之前的数据是{balance: 150, DB_TRX_ID: 20, DB_ROLL_PTR: Undo_Addr_A}”。
  2. 获取Undo Log地址: 假设这条新日志的地址是 Undo_Addr_B
  3. 修改数据页:
    • balance变为200
    • 事务ID更新为 30
    • 回滚指针 DB_ROLL_PTR 更新为新日志的地址 Undo_Addr_B

最终状态:

1
2
3
4
5
6
Data Page (表里):
id: 1, balance: 200, DB_TRX_ID: 30, DB_ROLL_PTR: Undo_Addr_B

Undo Log Segment:
Undo_Addr_A: { original_data: {balance: 100, DB_TRX_ID: 10, DB_ROLL_PTR: null} }
Undo_Addr_B: { original_data: {balance: 150, DB_TRX_ID: 20, DB_ROLL_PTR: Undo_Addr_A} }

3. “版本链”的形成与查询

现在,一个版本链就形成了:

当前数据页 (版本30) -> DB_ROLL_PTR 指向 Undo_Addr_B -> Undo_Addr_B 里的 DB_ROLL_PTR 指向 Undo_Addr_A -> Undo_Addr_A 里的 DB_ROLL_PTRnull (链表结束)。

查询过程(一个需要旧版本的事务来了):

  1. 这个事务创建了自己的 Read View
  2. 它首先读取数据页上的最新版本(Trx_ID=30balance=200)。
  3. Read View 的算法一判断,发现 Trx_ID=30 对它不可见
  4. 于是,它顺着 DB_ROLL_PTR (Undo_Addr_B) 去 undo log 里找到了上一个版本的数据(Trx_ID=20balance=150)。
  5. 再用 Read View 判断,发现 Trx_ID=20 对它也不可见
  6. 于是,它再顺着这个版本里的回滚指针 (Undo_Addr_A),又找到了更上一个版本(Trx_ID=10balance=100)。
  7. Read View 判断,发现 Trx_ID=10 对它可见
  8. 好了,查询结束,向用户返回 balance=100

4. 存储开销问题

你问的非常对,这个机制确实需要花费大量的存储空间

  • 每次 UPDATE 操作(DELETE类似),都会在 undo log 中创建一条记录,这个记录的大小取决于你修改的列。如果是一个大字段,undo log会增长得很快。
  • 如果有一个长时间运行的事务(比如一个跑了几个小时的报表查询),它会持有一个非常旧的 Read View
  • 为了满足这个旧 Read View 的查询需求,系统不能删除在这期间产生的所有历史版本数据。
  • 这会导致 undo log 文件持续膨胀,无法被清理回收,严重时可能占满整个磁盘空间。这就是为什么“长事务”是DBA非常关注和需要避免的问题。

清理过程 (Purge):
当一个历史版本确定再也不会被任何活跃的 Read View 访问到时,后台的purge线程会异步地、安全地清理这些undo log记录,回收空间。

总结

  • 历史数据存储在**undo log**中,不是在数据表里。
  • 存储方式是版本链,通过每行数据上的回滚指针 (DB_ROLL_PTR) 将当前版本和undo log中的旧版本串联起来。
  • 这种方式确实会带来显著的存储开销,特别是存在长事务时。
  • 不是类似map的随机访问结构,而是一个需要顺序回溯的链式结构,但由于undo log通常也能被缓存到内存中,这个回溯过程在大多数情况下是很快的。

问题2:为什么存储是“行”而不是“单元格”?


精准的总结

我们把你修改后的总结再梳理一遍,就完全正确了:

是的,总结下来就是:

  1. 不是每个单元格,而是每一行数据 (Row) 作为一个整体,在物理存储上额外包含了几个隐藏字段(最重要的就是事务ID DB_TRX_ID 和指向旧版本的回滚指针 DB_ROLL_PTR)。

  2. 这些隐藏字段负责记录整行数据的版本信息,而不是单个单元格。

  3. 当一行数据被修改时,它的旧版本(包含所有列的旧值)会被完整地记录到 undo log 中。

  4. 通过行记录中的回滚指针,将数据页上的最新版本undo log 中的一系列旧版本串联起来,形成一个版本链

  5. 当一个查询需要历史数据时,它就从最新版本开始,顺着这个版本链逐个版本地回溯,直到根据自身的 Read View 找到一个可见的版本为止。这个可见版本的数据(整行数据)就是查询最终看到的结果。


这个区别非常重要,原因如下:

  • 事务的原子性: 数据库的更新操作通常是针对的。一个 UPDATE 语句可能同时修改一行的多个列(多个单元格)。UPDATE users SET name='Tom', age=30 WHERE id=1;。为了保证事务回滚的原子性,必须把整行的旧状态一次性记录下来,这样才能一步恢复。如果按单元格记录,回滚会变得极其复杂和低效。

  • 存储效率: 为每一行增加几个隐藏字段的开销是固定的。如果为每个单元格都增加这些隐藏字段,那么存储开销将会成倍增长,变得无法接受。

  • 实现简洁性: 以行为单位进行版本控制,整个MVCC和事务回滚的逻辑会清晰和简单得多。

举个例子,强调一下这个区别:

假设Trx_50执行了 UPDATE users SET age=31 WHERE id=1; (之前 name=’Tom’, age=30)。

  • undo log 中记录的是
    { id:1, name:'Tom', age:30, DB_TRX_ID: 40, DB_ROLL_PTR: ... }
    看到了吗?即使只修改了 ageundo log 也记录了未被修改的 name 列的旧值,它保存的是整行的快照

  • 数据页上更新为
    { id:1, name:'Tom', age:31, DB_TRX_ID: 50, DB_ROLL_PTR: (指向上面那条undo log) }

所以,你的总结在核心思想(隐藏字段、版本链、历史数据)上是完全正确的,只需要把作用的粒度从“单元格”修正为“行”,就完美了!这是一个非常细致但关键的技术点。

Author

Cofeesy

Posted on

2025-09-10

Updated on

2025-09-10

Licensed under

Comments