0%

MySQL学习笔记1 事务并发

最近一段时间学习了MySQL的锁,各种各样的锁,全局锁、表锁、MDL、行锁、间隙锁等等。

每种锁都有其存在的意义,都是为了解决某一种问题。锁是解决事务并发问题的基本手段之一,除了锁,还有多版本控制等无锁手段。

假如不使用一些手段,那么并发事务会存在脏读、不可重复读、幻读等问题。为了学习MySQL的并发事务隔离手段,我们首先需要了解事务存在的这几类并发问题。

事务并发问题

脏读

什么是脏读?脏读就是读到了其他未提交事务的数据。为了让这一过程更易理解,我们使用MySQL来复现。MySQL 5.7的事务默认隔离级别是可重复读,该级别下不存在问题。所以需要将MySQL的隔离级别设置为READ UNCOMMITTED(读未提交),该级别下会存在脏读。具体过程如下:

session A,读取记录:

MySQL root@(none):my_db> SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED
MySQL root@(none):my_db> begin;

MySQL root@(none):my_db> select balance from account where name = ‘A’;
+———+
| balance |
+———+
| 1000 |
+———+

session B,修改记录:

MySQL root@(none):my_db> begin;
MySQL root@(none):my_db> update account set balance = balance + 100 where name = ‘A’;

session A,读取记录:

MySQL root@(none):my_db> select balance from account where name = ‘A’;
+———+
| balance |
+———+
| 1100 |
+———+

可以发现,session A读到了尚未提交的session B的更新,属于脏读。

脏读存在什么问题呢?假如session B回滚,那么这个数据就是脏数据,这会影响逻辑的正确性。举一个具体例子,客户A正在ATM机提款,这时候客户B想给他转了一笔钱,业务逻辑是先扣除B的余额,再给A加上钱。在给A加完钱后的一时刻,银行后台读取了A的账户余额,客户B的操作由于某些原因回滚了,这时候读取到的就是脏数据,造成的后果是多给A钱,而客户B没有损失。

为了解决这个问题,MySQL引入了一致性视图的概念,在事务开启时给数据库的状态“拍”了个快照。

具体来说,给每个事务设置了trx id,单调递增,是事务的唯一标志。当发生查询时,会查找小于等于自己trx id版本的记录。假设在session A开始时,trx id是1。在session B开启时,trx id是2。那么在session B更新记录时,这条记录的版本号是2。而在session A查询时,它查不到这条版本号为2的记录,会找到未更新前的那条记录。这就解决了上面提到的脏读问题。

我们在将事务的隔离级别设置为可重复读,再实践一下:

session A,读取记录:

MySQL root@(none):my_db> begin
Query OK, 0 rows affected
Time: 0.000s
MySQL root@(none):my_db> select balance from account where name = ‘A’;
+———+
| balance |
+———+
| 1000 |
+———+

session B,修改记录:

MySQL root@(none):my_db> begin;

MySQL root@(none):my_db> update account set balance = balance - 100 where name = ‘A’;

session A,读取记录:

MySQL root@(none):my_db> select balance from account where name = ‘A’;
+———+
| balance |
+———+
| 1000 |
+———+

可以发现session A读不到session B的更改了,解决了脏读问题。

总结:为了解决脏读问题,MySQL没有用到锁,而是使用了多版本控制机制,通过为每个事务开启一个视图来保持一致性。

不可重复读

什么是不可重复读?不可重复读就是一个事务在没有修改的情况下,同样的普通查询在不同时刻得到了不一样的结果。

上面的实验看起来也是种不可重复读,但是实际上它不是。不可重复读,指的是其他已提交事务产生的影响,而不是未提交事务的影响。这就是脏读和不可重复读的区别点。

让我们做个实验复现一下,首先需要将session A的事务隔离级别设置为READ COMMITTED(读已提交)

session A,设置隔离级别并读取记录:

MySQL root@(none):my_db> SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED

MySQL root@(none):my_db> select balance from account where name = ‘A’;
+———+
| balance |
+———+
| 900 |
+———+

session B,修改记录:

MySQL root@(none):my_db> update account set balance = balance - 100 where name = ‘A’;

session A,读取记录:

MySQL root@(none):my_db> select balance from account where name = ‘A’;
+———+
| balance |
+———+
| 800 |
+———+

可以发现两次session A读取的结果并不一致。注意这里session B和脏读的实验不同,没有使用begin开启事务。假如没有使用begin开启事务,那么默认情况下事务会马上提交。

让我们将隔离级别设置回可重复读,再实验一下:

session A,读取记录:

MySQL root@(none):my_db> select balance from account where name = ‘A’;
+———+
| balance |
+———+
| 600 |
+———+

MySQL root@(none):my_db> begin
MySQL root@(none):my_db> select balance from account where name = ‘A’;
+———+
| balance |
+———+
| 600 |
+———+

session B,修改记录:

MySQL root@(none):my_db> update account set balance = balance - 100 where name = ‘A’;

session A,读取记录:

MySQL root@(none):my_db> select balance from account where name = ‘A’;
+———+
| balance |
+———+
| 600 |
+———+

可以发现在可重复读的隔离级别下,可以重复读。原理同样是MVCC。

问题:更新的时候会读到最新值,算不可重复读吗?

不算,根据不可重复读的定义,只有在不修改只有读取的情况下才算不可重复读。实际上,在可重复读的级别下,更新的时候,读取到其他事务的值是符合预期的。

问题:这样会存在问题吗?

假设我们有这么一个业务场景,在扣除某个人的余额前,需要先判断它是否足额(保证非负),然后再执行更新操作。

经过实验发现存在这样的情况:

session A:

MySQL root@(none):my_db> begin;
MySQL root@(none):my_db> select balance from account where name = ‘A’;
+———+
| balance |
+———+
| 100 |
+———+

session B:

MySQL root@(none):my_db> update account set balance = 0 where name = ‘A’;

session A:

MySQL root@(none):my_db> select balance from account where name = ‘A’;
+———+
| balance |
+———+
| 100 |
+———+
MySQL root@(none):my_db> update account set balance = balance - 100 where name = ‘A’;
MySQL root@(none):my_db> select balance from account where name = ‘A’;
+———+
| balance |
+———+
| -100 |
+———+

可以发现出现了负值了,这是我们不允许出现的。

问题:怎么解决这个问题呢?

分析:使用记录锁。假如session A依赖于第一次查询的结果,那么就应该对它加锁,防止在自己完成事务前被修改。在MySQL中,可以使用for udpate对查询加记录所。在session A对记录加锁后,其他事务不再允许修改相应的记录,直到session A的事务结束(commit或rollback)。在执行语句加锁,在事务结束释放锁,被称为两阶段锁协议。

问题:为什么要在结束的时候释放,提前释放会有问题么?

假如提前释放其实可以提高并发度,但是MySQL似乎不支持提前释放。有一种特殊的自增锁(auto-inc),会自动提前释放。它是用于给自增字段生成id,加锁是为了保证递增。

问题:两阶段锁协议解决了什么问题?

问题:如何验证加了什么锁?

可以使用Innodb Lock Monitor,具体可以查看下面的教程。

记录锁的事务数据在SHOW ENGINE INNODB STATUS和InnoDB监视器输出中看起来类似于以下内容:

RECORD LOCKS space id 58 page no 3 n bits 72 index PRIMARY of table test.t trx id 10078 lock_mode X locks rec but not gap

我们分析下Monitor的输出信息,先执行一个简单的select … for update操作:

MySQL root@(none):my_db> begin
MySQL root@(none):my_db> select balance from account where name = ‘A’ for update;
+———+
| balance |
+———+
| 0 |
+———+

查看输出信息:

TRANSACTIONS
-———–
Trx id counter 2821
Purge done for trx’s n:o < 0 undo n:o < 0 state: running but idle
History list length 0
LIST OF TRANSACTIONS FOR EACH SESSION:
—TRANSACTION 421563048392544, not started
0 lock struct(s), heap size 1136, 0 row lock(s)
—TRANSACTION 2820, ACTIVE 6 sec
4 lock struct(s), heap size 1136, 3 row lock(s)
MySQL thread id 25, OS thread handle 140087744558848, query id 74 localhost root
TABLE LOCK table my_db.account trx id 2820 lock mode IX
RECORD LOCKS space id 24 page no 4 n bits 72 index uniq_index_name of table my_db.account trx id 2820 lock_mode X
Record lock, heap no 2 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 1; hex 41; asc A;;
1: len 8; hex 8000000000000001; asc ;;

RECORD LOCKS space id 24 page no 3 n bits 72 index PRIMARY of table my_db.account trx id 2820 lock_mode X locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
0: len 8; hex 8000000000000001; asc ;;
1: len 6; hex 000000000921; asc !;;
2: len 7; hex 350000014f0110; asc 5 O ;;
3: len 1; hex 41; asc A;;
4: len 4; hex 80000000; asc ;;

RECORD LOCKS space id 24 page no 4 n bits 72 index uniq_index_name of table my_db.account trx id 2820 lock_mode X locks gap before rec
Record lock, heap no 3 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 1; hex 42; asc B;;
1: len 8; hex 8000000000000002; asc ;;

我将其中关键信息加粗了一下,可以发现该事务加了4个锁。包括1个TABLE LOCK和3个RECORD LOCKS:

  • table lock
    • lock mode IX 意向锁,在添加行锁之前添加,不会和行级的X, S锁发生冲突,只会和表级的X, S锁发生冲突。
  • record locks
    • lock_mode X 对name=A的这行上写锁
    • index PRIMARY of table, lock_mode X locks rec but not gap 是对主键索引加锁,对name=A这行加锁
    • index uniq_index_name, lock_mode X locks gap before rec 是对二级索引uniq_index_name加锁,在记录前面加了gap锁

问题:lock_mode X和index PRIMARY of table, lock_mode X locks rec but not gap有什么区别?

问题:行锁有哪些?

记录所、gap锁。。。

幻读

幻读是什么?幻读是一个事务在没有修改的情况,在不同时刻的进行两次修改读,读取到了不同的结果。幻读是write query,而不可重复读是read query。

Todo

Todo

Innodb Lock Monitor

为了观察SQL是被哪种锁锁住了,可以使用Innodb Lock Monitor。

启用命令:

1
2
SET GLOBAL innodb_status_output=ON;
SET GLOBAL innodb_status_output_locks=ON;

可以认为innodb_status_output是monitor的总开关,innodb_status_output_locks是lock monitor的开关,两者需要都打开才算启用成功。这两条命令的生命周期为server本次运行的周期,当server关闭后,下次启动会恢复成关闭状态。

获取Monitor输出:

1
SHOW ENGINE INNODB STATUS\G

在终端上输出时,加上\G可以让输出的可读性更佳。输出的字段有许多,我们主要关注和锁相关的字段。主要是LATEST DETECTED DEADLOCKTRANSACTIONS字段。关于这两个字段的官方解释:

LATEST DETECTED DEADLOCK

This section provides information about the most recent deadlock. It is not present if no deadlock has occurred. The contents show which transactions are involved, the statement each was attempting to execute, the locks they have and need, and which transaction InnoDB decided to roll back to break the deadlock. The lock modes reported in this section are explained in Section 14.7.1, “InnoDB Locking”.

TRANSACTIONS

If this section reports lock waits, your applications might have lock contention. The output can also help to trace the reasons for transaction deadlocks.

注意点:

When InnoDB monitors are enabled for periodic output, InnoDB writes the output to mysqld server standard error output (stderr) every 15 seconds, approximately.

monitor的输出是周期性,大概15s会输出一次。

分布式锁

可以利用mysql的唯一索引实现分布式锁。原理存在唯一索引的情况,插入重复的key会进行加锁。下面介绍会加什么锁,以及可能的问题。

首先创建一个的带有唯一索引的表distributed_lock:

1
2
3
4
5
6
CREATE TABLE `distributed_lock` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '自增ID',
`c` int,
PRIMARY KEY (`id`),
UNIQUE KEY (`c`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general

使用两个事务,分别执行insert语句

1
2
begin;
insert into distributed_lock (c) values(1);

时序图如下:

image-20210626153812741

注意到session A成功获取到了锁,那么这里到底获取的哪些锁呢?

我们可以使用上面介绍的Lock Monitor一探究竟。

下面是session B阻塞那一时刻的完整日志:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
------------
TRANSACTIONS
------------
Trx id counter 7973
Purge done for trx's n:o < 7972 undo n:o < 0 state: running but idle
History list length 2
LIST OF TRANSACTIONS FOR EACH SESSION:
---TRANSACTION 421898576025440, not started
0 lock struct(s), heap size 1136, 0 row lock(s)
---TRANSACTION 7972, ACTIVE 1 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s), undo log entries 1
MySQL thread id 13, OS thread handle 140423219771136, query id 126 localhost root update
insert into distributed_lock (c) values(5)
------- TRX HAS BEEN WAITING 1 SEC FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 38 page no 4 n bits 72 index c of table `my_db`.`distributed_lock` trx id 7972 lock
mode S waiting
Record lock, heap no 6 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 4; hex 80000005; asc ;;
1: len 8; hex 800000000000000a; asc ;;

------------------
TABLE LOCK table `my_db`.`distributed_lock` trx id 7972 lock mode IX
RECORD LOCKS space id 38 page no 4 n bits 72 index c of table `my_db`.`distributed_lock` trx id 7972 lock
mode S waiting
Record lock, heap no 6 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 4; hex 80000005; asc ;;
1: len 8; hex 800000000000000a; asc ;;

---TRANSACTION 7967, ACTIVE 6 sec
2 lock struct(s), heap size 1136, 1 row lock(s), undo log entries 1
MySQL thread id 17, OS thread handle 140423553578752, query id 125 localhost root
TABLE LOCK table `my_db`.`distributed_lock` trx id 7967 lock mode IX
RECORD LOCKS space id 38 page no 4 n bits 72 index c of table `my_db`.`distributed_lock` trx id 7967 lock
_mode X locks rec but not gap
Record lock, heap no 6 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 4; hex 80000005; asc ;;
1: len 8; hex 800000000000000a; asc ;;

其中,transaction 7972被阻塞了,它应该就是session B。日志显示它被Record Lock阻塞了,该Record Lock应该就是c = 5的那一行。另外可以注意到表已经被加上了IX锁,这个锁表示插入意向锁。

transaction 7967表示session A。它持有两个锁,一个是表的IX锁,另一个是Record Lock。日志显示not gap,表示这个Record Lock不是间隙锁,因此是c = 5那一行的锁。

问题:表的IX锁有什么用?

问题:什么时候会加间隙锁?

参考

  1. SET TRANSACTION Statement
  2. InnoDB的事务和锁
  3. 《MySQL实战45讲》——极客时间
  4. InnoDB Gap Locks
  5. Enabling InnoDB Monitors
  6. InnoDB Standard Monitor and Lock Monitor Output
  7. innodb-locking