数据的多版本管理
理由或者出发点很多。
为了追溯数据的变动历史,防止被最新的数据覆盖,以及提升系统并发能力,以及拥有业务系统的矛盾处理依据,我们需要对数据进行多版本管理。
最简单的是,每次变动一版,都增加一条记录,主键赋值按时间戳的方法,或者自增编号的方法排序。
mysql中的多版本管理MVCC
我来举个例子吧,一个复杂的例子,如果两个事务TA,TB,都修改了同一行记录,此时的隔离级别是可重复度,这行为 k1,v1, v1的值为100,TA先修改成了200,TB又修改成了120, TA读了一次,TB又修改值为300,TA撤销,也就是原子性,那么请问undo log如何运作的?
首先把问题拆开来看。 我们假设TA的版本号为v10,TB的版本号为v11。
假设记录k1,v1的隐藏列,我们记录其版本号为v9,也就是上一次修改是v9,TA想要修改v1的值,那么我们发现v9小于v10,我们可以直接修改这个记录。
我们记录一个undo log为 版本v10,k1, v1=100,并且指向上一次的版本号,也就是版本号为v9的那个值。
然后TB 同样修改了,然后它就分叉了,如果事务TA回退了,那么这条分支就自动一路删掉了,如果它先提交了,那么TB再提交的时候,发现当前最新值已经不是v9版本了,而是v10,则提交失败。
更加通用的解释
MVCC(多版本并发控制)可以想象成数据库处理多人同时操作的一种"聪明"办法。我用一个生活中的例子帮你理解:
假设你和同事同时编辑同一份在线文档:
-
传统锁机制:
- 就像一个人编辑时把文档锁住,其他人只能排队等着。你改错别字时,同事连查看内容都不行,效率很低。
-
MVCC的魔法:
- 当你要修改文档时,系统不是直接改原文件,而是自动生成一个新版本(比如V2)。
- 同事查看时,看到的还是修改前的V1版本,就像给文档拍了快照。
- 你继续修改到V3版本时,另一个同事刚打开的页面仍然显示V1,直到他刷新才会看到最新版。
数据库里的具体表现:
- 每条数据都有隐藏的时间标签(如创建时间和删除时间)
- 你查询时,数据库只给你看在你开始操作时已经存在的版本
- 修改数据就像往本子上写新的一行,旧内容仍然保留(直到不再被需要时自动清理)
好处:
- 读操作不用等写操作(查余额不用等转账完成)
- 写操作不用等读操作(转账不用等所有人查完余额)
- 避免了很多锁冲突,就像高速公路同时允许多辆车通行
代价:
- 数据库需要额外空间存储旧版本
- 需要定期清理过期数据(类似手机清理缓存)
这就像时光机------每个操作都定格在某个时间点的数据状态,大家各看各的历史快照,互不干扰。实际在MySQL、PostgreSQL等数据库中正是用这种方式支撑高并发访问的。
ReadView
ReadView(读视图)是MVCC实现中最关键的数据结构,它定义了事务在特定时刻能看到哪些数据版本。以下是其专业解析:
ReadView核心组成
每个ReadView包含4个关键属性:
- creator_trx_id
当前事务自身的ID(只有写操作的事务才有trx_id,只读事务为0) - m_ids
生成ReadView时,系统中所有活跃(未提交)事务ID的集合 - min_trx_id
m_ids
中的最小值(即最老的活跃事务ID) - max_trx_id
系统预分配给下一个事务的ID值(非当前最大事务ID)
可见性判断规则
当访问某行数据时,按以下逻辑判断版本可见性(假设当前行的事务ID为trx_id
):
python
if trx_id < min_trx_id:
# 该版本在ReadView创建前已提交 → 可见
return VISIBLE
elif trx_id >= max_trx_id:
# 该版本由未来事务生成 → 不可见
return NOT_VISIBLE
elif trx_id in m_ids:
# 该版本所属事务仍活跃 → 不可见
return NOT_VISIBLE
else:
# 该版本已提交且不在活跃列表中 → 可见
return VISIBLE
例外处理 :若当前事务自身修改了该行(即trx_id == creator_trx_id
),则直接可见最新修改
不同隔离级别的ReadView生成策略
隔离级别 | ReadView生成时机 | 可见性效果 |
---|---|---|
Read Committed (RC) | 每次执行SELECT语句时新建ReadView | 总是看到最新已提交数据 |
Repeatable Read (RR) | 事务的第一个SELECT时创建ReadView | 整个事务看到相同数据快照 |
示例场景(RR级别):
sql
-- 事务A(trx_id=100)启动
START TRANSACTION;
-- 第一次查询,生成ReadView:
-- m_ids=[90,95], min_trx_id=90, max_trx_id=101
SELECT * FROM users; -- 看到版本V1
-- 事务B(trx_id=95)提交修改
COMMIT;
-- 事务A再次查询,复用原有ReadView:
SELECT * FROM users; -- 仍然看到V1(避免不可重复读)
ReadView与Undo Log的协作
-
当需要访问旧版本时:
- 通过
DB_ROLL_PTR
找到Undo Log中的历史版本 - 递归判断每个历史版本是否满足当前ReadView的可见性规则
- 通过
-
版本链遍历示例:
text当前行版本(trx_id=95)→ 不符合ReadView规则 ↓ 通过DB_ROLL_PTR回溯 版本V2(trx_id=90)→ 符合可见性 → 返回此版本
实现细节深度解析
-
快照时效性
PostgreSQL使用
xmin/xmax
系统列维护版本可见性,而MySQL通过ReadView+Undo Log实现 -
内存优化
InnoDB将ReadView缓存到内存,多个事务可能共享同一个ReadView(当无写事务时)
-
可见性判断加速
通过
min_trx_id
快速过滤旧版本,避免遍历整个活跃事务列表 -
长事务影响
若存在长时间未提交的事务(trx_id < min_trx_id),会导致大量旧版本无法被Purge
性能影响分析
-
最佳实践
- 在RC级别,频繁更新的热点数据查询可能更高效(每次看到最新提交)
- 在RR级别,复杂事务中的多次读取更安全(但可能读到旧数据)
-
监控指标
sqlSHOW ENGINE INNODB STATUS\G -- 观察TRX_IDS_IN_VIEW字段反映活跃事务数量
-
常见问题
- ReadView过大(活跃事务过多)→ 可见性判断延迟增加
- Undo Log链过长→ 查询性能下降(需控制事务时长)
通过ReadView机制,数据库实现了动态版本过滤:每个事务如同戴上了特制的"时间眼镜",只能看到符合其可见性规则的数据版本。这种设计在保证隔离性的同时,避免了传统锁机制的资源争用问题。
例子测试
同时修改同一条数据
MySQL Shell中,开启事务进行测试。
先看基础表
sql
select * from sys_user; -- 测试表
部分字段结果如下:
id | username | real_name
2 | 010222 | 张三2
查看事务等级,确认一下事务等级为 可重复读。
sql
mysql> SELECT @@transaction_isolation;
+-------------------------+
| @@transaction_isolation |
+-------------------------+
| REPEATABLE-READ |
+-------------------------+
1 row in set (0.01 sec)
同时开启两个会话窗口(测试同时修改同一条数据),显式声明事务开启
arduino
mysql> begin transaction;
在第一个会话里
sql
mysql> update sys_user set real_name = '张三3' where id = 2;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0
mysql> select * from sys_user;
部分字段结果如下:
id | username | real_name
2 | 010222 | 张三3
在第二个会话里,执行类似的修改
bash
mysql> update sys_user set real_name = '张三4' where id = 2;
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
可见,这是不行的。在可重复读级别,没法同时修改同一条数据。
条数统计
在上面的环境中,尝试 repeatable-read能否实现准确的行数统计呢?
在已经开启事务的情况下,在一个事务窗口中执行:
sql
mysql> insert into sys_user(username, password, real_name) values(010223,'123', '王五');
Query OK, 1 row affected (0.01 sec)
mysql> select count(*) from sys_user;
+----------+
| count(*) |
+----------+
| 3 |
+----------+
1 row in set (0.00 sec)
在另外一个窗口中执行
sql
mysql> select count(*) from sys_user;
+----------+
| count(*) |
+----------+
| 2 |
+----------+
1 row in set (0.00 sec)
可见,简单情况下,可重复读级别还是可以消除幻读的。
解释
在 MySQL 的 可重复读(REPEATABLE READ) 隔离级别下,同一事务中的多个会话是否可以同时修改同一条数据 取决于具体的操作和事务的并发控制机制。以下是详细的解释:
1. 同一事务内部
在 同一个事务 内部,可以 对同一条数据进行多次修改。每次修改都会覆盖前一次的值,最终事务提交时,数据库会保存最后一次修改的结果。
示例:
sql
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- 第一次修改
UPDATE accounts SET balance = balance + 50 WHERE id = 1; -- 第二次修改
COMMIT;
在上述示例中,id = 1
的账户余额先减少 100,再增加 50,最终 balance
会比原始值少 50。
2. 不同事务之间
在 不同的事务 之间,是否可以同时修改同一条数据 取决于事务的隔离级别和具体的操作:
a. 并发修改(写-写冲突)
在 REPEATABLE READ 隔离级别下,MySQL 使用 行级锁(Row-Level Locking) 来防止 写-写冲突。这意味着:
- 当一个事务正在修改某一行数据时,其他事务如果尝试 修改同一行 ,将会被 阻塞,直到当前事务提交或回滚。
- 这样可以确保 数据的一致性 ,避免出现 脏写(Dirty Write) 的情况。
示例场景:
假设有两个事务 T1 和 T2 同时尝试修改 id = 1
的账户余额:
-
T1 开始事务:
iniSTART TRANSACTION; UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-
T2 尝试同时开始事务并修改同一行:
sqlSTART TRANSACTION; UPDATE accounts SET balance = balance + 50 WHERE id = 1; -- 这里会被阻塞
-
T1 提交事务后,T2 的
UPDATE
操作才会执行,并基于 T1 提交后的最新值进行修改。
b. 并发读取(读-写场景)
在 REPEATABLE READ 隔离级别下:
- 当前事务 可以多次读取同一行数据,并且每次读取的结果都是一致的(即事务开始时的快照)。
- 其他事务 对同一行数据的 修改 在当前事务提交之前是不可见的,确保了 可重复读 的特性。
示例场景:
-
T1 开始事务并读取
id = 1
的余额:sqlSTART TRANSACTION; SELECT balance FROM accounts WHERE id = 1; -- 假设返回 1000
-
T2 修改
id = 1
的余额:iniSTART TRANSACTION; UPDATE accounts SET balance = balance - 100 WHERE id = 1; COMMIT;
-
T1 再次读取
id = 1
的余额:sqlSELECT balance FROM accounts WHERE id = 1; -- 仍然返回 1000,直到 T1 提交或回滚
-
T1 提交事务后(T1事务关闭后!!),再次读取才会反映最新的值:
sqlCOMMIT; SELECT balance FROM accounts WHERE id = 1; -- 返回 900
3. 防止幻读
虽然 REPEATABLE READ 可以防止 不可重复读 ,但在某些情况下,仍然可能出现 幻读(Phantom Read)。幻读指的是在一个事务中多次执行相同的查询,但返回的结果集在行数上发生了变化(即新增或删除的行)。
MySQL 如何处理:
-
InnoDB 存储引擎 通过 Next-Key Locking 机制,在 REPEATABLE READ 隔离级别下 部分防止幻读 。但对于某些复杂的查询,仍可能出现幻读 。如果需要完全防止幻读,可以使用 SERIALIZABLE 隔离级别,但这会显著降低并发性能。
这种可能还是没整明白。什么情况下可能出现?
总结
-
同一事务内部:可以多次修改同一条数据,后一次修改会覆盖前一次。
-
不同事务之间
:
- 写-写操作:会被阻塞,直到前一个事务完成,确保数据一致性。
- 读-写操作:读取操作基于事务开始时的快照,不受其他事务修改的影响,直到当前事务结束。
这种机制确保了在 REPEATABLE READ 隔离级别下,数据的一致性和可靠性,同时提供了合理的并发控制。
后续
下一篇,我们聊聊传统数据库以外的多版本管理。