基础
什么是事务
一个事务中,包含一个或多个操作,那么这些操作要么同时成功,要么同时失败。
举个例子,转账,A给B转账100,这是一个事务,这个事务包含两个操作。
A账户的钱-100:
1 | UPDATE account SET money = money - 100 WHERE name = 'A'; |
B账户的钱+100:
1 | UPDATE account SET money = money + 100 WHERE name = 'B'; |
这两个操作,要么同时成功,要么同时失败。
事务特性
事务有四个特性:
A
,Atomicity,原子性C
,Consistency,一致性I
,Isolation,隔离性D
,Durability,持久性
其中:
- 隔离性借助
锁机制
实现。 - 持久性借助
redo log
实现。 - 原子性借助
undo log
实现。 - 一致性是目的。
原子性
事务是一个不可分割的工作单元,要么全部提交,要么全部失败回滚,不存在部分成功,部分失败。
一致性
事务执行前后,数据从一个"合法的状态"变换到另外一个"合法的状态"。
那什么是合法的数据状态呢?满足"预定的约束"的状态就叫做"合法的状态"。
隔离型
隔离性,Isolation。
一个事务的执行不能被其他事务干扰,即一个事务内部的操作及使用的数据对并发的其他事务是隔离的。
举个例子,假设A账户有200元,B账户0元。A账户往B账户转账两次,每次金额为50元,分别在两个事务中执行。如果无法保证隔离性,会可能会出现如下的情况:
A账户的钱,确实少了100,但是B账户的钱,只增加了50。
类似于这种的,就被称为脏写
。
我们在《基于Java的后端开发入门:7.多线程 [1/2]》,讨论"线程安全"的时候,举了一个卖票的例子,也属于"一个事务的执行被其他事务干扰"。
持久性
持久性,Durability。
事务一旦被提交,它对数据库中数据的改变就是永久性的,接下来的其他操作和数据库故障不会对其有任何影响。
事务的状态
事务的状态有:
- 活动的(active)
事务对应的数据库操作正在执行过程中时,此时事务处于活动的状态。 - 部分提交的(partially committed)
当事务中的所有的操作都执行完成,但还没有将执行结果刷新到磁盘时,此时事务处于部分提交的状态。
(个人认为,这个名字取的不太好,称之为提交前
,预提交
更好) - 失败的(failed)
当事务处在活动的
或者部分提交的
状态时,可能遇到了某些错误(数据库自身的错误、操作系统错误,甚至停电了)而无法继续执行,或者人为的停止当前事务的执行,此时事务处于失败的
状态。 - 中止的(aborted)
数据库状态变为失败的
时,会进行回滚操作,即将数据库恢复到事务执行前的状态,回滚后事务处于中止的状态。 - 提交的(committed)
当一个处在部分提交的
状态的事务将修改过的数据都同步到磁盘上之后,我们就可以说该事务处在了提交的
状态。
其中,只有提交的
和中止的
是事务的最终状态。
如图是状态之间的转换关系
存储引擎的支持
并不是所有的存储引擎都支持事务。在MySQL中,有且仅有InnoDB
支持事务。
在《4.结构》,我们提到了一个命令,SHOW ENGINES
,通过这个可以查看。
示例代码:
1 | SHOW ENGINES |
运行结果:
1 | +------------------+-------+--------------------------------------------------------------+------------+----+----------+ |
解释说明:Transactions
,事务,有且仅有InnoDB
支持事务。
使用事务
使用事务有两种方式:
- 显式
- 隐式
显式
开启事务
通过START TRANSACTION
或者BEGIN
,显式的开启一个事务。
START TRANSACTION
和BEGIN
的区别在于,BEGIN
的后边能跟随修饰符:
READ ONLY
:标识当前事务是一个只读事务,也就是属于该事务的操作只能读取数据,而不能修改数据。READ WRITE
:标识当前事务是一个读写事务,也就是属于该事务的数据库操作既可以读取数据,也可以修改数据。WITH CONSISTENT SNAPSHOT
:开启一致性读。
默认是READ WRITE
。
开启事务之后,可以进行一系列的DDL操作。
提交和回滚
提交,示例代码:
1 | COMMIT; |
回滚,示例代码:
1 | ROLLBACK; |
回滚之后,事务处于中止状态。
特别的,我们还可以将事务回滚到某个保存点,示例代码:
1 | BEGIN; |
ROLLBACK TO s1
,回归到保存点。- 回滚到保存点,事务并不会处于中止状态,即事务没有结束。
还是需要进行后续操作,最终提交或者回滚。
例子
我们举一个显式事务的例子。
假设存在一张表,如下:
1 | CREATE TABLE test_tx_1 |
开启事务,插入数据,提交,示例代码:
1 | BEGIN; |
然后,我们看看表里的数据,示例代码:
1 | SELECT * FROM test_tx_1; |
运行结果:
1 | +----+ |
存在一条数据,张三
,这个是没问题。
再开启一个事务,插入一条数据,不提交,看看表里的数据。
示例代码:
1 | BEGIN; |
1 | INSERT INTO test_tx_1 VALUES ('李四'); |
1 | SELECT * FROM test_tx_1; |
运行结果:
1 | +----+ |
然后我们回滚,再看看。
示例代码:
1 | ROLLBACK; |
1 | SELECT * FROM test_tx_1; |
运行结果:
1 | +----+ |
只有一条数据,张三
,这个也是没有问题的。
现在,我们不开启事务,直接插入数据,查看,示例代码:
1 | INSERT INTO test_tx_1 VALUES ('王五'); |
1 | SELECT * FROM test_tx_1; |
运行结果:
1 | +----+ |
这是因为默认会自动提交,由系统变量autocommit
控制。
隐式
autocommit
我们可以通过修改autocommit
,设置不自动提交,以此隐式的开启事务。
通过SHOW VARIABLES
,查看这个变量的值,示例代码:
1 | SHOW VARIABLES LIKE 'autocommit'; |
运行结果:
1 | +-------------+-----+ |
通过如下方式,可以修改autocommit
的值。
关闭,示例代码:
1 | SET autocommit = OFF |
- 通过
SET autocommit = FALSE
和SET autocommit = 0
,也可以关闭。
开启,示例代码:
1 | SET autocommit = ON |
隐式自动提交
以下几种情况,是隐式自动提交,即使SET autocommit = OFF
也无效。
- 数据定义语言,Data Definition Language,DDL
- 隐式的修改MySQL的表。
例如:ALTER USER
、CREATE USER
、DROP USER
、SET PASSWORD
- 之前语句所属的事务被自动提交。
有好几种情况,都会导致之前语句所属的事务被自动提交。例如:- 在一个事务还没提交或者回滚时就又使用
START TRANSACTION
或者BEGIN
开启了另一个事务时。
示例代码:1
2
3
4
5BEGIN
UPDATE #事务中的第一条语句
UPDATE #事务中的第一条语句
... #事务中的其他语句
BEGIN #此时会隐式的提交之前语句所属的事务 - 当前的
autocommit
系统变量的值为OFF
,我们手动把它调为ON
时。 LOCK TABLES
、UNLOCK TABLES
等关于锁定的语句。LOAD DATA
批量导入数据。- 主从复制的一些语句:
START SLAVE
、STOP SLAVE
、RESET SLAVE
等。
- 在一个事务还没提交或者回滚时就又使用
隔离级别
并发带来的问题
我们知道,一个MySQL服务,可能会连着多个客户端,与一个客户端的连接,我们称之为一个会话(Session)。
每个客户端都可以在自己的会话中向MySQL服务发出请求语句,一个请求语句可能是某个事务的一部分,也就是对于MySQL服务来说可能同时处理多个事务。
那么,这时候,就可能有问题了。
脏写
脏写,Dirty Write。
假设存在两个事务,事务A和事务B,如果事务A修改
了,未提交
事务B的修改过
的数据,那就意味着发生了脏写。
事务A | 事务B |
---|---|
BEGIN |
|
BEGIN |
|
UPDATE t SET name = '张三' WHERE id = 1 |
|
UPDATE t SET name = '李四' WHERE id = 1 |
|
COMMIT |
|
COMMIT |
脏读
脏读,Dirty Read。
假设存在两个事务,事务A和事务B,如果事务A读取
了已经被事务B更新
但还没有提交
的数据,之后如果事务B又回滚
,那么事务A读取
的数据就是临时且无效
的。
事务A | 事务B |
---|---|
BEGIN |
|
BEGIN |
|
UPDATE t SET name = '张三' WHERE id = 1 |
|
SELECT * FROM t WHERE id = 1 |
|
COMMIT |
|
ROLLBACK |
不可重复读
不可重复读,Non-Repeatable Read,也有资料称其为虚读。
假设存在两个事务,事务A和事务B,事务A读取
了某个字段,然后事务B更新
了该字段,之后事务A再次读取
该数据,这样事务A再次读取的内容就不一样
了,即不可重复读。
事务A | 事务B |
---|---|
BEGIN |
|
SELECT * FROM t WHERE id = 1 |
|
UPDATE t SET name = '张三' WHERE id = 1 |
|
SELECT * FROM t WHERE id = 1 |
|
UPDATE t SET name = '李四' WHERE id = 1 |
|
SELECT * FROM t WHERE id = 1 |
幻读
幻读,Phantom。
假设存在两个事务,事务A和事务B,事务A读取
了某个字段,然后事务B在该表中插入
了一些新的行。之后, 如果事务A再次读取
,会多出几行。那就意味着发生了幻读。
事务A | 事务B |
---|---|
BEGIN |
|
SELECT * FROM t WHERE id > 0 |
|
INSERT INTO t (id,name) VALUE(1,'张三') |
|
SELECT * FROM t WHERE id > 0 |
|
INSERT INTO t (id,name) VALUE(2,'李四') |
|
SELECT * FROM t WHERE id > 0 |
注意,如果事务B删除了某些数据,导致事务A读取到的记录少了,这种不属于幻读。幻读调的是一个事务按照某个相同条件多次读取时,后读取时读到了之前没有读到的记录。对于这种现象,如果一定要归类,应该属于不可重复读。
严重性排序
通常,四种问题的严重性排序如下:
四种隔离级别
为什么会有上述问题?因为事务之间没有很好的进行隔离导致的。
那么怎么解决问题?设置事务之间的隔离级别。
在SQL标准中,有四种隔离级别,其分别能解决的问题下表所示。
脏写 | 脏读 | 不可重复读 | 幻读 | |
---|---|---|---|---|
READ UNCOMMITTED 读未提交 |
✔ | |||
READ COMMITTED 读已提交 |
✔ | ✔ | ||
REPEATABLE READ 可重复读 |
✔ | ✔ | ✔ | |
SERIALIZABLE 串行化 |
✔ | ✔ | ✔ | ✔ |
需要注意的是,不同的隔离级别,有不同的锁和并发机制,隔离级别越高,数据库的并发性能就越差。
此外,对于MySQL,还略有不同,MySQL的REPEATABLE READ
,也可以解决幻读,即:
脏写 | 脏读 | 不可重复读 | 幻读 | |
---|---|---|---|---|
READ UNCOMMITTED 读未提交 |
✔ | |||
READ COMMITTED 读已提交 |
✔ | ✔ | ||
REPEATABLE READ 可重复读 |
✔ | ✔ | ✔ | ✔ |
SERIALIZABLE 串行化 |
✔ | ✔ | ✔ | ✔ |
具体实现方式,就是MVCC,我们在本章最后会讨论。
操作
查看
有两种方法可以查看事务的隔离级别:
SHOW variables like 'transaction_isolation'
SELECT @@transaction_isolation
示例代码:
1 | SHOW variables like 'transaction_isolation' |
运行结果:
1 | +---------------------+---------------+ |
示例代码:
1 | SELECT @@transaction_isolation |
运行结果:
1 | +-----------------------+ |
注意,在5.7.20
之前的MySQL中,没有transaction_isolation
,而是tx_isolation
。
设置
有两种方法可以设置事务的隔离级别:
SET [GLOBAL|SESSION] TRANSACTION ISOLATION LEVEL 【隔离级别】;
【隔离级别】的枚举值有:READ UNCOMMITTED
、READ COMMITTED
、REPEATABLE READ
、SERIALIZABLE
。SET [GLOBAL|SESSION] TRANSACTION_ISOLATION = '【隔离级别】'
【隔离级别】的枚举值有:READ-UNCOMMITTED
、READ-COMMITTED
、REPEATABLE-READ
、SERIALIZABLE
。
注意:
- 与《5.索引和优化》中设置慢查询的
long_query_time
类似,GLOBAL
只对新的连接生效,对当前的session无效。 - 如果在某个事务执行期间执行期间修改了事务的隔离级别,只会对后续的事务有效,不会影响当前正在执行的事务。
两种方法,个人更推荐用第二种,因为第二种更像传统意义上的"计算机命令",不同语义部分用空格隔开,两个单词组成的,用连字符或者下划线连接。
redo log
作用
根据我们在《4.结构》中的讨论,我们知道,InnoDB存储引擎是以"页"为单位来管理存储空问的;在真正访问页面之前,需要把在磁盘上的页缓存到内存中的缓冲池之后才可以访问,所有的变更都必须先更新缓冲池中的数据,然后缓冲池中的更新再以一定的规则刷入磁盘中。
这么做是有问题的:最坏的情况,事务提交后,刚写完缓冲池,然后数据库宕机了,那么这段数据就是丢失的,无法恢复。
那还谈什么持久性
?
那这样,我们先写文件,这样比修改数据库快,也更稳定,先赶紧记下来。比如某个事务将第1号表空间中第10号页面中偏移量为100处的那个字节的值1改成2。我们赶紧记下来:将第1号表空间的10号页面的偏移量为100处的值更新为2,之后即使发生了宕机,也可以通过我们写的那个文件来恢复,这个文件就被称为redo log
,而这种"先写日志再刷磁盘数据"的方法,被称为WAL(Write-Ahead Logging)。
我个人曾经写过一个程序,用于准实时的获取A股行情数据并存入数据库中,采取的就是类似的思路。拿到行情数据后,先写文件,因为写文件一定比写入数据库快,也更稳定(即使数据库崩了,也不影响写文件);再用一个线程,定时读文件,写入数据库。
与redo log
类似的一个概念是binlog
,两者之间的区别在于:
redo log
是存储引擎层产生的,而binlog
是数据库层产生的。- 在事务执行过程中,
redo log
不断记录,但binlog
只会在提交的时候记录。
例如:一个事务,对表做10万行的记录插入,在这个过程中,会一直不断的往redo log
按顺序的记录,而binlog
不会记录,直到这个事务提交,才会一次写入到binlog
。
组成
redo log
又可以分为两部分:
redo log buffer
,保存在内存中,是易失的。redo log file
,保存在硬盘中,是持久的。
redo log buffer
在MySQL服务启动时就会向操作系统申请了一大片称之为redo log buffer
的连续内存空间,这片内存空间被划分成若干个连续的redo log block
,如图所示:
redo log file
redo log file
,默认在数据文件目录中,具体我们可以看看,示例代码:
1 | ll /var/lib/mysql |
运行结果:
1 | -rw-r----- 1 mysql mysql 50331648 Jun 3 18:12 ib_logfile0 |
有两个文件,一样的大小;而且这两个文件并不是已经写满了日志,只是先占据了磁盘空间。
流程
以更新事务为例:
- 先将原始数据从磁盘中读入内存中来,修改数据的内存拷贝。
- 生成一条重做日志并写入
redo log buffer
,记录的是数据被修改后的值。 - 将
redo log buffer
中的内容刷新到redo log file
。 - 将
data buffer
中修改的数据刷新到磁盘中。
注意3
和4
,先写日志,这就是WAL的体现
刷盘策略
刷盘策略,指定是将redo log buffer
中的内容刷新到redo log file
的策略。
需要注意的是,并不会直接从redo log buffer
刷盘到redo log file
中,还需要先刷入操作系统的page cache
(文件缓存系统)中去。
在InnoDB中,有三种刷盘策略,通过innodb_flush_log_at_trx_commit
进行控制,有1
、0
和2
,三种取值。
最后,我们可以体验三种刷盘策略。
假设存在一张表,表结构如下:
1 | CREATE TABLE test_load |
我们创建一个存储过程,示例代码:
1 | DELIMITER // |
查看刷盘策略,示例代码:
1 | show variables like 'innodb_flush_log_at_trx_commit'; |
运行结果:
1 | +--------------------------------+-------+ |
调用存储过程p_load
,参数填100000
,示例代码:
1 | call p_load(100000) |
运行结果:
1 | Query OK, 0 rows affected (3 min 35.89 sec) |
耗时:3 min 35.89 sec。
再来看看设置为0
的情况,示例代码:
1 | SET GLOBAL innodb_flush_log_at_trx_commit = 0 |
调用存储过程p_load
,参数填100000
,示例代码:
1 | call p_load(100000) |
运行结果:
1 | Query OK, 0 rows affected (1 min 47.51 sec) |
耗时:1 min 47.51 sec。
设置为2
,示例代码:
1 | SET GLOBAL innodb_flush_log_at_trx_commit = 2 |
调用存储过程p_load
,参数填100000
,示例代码:
1 | call p_load(100000) |
运行结果:
1 | Query OK, 0 rows affected (1 min 47.56 sec) |
耗时:1 min 47.56 sec。
三种情况的不同执行时间,似乎也验证了我们说的刷盘策略。
关于page cahce
,页缓存,我们在《消息队列(Kafka RabbitMQ):Kafka-3.Broker》也会讨论,Kafka的broker用其实现了数据的"高效读写"。
undo log
事务需要保证原子性
,但有时候事务执行到一半会出现一些情况,我们需要把数据改回原先的样子,这个过程称之为回滚。
怎么做会滚?每当我们要对一条记录做改动时,都需要"留一手",把回滚时所需的东西记下来。比如:
- 插入一条记录时,需要把这条记录的主键值记下来,之后回滚的时候需要把这个主键值对应的记录删除。
- 删除一条记录时,需要把这条记录的内容都记下来,之后回滚的时候,把由这些内容组成的记录插入到表中。
- 修改一条记录时,需要把修改这条记录前的旧值记录下来,这样之后回滚时再把这条记录更新为旧值就好了。
MySQL把这些为了回滚而记录的内容称之为undo log
。
因为insert
产生的undo log
被称为insert undo log
,因为update
和delete
产生的undo log
被称为update undo log
。
假设存在2个数值,分别为A=1
和B=2
,然后将A
修改为3
,B
修改为4
,其步骤如下:
start transaction
- 记录
A=1
到undo log
update A = 3
- 记录
A=3
到redo log
- 记录
B=2
到undo log
update B = 4
- 记录
B=4
到redo log
commmit
- 将
redo log
刷新到磁盘
除了用于回滚,
undo log
的另一个作用是用于实现MVCC,我们在下文讨论MVCC的时候会讨论。
锁机制
并发事务访问相同记录
并发事务访问相同记录的情况大致分为三种:
读和读
读和读,即并发事务读取相同的记录。
这种没有任何问题,读取操作本身不会对记录有任何影响。
在《基于Java的后端开发入门:7.多线程 [1/2]》,我们讨论"线程安全"的"原因"的时候,也提到过类似的现象,一个变量,多个线程在共享,但是如果都是只读的话,没有问题。
写和写
写和写
,即并发事务相继对相同的记录做出改动,在这种情况下会发生我们上文讨论的脏写
问题。
为了避免脏写,数据库强制未提交写事务必须排队执行,通过锁实现强制。
假设存在一条记录,此时事务T1
想要修改该记录,如果内存中没有与该记录相关的锁,会生成一个锁与该记录进行关联,如图所示:
trx
标识该锁属于哪个事务is_waiting
标识该事务是否在等待
在这里,T1
之前没有任何事务对该记录进行修改,所以is_waiting
是false
,表示无须等待,可以直接修改,即获取锁成功。
在事务T1
提交之前,如果事务T2
也想对该记录修改,就需要等待,is_waiting
是true
,即获取锁失败,如图所示:
直到事务T1
提交或中止,锁才会释放,然后T2
的is_waiting
变为false
,T2
开始执行,如图所示:
读和写
读和写,即一个事务进行读操作,另一个进行写操作。这种情况下可能发生脏读
、不可重复读
和幻读
的问题。
解决这些问题有两个办法:
- 对所有的读写操作都进行加锁。
- 对读操作使用MVCC,对写操作进行加锁。
锁的分类
从对数据的操作类型划分:
- 共享锁(读锁)
- 排他锁(写锁)
从锁粒角度划分:
- 表级锁
- 行级锁
- 页级锁
从锁的设计思想划分:
- 悲观锁
- 乐观锁
共享锁和排他锁
概述
- 共享锁(
Share Lock
、S Lock
)
对于同一份数据,多个事务进行读取时可以同时进行互不影响,禁止写操作。
因为主要用在读的操作上,也被称为读锁(readLock)。 - 排他锁(
Exclusive Lock
,X Lock
)
对于同一份数据,当一个事务进行写操作时,禁止其他事务的读、写操作。
因为主要用在写的操作上,也被称为写锁(writeLock)。
锁定读
读取时加共享锁的方法为:
1 | SELECT ... Lock in share mode; |
在8版本中,还有新的方法:
1 | SELECT ... for share; |
读取时加排他锁的方法为:
1 | SELECT.... for update |
锁定写
写操作有三种:
delete
insert
update
表级锁、行级锁和页级锁
表级锁
共享和排他
- 为表添加共享锁:
LOCK TABLES T read
- 为表添加排他锁:
LOCK TABLES t WRITE
- 释放锁:
UNLOCK TABLES
限制关系
锁类型 | 自己可读 | 自己可写 | 自己可操作其他表 | 他人可读 | 他人可写 |
---|---|---|---|---|---|
共享锁 | 是 | 否 | 否 | 是 | 否 |
排他锁 | 是 | 是 | 否 | 否 | 否 |
- 如果某个事务对某张表添加了表级别的
共享锁
或排他锁
,在锁被释放之前,会无法操作其他表。
意向锁
假设,存在一个事务,这个事务试图在某张表的表级加上共享锁或排他锁,如果没有意向锁存在,该事务需要去遍历各页或各行是否存在锁。现在有了意向锁,会自动在更大一级别的空间标识内部是否已经上过锁,而不必检查各页或各行。
举个例子,假设存在两个事务,事务A和事务B,事务A给某一行数据加上了锁(共享锁
或排他锁
),存储引擎会自动给更大一级的空间,比如页或表加上意向锁(意向共享锁
或意向排他锁
),标记该页或该表的内部已经上过锁(共享锁
或排他锁
)了,这样其他事务想要获取表级锁(表级共享锁
或表级排他锁
)的时候,只需要了解是否已经有事务获取了表级的意向锁(意向共享锁
或者意向排他锁
)。
自增锁
在讨论"自增锁"之前,我们需要讲清楚和AUTO_INCREMENT
有关的三种插入方式。
假设,现在存在一张表,该表一个AUTO_INCREMENT
的列,示例代码:
1 | CREATE TABLE test_ai |
那么,我们在插入的时候:
- 可以对
id
进行赋值,示例代码:1
2
3INSERT INTO test_ai (name)
VALUES ('zhangsan'),
('lisi'); - 也可以对其赋值。
这样,就有了插入数据有三种模式:
Simple inserts
事先知道要插入的行数的语句,比如我们上面举的例子就属于该类插入,已经确定要插入的行数。Bulk inserts
事先不知道要插入的行数的语句,例如INSERT ... SELECT
,LOAD DATA
,针对这种,InnoDB在每处理一行,为AUTO_INCREMENT
列分配一个新值。Mixed-mode inserts
部分指定了id
,部分又没指定id
,示例代码:1
2
3
4
5INSERT INTO test_ai (id, name)
VALUES (1, 'a'),
(NULL, 'b'),
(5, 'c'),
(NULL, 'd');
那么,如果有多个事务都在往这张表中插入数据呢?
这就涉及到自增锁了,在InnoDB中,针对自增锁,有三种模式,由innodb_autoinc_lock_mode
进行控制。
innodb_autoinc_lock_mode = 0
在该模式下,所有类型的insert语句都会使用表级别的自增锁。innodb_autoinc_lock_mode = 1
,在8版本之前,默认是这种。
在这个模式下,Bulk inserts
仍然会使用表级别的自增锁。innodb_autoinc_lock_mode = 2
,在8版本之后,默认是这种。
在这个模式下,所有类型的insert语句都不会使用表级别的自增锁。
这种模式,并发能力最强,但对于主从复制可能会有影响。
元数据锁
元数据锁(meta data lock)
- 对表执行DML时,InnoDB会对自动表添加元数据读锁。
- 对表执行DDL时,例如,修改表结构,InnoDB会自动对张表添加表级的元数据写锁。
元数据读锁和元数据读锁不互斥、但是元数据读锁和元数据写锁、元数据写锁和元数据写锁会互斥。
行级锁
行级锁,又分为
- 记录锁
对某一条记录加锁,分为共享和排他两种。 - 间隙锁
在某个查询条件的范围内,加入间隙锁,从而防止幻影记录的插入。 - 临键锁
临键锁
=记录锁
+间隙锁
,锁住某条记录,并阻止其他事务在该事务前面的间隙插入事务。 - 插入意向锁
通过间隙锁
的讨论,我们知道一个事务在插入一条记录时需要判断一下插入位置是不是被别的事务加了间隙锁,如果有的话,就需要等待。InnoDB规定事务在插入前的等待的时候也需要生成一个锁,表明有事务想在某个问隙中插入新记录,但是在等待。这就是插入意向锁(Insert Intention Locks)。
页级锁
页级锁:
- 粒度大小:
表
>页
>行
。 - 开销大小:
表
<页
<行
。
每一个层级的锁的数量是有限,当超过某个阀值时,会自动进行锁升级。
悲观锁和乐观锁
悲观锁和乐观锁,是从锁的设计思想来划分。
悲观锁
悲观锁,总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到拿到锁。
比如共享锁
、排他锁
、行级锁
、表级锁
,都是在做操作之前先上锁,当其他线程想要访问数据时,都需要阻塞挂起。
我们在《基于Java的后端开发入门:8.多线程 [2/2]》讨论的
synchronized
和ReentrantLock
就是悲观锁设计思想的实现。
乐观锁
乐观锁,认为对同一数据的并发操作不会总发生,属于小概率事件,不用每次都对数据上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据。
在下文会讨论的MVCC,可以认为是一种特殊的"乐观锁"。
我们在《基于Java的后端开发入门:8.多线程 [2/2]》讨论的
原子类
就是乐观锁设计思想的实现。
MVCC
概述
多版本并发控制(Multiversion Concurrency Control,MVCC),多版本并发控制,顾名思义,通过数据行的多个版本管理
来实现数据库的并发控制。
多个版本
,通过隐藏字段
和undo log 版本链
实现。管理
,通过ReadView
实现。
(MVCC没有正式的标准,在不同的数据库中的MVCC也可能是不同的,本文讨论的是MySQL的InnoDB引擎中的MVCC)
原理
隐藏字段
在《4.结构》,我们讨论过一行记录的结构,并提到了其中有些"其他信息",而我们的隐藏字段就在其他信息中。
例如,我们新增一条记录,其行格式如图:
上图中两个隐藏字段:
trx_id
:某个事务对某条聚簇索引记录进行改动时,记录该事务的ID,在这里trx_id
为8
。roll_pointer
:某个事务对某条聚簇索引记录进行改动时,都会把旧的版本写入到undo log
中,然后通过该字段,指向undo log
中的旧版本。在这里,指向的是insert undo log
。
undo log 版本链
假设,存在两个事务,分别是事务10和事务20,这两个事务,对某条记录进行UPDATE操作,如下:
事务10 | 事务20 |
---|---|
BEGIN; |
|
BEGIN; |
|
UPDATE student SET name="李四" WHERE id=1; |
|
UPDATE student SET name="王五" WHERE id=1; |
|
COMMIT; |
|
UPDATE student SET name="钱七" WHERE id=1; |
|
UPDATE student SET name="宋八" WHERE id=1; |
|
COMMIT; |
我们将这些undo log
都连起来,串成一个链表,如下:
这就是一个undo log 版本链
。
提一个问题,为什么在上例中,一定要等
事务10
提交之后,事务20
才会执行呢?因为事务10
开启之后,就有了排他锁。
ReadView
结构
ReadView中主要包含四个比较重要的内容,如下:
creator_trx_id
,创建这个ReadView的事务的ID。
注意,只有在对表中的记录做改动时(INSERT
、DELETE
、UPDATE
等)才会为事务分配事务ID,如果该事务只做了SELECT,ID为0
。trx_ids
,表示在生成ReadView时当前系统中活动的事务的ID列表。up_limit_id
,活动的事务中最小的事务ID。low_limit_id
,是"系统中"最大的事务ID值,这里要注意是"系统中",而不是"活动的事务"。是生成ReadView时系统中应该分配给下一个事务的ID值。
例如,现在有ID为1
、2
、5
这三个事务,之后5
提交了;那么一个新的读的事务在生成ReadView时,trx_ids
就包括1
和2
,up_limit_id
的值就是1
,而low_limit_id
的值是6
。
规则
有了这个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
列表中。- 如果在,说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问。
- 如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问。
流程
步骤
查询一条记录的大致步骤如下:
- 首先获取事务自己的版本号,也就是事务ID(因为一般都是SELECT,所以一般都是
0
) - 获取ReadView。
- 查询得到的数据,然后与ReadView中的事务版本号进行比较(比较规则参考上文)。
- 如果不符合ReadView规则,就需要从
undo log 版本链
中获取历史快照。 - 最后返回符合规则的数据。
接下来,我们来看看在不同的隔离级别下的情况。
READ COMMITTED
READ COMMITTED
:每次读取数据都生成一个ReadView。
假设,现在有两个事务,分别是事务10和事务20。
其中事务10执行如下操作,但还没提交。
1 | BEGIN; |
事务20操作其他的表,也没有提交。
最后,我们会得到undo do 版本链
如下:
然后,我们新建一个事务,查询记录。
1 | SELECT * FROM student WHERE id = 1; |
执行过程如下:
- 首先
SELECT
语句会生成一个ReadView,该ReadView的内容为
creator_trx_id
:0
trx_ids
:[10,20]
up_limit_id
:10
low_limit_id
:21
- 从
redo log 版本链
中进行读取,最新版本的name
为王五
,trx_id
为10
,在up_limit_id
和low_limit_id
之间,在trx_ids
列表中,所以是不可见,通过roll_point
寻找下一版本。 - 下一版本的
name
为李四
,trx_id
为10
,在up_limit_id
和low_limit_id
之间,在trx_ids
列表中,所以是不可见,通过roll_point
寻找下一版本。 - 下一版本的
name
为张三
,trx_id
为8
,小于up_limit_id
,可以被访问,所以最后返回name
为张三
的记录。
如果事务10提交了,再读一遍呢?
- 首先
SELECT
语句会生成一个ReadView,该ReadView的内容为
creator_trx_id
:0
trx_ids
:[20]
up_limit_id
:20
low_limit_id
:21
- 从
redo log 版本链
中进行读取,最新版本的name
为王五
,trx_id
为10
,小于up_limit_id
,可以被访问,所以最后返回name
为王五
的记录。
REPEATABLE READ
REPEATABLE READ
:只会在第一次执行查询语句的时候,生成一个ReadView。
不可重复读
首先,事务10执行了操作,但还没提交,事务20操作其他的表,也没有提交。
执行过程如下:
- 首先
SELECT
语句会生成一个ReadView,该ReadView的内容为
creator_trx_id
:0
trx_ids
:[10,20]
up_limit_id
:10
low_limit_id
:21
- 从
redo log 版本链
中进行读取,最新版本的name
为王五
,trx_id
为10
,在up_limit_id
和low_limit_id
之间,在trx_ids
列表中,所以是不可见,通过roll_point
寻找下一版本。 - 下一版本的
name
为李四
,trx_id
为10
,在up_limit_id
和low_limit_id
之间,在trx_ids
列表中,所以是不可见,通过roll_point
寻找下一版本。 - 下一版本的
name
为张三
,trx_id
为8
,小于up_limit_id
,可以被访问,所以最后返回name
为张三
的记录。
如果事务10提交了,再读一遍呢?
- 复用之前的ReadView
creator_trx_id
:0
trx_ids
:[10,20]
up_limit_id
:10
low_limit_id
:21
- 后续步骤略。
以此,解决了不可重复读
。
幻读
假设,我们的查询语句如下:
1 | SELECT * FROM student WHERE id >= 1; |
然后这时候,事务20插入了新的,例如id = 10
的数据,并且已经提交了呢?
对于id=1
的讨论略,我们主要讨论id=10
的。
- 复用之前的ReadView
creator_trx_id
:0
trx_ids
:[10,20]
up_limit_id
:10
low_limit_id
:21
- 读取到了
id=10
的记录,其trx_id
为20
,在up_limit_id
和low_limit_id
之间,在trx_ids
列表中,所以是不可见。
如果有一个新的事务30,插入了id=100
的数据呢?
- 复用之前的ReadView
creator_trx_id
:0
trx_ids
:[10,20]
up_limit_id
:10
low_limit_id
:21
- 读取到了
id = 100
的记录,其trx_id
为30
,大于low_limit_id
之间,所以是不可见。
以此,解决了幻读
。