作者:京东云开发者-京东物流 张士欣
链接:https://my.oschina.net/u/4090830/blog/10142911
在事务的实现机制上,MySQL 采用的是 WAL:Write-ahead logging,预写式日志,机制来实现的。
在使用 WAL 的系统中,所有的修改都先被写入到日志中,然后再被应用到系统中。通常包含 redo 和 undo 两部分信息。
为什么需要使用 WAL,然后包含 redo 和 undo 信息呢?举个例子,如果一个系统直接将变更应用到系统状态中,那么在机器掉电重启之后系统需要知道操作是成功了,还是只有部分成功或者是失败了。如果使用了 WAL,那么在重启之后系统可以通过比较日志和系统状态来决定是继续完成操作还是撤销操作。
redo log 称为重做日志,每当有操作时,在数据变更之前将操作写入 redo log,这样当发生掉电之类的情况时系统可以在重启后继续操作。
undo log 称为撤销日志,当一些变更执行到一半无法完成时,可以根据撤销日志恢复到变更之间的状态。
MySQL 中用 redo log 来在系统 Crash 重启之类的情况时修复数据,而 undo log 来保证事务的原子性。
一个事务可以是一个只读事务,或者是一个读写事务:可以通过 START TRANSACTION READ ONLY 语句开启一个只读事务。
在只读事务中不可以对普通的表进行增、删、改操作,但可以对用户临时表做增、删、改操作。
可以通过 START TRANSACTION READ WRITE 语句开启一个读写事务,或者使用 BEGIN、START TRANSACTION 语句开启的事务默认也算是读写事务。
在读写事务中可以对表执行增删改查操作。
如果某个事务执行过程中对某个表执行了增、删、改操作,那么 InnoDB 存储引擎就会给它分配一个独一无二的事务 id,针对 MySQL 5.7 分配方式如下:
对于只读事务来说,只有在它第一次对某个用户创建的临时表执行增、删、改操作时才会为这个事务分配一个事务 id,否则的话是不分配事务 id 的。
对于读写事务来说,只有在它第一次对某个表执行增、删、改操作时才会为这个事务分配一个事务 id,否则的话也是不分配事务 id 的。
有的时候虽然开启了一个读写事务,但是在这个事务中全是查询语句,并没有执行增、删、改的语句,那也就意味着这个事务并不会被分配一个事务 id。
这个事务 id 本质上就是一个数字,它的分配策略和隐藏列 row_id 的分配策略大抵相同,具体策略如下:
服务器会在内存中维护一个全局变量,每当需要为某个事务分配一个事务 id 时,就会把该变量的值当作事务 id 分配给该事务,并且把该变量自增 1。
每当这个变量的值为 256 的倍数时,就会将该变量的值刷新到系统表空间的页号为 5 的页面中一个称之为 Max Trx ID 的属性处,这个属性占用 8 个字节的存 储空间。
当系统下一次重新启动时,会将上边提到的 Max Trx ID 属性加载到内存中,将该值加上 256 之后赋值给全局变量,因为在上次关机时该全局变量的值可能大于 Max Trx ID 属性值。
这样就可以保证整个系统中分配的事务 id 值是一个递增的数字。先被分配 id 的事务得到的是较小的事务 id,后被分配 id 的事务得到的是较大的事务 id。
全称 Multi-Version Concurrency Control,即多版本并发控制,主要是为了提高数据库的并发性能。
同一行数据平时发生读写请求时,会上锁阻塞住。但 MVCC 用更好的方式去处理读写请求,做到在发生读写请求冲突时不用加锁。
这个读是指的快照读,而不是当前读,当前读是一种加锁操作,是悲观锁。
在事务并发执行遇到的问题如下:
脏读:如果一个事务读到了另一个未提交事务修改过的数据,那就意味着发生了脏读;
不可重复读:如果一个事务只能读到另一个已经提交的事务修改过的数据,并且其他事务每对该数据进行一次修改并提交后,该事务都能查询得到最新值,那就意味着发生了不可重复读;
幻读:如果一个事务先根据某些条件查询出一些记录,之后另一个事务又向表中插入了符合这些条件的记录,原先的事务再次按照该条件查询时,能把另一个事务插入的记录也读出来,那就意味着发生了幻读,幻读强调的是一个事务按照某个相同条件多次读取记录时,后读取时读到了之前没有读到的记录,幻读只是重点强调了读取到了之前读取没有获取到的记录。
MySQL 在 REPEATABLE READ 隔离级别下,是可以很大程度避免幻读问题的发生的。
对于使用 InnoDB 存储引擎的表来说,它的聚簇索引记录中都包含两个必要的隐藏列:
trx_id:每次一个事务对某条聚簇索引记录进行改动时,都会把该事务的事务 id 赋值给 trx_id 隐藏列;
roll_pointer:每次对某条聚簇索引记录进行改动时,都会把旧的版本写入到 undo 日志中,然后这个隐藏列就相当于一个指针,可以通过它来找到该记录修 改前的信息;
演示
-- 创建表
CREATETABLE mvcc_test (
id INT,
name VARCHAR(100),
domain varchar(100),
PRIMARYKEY(id)
)Engine=InnoDBCHARSET=utf8;
-- 添加数据
INSERTINTO mvcc_test VALUES(1,'habit','演示mvcc');
假设插入该记录的事务 id=50,那么该条记录的展示如图:假设之后两个事务 id 分别为 70、90 的事务对这条记录进行 UPDATE 操作。
trx_id=70 | trx_id=90 |
---|---|
begin | |
begin | |
update mvcc_test set name='habit_trx_id_70_01' where id=1 | |
update mvcc_test set name='habit_trx_id_70_02' where id=1 | |
commit | |
update mvcc_test set name='habit_trx_id_90_01' where id=1 | |
update mvcc_test set name='habit_trx_id_90_02' where id=1 | |
commit |
每次对记录进行改动,都会记录一条 undo 日志,每条 undo 日志也都有一个 roll_pointer 属性,可以将这些 undo 日志都连起来,串成一个链表。
对该记录每次更新后,都会将旧值放到一条 undo 日志中,就算是该记录的一个旧版本,随着更新次数的增多,所有的版本都会被 roll_pointer 属性连接成一个链表,把这个链表称之为版本链,版本链的头节点就是当前记录最新的值。另外,每个版本中还包含生成该版本时对应的事务 id。于是可以利用这个记录的版本链来控制并发事务访问相同记录的行为,那么这种机制就被称之为:多版本并发控制,即 MVCC。m_ids:表示在生成 ReadView 时当前系统中活跃的读写事务的事务 id 列表;
min_trx_id:表示在生成 ReadView 时当前系统中活跃的读写事务中最小的事务 id,也就是 m_ids 中的最小值;
max_trx_id:表示在生成 ReadView 时系统中应该分配给下一个事务的 id 值,注:max_trx_id 并不是 m_ids 中的最大值,事务 id 是递增分配的。比方说现在有 id 为 1,2,3 这三个事务,之后 id 为 3 的事务提交了。那么一个新的读事务在生成 ReadView 时,m_ids 就包括 1 和 2,min_trx_id 的值就是 1,max_trx_id 的值就是 4;
creator_trx_id:表示生成该 ReadView 的事务的事务 id;
如果被访问版本的 trx_id 属性值与 ReadView 中的 creator_trx_id 值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问;
如果被访问版本的 trx_id 属性值小于 ReadView 中的 min_trx_id 值,表明生成该版本的事务在当前事务生成 ReadView 前已经提交,所以该版本可以被当前事务访问;
如果被访问版本的 trx_id 属性值大于或等于 ReadView 中的 max_trx_id 值,表明生成该版本的事务在当前事务生成 ReadView 后才开启,所以该版本不可以被当前事务访问;
如果被访问版本的 trx_id 属性值在 ReadView 的 min_trx_id 和 max_trx_id 之间 min_trx_id < trx_id < max_trx_id,那就需要判断一下 trx_id 属性值是不是在 m_ids 列表中,如果在,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问;
如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边的步骤判断可见性,依此类推,直到版本链中的最后一个版本。如果最后一个版本也不可见的话,那么就意味着该条记录对该事务完全不可见,查询结果就不包含该记录;
-- T 70
UPDATE mvcc_test SET name ='habit_trx_id_70_01'WHERE id =1;
UPDATE mvcc_test SET name ='habit_trx_id_70_02'WHERE id =1;
此时表 mvcc_test 中 id 为 1 的记录得到的版本链表如下所示:假设现在有一个使用 READ COMMITTED 隔离级别的事务开始执行:
-- 使用 READ COMMITTED 隔离级别的事务
BEGIN;
-- SELECE1:Transaction 70、90 未提交
SELECT*FROM mvcc_test WHERE id =1;
-- 得到的列 name 的值为'habit'
这个 SELECE1 的执行过程如下:在执行 SELECT 语句时会先生成一个 ReadView,ReadView 的 m_ids 列表的内容就是 [70, 90],min_trx_id 为 70,max_trx_id 为 91,creator_trx_id 为 0。然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列 name 的内容是 habit_trx_id_70_02,该版本的 trx_id 值为 70,在 m_ids 列表内,所以不符合可见性要求第 4 条:如果被访问版本的 trx_id 属性值在 ReadView 的 min_trx_id 和 max_trx_id之间 min_trx_id < trx_id < max_trx_id,那就需要判断一下trx_id 属性值是不是在 m_ids 列表中,如果在,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问。
根据 roll_pointer 跳到下一个版本。下一个版本的列 name 的内容是 habit_trx_id_70_01,该版本的 trx_id 值也为 70,也在 m_ids 列表内,所以也不符合要求,继续跳到下一个版本。下一个版本的列 name 的内容是 habit,该版本的 trx_id 值为 50,小于 ReadView 中的 min_trx_id 值,所以这个版本是符合要求的第 2 条:如果被访问版本的 trx_id 属性值小于 ReadView 中的 min_trx_id 值,表明生成该版本的事务在当前事务生成 ReadView 前已经提交,所以该版本可以被当前事务访问。最后返回的版本就是这条列 name 为 habit 的记录。之后,把事务 id 为 70 的事务提交一下,然后再到事务 id 为 90 的事务中更新一下表 mvcc_test 中 id 为 1 的记录:
-- T 90
UPDATE mvcc_test SET name ='habit_trx_id_90_01'WHERE id =1;
UPDATE mvcc_test SET name ='habit_trx_id_90_02'WHERE id =1;
此时表 mvcc 中 id 为 1 的记录的版本链就长这样:然后再到刚才使用 READ COMMITTED 隔离级别的事务中继续查找这个 id 为 1 的记录,如下:
-- 使用 READ COMMITTED 隔离级别的事务
BEGIN;
-- SELECE1:Transaction 70、90 均未提交
SELECT*FROM mvcc_test WHERE id =1;-- 得到的列 name 的值为'habit'
-- SELECE2:Transaction 70 提交,Transaction 90 未提交
SELECT*FROM mvcc_test WHERE id =1;-- 得到的列 name 的值为'habit_trx_id_70_02'
这个 SELECE2 的执行过程如下:在执行 SELECT 语句时又会单独生成一个 ReadView,该 ReadView 的 m_ids 列表的内容就是 [90],min_trx_id 为 90,max_trx_id 为 91,creator_trx_id 为 0。然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列 name 的内容是 habit_trx_id_90_02,该版本的 trx_id 值为 90,在 m_ids 列表内,所以不符合可见性要求,根据 roll_pointer 跳到下一个版本。下一个版本的列 name 的内容是 habit_trx_id_90_01,该版本的 trx_id 值为 90,也在 m_ids 列表内,所以也不符合要求,继续跳到下一个版本。下一个版本的列 name 的内容是 habit_trx_id_70_02,该版本的 trx_id 值为 70,小于 ReadView 中的 min_trx_id 值 90,所以这个版本是符合要求的,最后返回这个版本中列 name 为 habit_trx_id_70_02 的记录。以此类推,如果之后事务 id 为 90 的记录也提交了,再次在使用 READ COMMITTED 隔离级别的事务中查询表 mvcc_test 中 id 值为 1 的记录时,得到的结果就是 habit_trx_id_90_02 了。总结:使用 READ COMMITTED 隔离级别的事务在每次查询开始时都会生成一个独立的 ReadView。REPEATABLE READ:在第一次读取数据时生成一个 ReadView;对于使用 REPEATABLE READ 隔离级别的事务来说,只会在第一次执行查询语句时生成一个 ReadView,之后的查询就不会重复生成了。比方说现在系统里有两个事务 id 分别为 70、90 的事务在执行:
-- T 70
UPDATE mvcc_test SET name ='habit_trx_id_70_01'WHERE id =1;
UPDATE mvcc_test SET name ='habit_trx_id_70_02'WHERE id =1;
此时表 mvcc_test 中 id 为 1 的记录得到的版本链表如下所示:假设现在有一个使用 REPEATABLE READ 隔离级别的事务开始执行:
-- 使用 REPEATABLE READ 隔离级别的事务
BEGIN;
-- SELECE1:Transaction 70、90 未提交
SELECT*FROM mvcc_test WHERE id =1;-- 得到的列name 的值为'habit'
这个 SELECE1 的执行过程如下:在执行 SELECT 语句时会先生成一个 ReadView,ReadView 的 m_ids 列表的内容就是 [70, 90],min_trx_id 为 70,max_trx_id 为 91,creator_trx_id 为 0。然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列 name 的内容是 habit_trx_id_70_02,该版本的 trx_id 值为 70,在 m_ids 列表内,所以不符合可见性要求,根据 roll_pointer 跳到下一个版本。下一个版本的列 name 的内容是 habit_trx_id_70_01,该版本的 trx_id 值也为 70,也在 m_ids 列表内,所以也不符合要求,继续跳到下一个版本。下一个版本的列 name 的内容是 habit,该版本的 trx_id 值为 50,小于 ReadView 中的 min_trx_id 值,所以这个版本是符合要求的,最后返回的就是这条列 name 为 habit 的记录。之后,把事务 id 为 70 的事务提交一下,然后再到事务 id 为 90 的事务中更新一下表 mvcc_test 中 id 为 1 的记录:
-- 使用 REPEATABLE READ 隔离级别的事务
BEGIN;
UPDATE mvcc_test SET name ='habit_trx_id_90_01'WHERE id =1;
UPDATE mvcc_test SET name ='habit_trx_id_90_02'WHERE id =1;
此刻,表 mvcc_test 中 id 为 1 的记录的版本链就长这样:然后再到刚才使用 REPEATABLE READ 隔离级别的事务中继续查找这个 id 为 1 的记录,如下:
-- 使用 REPEATABLE READ 隔离级别的事务
BEGIN;
-- SELECE1:Transaction 70、90 均未提交
SELECT*FROM mvcc_test WHERE id =1;-- 得到的列 name 的值为'habit'
-- SELECE2:Transaction 70 提交,Transaction 90 未提交
SELECT*FROM mvcc_test WHERE id =1; -- 得到的列 name 的值为'habit'
这个 SELECE2 的执行过程如下:因为当前事务的隔离级别为 REPEATABLE READ,而之前在执行 SELECE1 时已经生成过 ReadView 了,所以此时直接复用之前的 ReadView,之前的 ReadView 的 m_ids 列表的内容就是 [70, 90],min_trx_id 为 70,max_trx_id 为 91, creator_trx_id 为 0。然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列 name 的内容是 habit_trx_id_90_02,该版本的 trx_id 值为 90,在 m_ids 列表内,所以不符合可见性要求,根据 roll_pointer 跳到下一个版本。下一个版本的列 name 的内容是 habit_trx_id_90_01,该版本的 trx_id 值为 90,也在 m_ids 列表内,所以也不符合要求,继续跳到下一个版本。下一个版本的列 name 的内容是 habit_trx_id_70_02,该版本的 trx_id 值为 70,而 m_ids 列表中是包含值为 70 的事务 id 的,所以该版本也不符合要求,同理下一个列 name 的内容是 habit_trx_id_70_01 的版本也不符合要求。继续跳到下一个版本。下一个版本的列 name 的内容是 habit,该版本的 trx_id 值为 50,小于 ReadView 中的 min_trx_id 值 70,所以这个版本是符合要求的,最后返回给用户的版本就是这条列 name 为 habit 的记录。也就是说两次 SELECT 查询得到的结果是重复的,记录的列 name 值都是 habit,这就是可重复读的含义。如果之后再把事务 id 为 90 的记录提交了,然后再到刚才使用 REPEATABLE READ 隔离级别的事务中继续查找这个 id 为 1 的记录,得到的结果还是 habit。
select * from mvcc_test where id = 30;
这个时候是找不到 id = 30 的记录的。在事务 T2 中,执行插入语句:insert into mvcc_test values(30,'luxi','luxi');
此时回到事务 T1,执行:
update mvcc_test set domain='luxi_t1'where id=30;
select*from mvcc_test where id =30;
事务 T1 很明显出现了幻读现象。在 REPEATABLE READ 隔离级别下,T1 第一次执行普通的 SELECT 语句时生成了一个 ReadView,之后 T2 向 mvcc_test 表中新插入一条记录并提交。ReadView 并不能阻止 T1 执行 UPDATE 或者 DELETE 语句来改动这个新插入的记录,由于 T2 已经提交,因此改动该记录并不会造成阻塞,但是这样一来,这条新记录的 trx_id 隐藏列的值就变成了 T1 的事务 id。之后 T1 再使用普通的 SELECT 语句去查询这条记录时就可以看到这条记录了,也就可以把这条记录返回给客户端。因为这个特殊现象的存在,可以认为 MVCC 并不能完全禁止幻读。
show variables like 'innodb_buffer_pool_size';
可以在启动服务器的时候配置 innodb_buffer_pool_size 参数的值,它表示 Buffer Pool 的大小,配置如下:
[server]
innodb_buffer_pool_size = 268435456
其中,268435456 的单位是字节,也就是指定 Buffer Pool 的大小为 256M,Buffer Pool 也不能太小,最小值为 5M,当小于该值时会自动设置成 5M。启动 MySQL 服务器的时候,需要完成对 Buffer Pool 的初始化过程,就是先向操作系统申请 Buffer Pool 的内存空间,然后把它划分成若干对控制块和缓 存页。但是此时并没有真实的磁盘页被缓存到 Buffer Pool 中,之后随着程序的运行,会不断的有磁盘上的页被缓存到 Buffer Pool 中。在 Buffer Pool 中会创建多个缓存页,默认的缓存页大小和在磁盘上默认的页大小是一样的,都是 16KB。那么怎么知道该页在不在 Buffer Pool 中呢?在查找数据的时候,先通过哈希表中查找 key 是否在哈希表中,如果在证明 Buffer Pool 中存在该缓存也信息,如果不存在证明不存该缓存也信息,则通过读取磁盘加载该页信息放到 Buffer Pool 中,哈希表中的 key 是通过表空间号 + 页号作组成的,value 是 Buffer Pool 的缓存页。
刷新一个完整的数据页太浪费了;有时候仅仅修改了某个页面中的一个字节,但是在 InnoDB 中是以页为单位来进行磁盘 IO 的,也就是说在该事务提交时不得不将一个完整的页面从内存中刷新到磁盘,一个页面默认是 16KB 大小,只修改一个字节就要刷新 16KB 的数据到磁盘上显然是太浪费了。
随机 IO 刷起来比较慢;一个事务可能包含很多语句,即使是一条语句也可能修改许多页面,该事务修改的这些页面可能并不相邻,这就意味着在将某个事务修改的 Buffer Pool 中的页面刷新到磁盘时,需要进行很多的随机 IO,随机 IO 比顺序 IO 要慢,尤其对于传统的机械硬盘来说。
redo log 占用的空间非常小存储表空间 ID、页号、偏移量以及需要更新的值所需的存储空间是很小的;
redo log 是顺序写入磁盘的在执行事务的过程中,每执行一条语句,就可能产生若干条 redo log,这些日志是按照产生的顺序写入磁盘的,也就是使用顺序 IO;
log buffer 空间不足时,如果不停的往这个有限大小的 log buffer 里塞入日志,很快它就会被填满。InnoDB 认为如果当前写入 log buffer 的 redo log 量已 经占满了 log buffer 总容量的大约一半左右,就需要把这些日志刷新到磁盘上。
事务提交时,必须要把修改这些页面对应的 redo log 刷新到磁盘。
后台有一个线程,大约每秒都会刷新一次 log buffer 中的 redo log 到磁盘。
正常关闭服务器时等等。
情况一:事务执行过程中可能遇到各种错误,比如服务器本身的错误,操作系统错误,甚至是突然断电导致的错误。
情况二:程序员可以在事务执行过程中手动输入 ROLLBACK 语句结束当前的事务的执行。
插入一条记录时,至少要把这条记录的主键值记下来,之后回滚的时候只需要把这个主键值对应的记录删掉。
删除了一条记录,至少要把这条记录中的内容都记下来,这样之后回滚时再把由这些内容组成的记录插入到表中。
修改了一条记录,至少要把修改这条记录前的旧值都记录下来,这样之后回滚时再把这条记录更新为旧值。
往期推荐
点这里 ↓↓↓ 记得 关注✔ 标星⭐ 哦