关于MySQL涉及到的锁,大致可以总结如下:
MyISAM存储引擎在开发过程中几乎很少使用了,这里主要总结InnoDB存储引擎中的锁。
数据库中的记录,不可避免地出现并发访问的情况。并发事务,可能会带来以下问题:
脏写
当两个或多个事务更新同一行记录,会产生更新丢失现象。可以分为回滚覆盖和提交覆盖。
1)回滚覆盖:一个事务回滚操作,把其他事务已提交的数据给覆盖了
2)提交覆盖:一个事务提交操作,把其他事务已提交的数据给覆盖了
脏读
提交覆盖:一个事务提交操作,把其他事务已提交的数据给覆盖了
不可重复读
提交覆盖:一个事务提交操作,把其他事务已提交的数据给覆盖了
幻读
一个事务中多次按相同条件查询,结果不一致。后续查询的结果和面前查询结果不同,多了或少了几行记录。
并发事务访问相同记录的操作大致可以划分为3种:
读-读
情况:多个事务并发读取同一条记录
读取操作不会读数据本身造成什么印象,所以这种情况不会存在什么问题
写-写
情况:多个事务对同一条记录进行并发修改
这种情况下会发生脏写的问题,要解决这个问题,其中的一个方法通过锁的方式。如果事务之间涉及到相同的数据项时,会使用排他锁,或叫互斥锁,先进入的事务独占数据项以后,其他事务被阻塞,等待前面的事务释放锁
读-写
或写-读
情况:一个事务进行读取操作,另一个进行改动操作。
这种情况下可能发生脏读、不可重复读、幻读的问题。
要解决这些问题,有两种可选方案:
1)读操作利用多版本并发控制(MVCC),写操作进行加锁。
关于MVCC,可以查看:undo log与mvcc,写操作针对的是最新版本的记录,读记录的历史版本和改动记录的最新版本本身并不冲突,也就是采用MVCC时,读-写
操作并不冲突。
2)读、写操作都采用加锁的方式
实际开发中,有一些业务场景要求,每次都必须去读取记录的最新版本。比方在银行存款的事务中,需要先把账户余额读出来,操作完后,再写到数据库中。在将账户余额读取出来后,不允许其他事务再修改改账户余额,直到本次存款事务执行完成。这样在读取记录的时候也就需要对其进行加锁,也就意味着读
操作和写
操作也像写-写
操作那样排队执行。
从操作上对锁进行划分,锁可以分为共享锁和排他锁。
Shared Locks
,简称S锁
。针对同一份数据,多个读操作可以同时进行而不会互相影响。Exclusive Locks
,简称X锁
。当前写操作没有完成前,它会阻断其他写锁和读锁。顾名思义,共享锁就是,两个或多个事务可以同时获得同一条纪录的S锁
。排他锁就是,一个事务先获得了S锁
或者是X锁
,那么其他事务就不能再获取到该条记录的X锁
。S锁
与X锁
之间有以下特性:
数据准备
CREATE TABLE `account` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键id',
`name` varchar(32) DEFAULT NULL COMMENT '姓名',
`balance` int DEFAULT NULL COMMENT '余额',
PRIMARY KEY (`id`)
) ENGINE=InnoDB;
mysql> SELECT * FROM account;
+----+--------+---------+
| id | name | balance |
+----+--------+---------+
| 1 | lilei | 18 |
| 2 | hanmei | 11 |
| 3 | lucy | 111 |
| 4 | lilei | 18 |
+----+--------+---------+
S锁
:SELECT ... LOCK IN SHARE MODE;
mysql> begin;
mysql> SELECT * FROM account WHERE id = 1 LOCK IN SHARE MODE;
+----+-------+---------+
| id | name | balance |
+----+-------+---------+
| 1 | lilei | 1 |
+----+-------+---------+
上面的查询操作,对account表中的id = 1的记录增加了共享锁,其他事务依然可以查到该账户的余额,但是不能对账户余额进行修改,在某种场景下能够满足业务的需求。
X锁
:SELECT ... FOR UPDATE;
mysql> begin;
Query OK, 0 rows affected (0.01 sec)
mysql> SELECT * FROM account where id = 1 FOR UPDATE;
+----+-------+---------+
| id | name | balance |
+----+-------+---------+
| 1 | lilei | 1 |
+----+-------+---------+
此时id=1的记录,既不允许别的事务获取S锁
,也不允许获取这些记录的X锁
(或者直接修改这些记录)。如果别的事务想要获取这些记录的S锁
或者X锁
,就会阻塞,直到当前事务提交之后释放X锁
。
这里需要注意,普通的SELECT * FROM account WHERE id = 1;
语句是不加锁的。
手动获取InnoDB存储引擎提供的表的S锁
或者X锁
语法如下:
LOCK TABLES account READ
:InnoDB存储引擎会对表account加表级别的S锁``。
LOCK TABLES account WRITE
:InnoDB存储引擎会对表加表级别的X锁
。
在对某表中的记录执行SELECT、INSERT、DELETE、UPDATE
语句时,InnoDB存储引擎是不会为这个表添加表级别的S锁
或者X锁
的。
表级别的S锁
、X锁
有以下特点:
S锁
,那么:S锁
S锁
X锁
X锁
X锁
(意味着该事务要独占这个表),那么:S锁
S锁
X锁
X锁
在实际开发尽量减少出现表级别的S锁
和X锁
,粒度大,影响访问广,很少有场景使用到。
表级别的IS锁、IX锁
意向共享锁:英文名:Intention Shared Lock
,简称IS锁
。当事务准备在某条记录上加S锁
时,需要先在表级别加一个IS锁
。
意向独占锁:英文名:Intention Exclusive Lock
,简称IX锁
。当事务准备在某条记录上加X锁
时,需要先在表级别加一个IX锁
。
IS锁
和IX锁
的提出,只是为了后续在加表级别的S锁
和X锁
时判断表中是否有已经被加锁的记录,以避免用遍历的方式来查看表中有没有上锁的记录。
行锁
,也称为记录锁,顾名思义,就是作用在表记录上的锁。InnoDB行锁是通过对索引数据页上的记录加锁实现的,主要实现算法有 4 种:Record Lock
、Gap Lock
和 Next-key Lock
、Insert Intention Locks
。
Record Lock
锁:锁定单个行记录的锁。(记录锁,RC、RR隔离级别都支持)Gap Lock
锁:间隙锁,锁定索引记录间隙,确保索引记录的间隙不变。(范围锁,RR隔离级别支持)Next-key Lock
锁:记录锁和间隙锁组合,同时锁住数据,并且锁住数据前后范围。(Record Lock
+ Gap Lock
,RR隔离级别支持)Insert Intention Locks
锁:插入一条数据需要等待时,也会生成一个锁结构,就是插入意向锁。在RR
隔离级别,InnoDB对于记录加锁行为都是先采用Next-Key Lock
,但是当SQL操作含有唯一索引时,Innodb会对Next-Key Lock
进行优化,降级为Record Lock
,仅锁住索引本身而非范围。
锁的种类很多(容易晕),划分角度不同,就有不同的叫法;在这里统一总结下,开发过程中常用sql语句涉及到的锁:
select ... from
语句:InnoDB引擎采用MVCC机制实现非阻塞读,所以对于普通的select
语句,InnoDB不加锁;select ... from lock in share mode
语句:追加了共享锁,InnoDB会使用Next-Key Lock
锁进行处理,如果扫描发现唯一索引,可以降级为Record Lock
锁。select ... from for update
语句:追加了排他锁,InnoDB会使用Next-Key Lock
锁进行处理,如果扫描发现唯一索引,可以降级为Record Lock
锁。update ... where
语句:InnoDB会使用Next-Key Lock
锁进行处理,如果扫描发现唯一索引,可以降级为Record Lock
锁。delete ... where
语句:InnoDB会使用Next-Key Lock
锁进行处理,如果扫描发现唯一索引,可以降级为RecordLock
锁。insert
语句:一般情况下,新插入一条记录的操作并不加锁。有时候会有插入意向锁
下面以account
表操作为例,举例子分析下 InnoDB 对不同索引的加锁行为;
mysql> select version();
+-----------+
| version() |
+-----------+
| 8.0.27 |
+-----------+
1 row in set (0.00 sec)
mysql> select @@transaction_isolation;
+-------------------------+
| @@transaction_isolation |
+-------------------------+
| REPEATABLE-READ |
+-------------------------+
1 row in set (0.00 sec)
CREATE TABLE `account` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键id',
`code` varchar(32) DEFAULT NULL COMMENT '账号编码',
`name` varchar(32) DEFAULT NULL COMMENT '姓名',
`balance` int DEFAULT NULL COMMENT '余额',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_code` (`code`) USING BTREE COMMENT '账号编码唯一索引',
KEY `idx_name` (`name`) USING BTREE COMMENT '名称普通索引'
) ENGINE=InnoDB;
mysql> select * from account;
+----+------+--------+---------+
| id | code | name | balance |
+----+------+--------+---------+
| 1 | aa | tom | 1 |
| 3 | dd | hanmei | 11 |
| 8 | ff | lucy | 222 |
| 15 | jj | jack | 18 |
| 20 | ll | ocean | 400 |
+----+------+--------+---------+
加锁跟索引有关,这里把account
表相关的索引结构简化展示如下:
这里只展示了索引中的用户记录信息,同时强调聚簇索引中的记录是按照主键大小排序的。
Record Locks
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> update account set balance = 222 where id = 8;
Query OK, 1 row affected (0.00 sec)
通过另外的客户端查看锁情况
执行select * from performance_schema.data_locks\G;
说明在表层面获取到了IX锁
,在数据id=8这条记录加上了LOCK_REC_NOT_GAP
锁,也是X锁
。
Gap Locks
在普通索引上执行更新
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> update account set balance= 333 where name='lucy';
查看锁情况
mysql> select * from performance_schema.data_locks\G;
*************************** 1. row ***************************
-- 边幅原因 这里省略表锁,跟上面一样
*************************** 2. row ***************************
ENGINE: INNODB
ENGINE_LOCK_ID: 139645347953824:226:6:7:139645358804944
ENGINE_TRANSACTION_ID: 65451
THREAD_ID: 563249
EVENT_ID: 77
OBJECT_SCHEMA: fresh
OBJECT_NAME: account
PARTITION_NAME: NULL
SUBPARTITION_NAME: NULL
INDEX_NAME: idx_name
OBJECT_INSTANCE_BEGIN: 139645358804944
LOCK_TYPE: RECORD
LOCK_MODE: X
LOCK_STATUS: GRANTED
LOCK_DATA: 'lucy', 8
*************************** 3. row ***************************
ENGINE: INNODB
ENGINE_LOCK_ID: 139645347953824:226:4:12:139645358805288
ENGINE_TRANSACTION_ID: 65451
THREAD_ID: 563249
EVENT_ID: 77
OBJECT_SCHEMA: fresh
OBJECT_NAME: account
PARTITION_NAME: NULL
SUBPARTITION_NAME: NULL
INDEX_NAME: PRIMARY
OBJECT_INSTANCE_BEGIN: 139645358805288
LOCK_TYPE: RECORD
LOCK_MODE: X,REC_NOT_GAP
LOCK_STATUS: GRANTED
LOCK_DATA: 8
*************************** 4. row ***************************
ENGINE: INNODB
ENGINE_LOCK_ID: 139645347953824:226:6:4:139645358805632
ENGINE_TRANSACTION_ID: 65451
THREAD_ID: 563249
EVENT_ID: 77
OBJECT_SCHEMA: fresh
OBJECT_NAME: account
PARTITION_NAME: NULL
SUBPARTITION_NAME: NULL
INDEX_NAME: idx_name
OBJECT_INSTANCE_BEGIN: 139645358805632
LOCK_TYPE: RECORD
LOCK_MODE: X,GAP
LOCK_STATUS: GRANTED
LOCK_DATA: 'ocean', 20
4 rows in set (0.01 sec)
再执行以下sql时,发现执行阻塞
mysql> insert into account (id,code,name,balance) values(5,'ee','kk',20); -- 阻塞
mysql> insert into account (id,code,name,balance) values(10,'gg','my',20); -- 阻塞
mysql> insert into account (id,code,name,balance) values(10,'gg','pp',20); -- 不会被阻塞
Query OK, 1 row affected (0.00 sec)
加锁分析:
name字段是普通索引,对满足name='lucy'
条件的记录和主键会都加上X锁
。同时在字符'jack'-'lucy'
之间,'lucy'-'ocean'
之间加上了LOCK_GAP
锁,所以字符kk
和my
在插入时会发生阻塞,而字符pp
不属于锁的范围,所以能不被阻塞地添加到表中。
gap锁
的提出仅仅是为了防止插入幻影记录而提出的,如果对一条记录加了gap锁
(不论是共享gap锁
还是独占gap锁
),并不会限制其他事务对这条记录加Record Locks
或者继续加gap锁
。
对于最后一条记录,GAP
锁是如何处理的呢?也就是说给哪条记录加gap锁才能阻止其他事务插入name值在(‘tom’, +∞)这个区间的新记录呢?
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> update account set balance= 333 where name='tom'; -- 更新普通索引中的最后一条记录
-- 开启另外一个事务
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> insert into account (id,code,name,balance) values(30,'pp','zz',20); -- 想添加到tom后面,发生阻塞
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
Innodb数据页中有两条伪记录:
Infimum
记录,表示该页面中最小的记录。Supremum
记录,表示该页面中最大的记录。此时,GAP锁
如下:
Next-Key Lock
有时候既想锁住某条记录,又想阻止其他事务在该记录前面的间隙插入新记录,这时候就用到了Next-Key Locks
的锁,官方的类型名称为:LOCK_ORDINARY
,也可以简称为next-key
锁。
next-key锁
的本质就是一个Record Locks
和一个gap锁
的合体,它既能保护该条记录,又能阻止别的事务将新记录插入被保护记录前面的间隙。
Insert Intention Locks
一个事务在插入一条记录时需要判断插入位置是不是被别的事务加了gap锁
(next-key锁
也包含gap锁
),如果有的话,插入操作需要等待,直到持有gap锁
的事务释放锁。InnoDB规定事务在等待的时候也需要在内存中生成一个锁结构,表明有事务想在某个间隙中插入新记录,但是现在在等待。这种类型的锁命名为Insert Intention Locks
,官方的类型名称LOCK_INSERT_INTENTION
,也称为插入意向锁。
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> update account set balance = 55 where id >3 and id <=8;
-- 开启另外事务
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> insert into account (id,code,name,balance) values(6,'pp','zz',20);
查看锁情况
mysql> select * from performance_schema.data_locks\G;
*************************** 1. row ***************************
-- 省略表锁
*************************** 2. row ***************************
ENGINE: INNODB
ENGINE_LOCK_ID: 139645347952208:226:4:12:139645358792496
ENGINE_TRANSACTION_ID: 65493
THREAD_ID: 563275
EVENT_ID: 90
OBJECT_SCHEMA: fresh
OBJECT_NAME: account
PARTITION_NAME: NULL
SUBPARTITION_NAME: NULL
INDEX_NAME: PRIMARY
OBJECT_INSTANCE_BEGIN: 139645358792496
LOCK_TYPE: RECORD
LOCK_MODE: X,GAP,INSERT_INTENTION
LOCK_STATUS: WAITING
LOCK_DATA: 8
*************************** 3. row ***************************
- 省略表锁
*************************** 4. row ***************************
ENGINE: INNODB
ENGINE_LOCK_ID: 139645347953824:226:4:12:139645358804944
ENGINE_TRANSACTION_ID: 65492
THREAD_ID: 563249
EVENT_ID: 116
OBJECT_SCHEMA: fresh
OBJECT_NAME: account
PARTITION_NAME: NULL
SUBPARTITION_NAME: NULL
INDEX_NAME: PRIMARY
OBJECT_INSTANCE_BEGIN: 139645358804944
LOCK_TYPE: RECORD
LOCK_MODE: X
LOCK_STATUS: GRANTED
LOCK_DATA: 8
4 rows in set (0.00 sec)
从上面看出id=8
的记录添加了插入意向锁,并且锁的状态为等待。
InnoDB引擎锁机制是基于索引实现的记录锁定,当没有索引时,会导致所有记录被锁定(行锁升级为表锁这种说法不够严谨)
mysql> begin;
Query OK, 0 rows affected (0.00 sec)
mysql> update account set balance = 2 where balance = 1;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0
开启另外一个事务
mysql> update account set balance = 5 where id = 3; -- 阻塞
通过查看锁情况,发现每条记录都被加上了X锁
mysql> select * from performance_schema.data_locks\G;
*************************** 1. row ***************************
ENGINE: INNODB
ENGINE_LOCK_ID: 139645347952208:1287:139645358795600
ENGINE_TRANSACTION_ID: 65496
THREAD_ID: 563275
EVENT_ID: 94
OBJECT_SCHEMA: fresh
OBJECT_NAME: account
PARTITION_NAME: NULL
SUBPARTITION_NAME: NULL
INDEX_NAME: NULL
OBJECT_INSTANCE_BEGIN: 139645358795600
LOCK_TYPE: TABLE
LOCK_MODE: IX
LOCK_STATUS: GRANTED
LOCK_DATA: NULL
*************************** 2. row ***************************
ENGINE: INNODB
ENGINE_LOCK_ID: 139645347952208:226:4:13:139645358792496
ENGINE_TRANSACTION_ID: 65496
THREAD_ID: 563275
EVENT_ID: 94
OBJECT_SCHEMA: fresh
OBJECT_NAME: account
PARTITION_NAME: NULL
SUBPARTITION_NAME: NULL
INDEX_NAME: PRIMARY
OBJECT_INSTANCE_BEGIN: 139645358792496
LOCK_TYPE: RECORD
LOCK_MODE: X,REC_NOT_GAP
LOCK_STATUS: WAITING
LOCK_DATA: 3
*************************** 3. row ***************************
ENGINE: INNODB
ENGINE_LOCK_ID: 139645347953824:1287:139645358807936
ENGINE_TRANSACTION_ID: 65495
THREAD_ID: 563249
EVENT_ID: 120
OBJECT_SCHEMA: fresh
OBJECT_NAME: account
PARTITION_NAME: NULL
SUBPARTITION_NAME: NULL
INDEX_NAME: NULL
OBJECT_INSTANCE_BEGIN: 139645358807936
LOCK_TYPE: TABLE
LOCK_MODE: IX
LOCK_STATUS: GRANTED
LOCK_DATA: NULL
*************************** 4. row ***************************
ENGINE: INNODB
ENGINE_LOCK_ID: 139645347953824:226:4:1:139645358804944
ENGINE_TRANSACTION_ID: 65495
THREAD_ID: 563249
EVENT_ID: 120
OBJECT_SCHEMA: fresh
OBJECT_NAME: account
PARTITION_NAME: NULL
SUBPARTITION_NAME: NULL
INDEX_NAME: PRIMARY
OBJECT_INSTANCE_BEGIN: 139645358804944
LOCK_TYPE: RECORD
LOCK_MODE: X
LOCK_STATUS: GRANTED
LOCK_DATA: supremum pseudo-record
*************************** 5. row ***************************
ENGINE: INNODB
ENGINE_LOCK_ID: 139645347953824:226:4:7:139645358804944
ENGINE_TRANSACTION_ID: 65495
THREAD_ID: 563249
EVENT_ID: 120
OBJECT_SCHEMA: fresh
OBJECT_NAME: account
PARTITION_NAME: NULL
SUBPARTITION_NAME: NULL
INDEX_NAME: PRIMARY
OBJECT_INSTANCE_BEGIN: 139645358804944
LOCK_TYPE: RECORD
LOCK_MODE: X
LOCK_STATUS: GRANTED
LOCK_DATA: 1
*************************** 6. row ***************************
ENGINE: INNODB
ENGINE_LOCK_ID: 139645347953824:226:4:8:139645358804944
ENGINE_TRANSACTION_ID: 65495
THREAD_ID: 563249
EVENT_ID: 120
OBJECT_SCHEMA: fresh
OBJECT_NAME: account
PARTITION_NAME: NULL
SUBPARTITION_NAME: NULL
INDEX_NAME: PRIMARY
OBJECT_INSTANCE_BEGIN: 139645358804944
LOCK_TYPE: RECORD
LOCK_MODE: X
LOCK_STATUS: GRANTED
LOCK_DATA: 15
*************************** 7. row ***************************
ENGINE: INNODB
ENGINE_LOCK_ID: 139645347953824:226:4:10:139645358804944
ENGINE_TRANSACTION_ID: 65495
THREAD_ID: 563249
EVENT_ID: 120
OBJECT_SCHEMA: fresh
OBJECT_NAME: account
PARTITION_NAME: NULL
SUBPARTITION_NAME: NULL
INDEX_NAME: PRIMARY
OBJECT_INSTANCE_BEGIN: 139645358804944
LOCK_TYPE: RECORD
LOCK_MODE: X
LOCK_STATUS: GRANTED
LOCK_DATA: 20
*************************** 8. row ***************************
ENGINE: INNODB
ENGINE_LOCK_ID: 139645347953824:226:4:12:139645358804944
ENGINE_TRANSACTION_ID: 65495
THREAD_ID: 563249
EVENT_ID: 120
OBJECT_SCHEMA: fresh
OBJECT_NAME: account
PARTITION_NAME: NULL
SUBPARTITION_NAME: NULL
INDEX_NAME: PRIMARY
OBJECT_INSTANCE_BEGIN: 139645358804944
LOCK_TYPE: RECORD
LOCK_MODE: X
LOCK_STATUS: GRANTED
LOCK_DATA: 8
*************************** 9. row ***************************
ENGINE: INNODB
ENGINE_LOCK_ID: 139645347953824:226:4:13:139645358804944
ENGINE_TRANSACTION_ID: 65495
THREAD_ID: 563249
EVENT_ID: 120
OBJECT_SCHEMA: fresh
OBJECT_NAME: account
PARTITION_NAME: NULL
SUBPARTITION_NAME: NULL
INDEX_NAME: PRIMARY
OBJECT_INSTANCE_BEGIN: 139645358804944
LOCK_TYPE: RECORD
LOCK_MODE: X
LOCK_STATUS: GRANTED
LOCK_DATA: 3
9 rows in set (0.00 sec)