【MySQL】MySQL的锁与事务隔离级别详解
目录
1. 锁的概述
1.1 锁的定义
锁是计算机协调多个进程或线程并发访问某一资源的机制。
在数据库中,除了传统的计算资源(如CPU、RAM、I/O等)的争用以外,数据也是一种供需要用户共享的资源。如何保证数据并发访问的一致性、有效性是所有数据库必须解决的一个问题,锁冲突也是影响数据库并发访问性能的一个重要因素。从这个角度来说,锁对数据库而言显得尤其重要,也更加复杂。
1.2 锁的分类
- 从性能上分为乐观锁(用版本对比来实现)和悲观锁
- 从对数据库操作的类型分,分为读锁和写锁(都属于悲观锁)
读锁(共享锁):Shared Locks(S锁),针对同一份数据,多个读操作可以同时进行而不会互相影响
写锁(排它锁):Exclusive Locks(X锁),当前写操作没有完成前,它会阻断其他写锁和读锁
- 从对数据操作的粒度分,分为表锁,行锁,间隙锁
2. 三种锁(表锁、行锁、间隙锁)
2.1 表锁(偏向于读操作)
表锁偏向MyISAM存储引擎(该引擎只支持表锁),开销小,加锁快,无死锁,锁定粒度大,发生锁冲突的概率最高,并发度最低。
在对某个表执行SELECT、INSERT、DELETE、UPDATE语句时,InnoDB存储引擎是不会为这个表添加表级别的 S锁或者X锁的,如果想加表级锁需要手动添加。
在对某个表执行ALTER TABLE、DROP TABLE这些DDL语句时,其他事务对这个表执行SELECT、INSERT、 DELETE、UPDATE的语句会发生阻塞,或者,某个事务对某个表执行SELECT、INSERT、DELETE、UPDATE语句时,其他事务对这个表执行DDL语句也会发生阻塞。这个过程是通过使用的元数据锁(英文名:Metadata Locks,简称MDL)来实现的,并不是使用的表级别的S锁和X锁。
2.1.1 基本操作
- 建表SQL
CREATE TABLE `mylock` (
`id` INT (11) NOT NULL AUTO_INCREMENT,
`NAME` VARCHAR (20) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE = MyISAM DEFAULT CHARSET = utf8;
- 插入数据
INSERT INTO`test`.`mylock` (`id`, `NAME`) VALUES ('1', 'a');
INSERT INTO`test`.`mylock` (`id`, `NAME`) VALUES ('2', 'b');
INSERT INTO`test`.`mylock` (`id`, `NAME`) VALUES ('3', 'c');
INSERT INTO`test`.`mylock` (`id`, `NAME`) VALUES ('4', 'd');
- 手动增加表锁(读锁/写锁)
lock table 表名称 read/write,表名称2 read/write;
- 查看表上加过的锁
show open tables;
- 删除表锁
unlock tables;
- LOCK TABLES t1 READ:对表t1加表级别的S锁。
- LOCK TABLES t1 WRITE:对表t1加表级别的S锁。
尽量不用这两种方式去加锁,因为InnoDB的优点就是行锁,所以尽量使用行锁,性能更高。
2.1.2 案例分析(加读锁)
当前session(一个用户就有一个session,其实就可以类比成线程,不同的session就是不同的线程)和其他session都可以读该表
当前session中插入或者更新该表就会报错,其他session插入或更新则会等待。
因为读锁主要是用来做数据迁移的,在迁移过程中除了不能让其他的session对数据进行修改,自己也不能对数据进行修改
2.1.3 案例分析(加写锁)
当前session对该表的增删改查都没有问题,其他session对该表的所有操作(包括读操作)被阻塞
2.1.4 案例结论
MyISAM在执行查询语句(SELECT)前,会自动给涉及的所有表加读锁,在执行增删改操作前,会自动给涉及的表加写锁。
1、对MyISAM表的读操作(加读锁) ,不会阻寒其他进程对同一表的读请求,但会阻赛所有进程对表的写请求。只有当读锁释放后,才会执行其它进程的写操作。
2、对MylSAM表的写操作(加写锁) ,会阻塞其他进程对同一表的读和写操作,只有当写锁释放后,才会执行其它进程的读写操作。
总结:
简而言之,就是读锁会阻塞写,但是不会阻塞读。而写锁则会把其他session的读和写都阻塞。
2.1.5 IS锁和IX锁
- IS锁:意向共享锁、Intention Shared Lock。当事务准备在某条记录上加S锁时,需要先在表级别加一个IS锁。
- IX锁,意向排他锁、Intention Exclusive Lock。当事务准备在某条记录上加X锁时,需要先在表级别加一个IX锁。
IS、IX锁是表级锁,它们的提出仅仅为了在之后加表级别的S锁和X锁时可以快速判断表中的记录是否被上锁,以避免用遍历的方式来查看表中有没有上锁的记录。就是说当对一个行加锁之后,如果有打算给行所在的表加一个表锁,必须先看看该表的行有没有被加锁,否则就会出现冲突。IS锁和IX锁就避免了判断表中行有没有加锁时对每一行的遍历。直接查看表有没有意向锁就可以知道表中有没有行锁。
注意:如果一个表中有多个行锁,他们都会给表加上意向锁,意向锁和意向锁之间是不会冲突的。
2.1.6 锁的兼容性
| IS | IX | S | X |
IS | 兼容 | 兼容 | 兼容 | 不兼容 |
IX | 兼容 | 兼容 | 不兼容 | 不兼容 |
S | 兼容 | 不兼容 | 兼容 | 不兼容 |
X | 不兼容 | 不兼容 | 不兼容 | 不兼容 |
2.1.6 AUTO-INC锁
- 在执行插入语句时就在表级别加一个AUTO-INC锁,然后为每条待插入记录的AUTO_INCREMENT修饰的列分配递增的值,在该语句执行结束后,再把AUTO-INC锁释放掉。这样一个事务在持有 AUTO-INC锁的过程中,其他事务的插入语句都要被阻塞,可以保证一个语句中分配的递增值是连续的。
- 采用一个轻量级的锁,在为插入语句生成AUTO_INCREMENT修饰的列的值时获取一下这个轻量级 锁,然后生成本次插入语句需要用到的AUTO_INCREMENT列的值之后,就把该轻量级锁释放掉, 并不需要等到整个插入语句执行完才释放锁。
系统变量innodb_autoinc_lock_mode:
- innodb_autoinc_lock_mode值为0:采用AUTO-INC锁。
- innodb_autoinc_lock_mode值为2:采用轻量级锁。
- 当innodb_autoinc_lock_mode值为1:当插入记录数不确定是采用AUTO-INC锁,当插入记录数确定时采用轻量级锁
2.2 行锁(偏写)
行锁偏向InnoDB存储引擎(该引擎支持行锁和表锁),开销大,加锁慢,会出现死锁,锁定粒度最小,发生锁冲突的概率最低,并发度也最高。InnoDB与MYISAM的最大不同有两点:一是支持事务(TRANSACTION);二是采用了行级锁。
行锁的种类:
- LOCK_REC_NOT_GAP:单个行记录上的锁。
- LOCK_GAP:间隙锁,锁定一个范围,但不包括记录本身。比如锁定a=5以及其前后2个范围内的数据,也就是将a=3,4,6,7这些行都锁了起来,不包括a=5本身。GAP锁的目的,是为了防止同一事务的两次当前读,出现幻读的情况。
- LOCK_ORDINARY:锁定一个范围,并且锁定记录本身。比如锁定a=5以及其前后2个范围内的数据,也就是将a=3,4,5,6,7这些行都锁了起来。对于行的查询,都是采用该方法,主要目的是解决幻读的问题。
2.2.1 事务的相关使用方法
2.2.1.1 开启事务
- begin[work];
BEGIN语句代表开启一个事务,后边的单词WORK可有可无。开启事务后,就可以继续写若干条语句,这些语句 都属于刚刚开启的这个事务。
mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)
mysql> sql...
- START TRANSACTION
START TRANSACTION语句和BEGIN语句有着相同的功效,都标志着开启一个事务,比如这样:
mysql> START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)
mysql> sql..
2.2.1.2 提交事务
COMMIT
mysql> BEGIN; Query OK, 0 rows affected (0.00 sec)
mysql> UPDATE account SET balance = balance - 10 WHERE id = 1;
Query OK, 1 row affected (0.02 sec) Rows matched: 1 Changed: 1 Warnings: 0
mysql> UPDATE account SET balance = balance + 10 WHERE id = 2;
Query OK, 1 row affected (0.00 sec) Rows matched: 1 Changed: 1 Warnings: 0
mysql> COMMIT;
Query OK, 0 rows affected (0.00 sec)
2.2.1.3 手动中止事务
ROLLBACK
mysql> BEGIN; Query OK, 0 rows affected (0.00 sec)
mysql> UPDATE account SET balance = balance - 10 WHERE id = 1;
Query OK, 1 row affected (0.00 sec) Rows matched: 1 Changed: 1 Warnings: 0
mysql> UPDATE account SET balance = balance + 1 WHERE id = 2;
Query OK, 1 row affected (0.00 sec) Rows matched: 1 Changed: 1 Warnings: 0
mysql> ROLLBACK; Query OK, 0 rows affected (0.00 sec)
这里需要强调一下,ROLLBACK语句是我们程序员手动的去回滚事务时才去使用的,如果事务在执行过程中遇到 了某些错误而无法继续执行的话,事务自身会自动的回滚
2.2.1.4 自动提交事务
mysql> SHOW VARIABLES LIKE 'autocommit';
默认情况下,如果我们不显式的使用START TRANSACTION或者BEGIN语句开启一个事务,那么每一条语句都算 是一个独立的事务,这种特性称之为事务的自动提交。
如果我们想关闭这种自动提交的功能,可以使用下边两种方法之一:
- 显式的的使用START TRANSACTION或者BEGIN语句开启一个事务。这样在本次事务提交或者回滚前会暂时关闭掉自动提交的功能。
- 把系统变量autocommit的值设置为OFF,就像这样: SET autocommit = OFF; 这样的话,我 们写入的多条语句就算是属于同一个事务了,直到我们显式的写出COMMIT语句来把这个事务提交掉,或者显式的写出ROLLBACK语句来把这个事务回滚掉。
2.2.1.5 隐式提交
当我们使用START TRANSACTION或者BEGIN语句开启了一个事务,或者把系统变量autocommit的值设置为 OFF时,事务就不会进行自动提交,但是如果我们输入了某些语句之后就会悄悄的提交掉,就像我们输入了 COMMIT语句了一样,这种因为某些特殊的语句而导致事务提交的情况称为隐式提交,这些会导致事务隐式提交 的语句包括:
- 定义或修改数据库对象的数据定义语言(Data definition language,缩写为:DDL)。所谓的数据库对象,指的就是数据库、表、视图、存储过程等等这些东西。当我们使用CREATE、ALTER、 DROP等语句去修改这些所谓的数据库对象时,就会隐式的提交前边语句所属于的事务。
- 隐式使用或修改mysql数据库中的表:当我们使用ALTER USER、CREATE USER、DROP USER、 GRANT、RENAME USER、SET PASSWORD等语句时也会隐式的提交前边语句所属于的事务。
- 事务控制或关于锁定的语句:当我们在一个事务还没提交或者回滚时就又使用START TRANSACTION或者BEGIN语句开启了另一个事务时,会隐式的提交上一个事务。或者当前的 autocommit系统变量的值为OFF,我们手动把它调为ON时,也会隐式的提交前边语句所属的事 务。或者使用LOCK TABLES、UNLOCK TABLES等关于锁定的语句也会隐式的提交前边语句所属 的事务。
- 加载数据的语句:比如我们使用LOAD DATA语句来批量往数据库中导入数据时,也会隐式的提交 前边语句所属的事务。
- 其它的一些语句:使用ANALYZE TABLE、CACHE INDEX、CHECK TABLE、FLUSH、 LOAD INDEX INTO CACHE、OPTIMIZE TABLE、REPAIR TABLE、RESET等语句也会隐式的提交前边语句所属的事务
2.2.1.6 保存点
如果你开启了一个事务,并且已经敲了很多语句,忽然发现上一条语句有点问题,你只好使用ROLLBACK语句来 让数据库状态恢复到事务执行之前的样子,然后一切从头再来,总有一种一夜回到解放前的感觉。所以MYSQL 提出了一个保存点(英文:savepoint)的概念,就是在事务对应的数据库语句中打几个点,我们在调用 ROLLBACK语句时可以指定会滚到哪个点,而不是回到最初的原点。定义保存点的语法如下:
SAVEPOINT 保存点名称;
当我们想回滚到某个保存点时,可以使用下边这个语句(下边语句中的单词WORK和SAVEPOINT是可有可无 的):
ROLLBACK [WORK] TO [SAVEPOINT] 保存点名称;
不过如果ROLLBACK语句后边不跟随保存点名称的话,会直接回滚到事务执行之前的状态。 如果我们想删除某个保存点,可以使用这个语句:
RELEASE SAVEPOINT 保存点名称;
2.2.2 行锁支持事务
- 事务(Transaction)及其ACID属性
事务是由一组SQL语句组成的逻辑处理单元,事务具有以下4个属性,通常简称为事务的ACID属性。我们把需要保证原子性、隔离性、一致性和持久性的一个或多个数据库操作称之为一个事务。
- 原子性(Atomicity) :事务是一个原子操作单元,其对数据的修改,要么全都执行,要么全都不执行。
- 一致性(Consistent) :在事务开始和完成时,数据都必须保持一致状态。这意味着所有相关的数据规则都必须应用于事务的修改,以保持数据的完整性;事务结束时,所有的内部数据结构(如B树索引或双向链表)也都必须是正确的。如果不一致会出现同一个事务上半部分使用到的数据和下半部分不一致,也就出现了同一个事物前后使用数据不一致的情况,造成事务出现混乱。
- 隔离性(Isolation) :数据库系统提供一定的隔离机制,保证事务在不受外部并发操作影响的“独立”环境执行。这意味着事务处理过程中的中间状态对外部是不可见的,反之亦然。
- 持久性(Durable) :事务完成之后,它对于数据的修改是永久性的,即使出现系统故障也能够保持。
以一个小明转账的场景来逐个分析事务和ACID属性:
事务:
场景:小明向小强转账10元
原子性:
转账操作是一个不可分割的操作,要么转失败,要么转成功,不能存在中间的状态,也就是转了一半的这种情 况。我们把这种要么全做,要么全不做的规则称之为原子性。
隔离性:
另外一个场景:
1. 小明向小强转账10元
2. 小明向小红转账10元
隔离性表示上面两个操作是不能相互影响的
一致性:
对于上面的转账场景,一致性表示每一次转账完成后,都需要保证整个系统的余额等于所有账户的收入减去所有 账户的支出。 如果不遵循原子性,也就是如果小明向小强转账10元,但是只转了一半,小明账户少了10元,小强账户并没有增加,所以没有满足一致性了。
同样,如果不满足隔离性,也有可能导致破坏一致性。
所以说,数据库某些操作的原子性和隔离性都是保证一致性的一种手段,在操作执行完成后保证符合所有既定的 约束则是一种结果。
实际上我们也可以对表建立约束来保证一致性。
持久性:
对于转账的交易记录,需要永久保存。
- 并发事务处理带来的问题
更新丢失(Lost Update)
当两个或多个事务选择同一行,然后基于最初选定的值更新该行时,由于每个事务都不知道其他事务的存在(隔离性),就会发生丢失更新问题–最后的更新覆盖了由其他事务所做的更新。可以用乐观锁引入版本概念来解决。
脏读(Dirty Reads)
一个事务正在对一条记录做修改,在这个事务完成并提交前,这条记录的数据就处于不一致的状态;这时,另一个事务也来读取同一条记录,如果不加控制,第二个事务读取了这些“脏”数据,并据此作进一步的处理,就会产生未提交的数据依赖关系。这种现象被形象的叫做“脏读”。
一句话:事务A读取到了事务B已经修改但尚未提交的数据,还在这个数据基础上做了操作。此时,如果B事务回滚,A读取的数据无效,不符合一致性要求。
不可重复读(Non-Repeatable Reads)
一个事务在读取某些数据后的某个时间,再次读取以前读过的数据,却发现其读出的数据已经发生了改变、或某些记录已经被删除了!这种现象就叫做“不可重复读”。
一句话:事务A读取到了事务B已经提交的修改数据,不符合隔离性
幻读(Phantom Reads)
一个事务按相同的查询条件重新读取以前检索过的数据,却发现其他事务插入了满足其查询条件的新数据,上一次读取还没有,下一次读取就有了,好像出现了“幻觉”,这种现象就称为“幻读”。
一句话:事务A读取到了事务B提交的新增数据,不符合隔离性
脏读是事务B里面修改了数据
幻读是事务B里面新增了数据
- 事务隔离级别
“脏读”、“不可重复读”和“幻读”,其实都是数据库读一致性问题,必须由数据库提供一定的事务隔离机制来解决。
数据库的事务隔离越严格,并发副作用越小,但付出的代价也就越大,因为事务隔离实质上就是使事务在一定程度上“串行化”进行,这显然与“并发”是矛盾的。
同时,不同的应用对读一致性和事务隔离程度的要求也是不同的,比如许多应用对“不可重复读"和“幻读”并不敏感,可能更关心数据并发访问的能力。
注意:这四种隔离级别是SQL的标准定义,不同的数据库会有不同的实现。
常看当前数据库的事务隔离级别: show variables like 'tx_isolation';
设置事务隔离级别:set tx_isolation='REPEATABLE-READ';
每一个事务都会遵守设置好的事务隔离级别,MySQL默认隔离级别是可重复读
2.2.3 行锁案例分析
用下面的表演示,需要开启事务,Session_1更新某一行,Session_2更新同一行被阻塞,但是更新其他行正常
2.2.4 隔离级别案例分析
CREATE TABLE `account` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(255) DEFAULT NULL,
`balance` int(11) DEFAULT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
INSERT INTO `test`.`account` (`name`, `balance`) VALUES ('lilei', '450');
INSERT INTO `test`.`account` (`name`, `balance`) VALUES ('hanmei', '16000');
INSERT INTO `test`.`account` (`name`, `balance`) VALUES ('lucy', '2400');
1、读未提交:
(1)打开一个客户端A,并设置当前事务模式为read uncommitted(未提交读),查询表account的初始值: 两个客户端都要修改事务隔离级别
set tx_isolation='read-uncommitted';
(2)在客户端A的事务提交之前,打开另一个客户端B,更新表account:
(3)这时,虽然客户端B的事务还没提交,但是客户端A就可以查询到B已经更新的数据:
(4)一旦客户端B的事务因为某种原因回滚,所有的操作都将会被撤销,那客户端A查询到的数据其实就是脏数据:
(5)在客户端A执行更新语句update account set balance = balance - 50 where id =1,lilei的balance没有变成350,居然是400,是不是很奇怪,数据不一致啊,如果你这么想就太天真 了,在应用程序中,我们会用400-50=350,并不知道其他会话回滚了,要想解决这个问题可以采用读已提交的隔离级别
2、读已提交
(1)打开一个客户端A,并设置当前事务模式为read committed(未提交读),查询表account的所有记录:
set tx_isolation='read-committed';
(2)在客户端A的事务提交之前,打开另一个客户端B,更新表account:
(3)这时,客户端B的事务还没提交,客户端A不能查询到B已经更新的数据,解决了脏读问题:
(4)客户端B的事务提交
(5)客户端A执行与上一步相同的查询,结果 与上一步不一致,即产生了不可重复读的问题
3、可重复读
(1)打开一个客户端A,并设置当前事务模式为repeatable read,查询表account的所有记录
set tx_isolation='repeatable-read';
(2)在客户端A的事务提交之前,打开另一个客户端B,更新表account并提交
(3)在客户端A查询表account的所有记录,与步骤(1)查询结果一致,没有出现不可重复读的问题
(4)在客户端A,接着执行update account set balance = balance - 50 where id = 1,balance没有变成400-50=350,lilei的balance值用的是步骤(2)中的350来算的,所以是300,数据的一致性倒是没有被破坏。可重复读的隔离级别下使用了MVCC机制(多版本并发控制机制),select操作不会更新版本号,是快照读(历史版本),读快照的数据比读真是在数据库中的最新数据速度要快;insert、update和delete会更新版本号,是当前读(当前版本)。可以解决超卖问题。
以后些项目的时候在MySQL默认事务隔离级别:可重复读下不要先用select查询到数据A的值a1,然后在Java代码中将查询出来的数据a1做加减操作后再写回到数据库的A数据中,这样是有可能出现并发问题的。最正确的方式就是直接用数据库的update语句来直接更新数据,这样使用的A值才会使真正的A值,而不会出现使用历史数据(快照)
(5)重新打开客户端B,插入一条新数据后提交
(6)在客户端A查询表account的所有记录,没有 查出 新增数据,所以没有出现幻读
(7)验证幻读
在客户端A执行update account set balance=888 where id = 4;能更新成功,再次查询能查到客户端B新增的数据
因为可重复读只是在读方面直接读取快照,但是当使用修改,删除等语句的时候,数据库就会直接去读取在数据库存储的最新真实数据,这就出现了幻读现象。
4.串行化
(1)打开一个客户端A,并设置当前事务模式为serializable,查询表account的初始值:
set tx_isolation='serializable';
mysql> set session transaction isolation level serializable;
Query OK, 0 rows affected (0.00 sec)
mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)
mysql> select * from account;
+------+--------+---------+
| id | name | balance |
+------+--------+---------+
| 1 | lilei | 10000 |
| 2 | hanmei | 10000 |
| 3 | lucy | 10000 |
| 4 | lily | 10000 |
+------+--------+---------+
4 rows in set (0.00 sec)
(2)打开一个客户端B,并设置当前事务模式为serializable,插入一条记录报错,表被锁了插入失败,mysql中事务隔离级别为serializable时会锁表,因此不会出现幻读的情况,将所有的并发操作都进行了同步操作变成了串行了,同一个表在同一时间只能被同一个session操作,这种隔离级别并发性极低,开发中很少会用到。注意:两个session都是读的时候,串行化不会阻塞session,只有出现修改操作才会阻塞
mysql> set session transaction isolation level serializable;
Query OK, 0 rows affected (0.00 sec)
mysql> start transaction;
Query OK, 0 rows affected (0.00 sec)
mysql> insert into account values(5,'tom',0);
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
2.2.5 间隙锁
Mysql默认级别是repeatable-read,有办法解决幻读问题吗?
间隙锁在某些情况下可以解决幻读问题
除了串行化的事务隔离级别,要避免幻读可以用间隙锁在Session_1下面执行
update account set name = 'zhuge' where id > 10 and id <=20;,
则其他Session没法插入这个范围内的数据
间隙锁的官方定义:
当我们用范围条件条件检索数据(非聚簇索引、非唯一索引),并请求共享或排他锁时,InnoDB会给符合条件的数据记录的索引项加锁;对于键值在条件范围内但并不存在的记录,称为间隙,InnoDB也会为这些间隙加锁,即间隙锁。
Next-Key锁是符合条件的行锁加上间隙锁
2.2.5.1 间隙锁产生的条件
在InnoDB下,间隙锁的产生需要满足三个条件:
- 隔离级别为RR
- 当前读
- 查询条件能够走到索引
2.2.5.2 间隙锁的作用
MySQL官方文档:间隙锁的目的是为了让其他事务无法在间隙中新增数据。
在RR模式的InnoDB中,间隙锁能起到两个作用:
1. 保障数据的恢复和复制
2. 防止幻读
- 防止在间隙中执行insert语句
- 防止将已有数据update到间隙中
数据库数据的恢复和复制是通过binlog(用来实现数据库恢复和复制的一个日志文件)实现的,binlog中记录了执行成功的DML语句(在阿里得到了极广泛的应用),在数据恢复时需要保证数据之间的事务顺序,间隙锁可以避免在一批数据中插入其他事务。
2.2.6 MVCC机制原理
2.2.6.1 版本链
对于使用InnoDB存储引擎的表来说,它的聚簇索引记录中都包含两个必要的隐藏列(row_id并不是必要的,我们 创建的表中有主键或者非NULL唯一键时都不会包含row_id列):
- trx_id:每次对某条记录进行改动时,都会把对应的事务id赋值给trx_id隐藏列。
- roll_pointer:每次对某条记录进行改动时,这个隐藏列会存一个指针,可以通过这个指针找到该记 录修改前的信息。
版本链示意图:
版本链是从上到下依次链接的,每一层就代表的一个事务对某一行进行的操作。最上面是最新的事务版本,最下面是最老的事务版本。
每一层的最右边是一个指针(roll_pointer),指向的是当前事务修改前的记录。这个指针也可以用来回滚事务。
每一层右数第二个是事务id(trx_id),是事务的唯一标识。版本年的事务id时递增的,事务id越大,表示事务越新。
每一层剩下的就是真实的行数据(也有可能包含数据库自己生成的row_id)。
2.2.6.2 ReadView
对于使用READ UNCOMMITTED(读未提交)隔离级别的事务来说,直接读取记录的最新版本就好了,对于使用 SERIALIZABLE隔离级别的事务来说,使用加锁的方式来访问记录。对于使用READ COMMITTED和 REPEATABLE READ隔离级别的事务来说,就需要用到我们上边所说的版本链了,核心问题就是:需要判断一下版本链中的哪个版本是当前事务可见的。为了实现这个功能,就引入了ReadView。
ReadView中主要包含4个比较重要的内容:
- m_ids:表示在生成ReadView时当前系统中活跃的读写事务的事务id列表。就可以理解为里面存的是还没有提交的事务id。
- min_trx_id:表示在生成ReadView时当前系统中活跃的读写事务中最小的事务id,也就是m_ids中的最小 值。 就是还未提交的事务中最老的一个事务。
- max_trx_id:表示生成ReadView时系统中应该分配给下一个事务的id值。
- creator_trx_id:表示生成该ReadView的事务的事务id。
ReadView会在事务进行select的时候被创建,每一个事务都会有一个自己的ReadView,但是增删改操作不会使用ReadView,只有查询操作会使用。
注意max_trx_id并不是m_ids中的最大值,事务id是递增分配的。比方说现在有id为1,2,3这三个事务,都做出了修改但是还未提交,之后id为3的事务提交了。那么一个新的读事务在生成ReadView时,m_ids就包括1和2,min_trx_id的值就是1, max_trx_id的值就是4。
有了这个ReadView,这样在访问某条记录时,只需要按照下边的步骤判断记录的某个版本是否可见:
- 如果被访问版本的trx_id属性值与ReadView中的creator_trx_id值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。
- 如果被访问版本的trx_id属性值小于ReadView中的min_trx_id值,表明生成该版本的事务在当前事务生成ReadView前已经提交,所以该版本可以被当前事务访问。
- 如果被访问版本的trx_id属性值大于ReadView中的max_trx_id值,表明生成该版本的事务在当前事务生成ReadView后才开启,所以该版本不可以被当前事务访问。
- 如果被访问版本的trx_id属性值在ReadView的min_trx_id和max_trx_id之间,那就需要判断一下 trx_id属性值是不是在m_ids列表中,如果在,说明创建ReadView时生成该版本的事务还是活跃的(未提交),该版本不可以被访问;如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问。
2.2.6.3 读已提交(READ COMMITTED)的实现方式
每次读取数据前都生成一个ReadView,然后查询版本链中的事务id,从上面的事务开始向下找,找到的第一个事务id不在ReadView的m_ids中的,读取版本链中该事务中的真实行数据。即读最新的已提交事务的行数据。
2.2.6.4 可重复读(REPEATABLE READ)的实现方式
在第一次读取数据时生成一个ReadView,然后后面的所有读操作都是用第一次生成的这个ReadView。这样只会读取第一次读数据时最新的已提交数据,就算是后来那些存在ReadView的m_ids的事务提交了,已经从m_ids中去除了,但是后面的查询操作还是会使用最开始的ReadView,认为他们还没有提交,这也就实现了可重复读。
2.2.6.5 MVCC总结
MVCC(Multi-Version Concurrency Control ,多版本并发控制)指的就是在使用READ COMMITTD、 REPEATABLE READ这两种隔离级别的事务在执行普通的SEELCT操作时(其他的修改错做不会使用MVCC)访问记录的版本链的过程。可以使不同事务的读-写、写-读操作并发执行,从而提升系统性能。READ COMMITTD、REPEATABLE READ这两个隔离级 别的一个很大不同就是:生成ReadView的时机不同,READ COMMITTD在每一次进行普通SELECT操作前都会 生成一个ReadView,而REPEATABLE READ只在第一次进行普通SELECT操作前生成一个ReadView,之后的查 询操作都重复使用这个ReadView就好了。
2.2.7 案例结论
Innodb存储引擎由于实现了行级锁定,虽然在锁定机制的实现方面所带来的性能损耗可能比表级锁定会要更高一下,但是在整体并发处理能力方面要远远优于MYISAM的表级锁定的。当系统并发量高的时候,Innodb的整体性能和MYISAM相比就会有比较明显的优势了。
但是,Innodb的行级锁定同样也有其脆弱的一面,当我们使用不当的时候,可能会让Innodb的整体性能表现不仅不能比MYISAM高,甚至可能会更差。
2.2.8 行锁分析
通过检查InnoDB_row_lock状态变量来分析系统上的行锁的争夺情况
show status like'innodb_row_lock%';
对各个状态量的说明如下:
Innodb_row_lock_current_waits: 当前正在等待锁定的数量
Innodb_row_lock_time: 从系统启动到现在锁定总时间长度
Innodb_row_lock_time_avg: 每次等待所花平均时间
Innodb_row_lock_time_max:从系统启动到现在等待最长的一次所花时间
Innodb_row_lock_waits:系统启动后到现在总共等待的次数
对于这5个状态变量,比较重要的主要是:
Innodb_row_lock_time_avg (等待平均时长)
Innodb_row_lock_waits (等待总次数)
Innodb_row_lock_time(等待总时长)
尤其是当等待次数很高,而且每次等待时长也不小的时候,我们就需要分析系统中为什么会有如此多的等待,然后根据分析结果着手制定优化计划。
2.2.9 死锁
set tx_isolation='repeatable-read';
Session_1执行:select * from account where id=1 for update;
Session_2执行:select * from account where id=2 for update;
Session_1执行:select * from account where id=2 for update;
Session_2执行:select * from account where id=1 for update;
查看近期死锁日志信息:show engine innodb status\G;
大多数情况mysql可以自动检测死锁并回滚产生死锁的那个事务,但是有些情况mysql没法自动检测死锁
2.2.10 优化建议
- 尽可能让所有数据检索都通过索引来完成,避免无索引行锁升级为表锁
- 合理设计索引,尽量缩小锁的范围
- 尽可能减少检索条件,避免间隙锁
- 尽量控制事务大小,减少锁定资源量和时间长度
- 尽可能低级别事务隔离
3. 读锁和写锁
上文讲了读锁(S锁)和写锁(X锁)
S锁和X锁的冲突情况:
|
X锁 |
S锁 |
X锁 |
冲突 |
冲突 |
S锁 |
冲突 |
不冲突 |
3.1 读操作
对于普通 SELECT 语句,InnoDB 不会加任何锁。如果select语句想要加锁需要手动添加。
3.1.1 select 加读锁
- select … lock in share mode
将查找到的数据加上一个S锁,允许其他事务继续获取这些记录的S锁,不能获取这些记录的X锁(会阻塞)
3.1.2 select 加写锁
- select … for update
将查找到的数据加上一个X锁,不允许其他事务获取这些记录的S锁和X锁。
3.2 写操作
对于写操作,InnoDB会自动为其加读锁,
- DELETE:删除一条数据时,先对记录加X锁,再执行删除操作。
- INSERT:插入一条记录时,会先加隐式锁 隐式锁来保护这条新插入的记录在本事务提交前不被别的事务 访问到。
- UPDATE:
- 如果被更新的列,修改前后没有导致存储空间变化,那么会先给记录加X锁,再直接对记录进行修改。
- 如果被更新的列,修改前后导致存储空间发生了变化,那么会先给记录加X锁,然后将记录删掉,再Insert一条新记录
隐式锁:一个事务插入一条记录后,还未提交,这条记录会保存本次事务id,而其他事务如果想来读取这个记录会发现事务id不对应,所以相当于在插入一条记录时,隐式的给这条记录加了一把隐式锁
4. 悲观锁和乐观锁
4.1 悲观锁
悲观锁(Pessimistic Locking),悲观锁是指在数据处理过程,使数据处于锁定状态,一般使用数据库的锁机制实现。
悲观锁用的就是数据库的行锁,认为数据库会发生并发冲突,直接上来就把数据锁住,其他事务不能修改,直至提交了当前事务。
现在互联网高并发的架构中,受到fail-fast思路的影响,悲观锁已经非常少见了。上面讲的那些读锁和写锁都是悲观锁,所以在这里就不再赘述。
4.2 乐观锁
乐观锁相对悲观锁而言,它认为数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则让返回错误信息,让用户决定如何去做。
乐观锁其实是一种思想,认为不会锁定的情况下去更新数据,如果发现不对劲,才不更新(回滚)。在数据库中往往添加一个version字段(版本号)来实现。乐观锁可以用来避免更新丢失。接下来我们看一下乐观锁在数据表和缓存中的实现。
4.2.1 数据表中的实现
利用数据版本号(version)机制是乐观锁最常用的一种实现方式。一般通过为数据库表增加一个数字类型的 “version” 字段,当读取数据时,将version字段的值一同读出,数据每更新一次,对此version值+1。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的version值进行比对,如果数据库表当前版本号与第一次取出来的version值相等,则予以更新,否则认为是过期数据,返回更新失败。
例子:
//step1: 查询出商品信息
select (quantity,version) from items where id=100;
//step2: 根据商品信息生成订单
insert into orders(id,item_id) values(null,100);
//step3: 修改商品的库存
update items set quantity=quantity-1,version=version+1 where id=100 and version=#{version};
既然可以用version,那还可以使用时间戳字段,该方法同样是在表中增加一个时间戳字段,和上面的version类似,也是在更新提交的时候检查当前数据库中数据的时间戳和自己更新前取到的时间戳进行对比,如果一致则OK,否则就是版本冲突。
需要注意的是,如果你的数据表是读写分离的表,当master表中写入的数据没有及时同步到slave表中时会造成更新一直失败的问题。此时,需要强制读取master表中的数据(将select语句放在事务中)。即:把select语句放在事务中,查询的就是master主库了!
4.2.2 乐观锁的锁粒度
乐观锁广泛用于状态同步,我们经常会遇到并发对一条物流订单修改状态的场景,所以此时乐观锁就发挥了巨大作用。但是乐观锁字段的选用也需要非常讲究,一个好的乐观锁字段可以缩小锁粒度。
商品库存扣减时,尤其是在秒杀、聚划算这种高并发的场景下,若采用version号作为乐观锁,则每次只有一个事务能更新成功,业务感知上就是大量操作失败。因为version的粒度太大,更新失败的概率也就会变大。
但是如果我们挑选库存字段作为乐观锁(通过比较库存数来判断数据版本),这样我们的锁粒度就会减小,更新失败的概率也会大大减小。
// 以库存数作为乐观锁
//step1: 查询出商品信息
select (inventory) from items where id=100;
//step2: 根据商品信息生成订单
insert into orders(id,item_id) values(null,100);
//step3: 修改商品的库存
update items set inventory=inventory-1 where id=100 and inventory-1>0;
没错!你参加过的天猫、淘宝秒杀、聚划算,跑的就是这条SQL,通过挑选乐观锁,可以减小锁力度,从而提升吞吐。
4.2.3 扩展训练
在阿里很多系统中都能看到常用的features、params等字段,这些字段如果不进行版本控制,在并发场景下非常容易出现更新丢失的问题。
比如:
线程 | 原始features | 目标features |
T-A | a=1; | a=1;b=1; |
T-B | a=1; | a=1;c=1; |
我们期望最终更新的结果为:
a=1;b=1;c=1;
此时若SQL写成了
update
lg_order
set
features=#features#
where
order_id=#order_id#
那么随着T-A和T-B的先后顺序不同,我们得到的结果有可能会是a=1;b=1;或a=1;c=1;
所以就要引入乐观锁进行版本控制。
若此时采用乐观锁,利用全局字段version进行处理,则会发现与lg_order的其他字段变更有非常高的冲突率,因为version字段是全局的。不管是不是features字段的更新,只要是这一行数据有更新就都会自增version字段。
update
lg_order
set
features=#features#,
version=version+1
where
order_id=#order_id#
and version=#ori_version#
这种SQL会因为version的失败而导致非常高的失败率,因为其他字段也在并发变更。
怎么办?
我们会发现一般设计库表的时,凡事拥有features类似字段的,都会有一个features_cc与之成对出现,很多厂内年轻一辈的程序员很少注意到这个字段,我们努力纠正过很久,现在应该好很多了。
version和_cc的区别是什么?
version和_cc是2个版本控制,一个是控制这一条数据,一个是控制这个一个字段的。比如一个字段a,如果引入version字段,那么这个version字段表示的时这一行数据的版本,就是说不管这一行数据哪个字段进行更新,都会对version字段进行自增。但是a_cc就不一样,a_cc专门用来表示a这个字段的版本号,只有a字段更新才会对a_cc进行自增,其他字段更新不会对a_cc进行自增。
features_cc的作用就是features的乐观锁版本的控制,这样就规避了使用version与整个字段冲突的尴尬。
update
lg_order
set
features=#features#,
features_cc= features_cc +1
where
order_id=#order_id#
and features_cc =#ori_ features_cc#
这里需要注意的是,需要应用owner仔细review自己相关表的SQL,要求所有涉及到这个表features字段的变更都必须加上features_cc= features_cc +1进行计算,否则会引起并发冲突,平时要做好保护措施,不然很中意中标。
在实际的环境中,这种高并发的场景中尤其多,大家思考一下是否自觉的加上了对features字段的乐观锁保护。
不过需要提出的是,做这种字段的精耕细作控制,是以提高维护成本作为代价的。
若变更太频繁,可以提出来单独维护,做到冷热数据分离。
参考资料:
https://www.jianshu.com/p/ed896335b3b4
高性能MySQL(第3版)_扫描版