事务隔离级别是数据库事务处理的基础,本文简单介绍了几种事务隔离级别以及悲观事务和乐观事务,加深对TiDB中事务和锁机制的理解。
1、事务的隔离级别
事务隔离级别是数据库事务处理的基础,SQL-92标准定义了4种隔离级别:读未提交(READ UNCOMMITTED)、读已提交(READ COMMITTED)、可重复读(REPEATABLE READ)、串行化(SERIALIZABLE)。详见下表:
不同的隔离级别有不同的现象,并有不同的锁和并发机制。隔离级别越高,数据库的并发性能就越差。在TiDB实现了快照隔离(Snapshot Isolation, SI)级别的一致性,为与MySQL保持一致,又称其为“可重复读”。
1.1 脏读/不可重复读/幻读现象
- 脏读
A事务读取B事务尚未提交的更改数据,并在这个数据的基础上进行操作,这时候如果事务B回滚,那么A事务读到的数据是不被承认的。
- 不可重复读
不可重复读是指在同一个事务中,同一个查询在T1时刻读取一行数据,在T2时刻重新读取这一行数据的时候,发现这一行数据已经发生了修改(被更新或者删除)。假如A在取款事务的过程中,B往该账户转账100,A两次读取的余额发生不一致。
- 幻读
幻读是指在同一个事务中,当同一个查询多次执行的时候,由于其它插入操作的事务提交,会导致每次返回不同的结果集。不可重复读和幻读的区别是:前者是指读到了已经提交的事务的更改数据(修改或删除),后者是指读到了其他已经提交事务的新增数据。
1.2 Row lock mode
- Share:lock owner
和任何并发程序可以read但是不能change locked page或row,并发程序可能获得S-lock、U-lock,也可能没有lock就进行读操作
-
Update:lock owner可read但是不能change locked page或row,但是owner可以将U-lock升级为X-lock这样就可以修改page或row
- 升级为X-lock这个过程可能会引起其它S-lock的并发进程暂停在那
- 当lock owner读数据的时候并决定是否需要修改它的时候,U-lock会减少deadlocks的几率
-
Exclusive:只有lock owner才能read或change locked page或row,并发程序只有当程序处于UNCOMMITTED read isolation的时候才能访问数据
-
Lock mode compatibility,见下表
比如说User A对page hold住S-lock,如果User B想对page请求X-lock,则User A的lockmode会拒绝User B的请求。
1.3 隔离级别
1.3.1 可重复读隔离级别(Repeatable Read)
当事务隔离级别为可重复读时,只能读到该事务启动时已经提交的其他事务修改的数据,未提交的数据或在事务启动后其他事务提交的数据是不可见的。对于本事务而言,事务语句可以看到之前的语句做出的修改。
事务读取数据在读操作开始的瞬间就加上行级共享S锁,而且在事务结束的时候才释放。但是,由于加的是行级别的锁,仍然可能发生幻读的问题。
1.3.2 读已提交隔离级别(Read Committed)
读提交,就是一个事务要等另一个事务提交后才能读取它的数据,否则是读取不到另外一个事务的更改的数据。
事务读取数据(读到数据的时候)加行级共享S锁,读完释放;事务写数据时候(写操作发生的瞬间)加行级独占X锁,事务结束释放。由于事务写操作加上独占X锁,因此事务写操作时,读操作也不能进行,因此,不能读到事务的未提交数据,避免了脏读的问题。但是由于,读操作的锁加在读上面,而不是加在事务之上,所以,在同一事务的两次读操作之间可以插入其他事务的写操作,所以可能发生不可重复读的问题。
1.3.3 读未提交(Read Uncommitted)
读未提交,就是一个事务可以读取另一个未提交事务的数据,也称为脏读。在读数据时候不加锁,写数据时候加行级别的共享锁,提交时释放锁。行级别的共享锁,不会对读产生影响,但是可以防止两个同时的写操作
1.3.4 序列化(Serialization)
最严格的隔离级别,强制事务串行执行,使之不可能冲突,从而解决幻读的问题,资源消耗最大。在读操作时,加表级共享锁,事务结束时释放;写操作时候,加表级独占锁,事务结束时释放。在这个级别,可能会导致大量的锁超时和锁竞争现象,实际上也很少用到。
1.4 Row locks duration
Row locks的duration和isolation level有关:
- 例1是一个Read Committed,S-lock会held到cursor移动到另一个row的时间,U-lock也如此。但是X-lock会held到commit
- 例2是REPEATABLE READ,S-lock会held到commit过程,X-lock也会held到commit
下表给出了row locks什么时候会获得,什么时候会释放:
2、乐观事务和悲观事务
2.1 乐观事务
乐观事务认为数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突进行检测,如果发现冲突了,则返回错误的信息,让用户决定如何处理。乐观事务的实现通常是记录数据版本,使用版本号或者时间戳记录数据的版本信息。
对于乐观事务模型来说,比较适合冲突率不高的场景,因为直接提交大概率会成功,冲突的是小概率事件,但是一旦遇到事务冲突,回滚的代价会比较大。事务的冲突主要是指事务并发执行的时候,对相同key的读写操作,主要分为读写冲突和写写冲突。在TiDB的乐观锁机制中,因为是在客户端对事务commit时,才会触发两阶段提交,检测是否存在写写冲突。所以,在乐观锁中,存在写写冲突时,很容易在事务提交时暴露,因而更容易被用户感知。
如图所示乐观锁冲突例子:
- 事务A 在时间点t1开始事务,事务B在事务t1之后的t2开始
- 事务A、事务B会同时去更新同一行数据
- 时间点t4时,事务A想要更新id = 1的这一行数据,虽然此时这行数据在 t3 这个时间点被事务B已经更新了,但是因为TiDB乐观事务只有在事务commit时才检测冲突,所以时间点 t4 的执行成功了
- 时间点t5,事务B成功提交,数据落盘
- 时间点t6,事务A尝试提交,检测冲突时发现t1之后有新的数据写入,返回冲突,事务A提交失败,提示客户端进行重试
2.2 悲观事务
悲观事务指的是数据被外界修改持保守(悲观)态度,因此在整个数据处理过程中,将数据处于锁定状态。悲观事务的实现依赖于数据库提供的锁机制,在每条SQL处理都会检测锁冲突。在数据库中,悲观事务的处理流程如下:
- 在对任意记录进行修改前,先尝试为该记录加上排他锁X-lock。
- 如果加锁失败,说明该记录正在被修改,那么当前查询可能要等待或者抛出异常
- 如果成功加锁,那么就可以对记录做修改,事务完成后就会解锁
- 期间如果有其他对该记录做修改或加排他锁的操作,都会等待解锁或直接抛出异常
悲观事务适合于锁冲突高并且需要严格保证事务性的业务场景,但是处理锁会带来额外的性能开销,同时也会增加死锁的几率。
TiDB中悲观事务的实现原理是在一个事务执行DML(UPDATE/DELETE)的过程中,TiDB不仅会将需要修改的行在本地缓存,同时还会对这些行直接上悲观锁,这里的悲观锁的内容是空的,只是一个占位符,待到Commit的时候,直接将这些悲观锁改写成标准的Percolator模型的锁。对于读请求,遇到悲观锁的时候,不用像乐观事务那样等待解锁,可以直接返回最新的数据;至于写请求,遇到悲观锁时,正常的等锁即可。
3、TiDB中事务语句
TiDB支持分布式事务,提供乐观事务与悲观事务两种事务模型。TiDB 3.0.8版本以后,TiDB 默认采用悲观事务模型。
1)开启事务
开启一个新事务,既可以使用BEGIN语句,也可以使用START TRANSACTION语句
mysql> BEGIN;
mysql> START TRANSACTION;
mysql> START TRANSACTION WITH CONSISTENT SNAPSHOT;
2)提交事务
COMMIT语句用于提交TiDB在当前事务中进行的所有修改。
mysql> COMMIT;
3)回滚事务
ROLLBACK语句用于回滚并撤销当前事务的所有修改。
mysql> ROLLBACK
如果客户端连接中止或关闭,也会自动回滚该事务。
4)自动提交
自动提交由系统变量autocommit控制,默认情况下是打开的
mysql> SELECT @@autocommit;
+--------------+
| @@autocommit |
+--------------+
| 1 |
+--------------+
1 row in set (0.00 sec)
mysql> SET autocommit = 0;
5)事务原子性
TiDB支持语句执行失败后的原子性回滚。如果语句报错,则所做的修改将不会生效。该事务将保持打开状态,并且在发出COMMIT或ROLLBACK语句之前可以进行其他修改。
4、MVCC多版本并发控制
MVCC多版本并发控制主要是解决并发读写之间阻塞的问题,在MVCC中,每当想要更改或者删除某个数据对象时,数据库不会在原地去删除或这修改这个已有的数据对象本身,而是创建一个该数据对象的新的版本,这样的话同时并发的读取操作仍旧可以读取老版本的数据,而写操作就可以同时进行。
4.1 MVCC在mysql的具体实现
4.1.1 MySQL中数据结构
在mysql中,在实现MVCC时,会为每一个表添加如下几个隐藏的字段:
- 6字节的DATA_TRX_ID:标记了最新更新这条行记录的transaction id,每处理一个事务,其值自动设置为当前事务ID(DATA_TRX_ID只有在事务提交之后才会更新);
- 7字节的DATA_ROLL_PTR:一个rollback指针,指向当前这一行数据的上一个版本,找之前版本的数据就是通过这个指针,通过这个指针将数据的多个版本连接在一起构成一个undo log版本链;
- 6字节的DB_ROW_ID:隐含的自增ID,如果数据表没有主键,InnoDB会自动以DB_ROW_ID产生一个聚簇索引。这是一个用来唯一标识每一行的字段;
- DELETE BIT位:用于标识当前记录是否被删除,这里的不是真正的删除数据,而是标志出来的删除。真正意义的删除是在commit的时候。
4.1.2 增删改查
1)增加:INSERT
设置新记录的DATA_TRX_ID为当前事务ID,其他的采用默认的。
2)删除:DELETE
修改DATA_TRX_ID的值为当前的执行删除操作的事务的ID,然后设置DELETE BIT为True,表示被删除
3)修改:UPDATE <==> INSERT + DELETE
- 用X锁锁定该行(因为是写操作);
- 记录redo log:将更新之后的数据记录到redo log中,以便日后使用;
- 记录undo log:将更新之后的数据记录到undo log中,设置当前数据行的DATA_TRX_ID为当前事务ID,回滚指针DATA_ROLL_PTR指向undo log中的当前数据行更新之前的数据行,同时设置更新之前的数据行的DATA_TRX_ID为当前事务ID,并且设置DELETE BIT为True,表示被删除。
4)查找:SELECT
- 如果当前数据行的DELETE BIT为False,只查找版本早于当前事务版本的数据行(也就是数据行的DATA_TRX_ID必须小于等于当前事务的ID),这确保当前事务读取的行都是事务之前已经存在的,或者是由当前事务创建或修改的行;
- 如果当前数据行的DELETE BIT为True,表示被删除,那么只能返回DATA_TRX_ID的值大于当前事务的行。获取在当前事务开始之前,还没有被删除的行。
4.2 MVCC与隔离级别的关系
1)Read Uncimmitted级别
由于存在脏读,即能读到未提交事务的数据行,所以不适MVCC。原因是MVCC的DATA_TRX_ID只有在事务提交之后才会更新,而在Read uncimmitted级别下,由于是读取未提交的,所以说MVCC在这个级别下是不适用的。
2)Read Committed级别
查找操作:假设当前有事务A、事务A+1、数据B(DATA_TRX_ID为A-1)
- 事务A进行查找,此时找出事务ID小于它本身的,所以此时数据B可以被找到;
- 如果在事务A还没有执行完毕的时候,事务A+1对数据B进行了更新操作,那么此时数据B的undo log则被更新为“数据B(DATA_TRX_ID为A+1)-> 数据B(DATA_TRX_ID为A-1)”;
- 此时如果事务A再次进行查找操作,会更新read_view。更新旧的read_view,并且开启新的事务A+2。那么根据MVCC的规定,就能够找到数据B(DATA_TRX_ID为A+1),可以找到更新之后的。这样子的话就等价于能够读取到别的事务commit的最新的数据记录。这就符合RC级别的语义。
3)Repeatable Read级别
查找操作:假设当前有事务A、事务A+1,数据B(DATA_TRX_ID为A-1)。
- 事务A进行查找,此时找出事务ID小于它本身的,所以此时数据B可以被找到;
- 如果在事务A还没有执行完毕的时候,事务A+1对数据B进行了更新操作,那么此时数据B的undo log则被更新为“数据B(DATA_TRX_ID为A+1)-> 数据B(DATA_TRX_ID为A-1)”;
- 此时如果事务A再次进行查找操作,那么根据MVCC的规定,还是只能找到数据B(DATA_TRX_ID为A-1)(因为B(DATA_TRX_ID为A+1)的事务ID比当前事务A的事务ID大,所以不会被找到),不会找到更新之后的。这样子的话就等价于只能够读取到事务A开始时读取到的数据记录。这就符合RR级别的语义。
4)Serialization级别
串行化由于是会对所涉及到的表加锁,并非行锁,自然也就不存在行的版本控制问题
因此MVCC只适用于MySQL隔离级别中的读已提交(Read committed)和可重复读(Repeatable Read)。
4.3 TiDB中的MVCC实现
TiKV的MVCC是通过在key后面添加版本号实现的,没有MVCC之前,可以把TiKV看做这样的:
Key1 -> Value
Key2 -> Value
……
KeyN -> Value
有了MVCC之后,TiKV的Key排列是这样的:
Key1_Version3 -> Value
Key1_Version2 -> Value
Key1_Version1 -> Value
……
Key2_Version4 -> Value
Key2_Version3 -> Value
Key2_Version2 -> Value
Key2_Version1 -> Value
……
KeyN_Version2 -> Value
KeyN_Version1 -> Value
……
至此,TiDB系列也基本完结,感兴趣的可以翻看之前的整理:
- 数据库系列之TiDB基本概念及集群环境部署
- 数据库系列之TiDB日常操作维护
- 数据库系列之TiDB数据导出和导入工具
- 数据库系列之TiDB Data Migration工具
- 数据库系列之TiDB备份恢复
- 数据库系列之TiDB存储引擎TiKV实现机制
- 数据库系列之TiDB中的执行计划
- 数据库系列之TiDB的SQL优化流程
- 数据库系列之TiDB Binlog同步复制工具
- 数据库系列之TiDB中的事务
参考资料:
- http://blog.itpub.net/26736162/viewspace-2638951/
- https://www.cnblogs.com/-brl/p/10273782.html
- https://pingcap.com/blog-cn/pessimistic-transaction-the-new-features-of-tidb/
- https://pingcap.com/blog-cn/best-practice-optimistic-transaction/
- https://www.cnblogs.com/garfieldcgf/p/5834201.html
- https://www.jianshu.com/p/8845ddca3b23
- https://www.cnblogs.com/axing-articles/p/11415763.html
转载请注明原文地址:https://blog.csdn.net/solihawk/article/details/119381370
文章会同步在公众号“牧羊人的方向”更新,感兴趣的可以关注公众号,谢谢!