基于MySQL 5.6.16
记录数
,没法锁定不存在的记录,因此没法阻止插入,会出现幻读。session1 | session2 |
---|---|
begin tx | |
select name from user where id = 7 | - |
- | begin tx |
- | update user set name = 'chen' where id = 7 |
- | commit tx |
select name from user where id = 7 // 数据不一致 | - |
commit tx | - |
session1 | session2 |
---|---|
begin tx | - |
select name from user where id = 7 | - |
- | begin tx |
- | update user set name = 'chen' where id = 7 |
select name from user where id = 7 // 脏数据 | - |
commit tx | - |
- | commit tx |
以上为已过期的处理事务的方式(92年被批准的标准),列出来是为了引出共享锁
和排它锁
的概念!html
select * from user where name = 'lin' for update
以上SQL是否有加锁,对那些记录加锁?mysql
主键
时,锁的是聚簇索引
对应的记录。惟一索引
时,锁的是惟一索引
对应的记录、聚簇索引
对应的记录。普通索引
时,锁的是普通索引
对应的记录和间隙
,聚簇索引
对应的记录。表级别
的,当对记录加锁时,同时会在表上加上对应的意向锁(共享锁 -> 意向共享锁,排它锁 -> 意向排它锁)。排他锁
时,发现表上若是已经有了意向锁,就会被阻塞。- | 共享锁(S) | 排它锁(X) | 意向共享锁(IS) | 意向排它锁(IX) |
---|---|---|---|---|
共享锁(S) | 兼容 | 不兼容 | 兼容 | 不兼容 |
排它锁(X) | 不兼容 | 不兼容 | 不兼容 | 不兼容 |
意向共享锁(IS) | 兼容 | 不兼容 | 兼容 | 兼容 |
意向排它锁(IX) | 不兼容 | 不兼容 | 兼容 | 兼容 |
还有其余的“自增锁”、“insert时候的隐式锁”,本文不会说明,详细可参考:解决死锁之路 - 常见 SQL 语句的加锁分析git
select * from user where name = 'lin'
以上是否有加锁?
在读已提交
和可重复读
级别下,查询使用了MVCC
方式,是不加锁
的。
InnoDB 每一个汇集索引都有 4 个隐藏字段,分别是行ID
(DB_ROW_ID,隐含的自增ID,若是数据表没有主键,InnoDB会自动以DB_ROW_ID产生一个聚簇索引),最近更改的事务ID
(DB_TRX_ID,每条记录有独立的事务ID,数据修改并提交成功的同时,会将事务ID修改为当前事务ID,新ID确定会大于旧ID),undo Log 的指针
(回滚指针DB_ROLL_PTR,记录删除的时候全局事务ID号,指向这条记录在undo log上的回滚数据),删除标记
(记录头信息有专门的bit标志,用来表示当前记录是否已经被删除,当删除时,不会当即删除,而是打标记,而后异步删除)。
数据库每次对数据进行更新操做时,会将修改前
的数据保存到undo log
中,经过undo log
能够实现事务回滚
,而且能够根据undo log
回溯到某个特定的版本
的数据,实现MVCC
。undo log
分为insert
和update
,delete
与update
操做被归成一类。
其中DB_ROLL_PTR长度为7个字节(56个字节),数据结构以下:github
UNIV_INLINE void trx_undo_decode_roll_ptr( /*=====================*/ roll_ptr_t roll_ptr, /*!< in: roll pointer */ ibool* is_insert, /*!< out: TRUE if insert undo log */ ulint* rseg_id, /*!< out: rollback segment id */ ulint* page_no, /*!< out: page number */ ulint* offset) /*!< out: offset of the undo entry within page */ { #if DATA_ROLL_PTR_LEN != 7 # error "DATA_ROLL_PTR_LEN != 7" #endif #if TRUE != 1 # error "TRUE != 1" #endif ut_ad(roll_ptr < (1ULL << 56)); *offset = (ulint) roll_ptr & 0xFFFF; //获取低16位 为OFFSET roll_ptr >>= 16; //右移16位 *page_no = (ulint) roll_ptr & 0xFFFFFFFF;//获取32位为 page no roll_ptr >>= 32;//右移32位 *rseg_id = (ulint) roll_ptr & 0x7F;//获取7位为segment id roll_ptr >>= 7;//右移7位 *is_insert = (ibool) roll_ptr; // TRUE==1 ,最高位,标识修改或插入 }
字节位置 | 字节长度 | 做用 |
---|---|---|
55 | 1 | 操做类型:1=INSERT,0=UPDATE |
48-54 | 7 | undolog segment id |
16-47 | 32 | undolog 页编号 |
0-15 | 16 | undolog 页上的偏移量 |
查询:返回的记录须要知足两个条件。sql
修改:分为两种状况,update的列是不是主键列。数据库
旧版本信息
记录在undo log中,设置当前记录的事务ID为当前事务ID。若是是主键列,update分两部执行:安全
mysql有后台purge进程
来删除无用的undo log,按顺序从老到新定时扫描undo log,直到彻底清除或者遇到一个不能清除的undo log。purge进程有本身的read view(等同于进程开始时最老的活动事务以前的view,trx_sys->mvcc->clone_oldest_view),保证清除的数据对任何事务来讲都是不可见的。session
MySQL在RC级别下经过MVCC解决了脏读,在RR级别下经过MVCC方案解决了脏读、不可重复读。数据结构
事务级
快照、RC是语句级
快照。RR:在一个事务内同一快照读执行任意次数,获得的数据一致;且只能读到第一次执行前已经提交的数据或本事务内更改的数据。并发
设该行的当前事务id为trx_id,read view中最先的事务id为trx_id_min, 最迟的事务id为trx_id_max(trx_id_max是当前全部已提交的事务中最大XID+1)。
trx_id < trx_id_min
的话,那么代表该行记录所在的事务已经在本次新事务建立以前就提交了,因此该行记录的当前值是可见
的。trx_id > trx_id_max
的话,那么代表该行记录所在的事务在本次新事务建立以后才开启,因此该行记录的当前值不可见
。若是trx_id_min <= trx_id <= trx_id_max
,遍历read view
,查找trx_id
是否在read view
列表中:
trx_id
在read view
列表中,此记录的最后一次修改在read_view建立时还没有commit
,不可见
。trx_id
不在read view
列表中,此记录在read_view
建立以前已经commit
,可见
。不可见
,则从该行记录的回滚指针DB_ROLL_PTR
指向的Undo Log
中取出对应的数据,而后从新从第一步开始判断。read view
。读数据时,要不要开启“读事务”
RR隔离级别下,MySQL经过MVCC + next-key
(记录锁 + 间隙锁(gap锁))解决了幻读,gap只跟insert冲突,gap之间不冲突。
当前读:全部的锁定读都是当前读,也就是读取当前记录的最新版本,不会利用 undo log 读取镜像。
select * from user where age = 10 for update;
当age为普通索引
时,age索引加锁以下:
1 | 5 | 10 | 12 | 15 |
---|
“10”上会加上排它锁,(5, 10) 和 (10, 12)间会加上间隙锁。
RR模式是否解决了幻读?这一点还存在争议,好比github上的这一个争议:https://github.com/Yhzhtk/not...。
session1 | session2 |
---|---|
begin tx | |
select * from user where id = 7 | - |
- | begin tx |
- | inset into user(id) values(7) |
- | commit tx |
select * from user where id = 7 for update | - |
commit tx | - |
session1的两次select查询结果不一致。
对于这个争议,要看对幻读的定义,“快照读
和当前读
的结果不一致”属不属于幻读的范围。官网定义的“The so-called phantom problem occurs within a transaction when the same query produces different sets of rows at different times. ”,在我看来“same query”应该是表示彻底同样
的sql,因此要么都是当前读,要么都是快照读,若是按这个理解来看,RR级别下就解决了幻读。
delete from t1 where id = 10;
这个SQL会加什么锁?
回答这个问题,咱们须要知道如下前提:
结论:id是主键时,此SQL只须要在id=10这条记录上加X锁便可。
结论:若id列是unique列,其上有unique索引。那么SQL须要加两个X锁,一个对应于id unique索引上的id = 10的记录,另外一把锁对应于聚簇索引上的[name=’d’,id=10]的记录。
结论:若id列上有非惟一索引,那么对应的全部知足SQL查询条件的记录,都会被加锁。同时,这些记录在主键索引上的记录,也会被加锁。
因为id列上没有索引,所以只能走聚簇索引,进行所有扫描。从图中能够看到,知足删除条件的记录有两条,可是,聚簇索引上全部的记录,都被加上了X锁。不管记录是否知足条件,所有被加上X锁。既不是加表锁,也不是在知足条件的记录上加记录锁。
有人可能会问?为何不是只在知足条件的记录上加锁呢?这是因为MySQL的实现决定的。若是一个条件没法经过索引快速过滤,那么存储引擎层面就会将全部记录加锁后返回,而后由MySQL Server层进行过滤。所以也就把全部的记录,都锁上了。
注:在实际的实现中,MySQL有一些改进(semi-consistent read),在MySQL Server过滤条件,发现不知足后,会调用unlock_row方法,把不知足条件的记录放锁 (违背了2PL的约束)。这样作,保证了最后只会持有知足条件记录上的锁,可是每条记录的加锁操做仍是不能省略的。
结论:若id列上没有索引,SQL会走聚簇索引的全扫描进行过滤,因为过滤是由MySQL Server层面进行的。所以每条记录,不管是否知足条件,都会被加上X锁。可是,为了效率考量,MySQL作了优化,对于不知足条件的记录,会在判断后放锁,最终持有的,是知足条件的记录上的锁,可是不知足条件的记录上的加锁/放锁动做不会省略。同时,优化也违背了2PL的约束。
结论:Repeatable Read隔离级别下,id列上有一个非惟一索引,对应SQL:delete from t1 where id = 10; 首先,经过id索引定位到第一条知足查询条件的记录,加记录上的X锁,加GAP上的GAP锁,而后加主键聚簇索引上的记录X锁,而后返回;而后读取下一条,重复进行。直至进行到第一条不知足条件的记录[11,f],此时,不须要加记录X锁,可是仍旧须要加GAP锁,最后返回结束。
如图,这是一个很恐怖的现象。首先,聚簇索引上的全部记录,都被加上了X锁。其次,聚簇索引每条记录间的间隙(GAP),也同时被加上了GAP锁。这个示例表,只有6条记录,一共须要6个记录锁,7个GAP锁。试想,若是表上有1000万条记录呢?
在这种状况下,这个表上,除了不加锁的快照度,其余任何加锁的并发SQL,均不能执行,不能更新,不能删除,不能插入,全表被锁死。
结论:在Repeatable Read隔离级别下,若是进行全表扫描的当前读,那么会锁上表中的全部记录,同时会锁上聚簇索引内的全部GAP,杜绝全部的并发 更新/删除/插入 操做。固然,跟组合四:[id无索引, Read Committed]相似,这个状况下,也能够开启semi-consistent read,来缓解加锁开销与并发影响,可是semi-consistent read自己也会带来其余问题,不建议使用。
注:(我在:MySQL版本号5.6.16,RR级别,表数据量为223条记录的状况下作测试“update t set a = 'b'”,其中a是非索引字段,结果为:在事务结束前,会锁住全部的记录,能够经过“select * from information_schema.innodb_locks”看到,lock_model为“X”,lock_type为“RECORD”)。
注:就是所谓的semi-consistent read。semi-consistent read开启的状况下,对于不知足查询条件的记录,MySQL会提早放锁。针对上面的这个用例,就是除了记录[d,10],[g,10]以外,全部的记录锁都会被释放,同时不加GAP锁。semi-consistent read如何触发:要么是read committed隔离级别;要么是Repeatable Read隔离级别,同时设置了 innodb_locks_unsafe_for_binlog 参数。详细见:MySQL+InnoDB semi-consitent read原理及实现分析
结果:会锁住小于5到大于5这段的间隙,例如:{一、三、七、九、10}数据中会锁住(3, 7)这段间隙。
结论:在Repeatable Read隔离级别下,针对一个复杂的SQL,首先须要提取其where条件。Index Key肯定的范围,须要加上GAP锁;Index Filter过滤条件,视MySQL版本是否支持ICP,若支持ICP,则不知足Index Filter的记录,不加X锁,不然须要X锁;Table Filter过滤条件,不管是否知足,都须要加X锁。
注:一个SQL中的where条件如何拆分?具体的介绍,建议阅读SQL中的where条件,在数据库中提取与应用浅析。
从图中能够看出,在Repeatable Read隔离级别下,由Index Key所肯定的范围,被加上了GAP锁;Index Filter锁给定的条件 (userid = ‘hdc’)什么时候过滤,视MySQL的版本而定:
pubtime > 1 and pubtime < 20
的数据都经过回表
查出来以后,才作userid = ’hdc‘
过滤。MySQL加锁规则里面,包含了五个原则。
加锁的基本单位是next-key lock
,next-key lock 是前开后闭区间。
select * from t where c > 12 and c < 16 for update;
当c
为索引
时,假设c
的值有“1,3,11,15,17,20”,则加的锁为:(11, 15],(15, 17],即在(11,15)和(15, 17)上加间隙锁,15和17上加记录锁。
访问到的对象才会加锁。
select id from t where c = 15 lock in share mode;
c为索引,id为主键,加读锁时, 覆盖索引优化状况下, 不会访问主键索引, 所以若是要经过 lock in share mode 给行加锁避免数据被修改, 那就须要绕过索引优化, 如 select 一个不在索引中的值。
但若是改为 for update , 则 mysql 认为接下来会更新数据, 所以会将对应主键索引也一块儿锁了。
索引上的等值查询,给惟一索引加锁的时候,next-key lock 退化为记录锁。
当c是惟一索引或主键,假设c
的值有“1,3,11,15,17,20”。
select * from t where c = 15 for update;
只会在15上加记录锁。
select * from t where c >= 15 and c < 17 for update;
虽然查出来的结果跟=15
是同样的,可是加的锁却不同。Mysql定位到第一个符合条件的数据15
( 查询第一个符合条件的数据是,经过树搜索的方式定位记录,用的是“等值查询”的方法),因为在(11, 15]上是等值查询,因此退化成记录锁,总体加锁是:记录锁15和next-key (15, 17]
索引上的等值查询,向右遍历时且最后一个值不知足等值条件的时候,next-key lock 退化为间隙锁。
当c是普通索引,假设c
的值有“1,3,11,15,17,20”。
select * from t where c = 11 for update;
引擎在找到c=11
这一条索引项后继续向右遍历到c=15
这一条, 此时锁范围是 (3, 11], (11, 15)
范围查询会访问到不知足条件的第一个值为止。
select * from t where c >= 11 and c <= 15;
当c是惟一索引,假设c
的值有“1,3,11,15,17,20”。从常理上看,c是惟一索引,不会出现重复的数据,因此加的锁应该为为“11,(11, 15]”。
可是mysql在这种场景上,处理方式跟普通索引同样,会继续向后找第一个不知足条件的记录,最终加的锁是“11,(11, 15],(15, 17]”
RR
级别下,列id
有如下几条数据
id |
---|
5 |
10 |
15 |
20 |
25 |
30 |
条件 | 说明 | 锁 |
---|---|---|
id = 1 | 因为不存在1 的值,会在第一个大于1的值上加next-key,根据规则4,等值查询会退化成间隙锁 |
(-∞, 5) |
id < 5 | 根据规则5,向后查询第一个不符合条件的值,在找到的值上加next-key | (-∞, 5] |
id = 5 | 根据规则3,惟一索引/主键上等值查询,退化成记录锁 | 5 |
id <= 5 | 根据规则五,即便id是主键,也会继续向后查找 | (-∞, 5](5, 10] |
id > 5 and id < 10 | (5, 10] | |
id >= 5 and id < 10 | 规则三,惟一索引上等值查找,退化成记录锁 | 5, (5, 10] |
id >= 5 and id <= 10 | 5, (5, 10], (10, 15] | |
id = 8 | 8 记录不存在,同第一个条件 |
(5, 10) |
id = 10 | 10 | |
id > 25 and id < 30 | (25, 30] | |
id > 25 and id <= 30 | 数据末尾,会对正无穷大加锁 | (25, 30], (30, +∞] |
id >= 30 | 数据末尾,会对正无穷大加锁 | 30, (30, +∞] |
条件 | 说明 | 锁 |
---|---|---|
id = 10 | 普通索引跟惟一索引不同的点在于,普通索引是存在重复的可能性,<br/>因此即便等值查询,也是按next-key加锁,<br/>根据规则4,继续向后查询时,退化成间隙锁 | (5, 10], (10, 15) |
id = 12 | 12 记录不存在 |
(10, 15) |
id > 10 and id <= 15 | (10, 15], (15, 20] | |
id >= 10 and id <= 15 | (5, 10], (10, 15], (15, 20] |
条件 | 说明 | 锁 |
---|---|---|
id =15 | 因为id不是索引,在没有开启semi-consistent read 状况下会锁住所有数据(RR级别默认不开启) |
(-∞, 5], (5, 10], (10, 15], (15, 20], (20, 25], (25, 30], (30, +∞] |
RR
级别下,普通索引id
有如下几条数据
id |
---|
5 |
10 |
10 |
10 |
15 |
30 |
delete from t where id = 10
锁的是(5, 10], (10, 10], (10, 10], (10, 15)
delete from t where id = 10 limit 2
锁的是(5, 10], (10, 10]。
在删除数据的时候尽可能加 limit。这样不只能够控制删除数据的条数,让操做更安全,还能够减少加锁的范围。
索引搜索就是:找到第一个值,而后向左或向右遍历。
RR
级别下
id |
---|
5 |
10 |
15 |
20 |
25 |
30 |
select * from t where id >= 15 and id <= 20 order by id desc for update
若是id
是主键索引,因为order by id desc
,因此从id <= 20
开始等值查询第一个符合id=20
条件的数据,查找到20
以后,按道理根据规则3
,会退化成记录锁,但测试发现,还多锁了个间隙锁,这一点目前还没在哪一个资料上找到对这个的说明,继续向左查询,找到最后符合id >= 15
的数据15
,根据规则5还会继续查找到第一个不符合条件的数据10
,因此最终的加锁是(5, 10], (10, 15], (15, 20], (20, 25)。
若是id
是普通索引,以上SQL查找到20
以后,根据规则4,会退化成间隙锁,继续向左查询,找到最后符合id >= 15
的数据15
,根据规则5还会继续查找到第一个不符合条件的数据10
,因此最终的加锁是(5, 10], (10, 15], (15, 20], (20,25)
select * from t where id >= 15 and id < 22 order by id desc for update
若是id
是主键索引,因为id=22
不存在,找到(20, 25)这个间隙,因为MySQL定位第一个值用的是等值查找,根据规则4,会遍历到25
且退化成间隙锁,因此最终的加锁是(5, 10], (10, 15], (15, 20], (20,25)
RR
级别下,普通索引id
有如下几条数据
id |
---|
5 |
10 |
10 |
10 |
15 |
30 |
select * from t where id in (5, 10, 15) for update
MySQL是先加锁id=5
,在继续id=10
,id=15
,一个个加锁上去的。
session1 | session2 |
---|---|
begin tx | begin tx |
select * from t where id in (5, 10, 15) for update | select * from t where id in (5, 10, 15) order by id desc for update |
commit tx | commit tx |
order by id desc
致使session2是按顺序从15, 10, 5
加锁,session1和session2加锁顺序相反,致使这两条SQL可能会发生死锁。
RR
级别下,主键索引id
有如下几条数据
id |
---|
5 |
10 |
15 |
20 |
25 |
30 |
select * from t where id > 15 and id <= 20 for update
以上SQL的锁是(15, 20], (20, 25],若是这时候记录15
被删除(delete from t where id = 15
),以上SQL的锁会动态变成(10, 20], (20, 25],
RR级别下:
数据:
idx |
---|
2 |
5 |
9 |
6 |
14 |
15 |
事务:
session1 | session2 |
---|---|
begin tx | begin tx |
select * from user where idx = 3 for update | select * from user where idx < 3 for update |
commit tx | commit tx |
以上两个事物互相不干扰,session1
的锁范围是(2, 5),session2
的锁范围是(2, 5],间隙锁只会阻塞插入操做。
session1 | session2 |
---|---|
begin tx | begin tx |
delete from user where idx = 7 | delete from user where idx = 8 |
insert into user(idx) values (7) | insert into user(idx) values (8) |
commit tx | commit tx |
因为idx不存在值为7和8的记录,session1和session2都持有(5, 9)间隙锁,锁只有在事务提以后的时候才会释放,此时出现两个事务互相等待对方持有的间隙锁而没法插入,出现死锁。
避免更新或者删除不存在的记录,容易致使死锁问题。
既然RR级别下已经不会出现幻读,那为何还须要Serializable:
防止数据丢失(被覆盖):
session1 | session2 |
---|---|
begin tx | |
select name from user where id = 7 | - |
- | begin tx |
- | update user set name = 'chen' where id = 7 |
- | commit tx |
update user set name = 'lin' where id = 7 | - |
commit tx | - |
防止出现幻觉(RR级别,id为主键):
session1 | session2 |
---|---|
begin tx | |
select * from user where id = 7 // 发现数据不存在 | - |
- | begin tx |
- | inset into user(id) values(7) |
- | commit tx |
inset into user(id) values(7) // 报错,主键冲突 | - |
select * from user where id = 7 // 仍然发现数据不存在 | - |
commit tx | - |
参考:
InnoDB多版本(MVCC)实现简要分析
MySQL 加锁处理分析
MySQL · 引擎特性 · InnoDB undo log 漫游
SQL中的where条件,在数据库中提取与应用浅析
MySQL 在 RC 隔离级别下是如何实现读不阻塞的?
查看Mysql正在执行的事务、锁、等待
数据库分析手记 —— InnoDB锁机制分析
为何我只改一行的语句,锁这么多?
关于 MySQL 中 InnoDB 行锁的理解及案例