关于回朔数据的存储
问题:历史信息是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的基石。虽然它的名字叫“回滚日志”,但它有两大作用:
- 事务回滚:当事务需要
ROLLBACK时,根据undo log中的记录恢复到修改前的状态。 - 构建版本链:为MVCC提供历史数据版本。
undo log 是如何存储历史数据的?
它不是一个简单的map,而更像一个基于链表思想的**“反向操作日志”**。
我们用一个例子来模拟这个过程:
初始状态:id=1的行,balance为100。这是由事务ID 10 创建的。
1 | Data Page (表里): |
Step 1: 事务 Trx_20 把 balance 从 100 修改为 150
- 记录Undo Log: InnoDB会在
undo log段中记录一条日志,内容大致是:“我要把id=1这行改了,它之前的数据是{balance: 100, DB_TRX_ID: 10, DB_ROLL_PTR: null}”。 - 获取Undo Log地址: 假设这条
undo log记录的地址是Undo_Addr_A。 - 修改数据页:
- InnoDB直接在数据页上修改数据,
balance变为150。 - 更新事务ID为当前事务
20。 - 关键一步:将回滚指针
DB_ROLL_PTR设置为刚刚那条undo log的地址Undo_Addr_A。
- InnoDB直接在数据页上修改数据,
修改后状态:
1 | Data Page (表里): |
Step 2: 事务 Trx_30 再次把 balance 从 150 修改为 200
- 记录Undo Log: 又在
undo log段中记录一条新日志,内容是:“我要改id=1这行,它之前的数据是{balance: 150, DB_TRX_ID: 20, DB_ROLL_PTR: Undo_Addr_A}”。 - 获取Undo Log地址: 假设这条新日志的地址是
Undo_Addr_B。 - 修改数据页:
balance变为200。- 事务ID更新为
30。 - 回滚指针
DB_ROLL_PTR更新为新日志的地址Undo_Addr_B。
最终状态:
1 | Data Page (表里): |
3. “版本链”的形成与查询
现在,一个版本链就形成了:
当前数据页 (版本30) -> DB_ROLL_PTR 指向 Undo_Addr_B -> Undo_Addr_B 里的 DB_ROLL_PTR 指向 Undo_Addr_A -> Undo_Addr_A 里的 DB_ROLL_PTR 是 null (链表结束)。
查询过程(一个需要旧版本的事务来了):
- 这个事务创建了自己的
Read View。 - 它首先读取数据页上的最新版本(
Trx_ID=30,balance=200)。 - 用
Read View的算法一判断,发现Trx_ID=30对它不可见。 - 于是,它顺着
DB_ROLL_PTR(Undo_Addr_B) 去undo log里找到了上一个版本的数据(Trx_ID=20,balance=150)。 - 再用
Read View判断,发现Trx_ID=20对它也不可见。 - 于是,它再顺着这个版本里的回滚指针 (
Undo_Addr_A),又找到了更上一个版本(Trx_ID=10,balance=100)。 - 用
Read View判断,发现Trx_ID=10对它可见! - 好了,查询结束,向用户返回
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:为什么存储是“行”而不是“单元格”?
精准的总结
我们把你修改后的总结再梳理一遍,就完全正确了:
是的,总结下来就是:
不是每个单元格,而是每一行数据 (Row) 作为一个整体,在物理存储上额外包含了几个隐藏字段(最重要的就是事务ID
DB_TRX_ID和指向旧版本的回滚指针DB_ROLL_PTR)。这些隐藏字段负责记录整行数据的版本信息,而不是单个单元格。
当一行数据被修改时,它的旧版本(包含所有列的旧值)会被完整地记录到
undo log中。通过行记录中的回滚指针,将数据页上的最新版本与
undo log中的一系列旧版本串联起来,形成一个版本链。当一个查询需要历史数据时,它就从最新版本开始,顺着这个版本链逐个版本地回溯,直到根据自身的
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: ... }
看到了吗?即使只修改了age,undo log也记录了未被修改的name列的旧值,它保存的是整行的快照。数据页上更新为:
{ id:1, name:'Tom', age:31, DB_TRX_ID: 50, DB_ROLL_PTR: (指向上面那条undo log) }
所以,你的总结在核心思想(隐藏字段、版本链、历史数据)上是完全正确的,只需要把作用的粒度从“单元格”修正为“行”,就完美了!这是一个非常细致但关键的技术点。

