本文基于MySQL5.7为基础,讨论与数据库事务和锁的相关内容。
根据加锁的范围,MySQL里面的锁可以分成全局锁、表级锁和行锁三类。
全局锁能够对整个库实例进行加锁。
加锁的语法:
FLUSH TABLES WITH READ LOCK;
解锁的语法:
UNLOCK TABLES;
全局锁的典型使用场景是,做全库逻辑备份。应用全局锁做逻辑备份有以下问题:
如果你在主库上备份,那么在备份期间都不能执行更新,业务基本上就得停摆;
如果你在从库上备份,那么备份期间从库不能执行主库同步过来的binlog,会导致主从延迟。
MySQL官方逻辑备份工具mysqldump,以下参数代表不通的实现方式:
--lock-all-tables
,在整个转储期间获取全局锁定来实现的。适合全库逻辑备份。
--lock-tables
,在转储之前锁定所有要转储的表。适合多表逻辑备份。
--single-transaction
,导数据之前就会启动一个事务,来确保拿到一致性视图,因此需要将事务隔离模式设置为REPEATABLE READ
。适合REPEATABLE READ
的隔离级别,并且表支持事务。
对一个或者多个表进行加锁。
加锁的语法:
LOCK TABLES
tbl_name [AS alias] {READ [LOCAL] | [LOW_PRIORITY] WRITE}
[, tbl_name [AS alias] {READ [LOCAL] | [LOW_PRIORITY] WRITE}] ...
解锁的语法:
UNLOCK TABLES;
在还没有出现更细粒度的锁的时候,表锁是最常用的处理并发的方式。而对于InnoDB这种支持行锁的引擎,一般不使用lock tables命令来控制并发,毕竟锁住整个表的影响面还是太大。
读锁之间不互斥,因此你可以有多个线程同时对一张表增删改查。读写锁之间、写锁之间是互斥的,用来保证安全性。
行锁,是现阶段InnoDB引擎支持的锁,因此,常常伴随着事务一同出现。在InnoDB事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。
读锁 | 写锁 | |
---|---|---|
读锁 | 兼容 | 冲突 |
写锁 | 冲突 | 冲突 |
ACID(Atomicity、Consistency、Isolation、Durability,即原子性、一致性、隔离性、持久性)
MySQL通过SET AUTOCOMMIT, START TRANSACTION, COMMIT和ROLLBACK等语句支持本地事务。
语法:
START TRANSACTION | BEGIN [WORK]
COMMIT [WORK] [AND [NO] CHAIN] [[NO] RELEASE]
ROLLBACK [WORK] [AND [NO] CHAIN] [[NO] RELEASE]
SET AUTOCOMMIT = {0 | 1}
默认情况下,mysql是autocommit的,如果需要通过明确的commit和rollback来提交和回滚事务,那么需要通过明确的事务控制命令来开始事务。
START TRANSACTION或BEGIN语句可以开始一项新的事务。
COMMIT和ROLLBACK用来提交或者回滚事务。
如果我们只是对某些语句需要进行事务控制,则使用START TRANSACTION开始一个事务比较方便,这样事务结束之后可以自动回到自动提交的方式,如果我们希望我们所有的事务都不是自动提交的,那么通过修改AUTOCOMMIT来控制事务比较方便,这样不用在每个事务开始的时候再执行START TRANSACTION。
time | session_1 | session_2 |
---|---|---|
– | mysql> SELECT * FROM t1; Empty set |
mysql> SELECT * FROM t1; Empty set |
– | mysql> start transaction; Query OK, 0 rows affected mysql> insert into t1 values(‘1’,1); Query OK, 1 row affected |
|
– | mysql> SELECT * FROM t1; Empty set |
|
– | mysql> commit; Query OK, 0 rows affected 手动提交 |
mysql> SELECT * FROM t1; ±----±----+ | a | b | ±----±----+ | 1 | 1 | ±----±----+ 1 row in set |
– | mysql> insert into t1 values(‘2’,2); Query OK, 1 row affected 自动提交 |
|
– | mysql> SELECT * FROM t1; ±----±----+ | a | b | ±----±----+ | 1 | 1 | | 2 | 2 | ±----±----+ 2 row in set |
开始一个事务,会造成一个隐含的unlock tables被执行:
time | session_1 | session_2 |
---|---|---|
– | mysql> select * from t1; Empty set |
mysql> select * from t1; Empty set |
– | mysql> lock table t1 write; Query OK, 0 rows affected |
|
– | mysql> select * from t1; waiting… |
|
– | mysql> insert into t1 values(‘1’,1); Query OK, 1 row affected |
waiting… |
– | mysql> rollback; Query OK, 0 rows affected |
waiting… |
– | mysql> start transaction; Query OK, 0 rows affected 开始一个事务时,表锁被释放。 |
waiting… |
– | Waiting ending mysql> select * from t1; ±----±----+ | a | b | ±----±----+ | 1 | 1 | ±----±----+ 1 row in set 对于已经提交的事务(rollback之前是自动提交), 不能通过 rollback进行回滚。 |
如果两个事务对同一行数据进行更新,由于InnoDB会对该行数据进行加锁,所以只能一个事务获得锁,另一个事务必须等待。
time | session_1 | session_2 |
---|---|---|
– | mysql> select * from t1; ±----±----+ | a | b | ±----±----+ | 1 | 1 | ±----±----+ 1 row in set |
mysql> select * from t1; ±----±----+ | a | b | ±----±----+ | 1 | 1 | ±----±----+ 1 row in set |
update t1 set a = a+1; Query OK, 1 row affected Rows matched: 1 Changed: 1 Warnings: 0 |
||
update t1 set a = a+1; waiting… |
||
commit; Query OK, 0 rows affected |
||
waiting ending update t1 set a = a+1; Query OK, 1 row affected Rows matched: 1 Changed: 1 Warnings: 0 |
||
mysql> select * from t1; ±----±----+ | a | b | ±----±----+ | 2 | 1 | ±----±----+ 1 row in set |
||
mysql> select * from t1; ±----±----+ | a | b | ±----±----+ | 3 | 1 | ±----±----+ 1 row in set |
InnoDB实现了多版本并发控制(MVCC),这意味着不同的用户将看到与之交互的数据的不同版本。这样做是为了使用户能够看到系统的一致性视图,而没有昂贵且性能受限的锁定,而锁定会限制并发性。
在InnoDB的MVCC实现中,需要知道的关键一点是,当一个记录被修改时,被修改的数据的当前(“旧”)版本首先作为一个“undo record”隐藏在一个“undo log”中。它之所以称为撤销日志,是因为它包含撤销用户所做更改所需的信息,从而将记录还原为以前的版本。
每个记录都包含对其最新撤消记录的引用(称为回滚指针),每个撤消记录都包含对其以前撤消记录的引用(除了初始记录插入),形成记录链。这样,只要撤消记录(“历史”)仍然存在于撤消日志中,就可以轻松地构造记录的任何早期版本。
正是MVCC提供的一致性视图,使InnoDB支持可重复读。下面来看看它具体是怎么实现的。
InnoDB每一个事务都有一个唯一的id,叫做transaction id,它是在事务开始的时候向InnoDB的事务系统申请的,是按申请顺序严格递增的。
而每行数据也都是有多个版本的。每次事务更新数据的时候,都会生成一个新的数据版本,并且把transaction id赋值给这个数据版本的事务ID,记为row trx_id。同时,旧的数据版本要保留,并且在新的数据版本中,能够有信息可以直接拿到它。
图中虚线框里是同一行数据的4个版本,当前最新版本是V4,k的值是22,它是被transaction id 为25的事务更新的,因此它的row trx_id也是25。
图中虚线框里是同一行数据的4个版本,当前最新版本是V4,k的值是22,它是被transaction id 为25的事务更新的,因此它的row trx_id也是25。实际上,图中的三个虚线箭头,就是undo log;而V1、V2、V3并不是物理上真实存在的,而是每次需要的时候根据当前版本和undo log计算出来的。比如,需要V2的时候,就是通过V4依次执行U3、U2算出来。
InnoDB为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前正在“活跃”的所有事务ID。“活跃”指的就是,启动了但还没提交。
对于当前事务的启动瞬间来说,一个数据版本的row trx_id,有以下几种可能:
InnoDB利用了“所有数据都有多个版本”的这个特性,实现了“秒级创建快照”的能力。
分析下事务A的语句返回的结果,为什么是k=1。
现在事务A要来读数据了,它的视图数组是[99,100]。当然了,读数据都是从当前版本读起的。所以,事务A查询语句的读数据流程是这样的:
找到(1,3)的时候,判断出row trx_id=101,比高水位大,处于红色区域,不可见;
接着,找到上一个历史版本,一看row trx_id=102,比高水位大,处于红色区域,不可见;
再往前找,终于找到了(1,1),它的row trx_id=90,比低水位小,处于绿色区域,可见。
事务B的update语句,如果按照一致性读,好像结果不对哦?
事务B的视图数组是先生成的,之后事务C才提交,不是应该看不见(1,2)吗,怎么能算出(1,3)?
这里就用到了这样一条规则:更新数据都是先读后写的,而这个读,只能读当前的值,称为“当前读”(current read)。
其实,除了update语句外,select语句如果加锁(加上lock in share mode 或 for update),也是当前读。
所以,能算出(1,3)。
下面是读提交时的状态图,可以看到这两个查询语句的创建视图数组的时机发生了变化,就是图中的read view框。
在读已提交的隔离级别下,所有已提交的版本的数据都能被读到,不会提供一致性读。read view是在get的时候才被创建的,所以能都到所有已提交的数据。
[1] [eimhe.com]网易技术部的MySQL中文资料.
[2] MySQL 5.7 Reference Manual
[3] MySQL实战45讲
[4] The basics of the InnoDB undo logging and history system