Innodb引擎的锁机制
InnoDB与MyISAM的最大不同有两点:一是支持事务(TRANSACTION);二是采用了行级锁
事务的ACID
事务是为了实现业务上完整性而实现了,他可以由多条sql语句组成,这些语句要么全部成功,否则发生任何错误都将会回滚。事务具有4个属性。称为事务的ACID属性
- 原子性(Actomicity):事务是一个原子操作单元,其对数据的修改,要么全都执行,要么全都不执行。
- 一致性(Consistent):在事务开始和完成时,数据都必须保持一致状态。这意味着所有相关的数据规则都必须应用于事务的修改,以保持完整性;事务结束时,所有的内部数据结构(如B树索引或双向链表)也都必须是正确的。并且在操作数据的过程中,无论使用并行或者是串行的方式执行,得到的最后的结果应该是一致的。
- 隔离性(Isolation):隔离性是描述多个事务之间操作共同资源时如何处置的问题,选择哪一种隔离方式。数据库系统提供一定的隔离机制,保证事务在不受外部并发操作影响的“独立”环境执行。这意味着事务处理过程中的中间状态对外部是不可见的,反之亦然。
- 持久性(Durable):事务完成之后,它对于数据的修改是永久性的,即使出现系统故障也能够保持。
并发事务带来的问题
相对于串行处理来说,并发事务处理能大大增加数据库资源的利用率,提高数据库系统的事务吞吐量,从而可以支持可以支持更多的用户。但并发事务处理也会带来一些问题,主要包括以下几种情况。
- 更新丢失(Lost Update):当两个事务同时更新一个数据时候,由于同时读取数据表中的数据,并且两个事务都以自己读到的数据为基础进行更新,在写入表时候,后写入的值会覆盖先写入的值,原本应该两次更新的操作丢失了一次。“更新丢失”通常应该是完全避免的,但不能单靠数据库控制器解决,而应该时应用层的责任。
- 脏读(Dirty Reads):一个事务读取到了另一个事务未提交的数据,该数据可能会发生回滚而失效。
- 不可重复读(Non-Repeatable Reads):一个事务执行时,前后使用相同的查询语句,第二次读取时发现数据被修改,两次读取到的数据不一致。
- 幻读(Phantom Reads):一个事务按相同的查询条件重新读取以前检索过的数据,却发现其他事务插入了满足其查询条件的新数据,这种现象就称为“幻读”。
不可重复读和幻读都是两次的查询结果不同,但是不可重复读是倾向于行数据被修改,这个更好解决,对该行加锁即可。而幻读强调的是执行相同的查询时增加或者删除了一整行的数据。处理这个问题锁单行是无法解决的,需要通过锁表实现。这也是两个问题分开提出的原因。
事务的隔离级别
上面的问题在所有关系型数据中都是存在,所以在ISO/ANSI SQL92标准中定义了4个事务隔离级别来分别解决以上的问题。
隔离级别 |
脏读 |
不可重复读 |
幻读 |
读未提交 |
存在 |
存在 |
存在 |
读已提交 |
解决 |
存在 |
存在 |
可重复读 |
解决 |
解决 |
存在 |
序列化 |
解决 |
解决 |
解决 |
MySQL的隔离级别的具体实现和上面sql标准中定义的有一个区别的地方,可重复读的隔离级别下,MySQL解决了幻读的问题,而MySQL的默认隔离级别为可重复读,该级别可以避免上述的所有问题。
隔离级别的实现
实现事务的隔离级别,一般可以通过两种方式实现
- 使用锁,通过行锁或者表锁来保证共享的数据只能被一个事务获取,其他事务只能等待前一个事务完成后才能进行处理。
- MVCC并发控制:通过一定机制生成一个与请求时间点一致性数据快照(Snapshot),并用这个快照来提供一定级别(语句级或事务级)的一致性读取。从用户的角度,好像是数据库可以提供同一数据的多个版本,因此,这种技术叫做数据多版本并发控制(MultiVersion Concurrency Control,简称MVCC或MCC),也经常称为多版本数据库。
Innodb的锁机制实现
行锁
innodb实现了两种类型的行锁:
- 共享锁(读锁):一个事务获取了该行数据的共享读锁, 其他事务还可以继续获取该数据的共享读锁,但是无法获取排他的写锁。
- 排他锁(写锁):任意一个事务获取了该行数据的排他写锁后,该行数据共享锁和排他锁都无法被获取。直到该锁被释放。
一个事务中通过update,insert,delete等语句去修改一行数据,MySQL都会自动尝试的去获取一个排他性的写锁,这个获取锁的操作就会与其他获取同一个锁的事务竞争,只会有一个事务得到锁而执行操作,另一个继续竞争。而直接使用select 语句进行查询是不用获取锁的,可以随时进行查询,但是这也可能会造成获取到其他事务正在修改的数据(如果并不关心这一点可以使用该方式)。也可以在查询数据时使用锁去获取。
select * from table ..... for update
:对获取的这些数据添加一个排他的写锁,在事务提交前,其他事务都不能读写这些被加锁的数据。
select * from table ..... lock in share mode
:对获取的这些数据添加一个共享读锁,其他事务可以读取,但是不能修改数据。
行锁的实现方式
MySQL行锁是通过给索引上的索引项加锁来实现的,这一点与Oracle不同,后者是通过在数据块中对相应数据行加锁来实现的。MySQL这种行锁实现特点意味着:只有通过索引条件检索数据,才使用行级锁,否则,InnoDB将使用表锁!
例如 select * from table where name=tom
,如果name字段没有索引,将会进行全表扫描来获得数据,innodb在扫描之前将会锁住整个表,直到事务提交。如果name字段有索引,MySQL实际会在索引上锁,但是有多个索引时,也会对数据进行上锁,以避免使用不同的索引获取值时出现数据的并发访问问题。
但是并不是使用了索引字段进行查询就能保证查询一定走了索引,某些情况下,使用索引可能没有直接全表扫描的效率高,应该使用explain查看查询计划
意向锁(表锁)
意向锁也分为两种:
- 意向共享锁(IS):事务打算给数据行共享锁,事务在给一个数据行加共享锁前必须先取得该表的IS锁。
- 意向排他锁(IX):事务打算给数据行加排他锁,事务在给一个数据行加排他锁前必须先取得该表的IX锁。
意向锁是InnoDB自动加的,不需用户干预。例如进行全表扫描时,将会自动意向锁加锁。同时获取表锁前,也会确保该表中不存在行锁才能获取,否则任然可能会造成锁冲突。这四种锁的冲突与兼容情况如下:
锁 |
X |
IX |
S |
IS |
X |
冲突 |
冲突 |
冲突 |
冲突 |
IX |
冲突 |
兼容 |
冲突 |
兼容 |
S |
冲突 |
冲突 |
兼容 |
兼容 |
IS |
冲突 |
兼容 |
兼容 |
兼容 |
其他事务想要获取的锁的类型,必须和当前事务以获取锁的类型相互兼容,其他事务才能正常的获取,否则将会阻塞等待锁释放。
间隙锁
当我们用范围条件而不是相等条件检索数据,并请求共享或排他锁时,InnoDB会给符合条件的已有数据记录的 索引项加锁;对于键值在条件范围内但并不存在的记录,叫做“间隙(GAP)”,InnoDB也会对这个“间隙”加锁,这种锁机制就是所谓的间隙锁 (Next-Key锁)。
例如我们进行范围查询时候, select * from id > 20
此时我们指定的范围是id > 20,表中的这些数据都会被加锁而避免被修改,同时,他们这些数据之间间隙也是会被加锁的,如果插入一个id = 30的数据(表中没有改数据),人仍然会被阻塞而禁止插入,这也是解决幻读问题一种实现。
所以避免范围查询也是一个数据库优化方式,如果可以,应该尽量的减小这个范围, 最好使用相等的条件查询。
几种使用锁的情况
以上的现象是在MySQL的默认隔离级别repeated read下,使用read commited隔离级别时有不同表现。
read commited 隔离下锁的表现:
- 通过主键或者索引加锁,只会对查询的行加锁,其余未加锁行不受任何影响,其他事务可以*访问。
- 使用for update和全表扫描的方式加锁,同样只会锁住被查出的行,其余行没有被写锁锁住,可以正常获取。
- 插入数据不会受锁影响,可直接插入数据,也不能避免幻读
repeated read隔离级别下锁表现
- 通过主键或者索引加锁,只会对查询的行加锁,其余未加锁行不受任何影响,其他事务可以*访问。
- 使用全表扫描的方式加排他锁,表所有的行都会被锁住,即使不是被查询的数据,因为使用了表锁。
- 插入数据受到间隙锁的控制。加锁时候,根据where或者其他条件进行的查询,在这个where条件的范围内部的数据,都不能被修改,并且其他事务想要插入一条数据,必须不在这个范围内部,否则将会触发锁而阻塞。
- 全表扫描的情况下,全部的数据和间隙都会被锁住,也就无法插入数据,从而避免幻读。
同时还需要注意以下问题:
- 当表有多个索引的时候,不同的事务可以使用不同的索引锁定不同的行,另外,不论是使用主键索引、唯一索引或普通索引,InnoDB都会使用行锁来对数据加锁。
- 由于MySQL的行锁是针对索引加的锁,不是针对记录加的锁,所以虽然是访问不同行的记录,但是如果是使用相同的索引键,是会出现锁冲突的。例如使用查询条件
where name=tom and age=18
和where name=tom and age=20
,如果name字段建立了索引,虽然查询行数据时两行数据,但是由于使用的索引字段的条件时相同的,所以任然会锁冲突。
查看锁争抢情况
通过一个MySQL内部状态量来查看争用情况show status like ‘innodb_row_lock%‘;
+-------------------------------+-------+ | Variable_name | Value | +-------------------------------+-------+ | Innodb_row_lock_current_waits | 0 | | Innodb_row_lock_time | 45336 | | Innodb_row_lock_time_avg | 6476 | | Innodb_row_lock_time_max | 12442 | | Innodb_row_lock_waits | 7 | +-------------------------------+-------+
如果InnoDB_row_lock_waits和InnoDB_row_lock_time_avg的值比较高,说明锁竞争越严重(以上数据是测试了许多锁竞争后情况后的数值),我们需要分析锁竞争的原因,使用方案优化
MVCC并发控制的实现
仅仅使用上面的锁方案,在实现各个级别隔离性前提下,只能做到多个事务同时并发读取,而多个事务同时读写或多个事务同时写都会触发锁竞争,使得这些事务串行化,从而降低了并发量。而使用mvcc版本,可以实现同时读写时的并发执行。
mvcc想要实现的是读写之间的并发,也就是一个事务在读取内容的时候,可以有其他的一个写操作的事务(仅此一个,否则多个写操作并发)在执行,并且这不会破坏数据的一致性,同时达到隔离级别的要求。
快照读和当前读
数据库的更改会在undo log中形成一个版本链的关系,每次insert 或 update一个数据,都会创建该记录的新的版本 (delete也看作update操作,他会把改变记录的deleted位标记),并在该记录的行信息中修改该记录的事务的唯一id,将来作为事务可见性的判断。基于这个版本链,在MVCC并发控制中,每次写操作会创建一个新的版本,而读操作可以分成两类:快照读 (snapshot read)与当前读 (current read)。
快照读:当开启一个事务之后,第一次执行普通的select语句时(不加锁的select语句),会将本次在版本连中最新的一次已提交的记录做一次快照并暂存,且这个快照只有该事务可见,其他事务可以在原始的版本链中修改数据,但该事务的快照内容不会修改。
当前读:读取的是版本链中记录的最新的已提交的版本,并且,当前读返回的记录,都会加上锁,保证其他事务不会再并发修改这条记录。
快照读和当前读分别包括以下语句:
快照读:使用普通的 select 语句(不包括 select ... lock in share mode, select ... for update)都是快照读。
当前读:select ... lock in share mode
,select ... for update,insert
,update
,delete
语句,这些语句获取的是数据库中的最新数据。
InnoDB存储引擎默认事务隔离级别是RR(可重复读),是通过 "行级锁+MVCC"一起实现的,正常读的时候不加锁,写的时候加锁。而 MCVV 的实现依赖:隐藏字段、Read View、Undo log。
隐藏字段
列名 |
是否必须 |
占用空间 |
描述 |
row_id |
否 |
6 |
行唯一标识,递增 |
transaction_id |
是 |
6 |
表示最近一次对本记录行作修改(insert | update)的事务ID,delete操作是一个update操作,非真删除,会更改删除位,deleted |
roll_pointer |
是 |
7 |
回滚指针,指向当前记录行的undo log信息 |
Read View
这是一个读视图,也就是一个快照,当开启一个事务之后,使用普通的select第一次读取内容时,就会创建一个readview快照信息,记录当前时间点一些信息。主要包括
- 当前出现过的最大事务id+1,
- 当前活跃的事务列表,逆序排列,trx_ids
- 活跃事务列表中的最小id
- 创建的当前事务的id。
Read view主要配合undo log版本链做数据的可见性分析。
Undo log
undo log使用一个版本链来记录当前版本的数据和历史版本的数据,事务可以进行回滚也是利用undo log来实现的。
undo log主要会记录数据的insert和update操作,至于delete也是update操作,是将标记删除位的假删除。每次操作都会创建创建一个新的版本数据,并在数据的隐藏字段中记录了修改该数据事务id。这样即使该事务操过程中其他的事务对数据进行了修改,该线程比较这个事务id的不同,可以判断出哪个版本的数据是在本事务开启后才进行修改,从而不去读取哪些数据,也就是那些数据对本事务来说是不可见的,然后从版本链中找到由本事务最新修改的内容,在该事务读取数据的时候,不用对数据加锁。
简要的过程为
undo log中记录了一个版本链信息,数据的每一个版本都记录了修改他的事务id,当在一个事务中使用普通的查询语句时,将会生成一个Readview信息,这里有两种情况:
- 在RR,可重复读的模式下,readview的内容是不变,假如事务1第一次读取了数据,创建read view并将活跃事务2记录,然后事务2写入数据将其修改了并提交,事务1从新读取数据,由于视图没有修改,会将事务2修改的数排除在外,读取的还是事务1第一次读取的内容。这就不会出现不可重复读的情况,满足可重复读的隔离条件要求。
- 在RC,读已提交的模式下,每次新的查询都会生成新的read view,事务1第一次查询,生成第一个视图,并将事务2活跃线程记录,在其提交前都无法读取事务2修改的数据,当事务2提交后,事务2已经不是活跃线程,重新建立的视图中的活跃事务列表中没有记录事务2,则事务2的数据对事务1变得可见。这也满足读已提交的隔离条件,并会发生不可重复读问题。