在学习High Performance MySQL时候,看到关于MVCC在隔离级别为repeateable read的时候,测试发现update可以update到其他事务提交的数据,下次在相同事务下select的时候可以select到。
High Performance MySQL是按照下面的文字对UPDATE操作进行解释的,当时是按照下面的字面意思解释了上面的现象。
UPDATEInnoDB writes a new copy of the row, using the system version number for thenew row’s version. It also writes the system version number as the old row’sdeletion version.
最后无意间在google上搜到了淘宝dba对这个从源码层面的解释,遂copy于此,原帖地址:http://rdc.taobao.com/blog/cs/?p=392&cpage=1#comment-298
在隔离机制中,InnoDB默认采用的Repeatable Read 和MVCC机制保证在事务内部尽量保证逻辑一致性。但如下的现象依然让人觉得不太合理。
1、复现
a) 表结构
CREATE TABLE t
(
a
int(11) NOT NULL DEFAULT ‘0′,
b
int(11) DEFAULT NULL,
PRIMARY KEY (a
)
) ENGINE=InnoDB DEFAULT CHARSET=gbk
表中2条记录
’ 1 ‘ 100 ‘
’ 4 ‘ 400 ‘
+-+–+
b) 操作过程:开两个session,操作序列如下
Session 1 | Session 2 |
**1)Begin** | |
**2)Select * from t;** ' 1 ' 100 ' ' 4 ' 400 ' 2 rows in set (0.01 sec) | |
** ** | **3)Insert into t vlaues(2, 200);** |
**4)Select * from t;** ' 1 ' 100 ' ' 4 ' 400 ' 2 rows in set (0.01 sec) | |
**5)Update t set b = 200 where a = 2;** Query OK, 0 rows affected (0.01 sec) Rows matched: 1 Changed: 0 Warnings: 0 | |
**6)Select * from t;** ' 1 ' 100 ' ' 2 ' 200 ' ' 4 ' 400 ' 3 rows in set (0.01 sec) |
从session 1整个过程看来,它试图更新一个不存在的记录(a=2),结果更新成功,并且之后这个记录可以访问。
2、分析
从其他正常的表象看来,在事务内,只要不涉及更新,事务外的任何更新都是不可见的。上面试验中session 1内update之前执行的select *得到的结果仍是2条记录。
虽然更新冲突时的策略见仁见智,但例子中的这个现象应该提供一种可以选择的方式(至少应该允许配置)。
接下来的篇幅主要分析出现这种现象的原因,以及通过简单修改实现如下的方式:对于查询不可见的记录,update操作不应该成功。
由于更新冲突策略的复杂性,本文不解决更多的问题,简单比如:insert操作由于主键冲突的原因,插入依旧不允许。
3、源码相关
先来说明一下为什么步骤4)中的查询结果仍为2条记录。
Innodb内部每个事务开始时,都会有一个事务id, 同时事务对象中还有一个read_view变量,用于控制该事务可见的记录范围(MVCC)。对于每个访问到的记录行,会根据read_view的trx_id(事务id)与行记录的trx_id比较,判断记录是否逻辑上可见。
Session 2中插入的记录不可见,原因即为session 1先于session 2,因此新插入的数据经过判断,不在可见范围内。对应的源码在row/row0sel.c [4040-4055].
{说明: 源码版本5.1.45, 下同}
发生的逻辑为
If(!lock_clust_rec_cons_read_sees(..)){ //检查该记录是否本事务可见 row_sel_build_prev_vers_for_mysql(....); //不可见则找上一个版本 if (old_vers == NULL) {goto next_rec;} //上一个版本没有这个记录,放弃 } |
注意到表格中出现的Rows matched: 1。 这里是例子出现诡异的开始,也是根源。我们知道innoDB内部更新数据实际上是”先查后改”,跟这个Rows matched: 1结合起来,不难联想到,在执行update操作是,在”查”的阶段,事务能够访问到新插入的行。
猜测:问题出在,执行更新的时候,是否没有判断事务可见范围?
事实上确实如此,源代码上翻几行可以看到,在行数[3897-4017-4071]这个if-else逻辑。
if (prebuilt->select_lock_type != LOCK_NONE) { //该操作需要加锁 } else{ //{CODES A} } |
执行查询语句走的是else的逻辑,而控制版本可见范围的代码就在{CODES A}的位置中。
而当我们在session 1中执行update操作时,走的是if()的逻辑,这里,没有判断版本可见范围。
** 4**、简单修改** **
既然是因为update的”查”过程没有检查版本可见范围造成,我们试着加上。
在row/row0sel.c[3907]行插入如下:
if(trx->read_view){ if (UNIV_LIKELY(srv_force_recovery < 5) && !lock_clust_rec_cons_read_sees(rec, clust_index, offsets, trx->read_view)) { rec_t* old_vers; err = row_sel_build_prev_vers_for_mysql( trx->read_view, clust_index, prebuilt, rec, &offsets, &heap, &old_vers, &mtr); if (err != DB_SUCCESS) { goto lock_wait_or_error; } if (old_vers == NULL) { goto next_rec; } } } |
新的执行结果为
Session 1 | Session 2 |
**1)Begin** | |
**2)Select * from t;** ' 1 ' 100 ' ' 4 ' 400 ' 2 rows in set (0.01 sec) | |
** ** | **3)Insert into t vlaues(2, 200);** |
**4)Select * from t;** ' 1 ' 100 ' ' 4 ' 400 ' 2 rows in set (0.01 sec) | |
**5)Update t set b = 200 where a = 2;** Query OK, 0 rows affected (0.01 sec) **Rows matched: 0** Changed: 0 Warnings: 0 | |
**6)Select * from t;** ' 1 ' 100 ' ' 4 ' 400 ' **2 rows in set** (0.01 sec) |
重申:这个修改仅仅从本文的例子出发,达到”事务内查询无法访问的记录,不能更新”这个目的, 其他更新冲突策略不在此范围内。 仅作交流使用 -_-