事务隔离级别SI到RC以及优化

2017-08-20

谈事务一定会谈ACID,其中I是事务的隔离级别Isolation,这是一个老生常谈,却又不一定说得清楚的话题。

首先说RR和SI。RR和SI是一个差不多强度的隔离级别,RR有幻读问题,SI没有;而SI有写偏问题,RR没有。它们都解决了读未提交,不可重复读,然后都有对方处理不了的异常,所以算是差不多强度。只不过当年定义隔离级别的ANSI标准的时候,大家思维还局限在基于锁的实现,所以标准定义不是很完美,定义了RU,RC,RR,Serializability,后来基于MVCC的实现流行之后,才有了SI。

我们在MySQL里面执行下面这段代码:

mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from t;
+------+
| id   |
+------+
|    1 |
|    2 |
+------+
2 rows in set (0.00 sec)
// 如果这期间有一个其它事务,往t里面插入了一行insert into t values (3);
mysql> update t set id = id + 1;
Query OK, 3 rows affected (0.00 sec)
Rows matched: 3  Changed: 3  Warnings: 0
// 哎呀,妈蛋!为什么Changed是3行,难道是我的幻觉么?
mysql> commit;
Query OK, 0 rows affected (0.00 sec)

这里的现象就是发生了幻读,MySQL的默认隔离级别是RR的,无法阻止幻读。同样的这段代码,在TiDB里面执行就不会有问题,因为TiDB的默认隔离级别其实是SI的,SI没有幻读。

接下来解释RR为什么阻止不了幻读,在基于锁的实现里面做RR,处理读写冲突,锁只会加到写操作涉及到的那些行上面,新插入进去的3不会被加上写锁。如果真想处理这种异常就需要锁全表才行。 而在基于MVCC实现的SI里面,由于有多版本,更新操作看到的都是老版本数据,修改的也是老版本数据,新插入的3它是看不到的,所以不会出现幻读。

再看一个例子,假设当前的表是这样子:

mysql> select * from t;
+------+
| id   |
+------+
|    1 |
|    2 |
+------+
2 rows in set (0.00 sec)

事务A,如果它读到一个值是1的id,那么它就把id 2更新到42。

mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from t where id = 1;
+------+
| id   |
+------+
|    1 |
+------+
1 row in set (0.01 sec)
mysql> update t set id = 42 where id = 2;
Query OK, 1 row affected (0.00 sec)
mysql> commit;

事务B,如果它读到一个值是2的id,那么它就把id 1更新到42。

mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from t where id = 2;
+------+
| id   |
+------+
|    2 |
+------+
1 row in set (0.00 sec)
mysql> update t set id = 42 where id = 1;
Query OK, 1 row affected (0.01 sec)
mysql> commit;
Query OK, 0 rows affected (0.00 sec)

在可串行化条件下,事务A和事务B是不能够两者都执行成功的。即使在RR隔离级别里面,也是不能执行成功的。但是在TiDB里面可以执行成功,这就是SI不能处理的写偏(write skew)问题。 为什么SI不能处理写偏呢?因为MVCC里面读是不会将数据锁住的,导致当在一个事务读A写B,另一个事务读B写A的情况下,无法检查到读写冲突。如果想实现可串行化,必须采取某种方式破坏掉这种,读写在不同变量上,并且形成环的情况。

(写到这里突然想起来贵司的时候,聊到过write skew问题。聊了哪些细节记不太清了,记得有问到读不加锁,如果一个事务先读,而另一个事务写并且提交了,读操作岂不读到脏数据?...顺便八卦一下,当时的面试是首席架构师+CEO+CTO,现在新同学过来想要凑齐这么豪华的阵容应该是很难了)

其实之前的文章里面也有提到过隔离级别,为什么又想起写一篇呢?因为最近思考了一下,降低隔离级别到RC,可能带来的优化。

SI的实现,需要拿一次startTS和一次commitTS。快照读需要读到比startTS小的最近的一个commit的数据。如果拿时间戳要从一个中心化的授时服务里面拿的,拿两次时间戳得走两次网络,对于简单的query,这个开销是造成延迟的大头之一。所以TiDB里面做过的一个优化,这个优化是这样的,对于简单点查事务,我们只需要读到当时的最新的数据(简单点查不需要处理可重复读)。为了读到最新,我们直接使用一个无穷大的数当作startTS就可以,那么就并不需要去拿startTS。

另外一个优化是生成查询计划和拿startTS操作并行做,反正在执行器执行之前,都不会需要用到startTS,只有到二阶段提交的时候才用需要,所以可以并行化。

在RC隔离级别下,快照读操作即使遇到了锁,仍然是可以继续读的,而SI不行。所以冲突严重的情况下,RC隔离级别由于读写不冲突了,性能会进一步提高。还有没有办法继续优化?

我最近突然想到的一个点是:RC隔离级别并不需要拿startTS

先看RC级别下的读操作:读遇到锁可以忽略,并且不需要读到比startTS小的最近的那个版本,只需要读到的是已提交的。反正不需要满足可重复读,一直读最新的就好了,所以可以不拿startTS,直接传一个无穷大的数当startTS就可以。

接着我们看写操作,如果写操作不拿startTS会发生什么事情?写操作在prewrite的时候会先写锁,并且将startTS信息记录在锁里面。如果不拿startTS了,锁仍然还是可以写的,里面没有startTS信息而已。这个锁还是可以阻止写写冲突的,这就足够了。那么,startTS原本用来做什么的呢?除了可以判断大小,锁里面记录这个信息的作用是判定锁是由哪个事务加上的。到时候事务提交时清锁,就知道该如何清掉属于自己的锁。如果没了startTS作为事务的identify,那么我们还是需要记录一些东西来区分锁是谁加上的。这个用其它方式应该是可以实现的,UUID都可以考虑,细节不展开了。

所以结论是基于写写锁+MVCC读写,可以实现出RC隔离级别事务,并且只有写事务提交才需要拿一次时间戳。

做这个优化会带来什么问题么?

如果写操作不拿startTS,那么首先这是一个不能够SI/RC同时兼容的。比如某些session跑在SI隔离级别,而另一些session跑在RC隔离级别,最后写到存储里面数据不统一,有的写startTS有的不写,数据就坏了。这个问题可以通过禁用session级别,只允许global级别设置RC隔离级别解决,只要大家都跑在RC上就没问题。

另外一个是RC隔离级别不能阻止的一些异常。首先RC是不可重复读的,RC达不到RR这个很好理解,关键区别就在于startTS,(每次读新版本)跟(读比startTS小的最新版本)决定了是否可重复读。

还有一个丢失写(lost update)问题,在下面这个时序,x的初始值是100。事务2将x写成120并提交,事务1将x写成130并提交:

r1[x=100] r2[x=100] w2[x=120] c2 w1[x=130] c1

对于事务2来说,它提交了,但它的写也丢失了:从串行化的角度,事务1本该看到的是事务2写的结果120,然而事务1看到的却是100。

在用读写锁实现RR隔离级别的时候可以处理这种情况,是因为w2[x]的时候会被r1[x]的锁阻止。在基于MVCC实现的隔离级别里面也不会遇到丢失写异常,当事务1提交的时候,它会检测到在它解锁期间,有更大的TS的数据(来自事务1的)写入,然后提交失败。然而在上面用写写锁+MVCC读写实现RC,是不能够处理丢失写的。

有一个影响使用的场景,TiDB里面分配唯一ID。在SI隔离级别下,可以保证(读然后写)的事务之间,是没有重叠区域的。也是因为有startTS,下面那个事务begin的时候,把startTS写进去,于是上面执行到w1就会检查到读写冲突。在RC隔离级别不写startTS的情况下,读写不冲突了便会重叠,这样子分配唯一ID就无法实现了。

  [r1 -------------- w1]
          [r2 ----------------- w2]

大概就这些吧。最后出个题,在SI实现里,两阶段提交在prewrite操作的时候没有遇到锁,但是遇到了rollback的记录,并且rollback记录的ts是大于当前事务的ts的,这是否算冲突?能否忽略这条rollback?做了什么操作时序,可能遇到这样的情况?

这篇文章思路是这样的:

  • 比较了一下SI和RR隔离级别
  • 将SI退化到RC,能否有一些性能优化的空间,不拿startTS
  • 基于写写锁+MVCC无锁读写实现RC隔离级别
  • 实现成RC会有什么样的问题

个人感触,其实事务隔离级别这个东西,想深入理解最好的办法其实是自己实现某一种,比如RR或者SI,然后再去对比和思考其它各种,比如RC或者可串行化,如何实现,以及可能的优化,做一做权衡。

transactionisolationSIRC