avatar


6.事务

基础

什么是事务

一个事务中,包含一个或多个操作,那么这些操作要么同时成功,要么同时失败。

举个例子,转账,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';

这两个操作,要么同时成功,要么同时失败。

事务特性

事务有四个特性:

  1. A,Atomicity,原子性
  2. C,Consistency,一致性
  3. I,Isolation,隔离性
  4. D,Durability,持久性

其中:

  • 隔离性借助锁机制实现。
  • 持久性借助redo log实现。
  • 原子性借助undo log实现。
  • 一致性是目的。

原子性

事务是一个不可分割的工作单元,要么全部提交,要么全部失败回滚,不存在部分成功,部分失败。

一致性

事务执行前后,数据从一个"合法的状态"变换到另外一个"合法的状态"。
那什么是合法的数据状态呢?满足"预定的约束"的状态就叫做"合法的状态"。

A账户有200元,转账300元出去,此时A账户余额为-100元。

这个好像就不太对了,因为我们预定了一个约束,余额必须>=0,这时候就不符合一致性要求,

A账户200元,转账50元给B账户,A账户的钱扣了,但是B账户因为各种意外,余额并没有增加。

这个好像也不太对,因为我们预定了一个约束,要求A+B的总余额必须不变。

有些资料说,一致性是指,事务操作前后,数据总量不变。这个定义不够完整,只是在类似于这种的例子下,这个定义是OK的。

隔离型

隔离性,Isolation。
一个事务的执行不能被其他事务干扰,即一个事务内部的操作及使用的数据对并发的其他事务是隔离的。

举个例子,假设A账户有200元,B账户0元。A账户往B账户转账两次,每次金额为50元,分别在两个事务中执行。如果无法保证隔离性,会可能会出现如下的情况:

隔离型

A账户的钱,确实少了100,但是B账户的钱,只增加了50。
类似于这种的,就被称为脏写

我们在《基于Java的后端开发入门:7.多线程 [1/2]》,讨论"线程安全"的时候,举了一个卖票的例子,也属于"一个事务的执行被其他事务干扰"。

持久性

持久性,Durability。
事务一旦被提交,它对数据库中数据的改变就是永久性的,接下来的其他操作和数据库故障不会对其有任何影响。

事务的状态

事务的状态有:

  1. 活动的(active)
    事务对应的数据库操作正在执行过程中时,此时事务处于活动的状态。
  2. 部分提交的(partially committed)
    当事务中的所有的操作都执行完成,但还没有将执行结果刷新到磁盘时,此时事务处于部分提交的状态。
    (个人认为,这个名字取的不太好,称之为提交前预提交更好)
  3. 失败的(failed)
    当事务处在活动的或者部分提交的状态时,可能遇到了某些错误(数据库自身的错误、操作系统错误,甚至停电了)而无法继续执行,或者人为的停止当前事务的执行,此时事务处于失败的状态。
  4. 中止的(aborted)
    数据库状态变为失败的时,会进行回滚操作,即将数据库恢复到事务执行前的状态,回滚后事务处于中止的状态。
  5. 提交的(committed)
    当一个处在部分提交的状态的事务将修改过的数据都同步到磁盘上之后,我们就可以说该事务处在了提交的状态。

其中,只有提交的中止的是事务的最终状态。

如图是状态之间的转换关系
状态之间的转换关系

存储引擎的支持

并不是所有的存储引擎都支持事务。在MySQL中,有且仅有InnoDB支持事务。
《4.结构》,我们提到了一个命令,SHOW ENGINES,通过这个可以查看。

示例代码:

1
SHOW ENGINES

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
+------------------+-------+--------------------------------------------------------------+------------+----+----------+
|Engine |Support|Comment |Transactions|XA |Savepoints|
+------------------+-------+--------------------------------------------------------------+------------+----+----------+
|FEDERATED |NO |Federated MySQL storage engine |NULL |NULL|NULL |
|MEMORY |YES |Hash based, stored in memory, useful for temporary tables |NO |NO |NO |
|InnoDB |DEFAULT|Supports transactions, row-level locking, and foreign keys |YES |YES |YES |
|PERFORMANCE_SCHEMA|YES |Performance Schema |NO |NO |NO |
|MyISAM |YES |MyISAM storage engine |NO |NO |NO |
|MRG_MYISAM |YES |Collection of identical MyISAM tables |NO |NO |NO |
|BLACKHOLE |YES |/dev/null storage engine (anything you write to it disappears)|NO |NO |NO |
|CSV |YES |CSV storage engine |NO |NO |NO |
|ARCHIVE |YES |Archive storage engine |NO |NO |NO |
+------------------+-------+--------------------------------------------------------------+------------+----+----------+

解释说明:Transactions,事务,有且仅有InnoDB支持事务。

使用事务

使用事务有两种方式:

  1. 显式
  2. 隐式

显式

开启事务

通过START TRANSACTION或者BEGIN,显式的开启一个事务。

START TRANSACTIONBEGIN的区别在于,BEGIN的后边能跟随修饰符:

  1. READ ONLY:标识当前事务是一个只读事务,也就是属于该事务的操作只能读取数据,而不能修改数据。
  2. READ WRITE:标识当前事务是一个读写事务,也就是属于该事务的数据库操作既可以读取数据,也可以修改数据。
  3. WITH CONSISTENT SNAPSHOT:开启一致性读。

默认是READ WRITE

开启事务之后,可以进行一系列的DDL操作。

提交和回滚

提交,示例代码:

1
COMMIT;

回滚,示例代码:

1
ROLLBACK;

回滚之后,事务处于中止状态。

特别的,我们还可以将事务回滚到某个保存点,示例代码:

1
2
3
4
5
6
BEGIN;
UPDATE account SET balance = balance - 100 WHERE NAME = 'A';
UPDATE account SET balance = balance - 100 WHERE NAME = 'A':
SAVEPOINT s1:
UPDATE account SET balance = balance + 100 WHERE NAME = 'A';
ROLLBACK TO s1
  • ROLLBACK TO s1,回归到保存点。
  • 回滚到保存点,事务并不会处于中止状态,即事务没有结束。
    还是需要进行后续操作,最终提交或者回滚。

例子

我们举一个显式事务的例子。

假设存在一张表,如下:

1
2
3
4
5
CREATE TABLE test_tx_1
(
name varchar(20),
PRIMARY KEY (name)
) ENGINE = InnoDB;

开启事务,插入数据,提交,示例代码:

1
2
3
BEGIN;
INSERT INTO test_tx_1 VALUES ('张三');
COMMIT;

然后,我们看看表里的数据,示例代码:

1
SELECT * FROM test_tx_1;

运行结果:

1
2
3
4
5
+----+
|name|
+----+
|张三 |
+----+

存在一条数据,张三,这个是没问题。

再开启一个事务,插入一条数据,不提交,看看表里的数据。
示例代码:

1
BEGIN;
1
INSERT INTO test_tx_1 VALUES ('李四');
1
SELECT * FROM test_tx_1;

运行结果:

1
2
3
4
5
6
+----+
|name|
+----+
|张三 |
|李四 |
+----+

然后我们回滚,再看看。
示例代码:

1
ROLLBACK;
1
SELECT * FROM test_tx_1;

运行结果:

1
2
3
4
5
+----+
|name|
+----+
|张三 |
+----+

只有一条数据,张三,这个也是没有问题的。

现在,我们不开启事务,直接插入数据,查看,示例代码:

1
INSERT INTO test_tx_1 VALUES ('王五');
1
SELECT * FROM test_tx_1;

运行结果:

1
2
3
4
5
6
+----+
|name|
+----+
|张三 |
|王五 |
+----+

这是因为默认会自动提交,由系统变量autocommit控制。

隐式

autocommit

我们可以通过修改autocommit,设置不自动提交,以此隐式的开启事务。

通过SHOW VARIABLES,查看这个变量的值,示例代码:

1
SHOW VARIABLES LIKE 'autocommit';

运行结果:

1
2
3
4
5
+-------------+-----+
|Variable_name|Value|
+-------------+-----+
|autocommit |ON |
+-------------+-----+

通过如下方式,可以修改autocommit的值。

关闭,示例代码:

1
SET autocommit = OFF
  • 通过SET autocommit = FALSESET autocommit = 0,也可以关闭。

开启,示例代码:

1
SET autocommit = ON

隐式自动提交

以下几种情况,是隐式自动提交,即使SET autocommit = OFF也无效。

  1. 数据定义语言,Data Definition Language,DDL
  2. 隐式的修改MySQL的表。
    例如:ALTER USERCREATE USERDROP USERSET PASSWORD
  3. 之前语句所属的事务被自动提交。
    有好几种情况,都会导致之前语句所属的事务被自动提交。例如:
    1. 在一个事务还没提交或者回滚时就又使用START TRANSACTION或者BEGIN开启了另一个事务时。
      示例代码:
      1
      2
      3
      4
      5
      BEGIN
      UPDATE #事务中的第一条语句
      UPDATE #事务中的第一条语句
      ... #事务中的其他语句
      BEGIN #此时会隐式的提交之前语句所属的事务
    2. 当前的autocommit系统变量的值为OFF,我们手动把它调为ON时。
    3. LOCK TABLESUNLOCK TABLES等关于锁定的语句。
    4. LOAD DATA批量导入数据。
    5. 主从复制的一些语句:START SLAVESTOP SLAVERESET 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读取到的记录少了,这种不属于幻读。幻读调的是一个事务按照某个相同条件多次读取时,后读取时读到了之前没有读到的记录。对于这种现象,如果一定要归类,应该属于不可重复读。

严重性排序

通常,四种问题的严重性排序如下:

脏写>脏读>不可重复读>幻读\text{脏写} > \text{脏读} > \text{不可重复读} > \text{幻读}

四种隔离级别

为什么会有上述问题?因为事务之间没有很好的进行隔离导致的。
那么怎么解决问题?设置事务之间的隔离级别。

在SQL标准中,有四种隔离级别,其分别能解决的问题下表所示。

脏写 脏读 不可重复读 幻读
READ UNCOMMITTED读未提交
READ COMMITTED读已提交
REPEATABLE READ可重复读
SERIALIZABLE串行化

需要注意的是,不同的隔离级别,有不同的锁和并发机制,隔离级别越高,数据库的并发性能就越差。

此外,对于MySQL,还略有不同,MySQL的REPEATABLE READ,也可以解决幻读,即:

脏写 脏读 不可重复读 幻读
READ UNCOMMITTED读未提交
READ COMMITTED读已提交
REPEATABLE READ可重复读
SERIALIZABLE串行化

具体实现方式,就是MVCC,我们在本章最后会讨论。

操作

查看

有两种方法可以查看事务的隔离级别:

  1. SHOW variables like 'transaction_isolation'
  2. SELECT @@transaction_isolation

示例代码:

1
SHOW variables  like 'transaction_isolation'

运行结果:

1
2
3
4
5
+---------------------+---------------+
|Variable_name |Value |
+---------------------+---------------+
|transaction_isolation|REPEATABLE-READ|
+---------------------+---------------+

示例代码:

1
SELECT @@transaction_isolation

运行结果:

1
2
3
4
5
+-----------------------+
|@@transaction_isolation|
+-----------------------+
|REPEATABLE-READ |
+-----------------------+

注意,在5.7.20之前的MySQL中,没有transaction_isolation,而是tx_isolation

设置

有两种方法可以设置事务的隔离级别:

  1. SET [GLOBAL|SESSION] TRANSACTION ISOLATION LEVEL 【隔离级别】;
    【隔离级别】的枚举值有:READ UNCOMMITTEDREAD COMMITTEDREPEATABLE READSERIALIZABLE
  2. SET [GLOBAL|SESSION] TRANSACTION_ISOLATION = '【隔离级别】'
    【隔离级别】的枚举值有:READ-UNCOMMITTEDREAD-COMMITTEDREPEATABLE-READSERIALIZABLE

注意:

  • 《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,两者之间的区别在于:

  1. redo log是存储引擎层产生的,而binlog是数据库层产生的。
  2. 在事务执行过程中,redo log不断记录,但binlog只会在提交的时候记录。
    例如:一个事务,对表做10万行的记录插入,在这个过程中,会一直不断的往redo log按顺序的记录,而binlog不会记录,直到这个事务提交,才会一次写入到binlog

组成

redo log又可以分为两部分:

  1. redo log buffer,保存在内存中,是易失的。
  2. redo log file,保存在硬盘中,是持久的。

redo log buffer

在MySQL服务启动时就会向操作系统申请了一大片称之为redo log buffer的连续内存空间,这片内存空间被划分成若干个连续的redo log block,如图所示:

redo log buffer

redo log file

redo log file,默认在数据文件目录中,具体我们可以看看,示例代码:

1
ll /var/lib/mysql

运行结果:

1
2
-rw-r----- 1 mysql mysql  50331648 Jun  3 18:12 ib_logfile0
-rw-r----- 1 mysql mysql 50331648 Jun 3 18:12 ib_logfile1

有两个文件,一样的大小;而且这两个文件并不是已经写满了日志,只是先占据了磁盘空间。

流程

以更新事务为例:

redo的整体流程

  1. 先将原始数据从磁盘中读入内存中来,修改数据的内存拷贝。
  2. 生成一条重做日志并写入redo log buffer,记录的是数据被修改后的值。
  3. redo log buffer中的内容刷新到redo log file
  4. data buffer中修改的数据刷新到磁盘中。

注意34,先写日志,这就是WAL的体现

刷盘策略

刷盘策略,指定是将redo log buffer中的内容刷新到redo log file的策略。
需要注意的是,并不会直接从redo log buffer刷盘到redo log file中,还需要先刷入操作系统的page cache(文件缓存系统)中去。
在InnoDB中,有三种刷盘策略,通过innodb_flush_log_at_trx_commit进行控制,有102,三种取值。

1
默认值,每次事务提交时,都进行刷盘操作,包括:

  1. redo log buffer刷入page cache
  2. page cache刷入redo log file

0
不进行刷盘操作,不会主动从redo log buffer刷入page cache,更不会主动从page cache刷入redo log file
而是由InnoDB的一个线程,每隔1秒,从redo log buffer刷入page cache,从page cache刷入redo log file

这种方法,有两个坑:

  1. 对于还没提交的事务,也可能会被刷入磁盘中。
  2. 无法满足"持久性"的要求。

2
每次事务提交时,都会从redo log buffer刷入page cache,但不会主动从page cache刷入redo log file,而由操作系统按照自身策略从page cache刷入redo log file

如果MySQL服务挂了,影响有限,因为已经被刷入了操作系统的page cache,但是如果操作系统也在期间挂了,数据就丢失了。
虽然操作系统一般不会挂,但还是无法满足"持久性"的要求。

最后,我们可以体验三种刷盘策略。

假设存在一张表,表结构如下:

1
2
3
4
5
CREATE TABLE test_load
(
a INT,
b CHAR(80)
) ENGINE = INNODB;

我们创建一个存储过程,示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
DELIMITER //
CREATE PROCEDURE p_load(COUNT INT UNSIGNED)
BEGIN
DECLARE S INT UNSIGNED DEFAULT 1;
DECLARE C CHAR(80) DEFAULT REPEAT('a', 80);
WHILE S <= COUNT
DO
INSERT INTO test_load SELECT NULL, C;
COMMIT;
SET S = S + 1;
END WHILE;
END //
DELIMITER;

查看刷盘策略,示例代码:

1
show variables like 'innodb_flush_log_at_trx_commit';

运行结果:

1
2
3
4
5
+--------------------------------+-------+
| Variable_name | Value |
+--------------------------------+-------+
| 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:3.Broker》也会讨论,Kafka的broker用其实现了数据的"高效读写"。

undo log

事务需要保证原子性,但有时候事务执行到一半会出现一些情况,我们需要把数据改回原先的样子,这个过程称之为回滚。

怎么做会滚?每当我们要对一条记录做改动时,都需要"留一手",把回滚时所需的东西记下来。比如:

  • 插入一条记录时,需要把这条记录的主键值记下来,之后回滚的时候需要把这个主键值对应的记录删除。
  • 删除一条记录时,需要把这条记录的内容都记下来,之后回滚的时候,把由这些内容组成的记录插入到表中。
  • 修改一条记录时,需要把修改这条记录前的旧值记录下来,这样之后回滚时再把这条记录更新为旧值就好了。

MySQL把这些为了回滚而记录的内容称之为undo log
因为insert产生的undo log被称为insert undo log,因为updatedelete产生的undo log被称为update undo log

有些资料会认为,undo log是用于将数据库物理地恢复到之前的样子,这个是不对的。

打个比方,undo log是倒放,而不是直接的读取存档。

假设存在2个数值,分别为A=1B=2,然后将A修改为3B修改为4,其步骤如下:

  1. start transaction
  2. 记录A=1undo log
  3. update A = 3
  4. 记录A=3redo log
  5. 记录B=2undo log
  6. update B = 4
  7. 记录B=4redo log
  8. commmit
  9. redo log刷新到磁盘

除了用于回滚,undo log的另一个作用是用于实现MVCC,我们在下文讨论MVCC的时候会讨论。

锁机制

并发事务访问相同记录

并发事务访问相同记录的情况大致分为三种:

读和读

读和读,即并发事务读取相同的记录。
这种没有任何问题,读取操作本身不会对记录有任何影响。

《基于Java的后端开发入门:7.多线程 [1/2]》,我们讨论"线程安全"的"原因"的时候,也提到过类似的现象,一个变量,多个线程在共享,但是如果都是只读的话,没有问题。

写和写

写和写,即并发事务相继对相同的记录做出改动,在这种情况下会发生我们上文讨论的脏写问题。

为了避免脏写,数据库强制未提交写事务必须排队执行,通过锁实现强制。
假设存在一条记录,此时事务T1想要修改该记录,如果内存中没有与该记录相关的锁,会生成一个锁与该记录进行关联,如图所示:

记录锁

  • trx标识该锁属于哪个事务
  • is_waiting标识该事务是否在等待

在这里,T1之前没有任何事务对该记录进行修改,所以is_waitingfalse,表示无须等待,可以直接修改,即获取锁成功。
在事务T1提交之前,如果事务T2也想对该记录修改,就需要等待,is_waitingtrue,即获取锁失败,如图所示:

获取锁失败

直到事务T1提交或中止,锁才会释放,然后T2is_waiting变为falseT2开始执行,如图所示:

获取锁成功

读和写

读和写,即一个事务进行读操作,另一个进行写操作。这种情况下可能发生脏读不可重复读幻读的问题。
解决这些问题有两个办法:

  1. 对所有的读写操作都进行加锁。
  2. 对读操作使用MVCC,对写操作进行加锁。

对所有的读写操作都进行加锁。

对所有的读写操作都进行加锁,这个方法本身是OK的,但对于解决幻读需要注意一下。

假设,现在存在两个事务,事务A和事务B。
事务A,读取一个范围的记录,例如,读取id > 10的记录,对id > 10的记录都加锁,这个没有问题,事务B想修改这些记录也修改不了,因为都加锁了。
但如果事务B插入一条id > 10的数据,然后事务A再次读取,就可能读到新插入的数据,这条数据就被称为幻影记录。

关于这个问题的解决,和下文要讨论的间隙锁有关。

对读操作使用MVCC,对写操作进行加锁。

关于MVCC,我们会在下文进行讨论。

锁的分类

从对数据的操作类型划分:

  1. 共享锁(读锁)
  2. 排他锁(写锁)

从锁粒角度划分:

  1. 表级锁
  2. 行级锁
  3. 页级锁

从锁的设计思想划分:

  1. 悲观锁
  2. 乐观锁

共享锁和排他锁

概述

  1. 共享锁(Share LockS Lock)
    对于同一份数据,多个事务进行读取时可以同时进行互不影响,禁止写操作。
    因为主要用在读的操作上,也被称为读锁(readLock)。
  2. 排他锁(Exclusive LockX Lock)
    对于同一份数据,当一个事务进行写操作时,禁止其他事务的读、写操作。
    因为主要用在写的操作上,也被称为写锁(writeLock)。

锁定读

读取时加共享锁的方法为:

1
SELECT ... Lock in share mode;

在8版本中,还有新的方法:

1
SELECT ... for share;

读取时加排他锁的方法为:

1
SELECT.... for update

锁定写

写操作有三种:

  1. delete
  2. insert
  3. update

delete

对一条记录做DELETE操作的过程其实是先在B+树中定位到这条记录的位置,然后获取这条记录的排他锁,再执行delete mark操作。

insert

不加锁,有隐式锁来保护不被其他事务打扰。

update

可以分为三种情况:

  1. 未修改该记录的主键,并且被更新的列占用的存储空间在修改前后末发生变化。
    先在B+树中定位到这条记录的位置,然后获取该记录的排他锁,最后在原记录的位置进行修改操作。
  2. 未修改该记录的主键,并且至少有一个被更新的列占用的存储空间在修改前后发生变化。
    先在B+树中定位到这条记录的位置,然后获取该记录的排他锁,将该记录彻底删除掉(移入垃圾链表),再插入一条新记录。新插入的记录由INSERT操作提供的隐式锁进行保护。
  3. 修改了该记录的主键
    相当于在原记录上做DELETE操作之后再来一次INSERT操作,加锁操作就需要按照DELETE和INSERT的规则进行。

表级锁、行级锁和页级锁

表级锁

共享和排他

  • 为表添加共享锁:LOCK TABLES T read
  • 为表添加排他锁:LOCK TABLES t WRITE
  • 释放锁:UNLOCK TABLES

限制关系

锁类型 自己可读 自己可写 自己可操作其他表 他人可读 他人可写
共享锁
排他锁
  • 如果某个事务对某张表添加了表级别的共享锁排他锁,在锁被释放之前,会无法操作其他表。

意向锁

假设,存在一个事务,这个事务试图在某张表的表级加上共享锁或排他锁,如果没有意向锁存在,该事务需要去遍历各页或各行是否存在锁。现在有了意向锁,会自动在更大一级别的空间标识内部是否已经上过锁,而不必检查各页或各行。

举个例子,假设存在两个事务,事务A和事务B,事务A给某一行数据加上了锁(共享锁排他锁),存储引擎会自动给更大一级的空间,比如页或表加上意向锁(意向共享锁意向排他锁),标记该页或该表的内部已经上过锁(共享锁排他锁)了,这样其他事务想要获取表级锁(表级共享锁表级排他锁)的时候,只需要了解是否已经有事务获取了表级的意向锁(意向共享锁或者意向排他锁)。

自增锁

在讨论"自增锁"之前,我们需要讲清楚和AUTO_INCREMENT有关的三种插入方式。

假设,现在存在一张表,该表一个AUTO_INCREMENT的列,示例代码:

1
2
3
4
5
6
7
CREATE TABLE test_ai
(
`id` int NOT NULL AUTO_INCREMENT,
`name` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4;

那么,我们在插入的时候:

  1. 可以对id进行赋值,示例代码:
    1
    2
    3
    INSERT INTO test_ai (name)
    VALUES ('zhangsan'),
    ('lisi');
  2. 也可以对其赋值。

这样,就有了插入数据有三种模式:

  1. Simple inserts
    事先知道要插入的行数的语句,比如我们上面举的例子就属于该类插入,已经确定要插入的行数。
  2. Bulk inserts
    事先不知道要插入的行数的语句,例如INSERT ... SELECTLOAD DATA,针对这种,InnoDB在每处理一行,为AUTO_INCREMENT列分配一个新值。
  3. Mixed-mode inserts
    部分指定了id,部分又没指定id,示例代码:
    1
    2
    3
    4
    5
    INSERT INTO test_ai (id, name)
    VALUES (1, 'a'),
    (NULL, 'b'),
    (5, 'c'),
    (NULL, 'd');

那么,如果有多个事务都在往这张表中插入数据呢?
这就涉及到自增锁了,在InnoDB中,针对自增锁,有三种模式,由innodb_autoinc_lock_mode进行控制。

  1. innodb_autoinc_lock_mode = 0
    在该模式下,所有类型的insert语句都会使用表级别的自增锁。
  2. innodb_autoinc_lock_mode = 1,在8版本之前,默认是这种。
    在这个模式下,Bulk inserts仍然会使用表级别的自增锁。
  3. innodb_autoinc_lock_mode = 2,在8版本之后,默认是这种。
    在这个模式下,所有类型的insert语句都不会使用表级别的自增锁。
    这种模式,并发能力最强,但对于主从复制可能会有影响。

元数据锁

元数据锁(meta data lock)

  • 对表执行DML时,InnoDB会对自动表添加元数据读锁。
  • 对表执行DDL时,例如,修改表结构,InnoDB会自动对张表添加表级的元数据写锁。

元数据读锁和元数据读锁不互斥、但是元数据读锁和元数据写锁、元数据写锁和元数据写锁会互斥。

行级锁

行级锁,又分为

  1. 记录锁
    对某一条记录加锁,分为共享和排他两种。
  2. 间隙锁
    在某个查询条件的范围内,加入间隙锁,从而防止幻影记录的插入。
  3. 临键锁
    临键锁=记录锁+间隙锁,锁住某条记录,并阻止其他事务在该事务前面的间隙插入事务。
  4. 插入意向锁
    通过间隙锁的讨论,我们知道一个事务在插入一条记录时需要判断一下插入位置是不是被别的事务加了间隙锁,如果有的话,就需要等待。InnoDB规定事务在插入前的等待的时候也需要生成一个锁,表明有事务想在某个问隙中插入新记录,但是在等待。这就是插入意向锁(Insert Intention Locks)。

页级锁

页级锁:

  • 粒度大小:>>
  • 开销大小:<<

每一个层级的锁的数量是有限,当超过某个阀值时,会自动进行锁升级。

悲观锁和乐观锁

悲观锁和乐观锁,是从锁的设计思想来划分。

悲观锁

悲观锁,总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到拿到锁。
比如共享锁排他锁行级锁表级锁,都是在做操作之前先上锁,当其他线程想要访问数据时,都需要阻塞挂起。

我们在《基于Java的后端开发入门:8.多线程 [2/2]》讨论的synchronizedReentrantLock就是悲观锁设计思想的实现。

乐观锁

乐观锁,认为对同一数据的并发操作不会总发生,属于小概率事件,不用每次都对数据上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据。
在下文会讨论的MVCC,可以认为是一种特殊的"乐观锁"。

我们在《基于Java的后端开发入门:8.多线程 [2/2]》讨论的原子类就是乐观锁设计思想的实现。

MVCC

概述

多版本并发控制(Multiversion Concurrency Control,MVCC),多版本并发控制,顾名思义,通过数据行的多个版本管理来实现数据库的并发控制。

  • 多个版本,通过隐藏字段undo log 版本链实现。
  • 管理,通过ReadView实现。

(MVCC没有正式的标准,在不同的数据库中的MVCC也可能是不同的,本文讨论的是MySQL的InnoDB引擎中的MVCC)

原理

隐藏字段

《4.结构》,我们讨论过一行记录的结构,并提到了其中有些"其他信息",而我们的隐藏字段就在其他信息中。

例如,我们新增一条记录,其行格式如图:
隐藏字段

上图中两个隐藏字段:

  1. trx_id:某个事务对某条聚簇索引记录进行改动时,记录该事务的ID,在这里trx_id8
  2. 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 chain

这就是一个undo log 版本链

提一个问题,为什么在上例中,一定要等事务10提交之后,事务20才会执行呢?因为事务10开启之后,就有了排他锁。

ReadView

结构

ReadView中主要包含四个比较重要的内容,如下:

  1. creator_trx_id,创建这个ReadView的事务的ID。
    注意,只有在对表中的记录做改动时(INSERTDELETEUPDATE等)才会为事务分配事务ID,如果该事务只做了SELECT,ID为0
  2. trx_ids,表示在生成ReadView时当前系统中活动的事务的ID列表。
  3. up_limit_id,活动的事务中最小的事务ID。
  4. low_limit_id,是"系统中"最大的事务ID值,这里要注意是"系统中",而不是"活动的事务"。是生成ReadView时系统中应该分配给下一个事务的ID值。

例如,现在有ID为125这三个事务,之后5提交了;那么一个新的读的事务在生成ReadView时,trx_ids就包括12up_limit_id的值就是1,而low_limit_id的值是6

规则

有了这个ReadView,这样在访问某条记录时,只需要按照下边的步骤判断记录的某个版本是否可见:

  1. 如果被访问版本的trx_id等于ReadView中的creator_trx_id
    说明,当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。
  2. 如果被访问版本的trx_id小于ReadView中的up_limit_id
    说明,生成该版本的事务已经提交,所以该版本可以被当前事务访问。
  3. 如果被访问版本的trx_id大于或等于ReadView中的low_limit_id
    说明,生成该版本的事务在当前事务生成ReadView后才开启,所以该版本不可以被当前事务访问。
  4. 如果被访问版本的trx_id在ReadView的up_limit_idlow_limit_id之间,那就需要判断一下trx_id是不是在trx_ids列表中。
    1. 如果在,说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问。
    2. 如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问。

流程

步骤

查询一条记录的大致步骤如下:

  1. 首先获取事务自己的版本号,也就是事务ID(因为一般都是SELECT,所以一般都是0)
  2. 获取ReadView。
  3. 查询得到的数据,然后与ReadView中的事务版本号进行比较(比较规则参考上文)。
  4. 如果不符合ReadView规则,就需要从undo log 版本链中获取历史快照。
  5. 最后返回符合规则的数据。

接下来,我们来看看在不同的隔离级别下的情况。

READ COMMITTED

READ COMMITTED:每次读取数据都生成一个ReadView。

假设,现在有两个事务,分别是事务10和事务20。
其中事务10执行如下操作,但还没提交。

1
2
3
BEGIN;
UPDATE student SET name = '李四' WHERE id = 1;
UPDATE student SET name = '王五' WHERE id = 1;

事务20操作其他的表,也没有提交。
最后,我们会得到undo do 版本链如下:

undo do 版本链

然后,我们新建一个事务,查询记录。

1
SELECT * FROM student WHERE id = 1;

执行过程如下:

  1. 首先SELECT语句会生成一个ReadView,该ReadView的内容为
    creator_trx_id0
    trx_ids[10,20]
    up_limit_id10
    low_limit_id21
  2. redo log 版本链中进行读取,最新版本的name王五trx_id10,在up_limit_idlow_limit_id之间,在trx_ids列表中,所以是不可见,通过roll_point寻找下一版本。
  3. 下一版本的name李四trx_id10,在up_limit_idlow_limit_id之间,在trx_ids列表中,所以是不可见,通过roll_point寻找下一版本。
  4. 下一版本的name张三trx_id8,小于up_limit_id,可以被访问,所以最后返回name张三的记录。

如果事务10提交了,再读一遍呢?

  1. 首先SELECT语句会生成一个ReadView,该ReadView的内容为
    creator_trx_id0
    trx_ids[20]
    up_limit_id20
    low_limit_id21
  2. redo log 版本链中进行读取,最新版本的name王五trx_id10,小于up_limit_id,可以被访问,所以最后返回name王五的记录。

REPEATABLE READ

REPEATABLE READ:只会在第一次执行查询语句的时候,生成一个ReadView。

不可重复读

首先,事务10执行了操作,但还没提交,事务20操作其他的表,也没有提交。
执行过程如下:

  1. 首先SELECT语句会生成一个ReadView,该ReadView的内容为
    creator_trx_id0
    trx_ids[10,20]
    up_limit_id10
    low_limit_id21
  2. redo log 版本链中进行读取,最新版本的name王五trx_id10,在up_limit_idlow_limit_id之间,在trx_ids列表中,所以是不可见,通过roll_point寻找下一版本。
  3. 下一版本的name李四trx_id10,在up_limit_idlow_limit_id之间,在trx_ids列表中,所以是不可见,通过roll_point寻找下一版本。
  4. 下一版本的name张三trx_id8,小于up_limit_id,可以被访问,所以最后返回name张三的记录。

如果事务10提交了,再读一遍呢?

  1. 复用之前的ReadView
    creator_trx_id0
    trx_ids[10,20]
    up_limit_id10
    low_limit_id21
  2. 后续步骤略。

以此,解决了不可重复读

幻读

假设,我们的查询语句如下:

1
SELECT * FROM student WHERE id >= 1;

然后这时候,事务20插入了新的,例如id = 10的数据,并且已经提交了呢?
对于id=1的讨论略,我们主要讨论id=10的。

  1. 复用之前的ReadView
    creator_trx_id0
    trx_ids[10,20]
    up_limit_id10
    low_limit_id21
  2. 读取到了id=10的记录,其trx_id20,在up_limit_idlow_limit_id之间,在trx_ids列表中,所以是不可见。

如果有一个新的事务30,插入了id=100的数据呢?

  1. 复用之前的ReadView
    creator_trx_id0
    trx_ids[10,20]
    up_limit_id10
    low_limit_id21
  2. 读取到了id = 100的记录,其trx_id30,大于low_limit_id之间,所以是不可见。

以此,解决了幻读

文章作者: Kaka Wan Yifan
文章链接: https://kakawanyifan.com/11106
版权声明: 本博客所有文章版权为文章作者所有,未经书面许可,任何机构和个人不得以任何形式转载、摘编或复制。

评论区