前面我们说了undo日志写入undo页面链表时,先需要把undo page header、undo segment header、undo log header等。每个事务都会有相应的undo链表,如果只存储一点数据不是很浪费吗,于是有了可重用,满足当前链表只有一个页,并且小于总空间的3/4。还介绍了回滚段,默认128个回滚段,每个段有1024个undo slot,每个slot分配给不同的事务,对应一个单独的undo页面链表。Undo日志也会记录redo日志,但临时表的undo日志写入不会记录redo日志,他的记录过程是先修改了数据,则会在系统表空间申请一个rollback segment header页面地址,循环获取,从第0号,第33~127号。分配了回滚段后,在段里查看cache是否存在undo slot,不存在就去rollback_segment_header找到一个undo slot分配该事务,如果没找到,则需要去undo log segment申请一个first undo page
重用的undo日志 (3)—mysql进阶(六十六)
我们先创建一个表
mysql> create table hero( -> number int, -> name varchar(100), -> country varchar(100), -> primary key(number) -> )engine=innodb charset=utf8; Query OK, 0 rows affected (0.06 sec)
INSERT INTO hero VALUES(1, '刘备', '蜀');
事务隔离级别
我们知道mysql是客户端和服务端架构的软件,对于同一个服务器,有若干个客户端与之连接,每个连接上之后,可以称为【session】。每次客户端会发送一个请求,或者一个事务给服务端,但如果用阻塞的方式,那就性能太慢,为了提高效率而保证每个事务之间都有隔离性,鱼和熊掌不可兼得,舍弃一部分隔离性取来提高性能。
事务并发执行遇到的问题
我们先看看在几个事务同时并发运行时候可能遇到的问题:
脏写(dirty write)
如果一个事务修改了另一个未提交的事务,这就发生了脏写。
场景:trx1和trx2,两个事务修改了同一条记录,但trx1还没提交,trx2把他回滚了,这时候trx1就么有效果。
导致的结果:trx1明明修改了数据,并且commit但是什么都没变化。
脏读(dirty read)
如果一个事物修改后,还未提交或者准备回滚,但是其他事务读了这条未提交的数据,就是脏读。
场景:trx1读,trx2修改了hero成关羽,但是最后准备回滚,在回滚之前被并发的trx1读出来是关羽。
导致的结果:读出来是关羽。
不可重复读(Non-Repeatable Read)
当在同一个事务里,一条记录在其他事物被更改,导致多次查询出来的事务不一致,这种现象就是不可重复读。
场景:trx1读两次,trx2修改多次。
导致的结果:每次读取的数据都是不一致的。
幻读(Phantom)
幻读意味着insert,多次查询的数据,不一致,并且增多了。
场景:trx1读取两次,第一次读取了一条记录,第二次读取了两条记录,trx2事务insert了新纪录到表里。
导致的结果:后面读取的数据比前面读取的数据多。
注意:明确规定,删除或者修改都不算幻读,只有发生insert,多读了数据。
Sql标准中的四种隔离级别
综上所述,脏写>脏读>不可重复读>幻读
为了解决这些问题,于是mysql设计了四种隔离级别:
Read uncommit:未提交读。可能发生脏读,不可重复读,幻读。
Read commit:提交读。只发生不可重复读,幻读。
Repeatable read:可重复读。只发生幻读。
Serializable:可串行化。全部不会发生。
为啥没有脏写,因为脏写问题太严重了,任何情况下都不予许发生。(你想在你修改数据的时候,其他事物帮你吧数据回滚。。)
不同的数据库厂商对sql标准不同,比如oracle就只支持read committed和serializable。Mysql默认是repeatable read,但是可以禁止幻读发生。(后面会说如何禁止)
如何设置mysql隔离级别
mysql> set transaction isolation level read committed; Query OK, 0 rows affected (0.00 sec)
后面的level参数可以写入四个隔离级别
这种只对下一个transaction有效,当下一个transaction结束,则恢复到之前的隔离级别。并且该语句不能再事务执行期间执行,否则会报错。
当我们给transaction前面加一个关键字的时候:
使用GLOBAL关键字(在全局范围影响):
比方说 set global transaction isolation level read committed;
则只对执行完该语句之后产生的会话起作用,当前已存在的会话无效。
使用Session关键字(在会话范围影响):
比方说 set session transaction isolation level read committed;
则对当前会话所有后续事务有效。
该语句可以在已开启事务中间执行,但不会影响正在执行的事务。
如果在事务之间执行,则对后续事务有效。
如果我们在服务器启动时想改变默认隔离级别,可以修改启动参数transaction-isolation的值,比方我们在启动mysql的时候加上
--transaction-isolation=serializable,那么事务的隔离级别就变成了serializable。
查看的话我们可以通过show variables like ‘transaction_isolation’;
MVCC原理
版本链
我们前面说过innoDB包含两个必要的隐藏链,一个是trx_id和roll_pointer(row_id不是必须的,当没有主见和唯一索引才会创建。)
Trx_id:每次一个事务对某个聚簇索引记录进行更改,都会吧事务的id赋值给trax_id。就是属于某个事务的id,回滚对应的每个事务是独立且互相隔离,每个事物都有四个undo页面链表,临时表和普通表,insert undo 和update undo。
Roll Pointer:每次对页面改动的时候,会吧旧的日志记录到undo日志,这个指针可以找到他。指向回滚的页面,如果指向的是delete页面,delete有一个old roll pointer会指向上一个执行的sql,也就是insert 的undo页面。
比如我们在事务id为80的事务里插入一条sql,
mysql> SELECT * FROM hero; +--------+--------+---------+ | number | name | country | +--------+--------+---------+ | 1 | 刘备 | 蜀 | +--------+--------+---------+ 1 row in set (0.07 sec)
这时候这个数据结构就是:
Number:1
Name:刘备
Country:蜀
Trx_id:80
Roll_Pointer:指向inser undo页面。
(注意:实际上insert undo只有在事务未提交前起作用,当事务提交后,就没用了,它占用的undo log segment也会被系统收回,也就是undo日志占用 的undo页面链表要么被重用,要么被释放)。虽然insert undo占用的日志被释放,但是roll_pointer的值并不会被清除,roll_pointer属性占用7个字节,第一个比特位就是指向undo日志类型,如果该比特位值为1,那么代表它指向undo日志类型为insert undo。
当trx100和trx200两个事务同时并行修改一条数据会发生什么呢?
trx100:Begin。 trx200:begin。 trx100:update hero set name = ‘关羽’where number = ‘1’。 trx100:update hero set name = ‘张飞’where number = ‘1’。 trx100:commit。 trx200:update hero set name = ‘赵云’where number = ‘1’。 trx200:update hero set name = ‘诸葛亮’where number = ‘1’。 trx200:commit。
这时候交叉更新同一条记录不是会发生脏数据吗,后面会提到mysql的行锁。
每次对数进行update修改,都会记录一条undo日志,每条undo日志都会对应一个roll_pointer属性,可以吧上面这些数据串联起来,串联起来的头部第一条数据就是页面的真实数据,每次修改都会吧修改的数据放入undo页面。这个链表我们就称他为【版本链】,每个版本链还对应着事务id。
ReadView
对于使用read uncommitted隔离级别的事务来说,由于可以读取未提交的修改数据,所以直接读最新的数据就好。对于serializable隔离级别的,mysql选择用锁的方式来访问记录。对于read committed和repeatable read隔离级别的事务,都必须保证已经提交的事务修改过的记录,也就是另一个事务还未提交,其他是不能读取最新的数据。核心问题就是:判断下版本链中,哪个版本是对当前事务可见的。于是innoDB设计出readView的概念,这里面有四个比较重要的内容:
M_ids:表示生在readView时当前系统中活跃的读写事务的事务id列表。
Min_trx_id:表示在生成readView时当前系统中活跃的读写事务中最小的事务id,也就是m_ids中的最小值。
Max_trx_id:表示生成readView时系统应该分配给下一个事务的id值。
(注意:max_trx_id并不是m_ids里的最大值,比如m_ids里有三个事务,1,2,3,当事务3提交了,m_ids只有1,2两个事务,那么新的事务在生成readView时候max_trx_id就是4)
Creator_trx_id:表示生成该readView的事务id。
(前面说过只有再给表做改动时候才有事务id,select没有,如果不存在事务id,creator_trx_id为0)
有了这个readView,这样在访问某条记录时,只要按下面步骤判断记录的某个版本是否可见:
如果被访问的版本trx_id值与readView中的creator_trx_id值相同,意味着当前事务在访问他字节修改过的记录,所以该版本可以在当前事务访问。
如果被访问的版本trx_id值小于 readView中的creator_trx_id值,表名生成该版本的事务在当前事务生成readView前已经提交,所以该版本可以被当前事务访问。
如果被访问的版本trx_id值大于readView中的creator_trx_id值,表名生成该版本的事务在当前事务生成readView后才开启,所以该版本不可以被当前事务访问。
如果被访问的版本trx_id值在readView的max_trx_id和min_trx_id之间,那么就需要判断一下trx_id是否在m_ids列表,如果在,说明创建readView时生成该版本事务还是活跃的,该版本不可以被访问。如果不在,说明创建readView时生成该版本的事务已经被提交,该版本可以被访问。
如果某个版本的数据对当前事务不可见的话,就会顺着版本找到下一个版本的数据,继续按照上面的步骤判断可见性,以此类推,直到找到最后一个版本为止。如果最后一个版本也不可见,则意味着该条记录读当前事务不可见。
在mysql中,read committed和repeatable read非常大的区别就是生成read view的时机不同。我们以hero表为例,假如表里有一条事务id为80的事务插入一条数据。
mysql> SELECT * FROM hero; +--------+--------+---------+ | number | name | country | +--------+--------+---------+ | 1 | 刘备 | 蜀 | +--------+--------+---------+ 1 row in set (0.07 sec)
Reac committed每次读取数据前生成一条readView
比方说两个事务id分别为100、200的事务在执行:
# Transaction 100 BEGIN; UPDATE hero SET name = '关羽' WHERE number = 1; UPDATE hero SET name = '张飞' WHERE number = 1; # Transaction 200 BEGIN; # 更新了一些别的表的记录 ...
(注意:为什么这里事务200要更新别的,因为事务只有在执行delete,update,insert的时候才会分配事务id,这个id是自增的,所以我们目的是为了让他分配事务id)
此刻hero表的number为1的版本链如下:
1,张飞,蜀,100,roll_pointer
1,关羽,蜀,100,roll_pointer
1,刘备,蜀,80,roll_pointer
这里面1是页面中的记录,2,3是undo日志,一起就组成了版本链。
假设现在使用repatable read事务开始执行:
# 使用READ COMMITTED隔离级别的事务 BEGIN; # SELECT1:Transaction 100、200未提交 SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'刘备'
这个select的执行过程如下:
在执行select语句时会生成一个read View,readView的m_ids列表的内容是【100,200】,min_trx_id为100,max_trx_id为201,creator_trx_id为0。
然后从版本链中挑选可见的记录,从上可以看到,版本链可见的是‘张飞’,该版本的trx_id为100,在m_ids内,所以不符合可见性,根据roll_pointer跳到下一个版本。
下一个版本的name是‘关羽‘,该版本的trx_id也是100,也在m_ids列表内,所以也不符合可见性,跳到下一个版本。
下一个版本的name是‘刘备‘,该版本的trx_id是80,小于readView的min_trx_id的值100,所以这个版本符合要求,吧name为刘备返回给用户。
我们看下一个场景,吧事务100的提交
# Transaction 100 BEGIN; UPDATE hero SET name = '关羽' WHERE number = 1 UPDATE hero SET name = '张飞' WHERE number = 1; COMMIT;
然后把事务id为200中的hero表number为1的更新下:
# Transaction 200 BEGIN; # 更新了一些别的表的记录 ... UPDATE hero SET name = '赵云' WHERE number = 1; UPDATE hero SET name = '诸葛亮' WHERE number = 1;
此刻hero表number为1的链表如下:
1,诸葛亮,蜀,200,call_pointer
1,赵云,蜀,200,call_pointer
1,张飞,蜀,200,call_pointer
1,关羽,蜀,200,call_pointer
1,刘备,蜀,200,call_pointer
其中1是页面显示的记录,后面2,3,4,5是undo日志的记录。
继续使用刚repeatable read隔离级别读取数据:
# 使用REPEATABLE READ隔离级别的事务 BEGIN; # SELECT1:Transaction 100、200均未提交 SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'刘备 # SELECT2:Transaction 100提交,Transaction 200未提交 SELECT * FROM hero WHERE number = 1; # 得到的列name的值仍为'刘备'
因为当前事务隔离级别是repatable read ,而之前执行selet1的时候已经生成了readView,所以直接复用之前的readView,之前readView里的m_ids里有【100,200】,min_trx_id为100,max_trx_id为201,creator_trx_id为0。
然后从版本链中挑选可见记录,因为诸葛亮的trx_id是200,包含在m_ids,所以不可见,查看下一条。
赵云的trx_id也是200,所以继续看下一条。
张飞的trx_id是100,不符合可见,继续看下一条。
关羽的trx_id也是100,继续看下一条。
刘备的trx_id是80,小于min_trx_id,所以符合版本可见,返回刘备给用户。
也就是说,两次结果select是相同的数据,这就是可重复读。如果我们吧事务200也提交,继续在事务中读一次,结果还是刘备。
MVCC小结
所谓的mvcc,就是multi version concurrent controller,多版本控制并发,在mysql中指在read committed、repeatable read在执行select的时候,可以并发操作,这样可以在不同事务读写和写读并发操作,从而提升性能。Read committed和repeatable read区别就是,read committed是每次普通select 都会生成一个readView,而compatable read则是第一次select生成,后面都是重复利用。