redo log
来实现。undo log
来实现。那么剩下的隔离性则由本章的锁机制来实现。
锁是计算机协调多个进程或者线程并发访问某一资源的一种机制。
背景:在Mysql中,并发事务的访问大概可以分为三种情况:
那么如何解决脏读、不可重复读、幻读等问题呢?
脏读的产生是因为当前事务读取了另一个未提交事务写的一部分记录。 那么如果另一个事务在写记录的时候就给这条记录加锁,那么当前事务就读取不到这条记录了,也就避免了脏读。
不可重复读的产生和脏读是比较相似的,只不过从读操作变成了修改操作。当前事务先读取一条记录,另外一个事务对该记录做出了改动并提交,那么当前事务再次读取时就会获得不同的值。 同样给对应的记录添加锁时,当前的事务就无法修改这条记录,也就避免的不可重复读。
幻读的产生是因为当前事务读取了一个范围记录,然后另外的事务插入了一条满足当前事务的一个查询条件,此时当前事务再次查询的时候,就会发现多出来的记录。
但问题来了,采用加锁的方式来解决幻读的话,会遇到一些麻烦:因为当前事务在第一次读取记录的时候幻影记录并不存在,那么读取的时候就加上锁就不太合适,而且并不知道到底给谁加锁,因为你无法为一条不存在的记录加锁。
也因此,Mysql中MVCC在READ COMMITTED
和 REPEATABLE READ
隔离级别下会使用,因为其无法解决幻读。
接下来看下Mysql对于锁的分类。
此时可以分为两种锁:
读锁(read lock
):英文用S
标识。针对同一份数据,多个事务的读操作可以同时进行而不会互相影响,相互不阻塞的。
对读取的记录加S锁:
select ... lock in share mode;
# 8.0后的语法
select ... for share;
倘若当前事务对某一条语句加了S锁。那么当别的事务继续获取当前记录的时候,允许它们获取这个S锁,但是不能获取这些记录的X锁,倘若想获取X锁,则发生阻塞。直到当前事务提交之后将记录上的S锁释放掉。
写锁(write lock
):英文用 X
表示。当前写操作没有完成前,它会阻断其他写锁和读锁。 这样就能确保在给定的时间里,只有一个事务能执行写入,并防止其他用户读取正在写入的同一资源。
对读取的记录加X锁:
select ... for update;
若当前事务加了X锁,那么其他的事务对于该数据既不能加S锁也不能加X锁。会阻塞。
注意:读写锁可以加在表上和数据行上。
在Mysql5.7及更老的版本中,对于加X锁的时候,倘若获取不到,那么进入阻塞一直等待,直到innodb_lock_wait_timeout
超时。在Mysql8.0中,可以添加NOWAIT、SKIP LOCKED
语法,跳过锁等待,若查询的行已经加了锁:
NOWAIT
:立即报错返回。SKIP LOCKED
:立即返回,但是返回的结果中不包含被锁定的行。倘若锁根据粒度来进行划分,那么此时可以分为三种锁:
表锁会锁定整张表,是Mysql中最基本的锁策略,并不依赖于存储引擎,是开销最小的策略(粒度最大),可以很好地避免死锁问题。
对表A添加表级别的S/X锁,语法如下:
LOCK TABLES A READ;# 添加读锁
LOCK TABLES A WRITE;# 添加写锁
InnoDB
支持多粒度锁,允许行级锁和表级锁共存,其中意向锁就是一种表锁。
意向锁:
意向锁能解决什么问题?假设此时有两个事务,T1和T2,若T2尝试在表级别上尝试加排它锁:
即给更大一级别的空间做个标识,标识空间内的元素是否加上了锁。 那么换句话说就是意向锁会告诉其他事务,当前表中的某些记录已经被其他事务锁定了。
意向锁分为两种:
intention shared lock, IS
):事务有意向对表中的某些行加共享锁(S锁)。-- 事务要获取某些行的 S 锁,必须先获得表的 IS锁。
SELECT column FROM table ... LOCK IN SHARE MODE;
intention exclusive lock, IX
):事务有意向对表中的某些行加排他锁(X锁)。-- 事务要获取某些行的 X 锁,必须先获得表的 IX锁。
SELECT column FROM table ... FOR UPDATE;
案例如下:
1.打开两个会话:
2.在第一个会话中,开启一个事务,为这个表中的某一条数据添加X锁:
3.在另一个会话中,同样开启一个事务,尝试对该表添加个读锁:
4.此时会话2是进入阻塞状态的,因为会话1中,哪怕是给表中的某一条记录添加了X锁,但是Mysql会自动的给对应的表添加个意向锁IX锁。因此此时其他的事务还想要添加表锁的时候,就会进入阻塞(无论是读锁还是写锁)。
5.此时尝试将会话1中的事务提交。
6.此时再看会话2:发现阻塞结束了
意向锁和普通的排他/共享锁(表级别的)之间的兼容关系如下:
我们平常使用过自增主键,例如:
CREATE TABLE `teacher` (
`id` int NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
对于这种拥有自增ID
的,在数据插入的时候我们可以不显式地指定对应的值,例如:
INSERT INTO `teacher` (name) VALUES ('zhangsan'), ('lisi');
这样的数据插入方式一共有三种:
Simple inserts
): 可以预先确定要插入的行数。Bulk inserts
):事先不知道要插入的行数。Mixed-mode inserts
):这些是简单插入模式但是指定部分新行的自动递增值。其实上面一个简单的插入就用到了自增锁:
AUTO-INC
锁就是当向 含有AUTO_INCREMENT
列的表插入数据时需要获取到的一种特殊表级锁。在执行插入语句时就会添加自增锁。AUTO_INCREMENT
修饰的列分配递增值,在该语句执行结束后,就会将自增锁释放掉。当我们向一个含有AUTO_INCREMENT
关键字的主键插入值的时候,每条语句都要为这个表锁进行竞争, 这样就导致并发能力低下,因此InnoDB
可以通过innodb_autoinc_lock_moda
参数来提供不同的锁定机制:
insert
语句都会获得一个特殊的表级AUTO-INC
锁,用于插入具有 AUTO_INCREMENT
列的表。mutex
(轻量锁) 的控制下获得所需数量的自动递增值来避免添加自增表级锁, 它只在分配过程的持续时间内保持,而不是直到语句完成。insert
语句中是唯一且单调递增的。在对某个表执行SELECT、INSERT、DELETE、UPDATE
语句时,InnoDB
存储引擎是不会为这个表添加表级 别的 S锁 或者 X锁 的。 在对某个表执行一些诸如 ALTER TABLE 、 DROP TABLE
这类的 DDL
语句时,其他事务对这个表并发执行诸如SELECT、INSERT、DELETE、UPDATE
的语句会发生阻塞。反之同理。这个过程其实是通过在 server
层使用一种称之为元数据锁 (英文名: Metadata Locks,MDL
)结构来实现的。
MDL锁的作用:保证读写的正确性。
MDL锁是在访问表的时候自动添加的,不需要显式的调用。
注意:MyISAM
不支持行锁,因此接下来的内容都是针对于InnoDB
的。
行级锁只在存储引擎层实现:
行锁可以分为四种:
当在一个事务中,对某一条记录进行了update
操作,那么就会加上对于的记录锁。记录锁是有S锁和X锁之分的,称之为S型记录锁
和X型记录锁
。
我们在上文中提到,通过加锁的方式去解决幻读问题是行不通的,因为无法为一条不存在的幻影记录进行加锁。因此InnoDB
提出来一种锁叫间隙锁。gap锁
的提出仅仅是为了防止插入幻影记录。例如,给id
为8的记录增加一个间隙锁,官方名称叫LOCK_GAP
,简称gap锁
:
此时意味着不允许别的事务在id值为8的记录前边的间隙插入新的记录。 即在(3,8)
这个id
区间内,不允许其他事务插入新数据。
注意,倘若以上述图为例,为id
为25的数据添加一个X锁(不存在的数据),那么此时,这个间隙锁将会是(20,正无穷)
。
同时,间隙锁容易造成死锁。因为间隙锁会将某个范围的数据进行锁定,若范围控制的不好,容易造成不同事物之间的抢夺锁行为,造成死锁
有时候我们希望锁住当前记录 ,又想阻止其他事务在该记录前边的 间隙插入记录 。即一个左开右闭区间。 此时InnoDB
就提出了Next-key Locks
。简称next-key锁
。在事务级别为可重复读
的情况下使用的数据库锁默认就是临键锁。
相当于间隙锁的一个升级了,我觉得可以这么理解:
next-key锁 = 记录锁 + 间隙锁。
一个事务在插入一条记录的时候,需要判断一下插入位置是否被别的事务加了gap锁
,若有的话,插入操作需要等待,直到拥有gap锁
的事务提交。
InnoDB
规定:事务在等待的时候需要在内存中生成一个锁结构,表名有事务想在某个间隙中插入新纪录。
这种类型的锁就叫Insert Intention Locks
,就是插入意向锁,也是gap锁
的一种。在insert
操作一条记录之前产生。
特点:
页锁也就是在页的粒度上进行锁定,锁定的数据资源比行锁要多。页锁的开销介于表锁和行锁之间,会出现死锁。
此时,Mysql中的锁分为两种,这里的锁并不是真正的锁,而是一种设计思想。
对数据被其他事务的修改持保守态度,会通过数据库自身的锁机制来实现,从而保证数据操作的排它性。比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁,当其他线程想要访问数据时,都需要阻塞挂起。
例如Java当中的Synchronized
还有ReentrantLock
都是悲观锁的设计。
在Mysql中,例如这样的语句就是个典型的悲观锁实现:
select ... for update;
但是值得注意的是:select ... for update;
执行过程中会将所有扫描到的行都锁上,因此在Mysql中使用悲观锁必须确定使用了索引,而不是全表扫描,否则会将整个表锁住。
乐观锁则认为对统一数据的并发操作属于小概率事件,保持乐观态度,不用每次都对数据进行上锁,通过程序来实现。 例如:
AutomicInteger
类等等)。乐观锁的版本号机制如下:
version
。version
的值,然后对数据进行修改的时候。update ... set version = version + 1 where version = version
。乐观锁的时间戳机制如下:
两种锁的适用场景:
隐式锁存在于哪呢:
隐式锁的逻辑过程
InnoDB
的每条记录中都一个隐含的trx_id
字段,这个字段存在于聚簇索引的B+树中。trx_id
检查该事务是否是活动的事务(未提交或回滚)。如果是活动的事务,首先将隐式锁转换为显式锁 (就是为该事务添加一个锁)。waiting
状态。如果没有冲突不加锁,跳到第五步。trx_id
写入trx_id
字段。本文当中诸如以下这样显式加锁的方式都称之为显式锁:
# 排它锁
select ... for update
# 共享锁
select .... lock in share mode
Mysql中死锁指的是:多个事务在同一资源上相互占用,并请求锁定对方占用的资源,从而导致恶性循环。
出现死锁后,Mysql有两种策略:
innodb_lock_wait_timeout
控制。默认50s。 - 第二种:发起死锁检测,主动回滚死锁链中持有最少行级排它锁的事务。 让其他事务得以继续执行。该策略通过参数innodb_deadlock_detect=on
开启。给一条记录加锁的本质也就是在内存中创建一个锁结构与之关联。 但是也并不是一个事务对多条记录加锁,那么就会生成多个表结构,只有符合条件的记录才会放到一个表结构中:
这里存储的是一个指针,通过指针来找到内存中关于该事务的更多信息,例如事务的ID。
对于行锁来说,需要记录一下加锁的记录是属于哪一个索引的,也是一个指针。
表锁:记录着是对哪一张表加的锁以及其他信息。
行锁:记录三个重要信息:
Space ID
:记录所在表空间。Page Number
:记录所在页号。n_bits
:对于行锁来说,一条记录就对应着一个比特位,一个页面中包含很多记录,用不同的比特位来区分到底是哪一条记录加了锁。这个n_bits
属性代表使用了多少比特位。rec_lock_type
:行锁的具体类型,只有在lock_type
的值为LOCK_REC
的时候,即该锁表示为行级锁,才会被细分为更多的类型,如下:
其他信息:存储了管理系统运行过程中生成的各种哈希表和链表。
比特位:如果是行锁结构的话,在该结构末尾还放置了一堆比特位,主要作为页中数据的一个映射。
MVCC:(Multiversion Concurrency Control
)多版本并发控制。通过数据行的多个版本管理来实现数据库的并发控制。
先理解以下概念。
快照读(一致性读):读取的是快照数据。例如不加锁的简单的Select语句都属于快照读。快照读的实现就基于MVCC。
select ... from student where ...;
当前读:读取的是记录的最新版本,读取的时候还要保证其他并发事务不会修改当前的记录。会对读取的记录进行加锁,即加锁的select操作。
select ... from student lock in share mode;
普通的Select
语句在以下两种隔离级别下会使用到MVCC读取记录:
READ COMMITTED
:,一个事务在执行过程中每次执行SELECT
操作时都会生成一个ReadView
,ReadView
的存在本身就保证了事务不可以读取到未提交的事务所做的更改 ,也就是避免了脏读现象。REPEATABLE READ
:,一个事务在执行过程中只有第一次执行SELECT
操作才会生成一个ReadView
,之后的SELECT
操作都复用这个ReadView
,这样也就避免了不可重复读和幻读的问题。MVCC的实现依赖于三个层面:
Undo Log
。ReadView
。关于ReadView
:事务在使用MVCC机制进行快照读操作的时候产生的读视图。
当事务启动的时候,就会生成当前数据库系统的一个快照。InnoDB
为每个事务都构造了一个数组,用来记录当前活跃事务的ID
(启动了但未提交)。
ReadView
中主要包含4个重要内容:
creator_trx_id
:创建这个ReadView
的事务ID
。(只有对表中的记录作出update
类改动时,才会为事务分配事务ID,否则在一个只读事务中的事务ID
值为0)也因此ReadView
和事务之间的关系是一对一的。trx_ids
:生成ReadView
的此时此刻,当前系统中活跃的事务ID
列表。up_limit_id
:活跃事务中的最小事务ID
。low_limit_id
:表示生成ReadView
时系统中应该分配给下一个事务的ID
值。使用ReadView
的规则,当访问某条记录的时候,遵循下属步骤就可以判断某条记录的某个版本是否可见。
trx_id
值和ReadView
中的creator_trx_id
值相同:意味着当前事务正在访问他自己修改过的记录,该版本可以被当前事务访问。trx_id
值 小于 ReadView
中的up_limit_id
值:意味着被访问的版本已经事务提交,该版本可以被当前事务访问。trx_id
值 大于或者等于 ReadView
中的low_limit_id
值:意味着该版本的事务在当前事务生成 ReadView
之后才开启,不可访问。trx_id
值 在 ReadView
中的up_limit_id
值和low_limit_id
值之间:那么当前事务是否可访问当前版本决定于trx_id
值是否存在于trx_ids
列表中。MVCC查找到一条记录的操作流程如下:
ReadView
。ReadView
中事务版本号进行比较。ReadView
规则,那么需要从Undo Log
中,即版本链获取历史快照。注意:隔离级别为读已提交的时候,一个事务中的每一次Select
查询都会重新获取一次ReadView
。 否则可能产生不可重复读或者幻读的情况。
现在我们有一张student
表,下面有一条由事务ID
为8插入的数据:
读提交:Read Committed
,在该隔离级别下,每次读取数据都会生成一个ReadView
。
现在有两个事务ID
分别为10和20的事务在执行:
那么此时student
表中的这条记录,其版本链如下:
倘若此时有一个事务正在读取这条数据:
select * from student where id = 1;
此时的执行过程如下:
select
语句的时候,根据该隔离级别的特性,会生成一个ReadView
。此时其trx_ids
列表的内容就是[10,20]
,up_limit_id
为10(最小值),low_limit_id
为21(下一个最大值),creator_trx_id
为0(只有update
类操作才会分配)。王五
,该版本的trx_id
是10,在trx_ids
列表中,不符合可见性要求,根据回滚指针roll_pointer
跳到下一个版本。李四
,其同理不符合版本要求,继续跳到下一个版本。张三
,trx_id
值为10,小于ReadView
中up_limit_id
的值10,因此符合版本要求,最终返回给用户。张三
所在的数据行。再去事务20中更新一下表student
中id
为1的记录:
此时此刻的版本链为:
此时的执行过程如下:
select
语句的时候,根据该隔离级别的特性,会生成一个ReadView
。此时其trx_ids
列表的内容就是[20]
(事务10已经提交,不再属于活跃事务),up_limit_id
为20(最小值),low_limit_id
为21(下一个最大值),creator_trx_id
为0(只有update
类操作才会分配)。宋八
,该版本的trx_id
是20,在trx_ids
列表中,不符合可见性要求,根据回滚指针roll_pointer
跳到下一个版本。钱七
,其同理不符合版本要求,继续跳到下一个版本。王五
,trx_id
值为10,小于ReadView
中up_limit_id
的值20,因此符合版本要求,最终返回给用户。王五
所在的数据行。在该隔离级别下的事务而言:只会在第一次执行查询语句时生成一个ReadView
。
现在有两个事务ID
分别为10和20的事务在执行:
那么此时student
表中的这条记录,其版本链如下:
倘若此时有一个事务正在读取这条数据:
select * from student where id = 1;
此时的执行过程如下:
select
语句的时候,根据该隔离级别的特性,会生成一个ReadView
。此时其trx_ids
列表的内容就是[10,20]
,up_limit_id
为10,low_limit_id
为21,creator_trx_id
为0。王五
,该版本的trx_id
是10,在trx_ids
列表中,不符合可见性要求,根据回滚指针roll_pointer
跳到下一个版本。李四
,其同理不符合版本要求,继续跳到下一个版本。张三
,trx_id
值为10,小于ReadView
中up_limit_id
的值10,因此符合版本要求,最终返回给用户。张三
所在的数据行。这里可以看出来和读提交隔离级别下的流程是一模一样的,但是倘若事务10提交之后,就会发生区别了(重要):
做和3.2.1节中一样的操作,事务10进行commit
提交,开启一个新事务对数据进行更新操作:
此时此刻的版本链为:
此时的执行过程如下:
select
语句的时候,根据该隔离级别的特性,由于第一次查询的时候已经生成了一个ReadView
,因此此时此刻复用之前的ReadView
。此时其trx_ids
列表的内容就是[10,20]
,up_limit_id
为20,low_limit_id
为21,creator_trx_id
为0。宋八
,该版本的trx_id
是20,在trx_ids
列表中,不符合可见性要求,根据回滚指针roll_pointer
跳到下一个版本。钱七
,其同理不符合版本要求,继续跳到下一个版本。张三
,trx_id
值为8,小于ReadView
中up_limit_id
的值10,因此符合版本要求,最终返回给用户。张三
所在的数据行。注意:只有在快照读的情况下,MVCC是可以解决幻读的。前提是快照读,快照读,快照读!
这里的举例和上面的特别相似,依旧假设student
表中只有一条数据,主键id
为1。事务ID
为10,那么其undoLog
为:
此时有两个事务A,B并发执行,事务ID
分别为20,30。流程如下:
ReadView
:trx_ids=[20,30],up_limit_id=20,low_limit_id=31,creator_trx_id=0
。能够查询到张三的这条数据。select * from student where id >= 1;