本文试图解释undo log,redo log,bin log的作用,以及Innodb的MVCC机制
undo log
undo log 记录的是sql语句执行更新前的数据,这里的更新是泛指,除了select其它都算更新。在读已提交和可重复读的隔离级别下,会记录事务中某条数据的修改版本链,用来支持MVCC,详细参考MVCC章节。
redo log
redo log的设计是为了提高性能,如果没有redo log,数据库更新一条数据的过程大致如下:
- 先根据where条件查询数据是否在内存中,不在的话去磁盘中查询,然后load到内存中
- 更新内存中的数据并同时更新磁盘中的数据或者更新内存中的数据异步更新磁盘中的数据 上述第二步中,如果更新内存中的数据并同时更新磁盘的数据做法中,磁盘的IO非常的慢,如果再加上需要寻找到这一条数据的寻道和定位时间,对性能影响极大,如果采用先更新内存,异步更新磁盘的方式可能在还未进行异步更新磁盘的时候发生宕机,此时内存中数据丢失导致整条数据丢失,最后磁盘中也没有更新后的数据,导致数据不一致,这个是无法接受的。 因此redo log设计为了解决第2步的两个问题,首先redo log是顺序写入的,就是不用去磁盘中寻找到条件符合的语句,我先将语句中变化写入到redo log中,就认为更新成功了,因为redo log是持久化在磁盘上的,不用担心宕机丢失问题(硬盘损坏就另说了),使用顺序写入替代寻找数据写入,对于性能而言是一种提升,但是引发了两个问题就是这个文件会不会很大,过期的redo log还会保存吗。第一个问题的答案是文件大小固定,并且循环使用,就像一个数组,当写完最后一个元素的时候如果再有元素加入,就去覆盖第一个,redo log中是有两个指针的,一个指示文件的当前位置,也就是说下一个日志写入是追加在这个指针后面的,追加完指针后移,另一个指针是指示当前哪些日志已经不再使用了,可以覆盖的日志,这就解释了第二个问题:过期的日志不会保存,会被覆盖掉。极端情况下整个日志写满了,没有空间可以用了,系统会先将一部分日志持久化到数据文件,就是吧redo log中记录的更改真正在数据表中更改掉,以次来释放足够的空间。redo log可覆盖判定条件:redo log中记录的东西已经在表中修改。另外,redo log是Innodb引擎提供的,其它引擎没有。
bin log
bin log主要用来记录sql操作,简单形象的理解就是记录了每条sql语句,是追加写,性能较高。如果你想把你的数据库恢复到某一秒,可以使用bin log来恢复。bin log是Mysql提供的,并不是某个特定的数据库引擎提供,因此具有通用性,不管选用哪个数据库引擎,只要打开了bin log开关,就会记录bin log。
Innodb中一个事务的过程
一个事务大致过程如下:
- 从第一条sql开始执行,系统为当前事务分配一个全局事务唯一的事务id,并且此id遵循自增id特性,后面生成的一定比前面的大
- 查询需要更新的数据,若此数据就在内存中,将其拷贝到undo log,若次数据不在内存中,从磁盘中查询这条数据然后load进内存,并将数据记录进undo log
将新数据写入内存(更新内存中对应的数据),此时事务处于prepare阶段,每一句sql的执行都会生成undo log,最开始放在undo log buffer中,在合适的时机(看如何设置,有只写buffer,有每秒写入操作系统page cache,还有调用fsync写磁盘)写入磁盘,执行过的sql语句写入bin log cache在此感谢 来自秘鲁的帕丁顿大佬的指正补充,第3步应该是: 将新数据写入内存(更新内存中对应的数据),此时事务处于prepare阶段,每一句sql的执行都会生成redo log,最开始放在redo log buffer中,在合适的时机(看如何设置,有只写buffer,有每秒写入操作系统page cache,还有调用fsync写磁盘)写入磁盘,执行过的sql语句写入bin log cache- 提交事务,将bin log cache一次性写入bin log文件(要么都成功,要么都失败),将undo log buffer中未写入磁盘的undo log写入磁盘
- 将redo log中的事务状态从prepare修改为commit。
事务执行中宕机如何保证一致性
上面步骤中,3,4,5,6步被称为两阶段提交,目的就是使redo log和bin log数据一致。现在来讨论下在上述5个步骤中,其中一个步骤在执行时宕机,undo log,redo log,bin log是怎么协作来保证数据的一致性的。
- 在第1步就失败了,sql的事务id分配失败,直接返回失败,业务可以直接接到这个失败并发起重试或者补偿
- 在第2步失败了,undo log写入失败,直接返回给业务
- 在第3步失败了,redo log写入失败,直接返回给业务,此时数据表没有任何更新,undo log记录了更新前的数据,不过并不影响数据一致性,因为并没有更改数据
- 在第4步失败了,bin log写失败事务回滚,bin log由于宕机失败,重启Mysql后检查事务状态,如果是prepare,就对比bin log和redo log中的日志是否一致,如果不一致则回滚此事务。此时bin log写入失败,显然和redo log不一致,事务回滚。如果bin log写入成功,redo log写入失败,此时bin log和redo log日志不一致,宕机恢复后此事务回滚。
- 在第5步失败了,bin log,redo log都写入成功,但是redo log prepare转台修改成commit的时候失败了或者修改成功了,但是还没返回给上层就宕机了,重启后,先检查commit标志,如果标志表示commit成功了只是提交过程失败了则直接提交,否则就去判断redo log和bin log中的数据是一致,一致的话就将当前事务进行提交,不一致直接回滚
undo log,redo log,bin log都是写磁盘,我们不能不能关掉其中一个?
- 关掉undo log,事务中间出现问题,回滚的时候发现找不到历史记录了,导致无法回滚,所以不能关闭
- 关掉redo log,关掉后undo log记录历史记录,然后直接写bin log,如果写bin log的时候宕机,重启后Mysql不知道当前的事务到底成功了还是失败了,也不知道该回滚还是该提交,完全没有记录,甚至都不知道事务的任何信息,因为bin log只记录执行的sql
- 关掉bin log,在commit的时候redo log有prepare改为commit失败,重启后Mysql无法知道redo log到底是不是真的该commit了,因为没有任何东西可以为他“作证”:事务中sql执行完了,但是没提交。另外一个理由就是bin log关闭了,你再也不能恢复到某一秒的数据了,也会影响主从复制(如果有的话),如果没有bin log,redo log只要没有从prepare 改为commit,一律进行回滚,这种操作是可以的,但是会“误杀”一些应该提交的事务,并且无法将数据表恢复到某一秒,所以bin log可以关闭,但是不建议关闭。
MVCC机制
Mysql行隐藏字段
Mysql的每一行最后是有个3个或者4个隐藏字段的
- 最近一次修改过当前行的事务id,我们记做tx_id(名字是我杜撰的)
- 指向上一个版本的指针,我们记做lst_ponit(名字依旧是杜撰的)
- 删除标记,delete后这个标记会记为true,我们记做del_flag(名字还是杜撰的)
- 如果存在主键(明确指出了主键会使用明确指出的,未明确指出的找第一个具备唯一索引的字段作为主键),则不会有此字段,若是没有主键,此字段会是一个数字型的自增主键,我们记做row_id(这个名字好像是对的)
本文中用到的表机构和初始化数据
CREATE TABLE `testtable` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`bus1` int(11) DEFAULT NULL COMMENT '业务字段1',
`bus2` varchar(20) DEFAULT NULL COMMENT '业务字段2',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8
insert into testtable values(1,11,'第一条数据');
insert into testtable values (3,22,'第二条数据');
insert into testtable values (4,33,'第三条数据');
insert into testtable values (10,44,'第四条数据');
版本链
假设表名为testtable有以下数据(假设插入两条数据的事务tx_id分别是10和20) |id|bus1|bus2|tx_id|lst_point|del_flag| |--|--|--|--|--|--| |1|11|我是第一条数据|10|null|false| |2|22|我是第二条数据|20|null|false| 注意:insert操作时undo log不会生成任何数据 然后对这两条数据进行一些操作
-- 事务A 分配的事务id为100
start transaction;
update testtable set bus2='我是更新后的第一条数据' where id = 1;
update testtable set bus2='我是第二次更新后的第一条数据' where id = 1;
commit
-- 事务A 分配的事务id为101
start transaction;
update testtable set bus2='我是第三次更新后的第一条数据' where id = 1;
commit
那么id=1对应的数据的版本链大概是这样的(序号这一列是我加上去的,方便一些解释) |序号|id|bus1|bus2|tx_id|lst_point|del_flag|所在地| |--|--|--|--|--|--|--|--| |1|1|11|我是第三次更新后的第一条数据|101|指向序号2|false|数据表| |2|1|11|我是第二次更新后的第一条数据|100|指向序号3|false|undo log| |3|1|11|我是第二次更新后的第一条数据|100|null|false|undo log| 可以看到3次对id=1的数据进行update操作,形成了一个版本链
MVCC机制
MVCC全称是多版本并发控制,通过对某一条记录的多个版本共存达到不加锁的目的。由于读已提交隔离级别是直接读取当前行的最新数据,串行化是事务依次进行,这两个隔离级别不使用MVCC,主要是不存在竞争。当事务遇到第一个select时会生成当前数据库的快照(read view),生成方式不是拷贝一份数据库数据出来而是将当前活跃的id放入一个数组中,只要分析好活跃事务的操作,其它的可以不考虑,因为只有活跃的事务会更改数据库。在可重复读隔离级别下,第一个select生成快照后,后面所有的select都使用这个快照,在读已提交隔离级别中,每次遇到select都会生成一次新的快照,目的就是已经提交的数据也能被查看到。 先给出可见性判定逻辑,当select发生生成快照时,自己的事务id记为self_tx_id(非官方名称),正在活跃事务的id最小值记为min_tx_id(非官方名称),此时事务最大的一个id记为max_tx_id(看清楚,是事务最大的id,不是活跃事务的最大id,事务的最大id是活跃事务和已提交事务中最大的id),活跃事务的数组记为activie_tx_ids(非官方名称);那么:
- 从当前记录中拿到最后更新这个记录的事务id(字段记为current_tx_id,就是上面提到的隐藏字段tx_id)
- 判断current_tx_id是否小于活跃事务的最小id——min_tx_id或等于自己的事务id,如果是走第3步,如果不是走第4步
- current_tx_id < min_tx_id 或current_tx_id == self_tx_id说明当前这条记录在本次快照前就已经提交,对当前的select可见
- 判断current_tx_id是否比最大事务id——max_tx_id大,如果是走第5步,如果不是走第6步
- 当前事务id——current_tx_id比快照时最大的事务id还大,说明是在快照完成后新启动的事务(快照前新启动的事务都会在快照时放入活跃事务数组,事务最大值也会是这个最新的事务id),这种情况我想到的场景是在可重复读的隔离级别下,在非首次select快照时发生,场景大概是事务A第一次select时做了一次快照,然后新启动了一个事务并对事务A中第一次select的数据做了更新,当事务A中第二次select时(where条件一致),可重复读隔离级别下会使用第一次select做的快照,这种情况下,事务B的id就比当时做快照时事务id最大值还大,这种情况下对于select是不可见的
- 当前事务id——current_tx_id在min_tx_id(含)和max_tx_id(含)之间,判断current_tx_id是否在活跃事务列表中,是则认为当前事务还未提交,不可见,不是则认为当前事务已经提交,可见。 理论较为拗口,那就通过一个示例来理解一下,以下操作隔离级别为可重复读。
序号 | 事务100 | 事务101 | 事务102 | 事务103 |
---|---|---|---|---|
1 | start transaction; | |||
2 | update testtable set bus2 = '我是事务100' where id =3; | |||
3 | start transaction; | |||
4 | update testtable set bus2 = '我是事务101' where id = 1; | |||
5 | commit; | |||
6 | start transaction; | |||
7 | update testtable set bus2 = '我是事务102' where id = 1; | |||
8 | select * from testtable where id = 1; | |||
9 | start transaction; | |||
10 | update testtable set bus2='我是事务103' where id = 1; | |||
11 | commit; | |||
12 | commit; | |||
13 | select * from testtable where id = 1; | |||
14 | select * from testtable where id = 3; | |||
15 | commit; | |||
分析事务100 第一个select版本链大致如下: |
序号 | id | bus1 | bus2 | tx_id | lst_point | del_flag |
---|---|---|---|---|---|---|
1 | 1 | 11 | 我是事务101 | 101 | null | false |
2 | 1 | 22 | 我是事务102 | 102 | 指向序号1 | false |
可以知道: | ||||||
当前事务id——self_tx_id是100 | ||||||
活跃事务的最小id——min_tx_id是100 | ||||||
事务的最大id——max_tx_id是102 | ||||||
活跃事务id数组——active_tx_ids是100,102 | ||||||
首先id=1这条记录最近一次更新是事务102更新的,因此current_tx_id是102,然后判断102在min_tx_id和max_tx_id之间,又在活跃事务id数组中,因此对于事务100来说,不可见,然后回溯到上一版本可以得知current_tx_id是101,101在min_tx_id和max_tx_id之间但不在活跃事务id数组中,所以可见。 |
事务100执行select之前的语句: 事务101执行所有语句: 事务102执行update: 此时事务100执行第一个select: 可以看到读取到的是事务101更新后的数据,没有读取到事务102更新的数据。 再分析事务100的第二个select,此时事务102进行了commit,事务103也执行完成。此时事务100使用的快照还是第一次select产生的快照,但是id=1这条记录的最后更新事务id是103,此时103比做快照时最大事务id还要大,事务100是无法看到这条记录的,进行版本回溯,回溯到最后修改事务id为102的版本中,发现102在活跃事务列表中,依旧是无法看到,那么继续回溯就到了事务id是100的版本中,此时可见,所以本次select读到的和上面的数据是一致的,我们通过数据库验证下: 事务103执行结果(注意这个结果是事务102 commit后才提交成功的,因为事务102也更新了id=1的数据,会把这条数据锁住,commit后释放锁,事务103才能提交成功,但不妨碍undo log版本链生成) 事务102执行结果: 此时执行第二次select的结果: 看到的依旧是事务101更新后的结果。 然后事务100执行第三次select: 这个这里就不详细分析了,就是当前事务中的select读取了当前事务已经修改的数据,会读取到当前事务更新后的数据。 至此MVCC分析完毕。上面的分析 都是建立在可重复读隔离级别下的,读已提交隔离级别中,每次遇到select都重新生成快照,分析方式与上面一致,这里就不再赘述了。