数据管理系统的第一个问题是数据存储问题,第二个问题是数据快速查找问题,第三个问题是并发数据一致性与性能的问题。
- 本文是笔记类型文章,只有结论没有证明,可用于复习巩固知识,不能用于新知识的学习。如有错误,恳请指正,不胜感谢。
- 转载请于文首标明出处:【MySQL】并发,事务与锁简述 – 掘金 (juejin.cn)
系列文章:
事务
事务指一个执行单元,也是恢复和并发控制的基本单位,用于保证系统在并发中保持一致性。这个操作可以仅仅是一条 SQL,也可以是一组连续的 SQL。InnoDB、XtraDB、PBXT 都是支持事务的存储引擎。
事务的特性
事务四大特性,被称为 ACID 特性:
-
原子性(Atomicity):事务不可分割,要么不执行,要么全部执行。
-
一致性(Consistency):事务执行前后数据的完整性保持一致。
-
隔离性(Isolation):事务同时执行不能彼此照成影响。
-
持久性(Durability):事务一旦执行,就必须持久化存储。
遵守事务的所有规则,就能保持在并发条件下数据的一致性。
并发中的数据一致性问题
-
更新丢失
条件:相同记录发生写写并发。
定义:事务 A 的写操作还未提交,修改被事务 B 的写操作覆盖了。破坏了事务的原子性和隔离性。
解决:写串行化,在写写并发时加互斥锁。
-
脏读
条件:相同记录发生读写并发。
定义:又称未提交读。事务 A 的写操作未提交,事务 B 读取到了未提交的修改,此时 A 回滚,导致 B 读取到了脏数据。破坏了事务的隔离性。
解决:限制事务只能看到已提交的修改。
-
不可重复读
条件:相同记录发生读写并发。
定义:读写并发时,事务 A 读取某行记录,随后事务 B 修改该行记录并提交,事务 A 再次读取改行记录与第一次读取时不一致。破坏了事务的一致性。
解决:在事务执行前,保存事务开始时的数据快照,每次查询都读取数据快照中的数据,不去读取最新版本的数据,则不会产生不可重复读问题。
-
幻读:
条件:命中相同查询条件的读操作与插入、删除操作并发执行。
定义:读写并发时,事务 A 进行查询,事务 B 删除或插入某些行并提交,所删除或插入的行刚好命中事务 A 的查询条件,导致事务 A 再次查询时少了或多了某些记录,这些记录称为幻行。
解决:在事务执行前,保存事务开始时的数据快照,每次查询时查询条件都只筛选数据快照中的数据,查询不覆盖最新版本的数据,则不会产生幻读问题。对于删除的行也是一样的,只要保证数据快照中保留有被删除的行,就读的到,不会突然消失。
事务隔离级别
由于不同用户对于数据一致性的要求不同,因此根据所解决问题的不同,事务可以分为四种隔离级别:
-
未提交读(READ UNCOMMITTED):
即可以读到其他事务未提交的数据。不存在更新丢失的问题,存在脏读、不可重复读和幻读的问题。
-
提交读(READ COMMITTED):
即只能读到其他事务提交的数据。虽然解决了脏读问题,但依旧存在不可重复读和幻读问题。
-
可重复读(REPEATABLE READ):
即保证事务内多次查询读取到的数据相同。不存在不可重复读的问题,但存在幻读问题。
-
可串行化(SERIALIZABLE):
最高的事务隔离级别,强制所有事务串行执行,没有幻读,不存在任何并发问题,因为不存在并发。这会导致大量的超时和锁争用问题,因此只有在不考虑并发的情况下才能使用此级别。
InnoDB 默认的事务隔离级别是可重复读,同时与标准隔离级别规定不同的是,InnoDB 还在可重复读这一隔离级别中解决了幻读问题。这样就不需要为了解决幻读而采用串行化这一极低效率的隔离级别了。
有一点需要注意的:InnoDB 的幻读解决方案是通过特殊的写操作实现的,单纯的快照读是无法完全解决幻读的。
快照读与当前读
在 MySQL 中,读操作分成两种,快照读与当前读,快照读就是读取事务开启时的数据快照,当前读就是读取最新版本的数据。正如前面所说,快照读就是用于解决当前读存在的不可重复读和幻读的问题。快照读使得事务内多次读取都可以读取到相同的结果,以此来保护事务的一致性。快照读是 InnoDB 默认的读取方式,是通过多版本并发控制(MVCC)实现的。
快照读存在的意义在于解决读写并发时的数据一致性问题,因此本质上是非阻塞读的实现。
当前读存在的意义在于让一些强实时性的查询能够读取到最新版本的数据,由于是最新版本的数据,因此当前读肯定存在不可重复读和幻读的隐患。这些隐患具体表现为:
-
事务 A 想要读取某数据,事务开始后事务 B 进行修改并提交。此时事务 A 第一次读取采用快照读,读取到事务刚开始时的值。接着再使用当前读,读取到新值,此时同一个事务内前后读取不一致造成不可重复读。
-
事务 A 想要读取
age = 6
的数据,事务开始后事务 B 插入一条age = 6
的数据并提交。此时事务 A 第一次读取采用快照读,仅读取到事务开始前的行。接着使用快照读,就读取到了幻行。
这两种隐患的原因都是,在事务内使用当前读之前使用了快照读。事务必须全程使用快照读,才能保证快照读能有效地维护数据一致性。此时的当前读破坏了快照读的作用,还原成了并发问题最初出现的场景。因此,如果要使用快照读,就必须保证事务内都使用快照读。
当前读与写操作
然而当前读是必要的,因为我们有时会有获取最新数据的需求,因此解决当前读的并发问题也是必要的。同时我们必须注意到的是,当我们进行写操作时,由于必须在数据实体上进行修改,因此 InnoDB 的写操作所使用的查询,都是当前读。这就意味着,快照读无法解决写操作中存在的不可重复读和幻读问题。
对于先进行快照读,再进行当前读或者写操作这种情况,InnoDB 是绝对无法保证并发安全的,这里必须靠用户自觉保证不要进行这样的查询才能保证一致性。
但对于当前读之后,再次进行当前读或者写操作,InnoDB 通过加锁的方式实现多次当前读或写操作时数据的一致性。在事务内,如果进行了一次当前读或者写操作,InnoDB 就会对相关数据加锁,直到事务提交才会释放锁。就是这一步保证了一致性。加锁的方式有两种:
-
共享锁:
SELECT ... FROM ... LOCK IN SHARE MODE
。这个就是 InnoDB 中的显式加共享锁,InnoDB 默认所有走索引的读都是快照读,不需要加锁的。 -
排他锁:
SELECT ... FROM ... FOR UPDATE
。写操作时默认会加排他锁,因为需要防止更新丢失。
对于不可重复读问题,当前读和写操作采用加行锁的方式,防止其他事务进行修改。对于幻读问题,当前读和写操作采用加间隙锁的方式,防止其他事务删除或插入符合查询条件的记录。
因此,无论是快照读还是当前读,一旦一个事务进行了和当前读或者写操作操作,必须保证事务内没有快照读,无论前后;一旦一个事务使用快照读,就必须保证事务内只能有快照读,不能有当前读和写操作,无论前后。
自动提交
MySQL 事务采用自动提交(AUTOCOMMIT)模式,如果不是显式开启事务,每条查询默认都会作为一个事务执行,这是事务在 MySQL 服务层的工作模式。
对于不支持事务的存储引擎,执行 AUTOCOMMIT 并没有任何问题,因为它们并没有回滚或者提交的概念,自动提交是没有作用的,但不影响使用。
多版本并发控制(MVCC)
我们知道,要想解决不可重复读和幻读的问题,可以通过快照读的方式进行解决。即在事务开始时保存一个数据快照,事务进行过程中的一切查询,都放回快照中的数据即可,这样就能保证并发查询的数据一致性。InnoDB 就是通过多版本并发控制(Multiple Version Concurrent Control)实现了快照读。但我们需要注意,MVCC 无法解决写操作中存在的不可重复读和幻读问题。
通过保存事务开启时的数据快照,使得这个事务无论运行多长时间,看到的都是数据一致的视图。根据事务开启时间的不同,在同一时刻不同事物看到的数据可能是不同的。
有的人在讲到 MVCC 时会扯这是乐观锁的实现,乱写文章的大有人在,MVCC 和乐观锁没有什么关系。MVCC 的目的在于实现快照读,只负责事务内数据读取的一致性。而乐观锁的目标在于解决读多写少场景下的写写并发,跟这个毫无关系。
他们二者的区别就是,一个是无锁并发,一个是有锁并发。
MVCC 的实现
MVCC 通过三个隐式字段、undo log 和读视图实现。
-
隐式字段
隐式字段包括
DATA_TRX_ID
(6B)、DATA_ROLL_PTR
(7B) 还有DB_ROW_ID
(6B)。DATA_TRX_ID
记录最近更新这条记录的事务 ID,事务只会读取与它的事务 ID 相同版本的数据。DB_ROLL_ID
是这条记录回滚段的指针,在 undo_log 中以链表的方式组织所有数据行的历史版本。DB_ROW_ID
仅在没有定义主键时出创建,即隐藏主键。同时,每条记录的头信息中都有一个专门的 flag(1bit)来标识当前记录是否已经被删除。 -
undo log
undo log 称为回滚日志,其中的数据我们称为回滚段。用于保存数据更新之前版本的数据,在事务进行 rollback 时可以直接进行数据恢复。同时由于多版本历史数据的存在,我们可以去读取旧版本的数据,快照读就是通过读取旧版本的数据实现的。undo log 主要分为两种:
- insert undo log:Insert 语句执行时使用的 undo log,写事务的 insert 操作对其他事务是不可见的,所以会先写在 insert undo log 上,等到事务提交后,这个 undo log 就会被删除。【这一点并不是很确定,暂时存疑】
- update undo log:Update 和 Delete 语句执行时产生的 undo log,事务提交之后不会立刻删除,需要由 purge 线程删除。
-
读视图
即读取时,根据当前事务 ID 去查询对应版本的数据。
锁
锁对于事务的实现是必不可少的。在 InnoDB 中,事务是依靠锁和 MVCC 共同实现的,有了这个认识逻辑就很容易疏通了。同时,锁和 MVCC 也是 InnoDB 实现高效并发的基础。
在 MySQL 中,从锁的粒度分,可以将锁分为表级锁、页级锁、行级锁和间隙锁;从锁的作用,可以将锁分为共享锁与排他锁。各粒度锁的基础对比如下:
锁类型 | 粒度 | 开销 | 速度 | 死锁 | 冲突 | 并发度 | 支持引擎 |
---|---|---|---|---|---|---|---|
表级锁 | 大 | 小 | 快 | × | 高 | 低 | MyISAM、Memory、InnoDB、BDB |
页级锁 | 中 | 中 | 中 | √ | 中 | 中 | BDB |
行级锁 | 小 | 大 | 慢 | √ | 低 | 高 | InnoDB、XtraDB |
间隙锁主要由 InnoDB 进行支持,用途也和其他常规的锁不同,在间隙锁处再详细了解。对于锁而言,只有更适合,没有更好,不同的锁适用于不同的场景,如表锁适用于读多写少的场景,如在线查询系统;行锁适用于高并发场景,如学生选课;页锁适用于数据量大,修改量适中的场景。
共享锁与排他锁
各引擎中锁的实现一般将锁设计为标准的共享锁(Share Lock,S Lock)和排他锁(Exclusive Lock,X Lock),可以粗略地称之为读锁和写锁,当时读写锁也存在于线程、进程的通信中,且作用与此处不太相同,因此最好称之为共享锁与排他锁。
在数据库中,共享锁允许事务读取它使用共享锁锁住的资源,而无法对这些资源进行修改,且其他事务可以同时对这些资源添加共享锁,这种情况叫锁兼容(Lock Compatible),但无法对这些资源添加排他锁,这种情况叫锁冲突。排他锁允许事务读取和修改它使用排他锁锁住的资源,但其他事务无法对添加了排他锁的资源加锁。具体的示例就是 LOCK IN SHARE MODE
和 FOR UPDATE
。
服务层的表锁
虽然锁默认是存储引擎负责的,但是 MySQL 服务层也有自己的锁实现,用于在 ALTER TABLE 或者其他操作时使用,而忽略引擎的锁机制。
MyISAM 的表锁
MyISAM 不支持事务,仅支持表锁,其表锁实现就是共享锁和排他锁模式,它的表锁有以下特点:
-
默认加锁:在执行 SELECT 操作前,它会自动给查询涉及的表加上共享锁。在执行写操作前,它会自动给涉及的表加排他锁,用户不需要显式加锁。
-
排他锁优先:排他锁默认比共享锁拥有更高的优先级,因此一个写请求可以插入到锁队列中共享锁的前面。但是可以在设置中设置优先级。
-
并发读写:对于插入这一写操作,MyISAM 支持并发读写,使用
concurrent_insert
变量进行设置:- 等于 0 时:不允许并发插入。
- 等于 1 时,如果表中间没有空洞,即表中间不存在被删除的行时,允许在加共享锁时向表末尾插入数据。
- 等于 2 时,无论表中是否存在空洞,都允许在表尾并发插入记录。
对于读排他锁而言,如果写操作频繁,会造成严重的锁争用情况,可以通过 SHOW STATUS LIKE 'Table_locks_waited';
进行查询,该值越大表示锁争用情况越严重。
InnoDB 的锁
记录锁
InnoDB 支持记录锁,一条记录就是一行,因此是行级别的锁,又称行锁,记录锁同样分为共享锁和排他锁。InnoDB 的行锁是锁索引而非锁数据,例如主键索引,锁的就是聚簇索引的主键,如果使用的是辅助索引,则除了会锁住辅助索引的 Key 之外,还会在聚簇索引中锁住该 Key 对应的主键。因此如果我们的操作需要加锁,同时查询条件无法命中任何索引,则会使用表锁锁住整张表。
需要注意的是,行锁只能解决删除数据时的幻读问题,插入新数据带来的幻行无法解决,需要通过间隙锁解决。
表锁使用
InnoDB 并不支持表锁,但如上面所说 InnoDB 依旧会自动使用表锁,我们也可以手动开启表锁,它所使用的表锁是 MySQL 服务层提供的表锁。
在让 InnoDB 使用表锁时,需要注意以下两点:
-
仅
autocommit=0
和innodb_table_lock=1
时(默认设置),InnoDB 才能知道服务层加的表锁,服务层才能感知 InnoDB 加的行锁,这种情况下,InnoDB 才能自动识别并处理涉及表锁的死锁。如果autocommit != 0
,InnoDB 是不会使用表锁的。 -
使用 LOCK TABLES 锁表时,要先 COMMIT 或 ROLLBACK 再进行 UNLOCK TABLE 操作,因为 UNLOCK TABLE 会自动提交事务。
表锁与行锁之间的冲突与兼容如下:
| | 行级 X Lock | 行级 S Lock |
| 表级 X Lock | 冲突 | 冲突 |
| 表级 S Lock | 冲突 | 兼容 |
复制代码
意向锁
InnoDB 支持包括表锁和行锁的多粒度锁定,一旦添加了 X 锁,其他事务就无法添加任何表锁,如果每次加表锁都需要去遍历查询是否存在行锁,这会带来一个非常严重的性能问题。因此 InnoDB 采用意向锁(Intention Lock)解决这个问题。意向锁用于表示事务希望对某些数据进行加锁。InnoDB 中的意向锁是表级别的,事务对表添加意向锁,就表示事务希望对表添加行锁或者表锁,因此所有事务在加锁之前必须添加意向锁。
意向锁分为意向共享锁(IS Lock)和意向排他锁(IX Lock),IS 和 IX 彼此是锁兼容的。同时意向锁是表级锁,和行级锁是不冲突的,只会和表级锁冲突。也就是加了 IX 之后,可以加行锁 X 的。所有锁的冲突与兼容性具体如下所示:
| | IX | IS | 表级 X | 表级 S | 行级 X | 行级 S |
| IX | 兼容 | 兼容 | 冲突 | 冲突 | 兼容 | 兼容 |
| IS | 兼容 | 兼容 | 冲突 | 兼容 | 兼容 | 兼容 |
| 表级 X | 冲突 | 冲突 | 冲突 | 冲突 | 冲突 | 冲突 |
| 表级 S | 冲突 | 兼容 | 冲突 | 兼容 | 冲突 | 兼容 |
| 行级 X | 兼容 | 兼容 | 冲突 | 冲突 | 冲突 | 冲突 |
| 行级 S | 冲突 | 兼容 | 冲突 | 兼容 | 冲突 | 兼容 |
复制代码
从以上这个例子也可以看出计算时应避免笛卡尔积操作
从以下的例子展示意向锁的作用:
-
事务 A 对表中的记录进行更新,因此需要添加排他行锁;事务 B 采用无索引方式查询,因此需要添加共享表锁,此时事务 B 通过遍历数据的方式得知该表已存在行锁,因此阻塞等待事务 A 执行。
-
在加入意向锁机制后,事务 A 先对表添加意向排他锁,在对行添加排他行锁。事务 B 对表添加意向共享锁,此时发现事务 A 已添加意向排他锁,因此得知表中存在排他锁,直接阻塞等待。
-
此时事务 C 希望对该表某些记录进行更新,对表添加意向排他锁,由于是行锁,所以事务 C 依旧可以在二者加锁数据不同的情况下添加行锁。
自增锁
自增锁是 MySQL 专门用于维护表自增值的锁,进行插入操作时,如果自增字段为空或者 0,就需要获取自增值。获取自增值时就需要获取自增锁,这是一个轻量级的锁,用于保证并发插入不会出现相同自增值的情况。对于多行插入,自增锁有三种工作模式,根据 innodb_autoinc_lock_mode
参数设置:
- 0,传统模式,插入的语句从执行开始到结束会一直持有锁。
- 1,连续模式,对于连续的插入语句可以一次性生成多个连续的自增值,且生成值之后就会释放自增锁。
- 2,插入模式,自增锁与语句执行无关,性能最高,但可能存在连续的插入语句中自增值出现跳跃的情况,因为其他事务也可能在获取自增值插入。
需要注意的是,在获取自增值时是肯定会加自增锁的,上面三种模式是针对于多行插入而言的,如连续插入、BLOCK INSERT
和 INSERT INTO ... SELECT
。这些设置是针对语句级别的,并不是事务级别的,因此,事务的回滚与自增值的增长无关,因此,如果事务在获取自增值插入后进行回滚,就会导致主键空洞。
因此尽量不要认定自增值一定比当前字段最大值大一。同时,如果在自增值未达到 x 时插入或更新该字段值为 x。当 AUTO_INCREMENT == x 时执行插入会报错,所以请不要在无聊的时候搞这些把戏。
间隙锁与临键锁
间隙锁用于锁住两条索引记录之间的间隙。如果向加了间隙锁的间隙中间添加记录,则会发生冲突。间隙锁与行锁合称临键锁(next-key-lock)。临键锁通过对记录和记录之间的间隙进行锁定,解决了当前读和写操作时存在的幻读问题。而普通查询不存在幻读问题,因为普通查询采用快照读的方式,不存在幻读问题。
间隙锁和行锁一样都是面向索引的锁,在唯一索引和非唯一索引中,临键锁并不是一直都会用到间隙锁和行锁:
对于等值查询,当索引使用的是唯一索引时,由于无法插入相同 key 的记录,因此不存在幻行出现的问题,只需要保证该记录不被删除即可,因此对于唯一索引的等值查询,临键锁退化为行锁,锁记录即可。当索引为普通索引时,由于可以存在重复的 Key,因此同时存在幻行出现或者幻行消失的问题,因此在对原有记录加上行锁之外,还需要使用间隙锁锁住所有命中记录的上下间隙。
对于范围查询,临键锁在唯一索引和非唯一索引的表现相同。所有命中范围的行都会加行锁保证不会被删除,所有命中的间隔都会加间隙锁保证不会被插入。
总结
以下梳理并发相关重要概念的关联:
-
事务是数据库管理系统中用于保证数据一致性所引申出来的实现方案。
-
事务只有在保证原子性、一致性、隔离性和持久性时,才能保证数据库管理系统中数据最终的一致性。
-
事务在并发进行的时候会出现各种各样的问题,包括更新丢失、脏读、不可重复读和幻读。一切问题都是因为写操作的并发执行,包括读写并发与写写并发。
-
更新丢失需要通过加锁解决。脏读需要通过提交时写入解决。不可重复读需要通过快照读或行锁解决。幻读需要通过快照读或临键锁解决。
-
写写并发的冲突通过加锁解决,读写并发的冲突通过快照读解决。
-
MVCC 的目标是实现快照读,快照读的目标是解决读写并发时的冲突。
-
当前读就是强制读取其他事务更新的结果,写操作也是需要根据当前最新数据版本进行操作,InnoDB 采用临键锁解决写写并发时的幻读问题。
-
行锁、间隙锁只针对索引使用,因此如果查询、更新无法命中索引,InnoDB 会使用表锁进行全表扫描,此时不会发生数据不一致的问题,但死锁风险很大。