基于MySQL 5.6.16
SQL92标准-事务级别:
- 序列化:排它锁
- 可重复读:读写锁,读读并行,写排他;由于读锁和写锁都是
记录数
,无法锁定不存在的记录,所以无法阻止插入,会出现幻读。 - 读已提交:读写锁,读读并行,读写并行(写读不能并行);事务1读的时候,事务2可以写,事务2提交事务释放锁之后,事务1再读,就会出现不可重复。
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 | - |
读未提交:写锁,读读并行,读写并行,写读并行;事务1读的时候,事务2可以写,事务2还未提交事务,事务1还可以再读,就会出现脏读。
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年被批准的标准),列出来是为了引出共享锁
和排它锁
的概念!
锁:共享锁、排它锁、意向共享锁、意向排它锁
select * from user where name = 'lin' for update
以上SQL是否有加锁,对那些记录加锁?
- 当name为
主键
时,锁的是聚簇索引
对应的记录。 - 当name为
唯一索引
时,锁的是唯一索引
对应的记录、聚簇索引
对应的记录。 - 当name为
普通索引
时,锁的是普通索引
对应的记录和间隙
,聚簇索引
对应的记录。 - 当name不是索引时,锁的是整个表(每一条记录上加记录锁和间隙锁,实际上MySQL有做了优化,再加完锁只有会排查并释放掉不符合条件的记录上的锁,后面会讲到)。
意向锁是什么?
- 当name不是索引时,需要锁住所有的记录,那是不是要一条条记录检查是否有加锁?这样判断的效率非常低。
- 意向锁是
表级别
的,当对记录加锁时,同时会在表上加上对应的意向锁(共享锁 -> 意向共享锁,排它锁 -> 意向排它锁)。 - 以上SQL,在加
排他锁
时,发现表上如果已经有了意向锁,就会被阻塞。
- | 共享锁(S) | 排它锁(X) | 意向共享锁(IS) | 意向排它锁(IX) |
---|---|---|---|---|
共享锁(S) | 兼容 | 不兼容 | 兼容 | 不兼容 |
排它锁(X) | 不兼容 | 不兼容 | 不兼容 | 不兼容 |
意向共享锁(IS) | 兼容 | 不兼容 | 兼容 | 兼容 |
意向排它锁(IX) | 不兼容 | 不兼容 | 兼容 | 兼容 |
还有其他的“自增锁”、“insert时候的隐式锁”,本文不会说明,详细可参考:解决死锁之路 - 常见 SQL 语句的加锁分析
MVCC(Multi-Version Concurrent Control)
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个字节),数据结构如下:
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 页上的偏移量 |
数据操作
查询:返回的记录需要满足两个条件。
- 当前数据未被删除,或者删除事务ID大于当前事务ID。
- 事务ID小于当前事务ID(下面会讲到可见性)。
- 插入:该操作生产的undo log仅仅用于事务回滚,一旦事务提交,就会被删除。
- 删除:将旧版本信息记录在undo log中,将数据标志为删除(Deleted bit设置为1,而不是直接删除记录),设置事务ID为当前事务ID。
修改:分为两种情况,update的列是否是主键列。
- 如果不是主键列,将
旧版本信息
记录在undo log中,设置当前记录的事务ID为当前事务ID。 如果是主键列,update分两部执行:
- 将数据标志为删除(Deleted bit设置为1),设置事务ID为当前事务ID。
- 插入新的记录,新记录的事务ID为当前事务ID。
- 如果有二级索引,二级索引也需要做相应的更新(二级索引中包含主键项)。
- 如果不是主键列,将
undo log清理
mysql有后台purge进程
来删除无用的undo log,按顺序从老到新定时扫描undo log,直到完全清除或者遇到一个不能清除的undo log。purge进程有自己的read view(等同于进程开始时最老的活动事务之前的view,trx_sys->mvcc->clone_oldest_view),保证清除的数据对任何事务来说都是不可见的。
数据可见性
MySQL在RC级别下通过MVCC解决了脏读,在RR级别下通过MVCC方案解决了脏读、不可重复读。
- RR是
事务级
快照、RC是语句级
快照。 RR:在一个事务内同一快照读执行任意次数,得到的数据一致;且只能读到第一次执行前已经提交的数据或本事务内更改的数据。
- 在InnoDB中,创建一个新事务的时候,InnoDB会将当前系统中的活跃事务列表(trx_sys->trx_list)创建一个副本(read view),副本中保存的是系统当前不应该被本事务看到的其他事务id列表。当用户在这个事务中要读取该行记录的时候,InnoDB会将该行当前的版本号与该read view进行比较。
设该行的当前事务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
中取出对应的数据,然后重新从第一步开始判断。 - 需要注意的是,新建事务(当前事务)与正在内存中commit 的事务不在活跃事务链表中。
- 如果
- RC:每次快照读均会创建新的
read view
。
读数据时,要不要开启“读事务”
- 如果你一次执行单条查询语句,则没有必要启用事务支持,数据库默认支持SQL执行期间的读一致性;
- 如果你一次执行多条查询语句,例如统计查询,报表查询,在这种场景下,多条查询SQL必须保证整体的读一致性,否则,在前条SQL查询之后,后条SQL查询之前,数据被其他用户改变,则该次整体的统计查询将会出现读数据不一致的状态,此时,应该启用事务支持。
间隙锁
RR隔离级别下,MySQL通过MVCC + next-key
(记录锁 + 间隙锁(gap锁))解决了幻读,gap只跟insert冲突,gap之间不冲突。
- 快照读:简单的select操作(select * from user where name = 'lin'),属于快照读,不加锁。
当前读:所有的锁定读都是当前读,也就是读取当前记录的最新版本,不会利用 undo log 读取镜像。
- select * from table where ? lock in share mode;
- select * from table where ? for update;
- insert into table values (…);
- update table set ? where ?;
- delete from table where ?;
- 在Serializable级别下,所有的读都是当前读。
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是主键?唯一索引?普通索引?非索引?
- 是否存在id值为10的数据?
1. id是主键 + RC:
结论:id是主键时,此SQL只需要在id=10这条记录上加X锁即可。
2. id是唯一索引 + RC:
结论:若id列是unique列,其上有unique索引。那么SQL需要加两个X锁,一个对应于id unique索引上的id = 10的记录,另一把锁对应于聚簇索引上的[name=’d’,id=10]的记录。
3. id是普通索引 + RC:
结论:若id列上有非唯一索引,那么对应的所有满足SQL查询条件的记录,都会被加锁。同时,这些记录在主键索引上的记录,也会被加锁。
4. id是非索引字段 + RC:
由于id列上没有索引,因此只能走聚簇索引,进行全部扫描。从图中可以看到,满足删除条件的记录有两条,但是,聚簇索引上所有的记录,都被加上了X锁。无论记录是否满足条件,全部被加上X锁。既不是加表锁,也不是在满足条件的记录上加记录锁。
有人可能会问?为什么不是只在满足条件的记录上加锁呢?这是由于MySQL的实现决定的。如果一个条件无法通过索引快速过滤,那么存储引擎层面就会将所有记录加锁后返回,然后由MySQL Server层进行过滤。因此也就把所有的记录,都锁上了。
注:在实际的实现中,MySQL有一些改进(semi-consistent read),在MySQL Server过滤条件,发现不满足后,会调用unlock_row方法,把不满足条件的记录放锁 (违背了2PL的约束)。这样做,保证了最后只会持有满足条件记录上的锁,但是每条记录的加锁操作还是不能省略的。
结论:若id列上没有索引,SQL会走聚簇索引的全扫描进行过滤,由于过滤是由MySQL Server层面进行的。因此每条记录,无论是否满足条件,都会被加上X锁。但是,为了效率考量,MySQL做了优化,对于不满足条件的记录,会在判断后放锁,最终持有的,是满足条件的记录上的锁,但是不满足条件的记录上的加锁/放锁动作不会省略。同时,优化也违背了2PL的约束。
5. id是主键 + RR:与第1个一样。
6. id是唯一索引 + RR:与第2个一样。
7. id是普通索引 + RR:
- 与第3个区别在于,这多了一个间隙锁(GAP锁),而且GAP锁不是加在记录上的,而是加载两条记录之间的位置。
- Insert操作,如insert [10,aa],首先会定位到[6,c]与[10,b]间,然后在插入前,会检查这个GAP是否已经被锁上,如果被锁上,则Insert不能插入记录。
结论:Repeatable Read隔离级别下,id列上有一个非唯一索引,对应SQL:delete from t1 where id = 10; 首先,通过id索引定位到第一条满足查询条件的记录,加记录上的X锁,加GAP上的GAP锁,然后加主键聚簇索引上的记录X锁,然后返回;然后读取下一条,重复进行。直至进行到第一条不满足条件的记录[11,f],此时,不需要加记录X锁,但是仍旧需要加GAP锁,最后返回结束。
8. id是非索引字段 + RR:
如图,这是一个很恐怖的现象。首先,聚簇索引上的所有记录,都被加上了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原理及实现分析
9. id是普通索引 + RR + 没有id=5这条记录:
结果:会锁住小于5到大于5这段的间隙,例如:{1、3、7、9、10}数据中会锁住(3, 7)这段间隙。
一条相对复杂的SQL:
结论:在Repeatable Read隔离级别下,针对一个复杂的SQL,首先需要提取其where条件。Index Key确定的范围,需要加上GAP锁;Index Filter过滤条件,视MySQL版本是否支持ICP,若支持ICP,则不满足Index Filter的记录,不加X锁,否则需要X锁;Table Filter过滤条件,无论是否满足,都需要加X锁。
注:一个SQL中的where条件如何拆分?具体的介绍,建议阅读SQL中的where条件,在数据库中提取与应用浅析。
- Index key:pubtime > 1 and puptime < 20。此条件,用于确定SQL在idx_t1_pu索引上的查询范围。Index key:pubtime > 1 and puptime < 20。此条件,用于确定SQL在idx_t1_pu索引上的查询范围。
- Index Filter:userid = ‘hdc’ 。此条件,可以在idx_t1_pu索引上进行过滤,但不属于Index Key。Index Filter:userid = ‘hdc’ 。此条件,可以在idx_t1_pu索引上进行过滤,但不属于Index Key。
- Table Filter:comment is not NULL。此条件,在idx_t1_pu索引上无法过滤,只能在聚簇索引上过滤。
从图中可以看出,在Repeatable Read隔离级别下,由Index Key所确定的范围,被加上了GAP锁;Index Filter锁给定的条件 (userid = ‘hdc’)何时过滤,视MySQL的版本而定:
- 在MySQL 5.6版本之前,不支持Index Condition Pushdown(ICP),因此Index Filter在MySQL Server层过滤,即所有符合
pubtime > 1 and pubtime < 20
的数据都通过回表
查出来之后,才做userid = ’hdc‘
过滤。 - 在5.6后支持了Index Condition Pushdown,则在index上过滤。
- 若不支持ICP,不满足Index Filter的记录,也需要加上记录X锁,若支持ICP,则不满足Index Filter的记录,无需加记录X锁 (图中,用红色箭头标出的X锁,是否要加,视是否支持ICP而定);而Table Filter对应的过滤条件,则在聚簇索引中读取后,在MySQL Server层面过滤,因此聚簇索引上也需要X锁。最后,选取出了一条满足条件的记录[8,hdc,d,5,good],但是加锁的数量,要远远大于满足条件的记录数量。
- 在MySQL 5.6版本之前,不支持Index Condition Pushdown(ICP),因此Index Filter在MySQL Server层过滤,即所有符合
进阶
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是主键
条件 | 说明 | 锁 |
---|---|---|
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是普通索引
条件 | 说明 | 锁 |
---|---|---|
id = 10 | 普通索引跟唯一索引不一样的点在于,普通索引是存在重复的可能性, 所以即使等值查询,也是按next-key加锁, 根据规则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不是索引
条件 | 说明 | 锁 |
---|---|---|
id =15 | 由于id不是索引,在没有开启semi-consistent read 情况下会锁住全部数据(RR级别默认不开启) |
(-∞, 5], (5, 10], (10, 15], (15, 20], (20, 25], (25, 30], (30, +∞] |
根据主键修改索引字段
update user set name = 'test' where id = 1
当name
为非主键索引,且id
为主键时,会先锁住主键id=1
的记录,再获取对应的name
索引的锁。
如果这时候刚好同时在执行了以下SQL:
update user set name = 'test' where name = 'me'
假设ID为1的记录,name等于me,则以上SQL先锁住name
索引,再获取对应的记录锁,会出现死锁。
limit
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。这样不仅可以控制删除数据的条数,让操作更安全,还可以减小加锁的范围。
order by
索引搜索就是:找到第一个值,然后向左或向右遍历。
- order by 是用最小的值来找第一个。
- order by desc 是用最大的值来找第一个。
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)
in
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)间隙锁,锁只有在事务提之后的时候才会释放,此时出现两个事务互相等待对方持有的间隙锁而无法插入,出现死锁。
避免更新或者删除不存在的记录,容易导致死锁问题。
Serializable级别作用
既然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 行锁的理解及案例