共享的资源
。为 保证数据的一致性
,需要对 并发操作进行控制
,因此产生了锁。同时 锁机制也为实现 MySQL 的各个隔离级别提供了保证
。锁冲突也是影响数据库 并发访问性
能的一个重要因素。所以锁对数据库非常重要,且复杂。并发事务访问同一记录的情况,大致分为:读-读、写-写、读-写。
并发问题的解决方案:
① 读操作利用多版本并发控制
(MVCC
),写操作进行加锁
。
采用 MVCC
方式,读-写
操作彼此并不冲突
,性能更高
。
MVCC
:就是生成一个ReadView
,通过ReadView找到符合条件的记录版本(历史版本由 undo 日志
构建)。查询语句只能读
到在生成ReadView 之前 已提交事务所做的更改
,在生成 ReadView 之前未提交的事务或者之后才开启的事务所做的更改是看不到的。而写操作
是针对最新版本的记录
,读记录的历史版本和改动记录的最新版本本身并不冲突。所以,采用MVCC时,读-写操作并不冲突
。
普通的 SELECT 语句在 READ COMMITTED 和 REPEATABLE READ 隔离级别下会使用到 MVCC 读取记录。
- 在
READ COMMITTED
隔离级别下,一个事务在执行过程中,每次执行 select 操作时都会生成一个 ReadView。ReadView 保证了事务不可读取到未提交的事务所做的修改
,即避免了脏读现象。- 在
REPEATABLE READ
隔离级别下,一个事务在执行过程中,只有**第一次执行 select 操作
** 才会生成一个 ReadView,之后的 select 操作都是**复用
**这个 ReadView ,即避免了不可重复读和幻读的问题。
② 读、写操作都采用加锁
的方式。
加锁
方式,读-写
操作彼此需要排队执行
,影响性能。 一般情况下都是采用 MVCC来解决读-写操作并发执行的问题,但是在某些特殊业务情况下(比如银行在存取款业务中),必须采用加锁的方式执行。
从数据操作的类型划分:读锁、写锁。
数据库中并发事务的 读-读并不会引发问题。在使用加锁
的方式,解决并发事务的读-写
问题时,MySQL 实现了一个由两种类型的锁组成的锁系统来解决,即S锁和X锁。
读锁(也称共享锁 或 S锁)
:针对同一份数据,多个事务的读操作可以同时进行,而不会相互影响,相互不阻塞。写锁(也称排它锁 或 X 锁)
:当前写操作没有完成前,它会阻断其他写锁和读锁。可以确保在给定的时间里,只有一个事务能执行写入,并防止其他用户读取正在写入的同一资源。注意:对于 InnoDB 存储引擎而言,读锁和写锁可以加在表上,也可以加在行上
。
S 锁、X 锁彼此的兼容性如下:
锁类型 | X 锁(排它锁/写锁) | S 锁(共享锁/读锁) |
---|---|---|
X 锁(排它锁/写锁) | 互斥 | 互斥 |
S 锁(共享锁/读锁) | 互斥 | 兼容 |
从数据操作的粒度划分:表级锁、页级锁、行锁。
表锁分为:表级别的S锁和X锁、意向锁、自增锁、元数据锁。
表级别的S 锁、X 锁:
在对某个表执行增删查改
语句时,InnoDB 是不会对该表添加表级别的S锁
或X锁
。当对表执行 ALTER TABLE
、DROP TABLE
这类 DDL
语句时,其他事务对该表并发执行增删查改语句时,会发生阻塞
。同理,一个事务对某表执行增删查改
语句时,在其他会话中对该表执行 DDL
语句时,也会发生阻塞
。该过程是通过在数据库的 server层使用 元数据锁
结构来实现。
通常不会使用 InnoDB 提供的表级别的 S锁和 X锁。特殊情况除外,比如在 崩溃恢复
等过程中使用。
InnoDB
支持 多粒度锁,它允许 行级锁
和 表级锁
共存,而 意向锁
就是其中的一种 表锁
。
意向锁是由存储引擎自己维护
的,用户无法手动操作意向锁,在为数据行加共享/排他锁之前,InnoDB 会先获取该数据行 所在数据表的对应意向锁
。如果我们给某一行数据加上了排它锁(X锁),数据库会自动给更大一级的空间(比如数据页或数据表)加上意向锁,告诉其他人这个数据页或数据表已经有人上过排他锁了。意向锁分为两种:
意向共享锁(IS):事务有意向对表种的某些行加共享锁(S锁)。
如果事务要获取某些行的 S 锁,必须先获得表的 IS 锁
。意向排它锁(IX):事务有意向对表中的某些行加排它锁(X锁)。
如果事务要获取某些行的 X 锁,必须先获得表的 IX 锁
。IS 锁、IX 锁彼此的兼容性如下:
锁类型 | 意向共享锁(IS) | 意向排他锁(IX) |
---|---|---|
意向共享锁(IS) | 兼容 | 兼容 |
意向排他锁(IX) | 兼容 | 兼容 |
IS 锁、S锁、IX 锁、X锁的兼容性(这里的S锁和X锁是表锁级别的,需要明确 意向锁是不会与行级的S锁、X锁互斥的)如下:
锁类型 | 意向共享锁(IS) | 意向排他锁(IX) |
---|---|---|
共享锁(S) | 兼容 | 互斥 |
排他锁(X) | 互斥 | 互斥 |
意向锁总结:
不与行级锁冲突的表级锁
,且意向锁之间互不排斥。某个事务正在某些行持有了锁
,或该事务准备去 持有锁
。行锁和表锁共存
,且满足事务隔离性的要求
。
MySQL表级锁的自增锁(Auto-increment Lock),是为了确保自增主键的唯一性
和连续性
而使用的一种锁机制。当一个表具有自增主键(AUTO_INCREMENT)时,每当插入新行时,主键的值会自动递增。它通过在插入操作时获取表级锁来防止并发事务之间的冲突和重复
,确保每个事务获得唯一的自增值。在高并发场景下,表级别的自增锁可能会成为性能瓶颈
。
元数据锁(MDL锁)属于表锁范畴,当对一个表做增删查改操作的时候,加 MDL 读锁
;当要对表做结构变更操作的时候,加 MDL 写锁
。读锁之间不互斥,读写锁之间、写锁之间是互斥的,用来保证变更表结构操作的安全性,解决了DML
和DDL
操作之间的一致性问题。不需要显式使用
,在访问一个表的时候会被自动加上。
DDL(Data Definition Languages、数据定义语言),这些语句定义了不同的数据库、表、视图、索引等数据库对象,还可以用来创建、删除、修改数据库和数据表的结构。主要的语句关键字包括 CREATE 、 DROP 、 ALTER 等。
DML(Data Manipulation Language、数据操作语言),用于添加、删除、更新和查询数据库记录,并检查数据完整性。主要的语句关键字包括 INSERT 、 DELETE 、 UPDATE 、 SELECT 等。
DCL(Data Control Language、数据控制语言),用于定义数据库、表、字段、用户的访问权限和安全级别。主要的语句关键字包括 GRANT 、 REVOKE 、 COMMIT 、 ROLLBACK 、 SAVEPOINT 等。
MySQL 服务器层并没有实现行锁机制
,行锁只是在存储引擎层实现
,以下行锁我们都讨论 InnoDB 下的行锁。InnoDB 和 MyISAM 最大的不同点:① 支持事务 ② 采用了行级锁。
行锁优点 | 行锁缺点 |
---|---|
锁定粒度小 ,发生锁冲突概率低 ,可实现的并发度高 。 |
锁的开销比较大 ,加锁比较慢 ,易出现死锁 情况。 |
MySQL的行级别锁有 记录锁(Record Locks)、间隙锁(Gap Locks)、临键锁(Next-Key Locks)、插入意向锁(Insert Intention Locks)。
记录锁(Record Lock),用于锁定数据库表中的特定记录
。当事务需要对某条记录进行修改或访问时,可以使用记录锁来确保数据的一致性和并发控制
。记录锁的主要作用是防止多个事务同时对同一条记录进行修改,从而避免数据冲突和不一致的结果。当一个事务获取了一条记录的锁时,其他事务无法对该记录进行修改,直到锁被释放。
记录锁分为S型记录锁
和X型记录锁
:
当一个事务获取了一条记录的 S 型记录锁后,其他事务可以继续获取该记录的 S 型记录锁,但不能继续获取该记录的 X 型记录锁。
当一个事务获取了一条记录的 X 型记录锁后,其他事务既不可以获取该记录的 S 型记录锁,也不可以获取该记录的 X 型记录锁。
间隙锁是在 innodb的 可重复读级别才会生效,且 Next-Key Locks 实际上是由间隙锁加行锁实现的
。
间隙锁(Gap Locks)
是为了 防止
并发事务中的 幻读
问题,并确保数据的一致性。许多公司的配置为:读已提交隔离级别,加上 binlog_format=row,业务上不需要可重复读的保证,就可以考虑 读已提交下操作数据的锁范围更小(没有间隙锁)。
间隙锁的工作机制:
间隙锁不仅锁定实际存在的记录,还会锁定索引中不存在的间隙
。这意味着如果一个事务执行范围查询,间隙锁将会锁定查询范围内的索引条目,以及范围边缘的间隙。
Next-Key Locks 是在InnoDB 存储引擎下,事务级别在 可重复读 的情况下使用的数据库锁,InnoDB 默认的锁就是 Next-Key Locks
. Next-Key 锁本质就**是一个记录锁和一个 Gap 锁的合体
**,它既能保护该条数据记录,又能阻止别的事务将新记录插入被保护记录前边的间隙。
Next-Key locks 的加锁规则,两原则、两优化、一Bug :
next-key lock 是前开后闭区间
。
示例表 tb_table 结构及数据:
id(主键) | user_name | tel_phone |
---|---|---|
1 | 张三 | 13166666667 |
5 | 李四 | 13166666668 |
10 | 王五 | 13166666669 |
# 以下示例在 innoDB 存储引擎 可重复读 的隔离级别前提下展开
## 情况一: Session 会话1
begin;
#这一步会锁定id区间为 (1,5)之间的数据,如果其他 session会话2插入id为2,3,4的数据会阻塞。
select * from tb_table where id = 3 FOR update;
commit;
-- --------------------
## 情况二: Session 会话1
begin;
# 这一步会锁定id区间为 (1,10]之间的数据,
# 如果其他 session会话2插入id区间为(2,3,4]和[6,10)的数据会阻塞,更新id=5或id=10的数据也会阻塞。
select * from zim_user where id >3 and id <7 FOR update;
commit;
-- --------------------
## 情况三: Session 会话1
begin;
# 这一步会锁定id区间为 (1,5]之间的数据,
select * from zim_user where id >3 and id <5 FOR update;
commit;
-- --------------------
## 情况四: Session 会话1
begin;
# 这一步会锁定id区间为 (1,10]之间的数据,
select * from zim_user where id >3 and id <=5 FOR update;
commit;
-- --------------------
## 情况四: Session 会话1
begin;
# 这一步会锁定id = 5 的数据,就是单纯的行记录锁。
select * from zim_user where id =5 FOR update;
commit;
一个事务在插入
一条记录时,需要判断一下插入位置是不是被其他事务加了 gap 锁
(next-key 锁
也包含了 gap 锁
),如果有的话,插入操作需要等待,直到 gap 锁的那个事务提交。但是 InnoDB 规定事务在等待的时候,也需要在内存中生成一个锁结构
,表明有事务想在某个间隙
中插入
新记录,但是现在在等待。InnoDB 就把这种类型的锁称为 插入意向锁
。插入意向锁是一种 Gap 锁,在 insert 操作时产生
。
插入意向锁之间互不排斥
,即使多个事务在同一区间插入多条记录,只要记录本身(主键、唯一索引)不冲突,那么事务之间就不会出现冲突等待。示例表 tb_table 结构及数据:
id(主键) | user_name | tel_phone |
---|---|---|
1 | 张三 | 13166666667 |
5 | 李四 | 13166666668 |
10 | 王五 | 13166666669 |
# 事务T1 给 id = 5 的数据加一个gap 锁。
begin;
# 这一步会锁定id区间为 (1,5]之间的数据,
select * from zim_user where id >3 and id <5 FOR update;
# 这里不 执行 commit; 保证事务 T1 一直持有锁
-------------------------------------------
# 事务 T2 插入一条数据
begin;
# 由于 T1 的间隙锁,导致 这一步阻塞,同时这里添加一个插入意向锁
insert into tb_table (id,user_name,tel_phone) values (2,'tom','13166666662');
-------------------------------------------
# 事务 T3 插入一条数据
begin;
# 由于 T1 的间隙锁,导致 这一步阻塞,同时这里添加一个插入意向锁
insert into tb_table (id,user_name,tel_phone) values (3,'jey','13166666663');
-------------------------------------------
## 事务 T1 提交后,事务T2 和事务T3 的插入并不互斥,插入意向锁之间互不排斥。
页锁就是在 页的粒度 上进行的锁,锁定的数据资源比行锁要多,因为一个页可以有多个行记录。页锁的开销介于表锁和行锁之间,会出现死锁
。锁粒度介于表锁和行锁之间,并发度一般
。
由于锁会占用内存空间,锁空间的大小是有限的
,所以每个层级的锁数量是有限制的。当某个层级的锁数量超过了这个层级的阈值时,就会进行**锁升级
**。锁升级就是用更大粒度的锁,代替多个更小粒度的锁,比如 InnoDB 中行锁升级为表锁,这个做可以降低占用的所空间,但是数据的并发度会降低。
从对待数据并发思维方式的角度来看,可将锁分为乐观锁和悲观锁。这里说的乐观锁
和悲观锁
并不是锁
,而是锁的设计思想
。
悲观锁
对数据被其他事务的修改,持悲观的保守态度,会通过数据库自身的锁机制来实现
,从而保证数据操作的排他性。
悲观锁每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞
直到它拿到锁(共享资源每次只给一个线程使用,其他线程阻塞,用完后再把资源转让给其他线程
)。比如行锁,表锁,读锁,写锁等,都是在操作之前先上锁,当其他线程想要访问数据时,都需要阻塞挂起。Java 中 synchronized
和 ReentrantLock
等独占锁就是悲观锁思想的实现。
select … for update 是 MySQL 中的悲观锁。
select … for update 语句执行过程中所有扫描的行都会被锁上
,因此**在 MySQL 中用悲观锁必须确定使用了索引,而不是全表扫描,否则将会把整个表锁住
**。
-- 对于并发量不高的场景,悲观锁解决 超卖示例(1、2、3步在同一个事务中):
# 第1步:查出商品库存
select quantity from tb_item where id = 1099 for update;
# 第2步:如果库存大于0,则根据商品信息产生订单
insert into tb_orders(item_id) values(1099);
# 第3步:修改商品的库存,num 表示购买数量
update tb_items set quantity = quantity-num where id = 1099;
悲观锁
大多数情况下依靠数据库的锁机制来实现,以保证程序的并发访问性,但是这样对数据库性能开销影响很大,特别是对 长事务
而言,这样的开销往往无法承受
,就需要乐观锁来解决。
乐观锁对同一数据的并发操作,不用每次都对数据上锁,但是在更新的时候会判断一下,在此期间别人有没有去更新这个数据。乐观锁不采用数据库自身的锁机制
,而**是通过在程序上采用 版本号 或者 CAS 机制实现
**。乐观锁 适用于多读
的应用类型,可以提高吞吐量。在 Java 中 java.util.concurrent.atomic
包下的原子变量类,就是使用了乐观锁的一种实现方式:CAS 实现的。
① 乐观锁的版本号机制
在表中设计一个
版本字段 version
,第一个读的时候获取 version 的值version_value
,然后对数据进行更新或者删除时,执行update … set version = version+1 where version = version_value
,此时如果已经有事务对这条数据进行了更改,修改就不会成功。
② 乐观锁的时间戳机制
-- 对于并发量不高的场景,乐观锁解决超卖问题 示例(1、2、3步在同一个事务中):
# 第1步:查出商品库存
select quantity from tb_item where id = 1099;
# 第2步:如果库存大于0,则根据商品信息产生订单
insert into tb_orders(item_id) values(1099);
# 第3步:修改商品的库存,num 表示购买数量
update tb_items set quantity = quantity-num,version = version+1 where id = 1099 and version = #{version};
### 注意:以上方案存在两个问题:
### - ① 如果数据表是读写分离的表,当 master表中写入的数据没有及时同步到slave表中时,会造成更新一直失 败,此时需要强制读取 master表中的数据(即将select 语句放到事务中即可)。
### - ② 如果高并发场景下,每次修改库存只有一个事务能更新成功,在业务感知上就有大量的失败操作。
### 优化方案(1、2、3步在同一个事务中):
# 第1步:查出商品库存
select quantity from tb_item where id = 1099;
# 第2步:如果库存大于0,则根据商品信息产生订单
insert into tb_orders(item_id) values(1099);
# 第3步:修改商品的库存,num 表示购买数量
update tb_items set quantity = quantity-num where id = 1099 and quantity-num > 0;
乐观锁和悲观锁的适用场景:
乐观锁适合 读操作多的场景,相对来说写的操作比较少
。它的**优点在于 程序实现,不存在死锁问题
**。乐观锁阻止不了除了程序以外的数据库操作,所以适用场景也会相对乐观。悲观锁适合写操作多的场景,因为写的操作具体排他性
。采用 悲观锁
的方式, 可以在数据库层面阻止其他事务对该数据的操作权限,防止 读-写 和写-写 冲突
。
从加锁方式划分,可以分为 隐式锁、显式锁。
隐式锁并不是在SQL语句中明确指定的,而是由InnoDB引擎根据特定操作自动添加的。
一个事务对新插入的记录可以不显式的加锁(生成一个锁结构),但是由于事务id 的存在,相当于加了一个 隐式锁
。别的事务在对这条记录加 S 锁
或 X 锁
时,由于 隐式锁
的存在,会先帮助当前事务生成一个锁结构,然后自己再生成一个锁结构后进入等待状态。隐式锁是一种 延迟加锁 的机制,从而来减少加锁的数量
。隐式锁在实际内存对象中,并不含有这个锁信息,只有当产生锁等待时,隐式锁转化为显式锁。
隐式锁的逻辑过程如下:
- ① InnoDB 的每条记录中,都有一个隐含的
trx_id
字段,这个字段存在于聚簇索引的 B+Tree 中。- ② 在操作一条记录前,首先根据记录中的 trx_id 检查该事务是否是活动的事务(未提交或未回滚),如果是活动的事务,首先将
隐式锁转换为显式锁
(就是为该事务添加一个锁)。- ③ 检查是否有锁冲突,如果有冲突,创建锁,并设置为 waiting 状态。如果没有冲突不加锁,跳到第 ⑤ 步。
- ④ 等待加锁成功,被唤醒,或者超时。
- ⑤ 写数据,并将自己的 trx_id 写入 trx_id 字段。
通过特定的语句进行加锁,我们一般称之为显式锁,例如:
# 显式加共享锁:
select … lock in share mode;
# 显式加排它锁
select … for update;
全局锁就是对 整个数据库实例
加锁,当需要对整个数据库处于 只读状态 的时候,可以使用该命令,之后其他线程的以下语句会被阻塞:数据更新语句(数据的增删改)、数据定义语句(包括建表、修改表结构等)和更新类事务的提交语句。全局锁的典型使用场景
是:做全库逻辑备份
。
# 全局锁命令:
Flush tables with read lock;
死锁:两个及以上事务,都持有对方需要的锁,并且在等待对方释放,并且双方都不会释放自己的锁。示例:
步骤 | 事务1 | 事务2 |
---|---|---|
1 | start transaction; update account set money =50 where id =2; |
start transaction; |
2 | update account set money = 10 where id =4; | |
3 | update account set money = 20 where id =4; | |
4 | update account set money =60 where id =2; |
产生死锁的必要条件:
① 两个或者两个以上的事务,每个事务都已经持有锁,并且申请新的锁。
② 锁资源同时只能被同一个事务持有或者不兼容。
③ 事务之间因为持有锁和申请锁导致彼此循环等待。
死锁的关键在于:两个及以上的 Session会话加锁的顺序不一致。
如何处理死锁:
① 等待,直到超时(innodb_lock_wait_timeout=50s).
回滚
,另外事务继续进行。InnoDB中,参数 innodb_lock_wait_timeout
用来设置超时时间。② 使用 InnoDB 的 wait-for graph 算法来主动进行死锁检测,从而处理死锁。
回滚undo量最小的事务
,让其他事务继续执行(innodb_deadlock_detect=on
表示开启这个逻辑)。
如何避免死锁:
- ① 合理设计索引,使业务 SQL 尽可能通过索引定位更少的行,减少锁竞争。
- ② 调整业务逻辑 SQL 执行顺序,避免 update/delete 长时间持有锁的 SQL 在事务前面。
- ③ 避免大事务,尽量将大事务拆成多个小事务来处理,小事务缩短锁定资源的时间,发生锁冲突的几率也更小。
- ④ 在并发比较高的系统中,不要显式加锁,特别是在事务里显式加锁。(如 select … fro update 语句,如果在事务里运行了 start transaction 或设置了 autocommit = 0,那么就会锁定所查找到的记录)
- ⑤ 降低隔离级别。如果业务允许,将隔离级别降低也可避免死锁。比如将隔离级别从 RR 调整到 RC,可以避免很多因为 gap 锁造成的死锁。
MySQL 锁的监控,一般通过检查 innodb_row_lock
等状态变量来分析系统上的行锁的争夺情况。
show status like 'innodb_row_lock%';
Innodb_row_lock_time
: 从系统启动到现在锁定总时间长度
;(等待总时长
)。Innodb_row_lock_time_avg
: 每次等待所花平均时间
;(等待平均时长
)。Innodb_row_lock_waits
: 系统启动后到现在总共等待的次数
;(等待总次数
)。
MySQL 把事务和锁信息记录在 information_schema
库中,涉及到的三张表分别是 INNODB_LOCKS
、INNODB_LOCK_WAITS
、INNODB_TRX
.
MySQL8.0 版本删除了
information_schema.INNODB_LOCKS
,添加了performance_schema.data_locks
查看事务的锁情况,其可以看到阻塞该事务的锁,也可看到该事务所持有的锁。MySQL8.0 版本
information_schema.INNODB_LOCK_WAITS
被performance_schema.data_lock_waits
所取代。
.