MVCC与隔离级别
mvcc与隔离级别
相关概念
为什么需要mvcc
没有mvcc情况下,多人同时操作一份数据,都需要通过加锁解决并发问题
首先是读,如果一个人正在写数据A,不加锁,然后我来读数据A,读到的数据可能是不准确的,因为那个写的数据可能成功,可能失败。
写,不加锁,事务A修改了数据A,出错了,回退之前,事务B已经把数据A修改过一遍了,那么事务A回退,数据就有问题了。
什么是mvcc
基于多版本的并发控制,同一个数据有多个版本,事务开启时看到是哪个版本就看到这个版本,实现读写不加锁,写写不冲突。
MySQL innodb只对读无锁,写操作仍是上锁的悲观并发控制
PostgreSQL 严格地无锁,对写操作也是乐观并发控制,这样的话写写不加锁,保存时通过比较版本号来判断是否commit成功,当修改是并发很高的地方,那么很多修改都会提交失败rollback,然后重新执行。事务回滚代价太大,还不如加锁了。
行
MySQL官方手册https://dev.mysql.com/doc/refman/5.7/en/innodb-multi-versioning.html
Internally, InnoDB adds three fields to each row stored in the database. A 6-byte DB_TRX_ID field indicates the transaction identifier for the last transaction that inserted or updated the row. Also, a deletion is treated internally as an update where a special bit in the row is set to mark it as deleted. Each row also contains a 7-byte DB_ROLL_PTR field called the roll pointer. The roll pointer points to an undo log record written to the rollback segment. If the row was updated, the undo log record contains the information necessary to rebuild the content of the row before it was updated. A 6-byte DB_ROW_ID field contains a row ID that increases monotonically as new rows are inserted. If InnoDB generates a clustered index automatically, the index contains row ID values. Otherwise, the DB_ROW_ID column does not appear in any index.
在内部,InnoDB为存储在数据库中的每一行添加三个字段。一个6字节的DB_TRX_ID字段表示插入或更新该行的最后一个事务的事务标识符。另外,删除在内部被视为更新,其中行中的特殊位被设置为将其标记为删除。每行还包含一个称为滚动指针的7字节DB_ROLL_PTR字段。滚动指针指向写入回滚段的撤消日志记录。如果该行已更新,则撤消日志记录包含在更新行之前重建该行内容所需的信息。一个6字节的DB_ROW_ID字段包含一个随着新行插入而单调递增的行ID。如果InnoDB自动生成聚集索引,则索引包含行ID值。否则,DB_ROW_ID列不会出现在任何索引中。
也就是说:
1. DB_TRX_ID 事务id
2. DB_ROLL_PTR 回滚指针
3. DB_ROW_ID 隐含单调递增的行ID
4. 删除位
undo log
undo log是为回滚和mvcc而用,记录事务数据修改前的行到undolog
行的更新
初始行:
每行有两个隐藏列,事务id,回滚指针。若没有指定主键,每行还会增加一个rowid隐含id。
事务1修改:
事务1修改操作:
1. 锁定这行记录
2. 记录undo日志
3. 修改当前行数据,回滚指针指向undo日志。
4. 记录redo日志
事务2修改:
修改与事务1一样,因为事务1提交了,事务2才能修改这行数据,所以事务2的回滚指针指向事务1的修改。
undo log如果一直不删除,则会通过当前记录的回滚指针回溯到该行创建时的初始内容,所幸Innodb中存在purge线程,它会查询那些比现在最老的活动事务还早的undo log,并删除它们,从而保证undo log文件不至于无限增长。
事务提交
当事务提交,Innodb只需要修改事务状态为COMMIT即可,不需要做额外操作,而rollback则需要根据回滚指针找到事务提交前的记录,修改回去。如果修改的数据很多,那么rollback的效率就不高。所以Innodb是一个COMMIT效率比Rollback高的存储引擎。
MVCC
innodb的实现:
1. 事务以排他锁的形式修改原始数据
2. 把修改前的数据存放于undo log,通过回滚指针与主数据关联
3. 修改成功(commit)啥都不做,失败则恢复undo log中的数据(rollback)
可见性判断
https://github.com/mysql/mysql-server/blob/5.7/storage/innobase/include/read0types.h
下图是ReadView的定义
m_low_limit_id 事务的最大id
m_up_limit_id 活跃事务的最小id
m_creator_trx_id 本事务id
m_ids 活跃事务id列表,eg:[m_up_limit_id, .... ,m_low_limit_id]
show engine innodb status\G;
事务刚创建时,beign之后是没有创建事务id的。
事务执行sql(update,delete,insert)语句之后才会创建事务id;
事务执行sql(select)语句的时候创建read view;
那为什么其他sql不创建呢。
代码逻辑
创建事务,查询的时候生成当前的global read view,这样就可以实现RR级别隔离
假设有readview:
m_low_limit_id Trx14
m_up_limit_id Trx9
m_ids [Trx9, Trx10, Trx11]
m_creator_trx_id Trx13
当查询到的行事务id小于最小的活跃事务id,说明是早已commit的,可以看见
if (id < m_up_limit_id || id == m_creator_trx_id) {
return(true);
}
当查询到的行事务id大于等于最大的活跃事务id,说明是后提交的,不可见。
if (id >= m_low_limit_id) {
return(false);
}
修改数据不是会加锁吗?为什么还会产出比当前事务id还大的id保存了。
因为可能当前事务没有修改这条数据,而后面的事务修改并commit了。
-------------------------
以下代码是检查 行id是否在活跃列表里面,在就返回false,不可见。
const ids_t::value_type* p = m_ids.data();
return(!std::binary_search(p, p + m_ids.size(), id));
每次语句执行创建read view,结束关闭,就可以实现RC级别隔离。
因为RC级别下,m_up_limit_id都是最新的最小活动事务,那么查询就能看到别的事务刚提交的数据。
隔离级别
未提交读(READ UNCOMMITTED),能读取到其他事务未提交的数据,基本不会用到。不支持mvcc
提交读(READ COMMITTED),本事务读取到的是最新的数据(其他事务提交后的)。这样就会产生不重复读。就是本次事务前后两次读取到的数据不一致。支持mvcc
可重复读(REPEATABLE READ),每次读取的数据都一样,但是会出现幻读。
串行化 Serializable,读加共享锁,写加排他锁,读写互斥。
以下是mysql的幻读1。
幻读2
像上面的,mysql的RR隔离级别下,是会出现幻读的,因为使用快照读,读取的都是这个事务开始的数据,要解决幻读,就需要变成当前读,也就是:
select * from table where ? lock in share mode;
select * from table where ? for update;
这样就能给数据加读锁,另外的事务要插入数据就插入不了。
可以加行锁就可以了,为啥还要加间隙锁来防止当前读的幻读呢。
id主键 | num(index索引) |
---|---|
1 | 10 |
3 | 15 |
5 | 15 |
7 | 20 |
![]() |
看看上面的索引表,当前读查询num=15的,如果只把3,15 和5,15锁住,还是不能防止2,15的插入和6,15的插入。索引就有了间隙锁。在10和15、15和20之间都加一个间隙锁。
间隙锁的目的:
(1)防止间隙内有新数据被插入
(2)防止已存在的数据,更新成间隙内的数据(例如防止num=10的记录通过update变成num=15)