相关文章:
深入理解Mysql——高性能索引与高性能SQL
深入理解Mysql——锁、事务与并发控制
深入理解Mysql——schema设计与大表alter操作
mysql explain-output 译文
本文对锁、事务、并发控制做一个总结,文章预计10分钟阅读完。本文内容均参考官方文档,尽可能严谨。读完本文将会对mysql锁\事务有一个较为深入的认识。
每个连接都会在mysql服务端产生一个线程(内部通过线程池管理线程),比如一个select语句进入,mysql首先会在查询缓存中查找是否缓存了这个select的结果集,如果没有则继续执行 解析、优化、执行的过程;否则会之间从缓存中获取结果集。
它们都是标准的行级锁。
共享锁(S)
共享锁也称为读锁,读锁允许多个连接可以同一时刻并发的读取同一资源,互不干扰;
排他锁(X)
排他锁也称为写锁,一个写锁会阻塞其他的写锁或读锁,保证同一时刻只有一个连接可以写入数据,同时防止其他用户对这个数据的读写。
注意:所谓共享锁、排他锁其实均是锁机制本身的策略,通过这两种策略对锁做了区分。
InnoDB支持多粒度锁(锁粒度可分为行锁和表锁),允许行锁和表锁共存。例如,一个语句,例如LOCK TABLES…WRITE接受指定表上的独占锁。为了实现多粒度级别的锁定,InnoDB使用了意图锁。
意向锁:表级别的锁。先提前声明一个意向,并获取表级别的意向锁(IS或IX),如果获取成功,则稍后将要或正在(才被允许),对该表的某些行加锁(S或X)了。(除了 LOCK TABLES … WRITE,很少用,略)
举例来说:
SELECT … LOCK IN SHARE MODE,要获取IS锁;An intention shared lock (IS)
SELECT … FOR UPDATE ,要获取IX锁;An intention exclusive lock (IX) i
意向锁协议
在事务能够获取表中的行上的共享锁之前,它必须首先获取表上的IS锁或更强的锁。
在事务能够获取表中的行上的独占锁之前,它必须首先获取表上的IX锁。
前文说了,意向锁实现的背景是多粒度锁的并存场景。如下兼容性的汇总:
意向锁仅表意向,是一种较弱的锁,意向锁之间兼容并行(IS、IX 之间关系兼容并行)。
X与IS\IX互斥;S与IX互斥。
可以体会到,意向锁是比X\S更弱的锁,存在一种预判的意义!先获取更弱的IX\IS锁,如果获取失败就不必要再花费跟大开销获取更强的X\S锁。
…
record lock是一个在索引行记录的锁。
比如,SELECT c1 FROM t WHERE c1 = 10 FOR UPDATE,如果c1 上的索引被使用到。
防止任何其他事务插入、更新或删除值为t的行。c1是10。
record lock 总是会在索引行上加锁。即使一个表并没有设置索引,这种时候innoDB会创建一个隐式的聚集索引(primary Key),然后在这个聚集索引上加锁。
在DML语句不走索引的语句中,比如这个DML update table set columnA=“A” where columnB=“B".
如果columnB字段不存在索引(或者不是组合索引前缀),会锁住所有记录也就是锁表。如果语句的执行能够执行一个columnB字段的索引,那么会锁住满足where的行(RecordLock)。
锁出现查看示例:
(使用show engine innodb status命令查看):
RECORD LOCKS space id 58 page no 3 n bits 72 index `PRIMARY` of table `test`.`t`
trx id 10078 lock_mode X locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
0: len 4; hex 8000000a; asc ;;
1: len 6; hex 00000000274f; asc 'O;;
2: len 7; hex b60000019d0110; asc ;;
Gap Locks:
锁定索引记录之间的间隙([2]),或者锁定一个索引记录之前的间隙([1]),或者锁定一个索引记录之后的间隙([3])。
示例:如图[1]、[2]、[3]部分。一般作用于我们的范围查询> 、< 、between…
例如, SELECT userId FROM t1 WHERE userId BETWEEN 1 and 4 FOR UPDATE; 阻止其他事务将值3插入到列userId中。因为该范围内所有现有值之间的间隙都是锁定的。
例如,如果id列有唯一的索引,下面的语句只对id值为100的行使用索引记录锁,其他会话是否在前一个间隙中插入行并不重要:
```
SELECT * FROM t1 WHERE id = 100;
```
如果id**没有索引或具有非惟一索引,则语句将锁定前面的间隙**。
间隙可以跨越单个索引值、多个索引值(如上图2,3),甚至是空的。
间隙锁是性能和并发性之间权衡的一种折衷,用于某些特定的事务隔离级别,如RC级别(RC级别:REPEATABLE
READ,我司为了减少死锁,关闭了gap锁,使用RR级别)。
在重叠的间隙中(或者说重叠的行记录)中允许gap共存
比如同一个gap中,允许一个事务持有 gap X-Lock(gap 写锁\排他锁),同时另一个事务在这个gap中持有(gap 写锁\排他锁)
CREATE TABLE `new_table` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`a` int(11) DEFAULT NULL,
`b` varchar(45) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_new_table_a` (`a`),
KEY `idx_new_table_b` (`b`)
) ENGINE=InnoDB AUTO_INCREMENT=15 DEFAULT CHARSET=utf8
INSERT INTO `new_table` VALUES (1,1,'1'),(2,3,'2'),(3,5,'3'),(4,8,'4'),(5,11,'5'),(6,2,'6'),(7,2,'7'),(8,2,'8'),(9,4,'9'),(10,4,'10');
######## 事务一 ########
START TRANSACTION;
SELECT * FROM new_table WHERE a between 5 and 8 FOR UPDATE;
##暂不commit
######## 事务二 ########
SELECT * FROM new_table WHERE a = 4 FOR UPDATE;
##顺利执行! 因为gap锁可以共存;
######## 事务三 ########
SELECT * FROM new_table WHERE b = 3 FOR UPDATE;
##获取锁超时,失败。因为事务一的gap锁定了 b=3的数据。
next-key lock 是record lock 与 gap lock 的组合。
比如 存在一个查询匹配b=3的行(b上有个非唯一索引),那么所谓NextLock就是:在b=3的行加了RecordLock 并且使用 GapLock锁定了b=3之前(“之前”:索引排序)的所有行记录。
mysql查询时执行 行级锁策略,会对扫描过程中匹配的行进行加锁(X 或S),也就是加Record Lock,同时会对这个记录之前的所有行加GapLock锁。
假设一个索引包含值10、11、13和20。该索引可能的NexKey Lock锁定以下区间:
(negative infinity, 10]
(10, 11]
(11, 13]
(13, 20]
(20, positive infinity)
另外,值得一提的是 : innodb中默认隔离级别(RR)下,next key Lock自动开启。
(很好理解,因为gap作用于RR,如果是RC,gapLock不会生效,那么next key lock自然也不会)
锁出现查看示例:
(使用show engine innodb status命令查看):
RECORD LOCKS space id 58 page no 3 n bits 72 index `PRIMARY` of table `test`.`t`
trx id 10080 lock_mode X
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;
Record lock, heap no 2 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
0: len 4; hex 8000000a; asc ;;
1: len 6; hex 00000000274f; asc 'O;;
2: len 7; hex b60000019d0110; asc ;;
一个insert intention lock 是一种发生在 insert 插入语句时的gap lock 间隙锁,锁定插入行之前的所有行。
这个锁以这样一种方式表明插入的意图,如果插入到同一索引间隙中的多个事务没有插入到该间隙中的相同位置,则它们不需要等待对方。
假设存在值为4和7的索引记录。尝试分别插入值为5和6的独立事务,在获得所插入行上的独占锁之前,每个事务使用insert intention lock 锁定4和7之间的间隙,但不会阻塞彼此,因为这些行不冲突。
示例:
mysql> CREATE TABLE child (id int(11) NOT NULL, PRIMARY KEY(id)) ENGINE=InnoDB;
mysql> INSERT INTO child (id) values (90),(102);
##事务一
mysql> START TRANSACTION;
mysql> SELECT * FROM child WHERE id > 100 FOR UPDATE;
+-----+
| id |
+-----+
| 102 |
+-----+
##事务二
mysql> START TRANSACTION;
mysql> INSERT INTO child (id) VALUES (101);
##失败,已被锁定
mysql> SHOW ENGINE INNODB STATUS
RECORD LOCKS space id 31 page no 3 n bits 72 index `PRIMARY` of table `test`.`child`
trx id 8731 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
0: len 4; hex 80000066; asc f;;
1: len 6; hex 000000002215; asc " ;;
2: len 7; hex 9000000172011c; asc r ;;...
AUTO-INC锁是一种特殊的表级锁,产生于这样的场景:事务插入(inserting into )到具有AUTO_INCREMENT列的表中。
在最简单的情况下,如果一个事务正在向表中插入值,那么其他任何事务必须等待向该表中插入它们自己的值,以便由第一个事务插入的行接收连续的主键值。
略
事务就是一组原子性的sql,或者说一个独立的工作单元。
事务就是说,要么mysql引擎会全部执行这一组sql语句,要么全部都不执行(比如其中一条语句失败的话)。
show variables like "autocommit";
set autocommit=0; //0表示AutoCommit关闭
set autocommit=1; //1表示AutoCommit开启
mysql默认采用AutoCommit模式,也就是每个sql都是一个事务,并不需要显示的执行事务。
如果autoCommit关闭,那么每个sql都默认开启一个事务,只有显式的执行“commit”后这个事务才会被提交。
比如,tim要给bill转账100块钱:
1.检查tim的账户余额是否大于100块;
2.tim的账户减少100块;
3.bill的账户增加100块;
这三个操作就是一个事务,必须打包执行,要么全部成功,
要么全部不执行,其中任何一个操作的失败都会导致所有三个操作“不执行”——回滚。
CREATE DATABASE IF NOT EXISTS employees;
USE employees;
CREATE TABLE `employees`.`account` (
`id` BIGINT (11) NOT NULL AUTO_INCREMENT,
`p_name` VARCHAR (4),
`p_money` DECIMAL (10, 2) NOT NULL DEFAULT 0,
PRIMARY KEY (`id`)
) ;
INSERT INTO `employees`.`account` (`id`, `p_name`, `p_money`) VALUES ('1', 'tim', '200');
INSERT INTO `employees`.`account` (`id`, `p_name`, `p_money`) VALUES ('2', 'bill', '200');
START TRANSACTION;
SELECT p_money FROM account WHERE p_name="tim";-- step1
UPDATE account SET p_money=p_money-100 WHERE p_name="tim";-- step2
UPDATE account SET p_money=p_money+100 WHERE p_name="bill";-- step3
COMMIT;
一个良好的事务系统,必须满足ACID特点:
A:atomiciy原子性
一个事务必须保证其中的操作要么全部执行,要么全部回滚,不可能存在只执行了一部分这种情况出现。
C:consistency一致性
数据必须保证从一种一致性的状态转换为另一种一致性状态。
比如上一个事务中执行了第二步时系统崩溃了,数据也不会出现bill的账户少了100块,但是tim的账户没变的情况。要么维持原装(全部回滚),要么bill少了100块同时tim多了100块,只有这两种一致性状态的
I:isolation隔离性
在一个事务未执行完毕时,通常会保证其他Session 无法看到这个事务的执行结果
D:durability持久性
事务一旦commit,则数据就会保存下来,即使提交完之后系统崩溃,数据也不会丢失。
查看系统隔离级别:
select @@global.tx_isolation;
查看当前会话隔离级别
select @@tx_isolation;
设置当前会话隔离级别
SET session TRANSACTION ISOLATION LEVEL serializable;
设置全局系统隔离级别
SET GLOBAL TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
事务中的修改,即使没有提交,对其他会话也是可见的。
可以读取未提交的数据——脏读。脏读会导致很多问题,一般不适用这个隔离级别。
实例:
-- ------------------------- read-uncommitted实例 ------------------------------
-- 设置全局系统隔离级别
SET GLOBAL TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
-- Session A
START TRANSACTION;
SELECT * FROM USER;
UPDATE USER SET NAME="READ UNCOMMITTED";
-- commit;
-- Session B
SELECT * FROM USER;
//SessionB Console 可以看到Session A未提交的事物处理,在另一个Session 中也看到了,这就是所谓的脏读
id name
2 READ UNCOMMITTED
34 READ UNCOMMITTED
一般数据库都默认使用这个隔离级别(mysql不是),
这个隔离级别保证了一个事务如果没有完全成功(commit执行完),事务中的操作对其他会话是不可见的。
-- ------------------------- read-cmmitted实例 ------------------------------
-- 设置全局系统隔离级别
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- Session A
START TRANSACTION;
SELECT * FROM USER;
UPDATE USER SET NAME="READ COMMITTED";
-- COMMIT;
-- Session B
SELECT * FROM USER;
//Console OUTPUT:
id name
2 READ UNCOMMITTED
34 READ UNCOMMITTED
---------------------------------------------------
-- 当 Session A执行了commit,Session B得到如下结果:
id name
2 READ COMMITTED
34 READ COMMITTED
也就验证了read committed级别在事物未完成commit操作之前修改的数据对其他Session 不可见,执行了commit之后才会对其他Session 可见。
我们可以看到Session B两次查询得到了不同的数据。
read committed隔离级别解决了脏读的问题,但是会对其他Session 产生两次不一致的读取结果(因为另一个Session 执行了事务,一致性变化)。
一个事务中多次执行统一读SQL,返回结果一样。
这个隔离级别解决了脏读的问题,幻读问题。
这里指的是innodb的rr级别,innodb中使用next-key锁对"当前读"进行加锁,锁住行以及可能产生幻读的插入位置,阻止新的数据插入产生幻行。
下文中详细分析。
具体请参考mysql手册
https://dev.mysql.com/doc/refman/5.7/en/innodb-storage-engine.html
最强的隔离级别,通过给事务中每次读取的行加锁,写加写锁,保证不产生幻读问题,但是会导致大量超时以及锁争用问题。
MVCC(multiple-version-concurrency-control)是个行级锁的变种,
它在普通读情况下避免了加锁操作,因此开销更低。
虽然实现不同,但通常都是实现非阻塞读,对于写操作只锁定必要的行。
一致性读 (就是读取快照)
select * from table …;
当前读(就是读取实际的持久化的数据)
特殊的读操作,插入/更新/删除操作,属于当前读,处理的都是当前的数据,需要加锁。
select * from table where ? lock in share mode;
select * from table where ? for update;
insert;
update ;
delete;
注意:select … from where… (没有额外加锁后缀)使用MVCC,保证了读快照(mysql称为consistent read),
所谓一致性读或者读快照就是读取当前事务开始之前的数据快照,在这个事务开始之后的更新不会被读到。
详细情况下文select的详述。
对于加锁读SELECT with FOR UPDATE(排他锁) or LOCK IN SHARE MODE(共享锁)、
update、delete语句,要考虑是否是唯一索引的等值查询。
INNODB的MVCC通常是通过在每行数据后边保存两个隐藏的列来实现(其实是三列,第三列是用于事务回滚,此处略去),
一个保存了行的创建版本号,另一个保存了行的更新版本号(上一次被更新数据的版本号)
这个版本号是每个事务的版本号,递增的。
这样保证了innodb对读操作不需要加锁也能保证正确读取数据。
下边在mysql默认的Repeatable Read隔离级别下,具体看看MVCC操作:
Select(快照读,所谓读快照就是读取当前事务之前的数据。):
a.InnoDB只select查找版本号早于当前版本号的数据行,这样保证了读取的数据要么是在这个事务开始之前就已经commit了的(早于当前版本号),要么是在这个事务自身中执行创建操作的数据(等于当前版本号)。
b.查找行的更新版本号要么未定义,要么大于当前的版本号(为了保证事务可以读到老数据),这样保证了事务读取到在当前事务开始之后未被更新的数据。
注意: 这里的select不能有for update、lock in share 语句。
总之要只返回满足以下条件的行数据,达到了快照读的效果:
(行创建版本号< =当前版本号 && (行更新版本号==null or 行更新版本号>当前版本号 ) )
Insert
InnoDB为这个事务中新插入的行,保存当前事务版本号的行作为行的行创建版本号。
Delete
InnoDB为每一个删除的行保存当前事务版本号,作为行的删除标记。
Update
将存在两条数据,保持当前版本号作为更新后的数据的新增版本号,同时保存当前版本号作为老数据行的更新版本号。
当前版本号—写—>新数据行创建版本号 && 当前版本号—写—>老数据更新版本号();
脏读:一事务未提交的中间状态的更新数据 被其他会话读取到。
当一个事务正在访问数据,并且对数据进行了修改,
而这种修改还没有 提交到数据库中(commit未执行),
这时,另外会话也访问这个数据,因为这个数据是还没有提交,
那么另外一个会话读到的这个数据是脏数据,依据脏数据所做的操作也可能是不正确的。
不可重复读:简单来说就是在一个事务中读取的数据可能产生变化,ReadCommitted也称为不可重复读。
在同一事务中,多次读取同一数据返回的结果有所不同。
换句话说就是,后续读取可以读到另一会话事务已提交的更新数据。
相反,“可重复读”在同一事务中多次读取数据时,能够保证所读数据一样,
也就是,后续读取不能读到另一会话事务已提交的更新数据。
幻读:会话T1事务中执行一次查询,然后会话T2新插入一行记录,这行记录恰好可以满足T1所使用的查询的条件。然后T1又使用相同 的查询再次对表进行检索,但是此时却看到了事务T2刚才插入的新行。
这个新行就称为“幻像”,因为对T1来说这一行就像突然 出现的一样。
innoDB的RR级别无法做到完全避免幻读,下文详细分析。
在使用的select …where语句中加入 for update(排他锁) 或者 lock in share mode(共享锁)语句来实现。其实就是锁住了可能造成幻读的数据,阻止数据的写入操作。
其实是因为数据的写入操作(insert 、update)需要先获取写锁,由于可能产生幻读的部分,已经获取到了某种锁,所以要在另外一个会话中获取写锁的前提是当前会话中释放所有因加锁语句产生的锁。
隐式锁:我们上文说的锁都属于不需要额外语句加锁的隐式锁。
显示锁:
SELECT ... LOCK IN SHARE MODE(加共享锁);
SELECT ... FOR UPDATE(加排他锁);
详情上文已经说过。
通过如下sql可以查看等待锁的情况
select * from information_schema.innodb_trx where trx_state="lock wait";
或
show engine innodb status;
死锁,就是产生了循环等待链条,我等待你的资源,你却等待我的资源,我们都相互等待,谁也不释放自己占有的资源,导致无线等待下去。
比如:
//Session A
START TRANSACTION;
UPDATE account SET p_money=p_money-100 WHERE p_name="tim";
UPDATE account SET p_money=p_money+100 WHERE p_name="bill";
COMMIT;
//Thread B
START TRANSACTION;
UPDATE account SET p_money=p_money+100 WHERE p_name="bill";
UPDATE account SET p_money=p_money-100 WHERE p_name="tim";
COMMIT;
当线程A执行到第一条语句UPDATE account SET p_money=p_money-100 WHERE p_name=“tim”;锁定了p_name="tim"的行数据;并且试图获取p_name="bill"的数据;
,此时,恰好,线程B也执行到第一条语句:UPDATE account SET p_money=p_money+100 WHERE p_name=“bill”;
锁定了 p_name="bill"的数据,同时试图获取p_name="tim"的数据;
此时,两个线程就进入了死锁,谁也无法获取自己想要获取的资源,进入无线等待中,直到超时!
innodb_lock_wait_timeout 等待锁超时回滚事务:
直观方法是在两个事务相互等待时,当一个等待时间超过设置的某一阀值时,对其中一个事务进行回滚,另一个事务就能继续执行。这种方法简单有效,在innodb中,参数innodb_lock_wait_timeout用来设置超时时间。
wait-for graph算法来主动进行死锁检测:
innodb还提供了wait-for graph算法来主动进行死锁检测,每当加锁请求无法立即满足需要并进入等待时,wait-for graph算法都会被触发。
1) 以固定的顺序访问表和行。比如两个更新数据的事务,事务A 更新数据的顺序 为1,2;事务B更新数据的顺序为2,1。这样更可能会造成死锁。
2) 大事务拆小。大事务更倾向于死锁,如果业务允许,将大事务拆小。
3) 在同一个事务中,尽可能做到一次锁定所需要的所有资源,减少死锁概率。
4) 降低隔离级别。如果业务允许,将隔离级别调低也是较好的选择,比如将隔离级别从RR调整为RC,可以避免掉很多因为gap锁造成的死锁。(我司mysql规范做法)
5) 为表添加合理的索引。可以看到如果不走索引将会为表的每一行记录添加上锁,死锁的概率大大增大。
延伸阅读:
https://dev.mysql.com/doc/refman/5.7/en/innodb-locking.html