目录
一、MySQL锁
1.1 概述
幻读问题
在并发事务场景下,很容易产生幻读等现象,而MySQL的默认存储引擎为InnoDB,其只支持RR(可重复读)隔离级别,因而需要各种锁以及MVCC机制来避免幻读
InnoDB存储引擎在 RR 级别下通过 MVCC和 Next-key Lock 来解决幻读问题:
1、执行普通 select,此时会以 MVCC 快照读的方式读取数据在快照读的情况下,RR 隔离级别只会在事务开启后的第一次查询生成 Read View ,并使用至事务提交。
所以在生成 Read View 之后其它事务所做的更新、插入记录版本对当前事务并不可见,实现了可重复读和防止快照读下的幻读
2、执行 select...for update/lock in share mode、insert、update、delete 等当前读在当前读下,读取的都是最新的数据,如果其它事务有插入新的记录,并且刚好在当前事务查询范围内,就会产生幻读!
InnoDB 使用 Next-key Lock来防止这种情况。当执行当前读时,会锁定读取到的记录的同时,锁定它们的间隙,防止其它事务在查询范围内插入数据。
锁的分类
- 根据锁的生效范围:
- 全局锁
- 表级锁(表锁,元数据锁,意向锁,AUTO-INC 锁)
- 行级锁(记录锁,间隙锁,临键锁)
- 根据操作类型的维度划分:
- 读锁:查询数据时使用的锁,
- 写锁:执行插入、删除、修改、DDL语句时使用的锁
- 根据互斥性划分:
- 共享锁(仅支持读)
- 独占锁(读,写均可独占)
- 根据思想维度划分:
- 乐观锁:先尝试执行,失败时再获取锁
- 悲观锁:先获取锁,然后再执行
MySQL不同隔离级别的底层实现
- RU级别:读操作不加锁,写操作加排他锁。
- RC级别:读操作使用MVCC机制,每次SELECT生成快照,写操作加排他锁。
- RR级别:读操作使用MVCC机制,首次SELECT生成快照,写操作加临键锁。
- 序列化级别:读操作加共享锁,写操作加临键锁。
锁的释放时机
读未提交:SQL执行完成后释放
可重复读:事务结束后释放
1.2 全局锁
使用方式
- flush tables with read lock
- unlock tables
使用场景
全库逻辑备份
改进方式
在支持事务的InnoDB存储引擎下,备份前先开启事务,因为其支持MVCC,所以会先生成快照,就可以一边对快照进行增删改查一边对数据库进行备份
1.3 表级锁
InnoDB支持多粒度锁的存储引擎,它的锁机制是基于聚簇索引实现的,当SQL执行时,如果能在聚簇索引命中数据,则加的是行锁,如无法命中聚簇索引的数据则加的是表锁
- 表锁
- 元数据锁(MDL)
- 意向锁
- AUTO-INC 锁
表锁
使用方式
- lock tables t_student read; //表级别的共享锁,读锁;
- lock tables t_stuent write; //表级别的独占锁,写锁;
- unlock tables
缺点
粒度太大,建议行锁
元数据锁(MDL)
为了保证当用户对表执行 CRUD 操作时,防止其他线程对这个表结构做了变更,事务执行期间,MDL长期持有,当有长事务时,如果写锁被别的读锁占用而导致后续所有读锁也无法生效就会阻塞,导致数据库线程爆满
使用方式
对一张表进行 CRUD 操作时,会自动加 MDL 读锁;
对一张表做结构变更操作的时候,会自动加 MDL 写锁;
意向锁
当表中有添加行锁时(执行插入、更新、删除操作),会同时添加此表的意向独占锁.当需要用到表锁时,快速判断表中的记录有没有行锁。这样可以防止全表扫描来判断有无行锁
普通的 select 是不会加行级锁的,普通的 select 语句是利用 MVCC 实现一致性读,是无锁的
AUTO-INC 锁
AUTO-INC 锁是特殊的表锁机制,锁不是在一个事务提交后才释放,而是执行完插入语句后就会立即释放。
在插入数据时,会加一个表级别的 AUTO-INC 锁,然后为被 AUTO_INCREMENT 修饰的字段赋值递增的值,等插入语句执行完成后,才会把 AUTO-INC 锁释放掉。
那么,一个事务在持有 AUTO-INC 锁的过程中,其他事务的如果要向该表插入语句都会被阻塞,从而保证插入数据时,被 AUTO_INCREMENT 修饰的字段的值是连续递增的。
1.4 行级锁
InnoDB会将表数据存储在聚簇索引中,每条行数据都会存储在树中的叶子节点上,因此行数据是独立的,所以可以对每一条数据上锁,但其他引擎大部分都不支持聚簇索引,表数据都是一起存储在一块的,所以只能基于整个表数据上锁,这也是为什么其他引擎不支持行锁的原因。
- 普通的 select 语句是不会对记录加锁的,因为它属于快照读。
- 如果要在查询时对记录加行锁,则为锁定读,方式为:
- select ... lock in share mode;//对读取的记录加共享锁
- select ... for update;//对读取的记录加独占锁
共享锁(S锁)满足读读共享,读写互斥。独占锁(X锁)满足写写互斥、读写互斥
在开启事务的情况下,查询使用FOR UPDATE,如果使用了索引/主键并且索引生效的情况下,是行锁,否则是表锁。
记录锁(Record Lock)
作用于一条记录,只能锁住已经存在的记录,为了避免插入新记录,需要依赖间隙锁。
间隙锁(Gap Lock)
锁定一个范围(左开右开),但是不包含记录本身
临键锁(Next-Key Lock)
Record Lock+Gap Lock ,锁定一个范围(左开右闭),并且锁定记录本身,用于解决幻读,
在 InnoDB 默认的隔离级别 RR 下,行锁默认使用的是 Next-Key Lock。但是,如果操作的索引是唯一索引或主键,InnoDB 会对 Next-Key Lock 进行优化,将其降级为 Record Lock,即仅锁住索引本身,而不是范围。
在能使用记录锁或者间隙锁就能避免幻读现象的场景下,next-key lock 就会退化成退化成记录锁或间隙锁。
常见规则
原则 1:加锁的基本单位是 next-key lock。next-key lock 是前开后闭区间。
原则 2:只有访问到的对象才会加锁。
优化 1:索引上的等值查询,
命中唯一索引,退化为行锁。
命中普通索引,左右两边的GAP Lock + Record Lock。
优化 2:
索引上的等值查询,未命中,所在的Net-Key Lock,退化为GAP Lock 。
索引+范围查询:
1.等值和范围分开判断。
2.索引在范围查询的时候 都会访问到所在区间不满足条件的第一个值为止。
3.如果使用了倒叙排序,按照倒序排序后,检索范围的右边多加一个GAP。
哪个方向还有命中的等值判断,再向同方向拓展外开里闭的区间。
行锁粒度粗化
- 在内存中专门分配了一块空间存储锁对象,当该区域满了后,会将行锁粗化为表锁。
- 进行范围性写操作时,由于要加的行锁较多,此时行锁开销会较大,会粗化成表锁。
插入意向锁
一个事务在插入一条记录的时候,需要判断插入位置是否已被其他事务加了间隙锁(next-key lock 也包含间隙锁)。
如果有的话,插入操作就会发生阻塞,直到拥有间隙锁的那个事务提交为止(释放间隙锁的时刻),在此期间会生成一个插入意向锁,表明有事务想在某个区间插入新记录,但是现在处于等待状态
1.5 乐观锁与悲观锁
乐观锁
在MySQL中则可以通过version版本号+CAS的形式实现乐观锁,即在表中多设计一个version字段,然后在SQL修改时以如下形式操作:
UPDATE ... SET version = version + 1 ... WHERE ... AND version = version;
一般的乐观锁都会配合轮询重试机制,比如上述T1执行失败后,再次执行相同语句,直到成功为止。
这种形式却并不适合所有情况,比如写操作的并发较高时,就容易导致一个事务长时间一直在重试执行,从而导致客户端的响应尤为缓慢。因此乐观锁更加适用于读大于写的业务场景,频繁写库的业务则并不适合加乐观锁。
悲观锁
数据库中的排它锁:for update
1.6 共享排他锁
SMO问题:
在SQL执行期间一旦更新操作触发B+Tree叶子节点分裂,那么就会对整个B+Tree加排它锁,这不但阻塞了后续这张表上的所有的更新操作,同时也阻止了所有试图在B+Tree上的读操作,也就是会导致所有的读写操作都被阻塞,影响巨大
为了解决这种情况,MySQL5.7引入共享排他锁,只锁定B+Tree的某个分支,而并不是锁定整颗B+树,从而做到不影响其他分支上的读写操作
1.7 实现原理
(十)全解MySQL之死锁问题分析、事务隔离与锁机制的底层原理剖析 - 掘金 (juejin.cn)
二、MVCC
MVCC(多版本并发控制Multi-Version Concurrency Control):每个连接到数据库的读者,在某个瞬间看到的是数据库的一个快照,写者写操作造成的变化在写操作完成之前(或者数据库事务提交之前)对于其他的读者来说是不可见的。每行数据都存在一个版本号,每次数据更新时都更新该版本
MVCC=隐藏字段+Undo-log+ReadView
MVCC只能在两个隔离级别使用,区别在于:生成ReadView的时机不同
READ COMMITTD在每一次进行普通SELECT操作前都会生成一个ReadView
而REPEATABLE READ只在第一次进行普通SELECT操作前生成一个ReadView,数据的可重复读其实就是ReadView的重复使用
实现原理
通过在每一行数据中额外保存两个隐藏的列实现
最近更新的事务ID - TRX_ID(6Bytes)+回滚指针 - ROLL_PTR(7Bytes)
当前行创建时的版本号(最近更新的事务ID)和删除时的版本号(回滚指针)。这里的版本号并不是实际的时间值,而是系统版本号。每开始新的事务,系统版本号都会自动递增。事务开始时刻的系统版本号会作为事务的版本号,用来和查询每行记录的版本号进行比较。每个事务又有自己的版本号,这样事务内执行CRUD操作时,就通过版本号的比较来达到数据版本控制的目的。
首先会去获取表中行数据的隐藏列,然后经过上述一系列判断后,可以得知:目前查询数据的事务到底能不能访问最新版的数据。
如果能,就直接拿到表中的数据并返回,
反之,不能则去Undo-log日志中获取旧版本的数据返回。