MySQL:锁的学习
1. 分类
1.1. 对锁的态度
- 乐观锁;
- 悲观锁;
1.2. 属性分类
- 排他锁;
- 共享锁;
1.3. 粒度级别分类
- 全局锁;
- 页级锁;
- 表级锁;
- 行级锁;
2. 对锁的态度
2.1. 乐观锁
乐观的认为别人不会去修改数据,没有锁,在确定修改时再重新查询一遍数据,如果数据被修改了就放弃修改数据。
一般是读的为主。如果频繁的写入,会导致较高的失败率。
乐观锁通过 CAS
或者版本机制来实现。
2.1.1. CAS
其核心思想是比较和交换,比较当前值和预期值是否一致,一致就更新,不一直就不更新,或者不断尝试更新。
很多编程语言为我们提供了原子操作,就是用来保证数据在并发修改下的安全的,利用的就是 CAS 原理。
这是 Go
语言的测试用例:
package main
import (
"fmt"
"sync"
"sync/atomic"
)
/**
* @Author: xxcheng
* @Email developer@xxcheng.cn
* @Date: 2024/1/24 14:06
*/
func main() {
var x int64
var y int64
wg := sync.WaitGroup{}
for i := 0; i < 4; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
for j := 0; j < 100000; j++ {
x++
atomic.AddInt64(&y, 1)
}
}(i)
}
wg.Wait()
fmt.Printf("x:[%d],y:[%d]\n", x, y)
}
x:[156663],y:[400000]
2.1.2. 版本一致
通过来比较当前版本和数据库中记录的版本是否一致来确定是否更新数据。如图所示:
思路语句:
update u_balance set blanace=13 where id=1 and version=1;
2.2. 悲观锁
悲观的认为别人都会去修改数据,所以每次操作数据的时候都要加锁,其他事务只能阻塞,直到当前事务提交之后。
一般是写入的多。
一个线程加锁,查询修改数据,未提交之前,有其他的线程事务查询和修改数据,但是此时阻塞住了,直到之前的线程提交了事务才会继续执行。
3. 属性分类
3.1. 排他锁
排他锁又叫作写锁,X锁。指定某一个表或者某一行记录添加上了排他锁之后,那么它就无法被其他线程的事务给读写了,会处于阻塞状态,直到事务提交。但是在实际中,为什么读操作不被影响,因为MySQL有 MVCC,读时没有锁。要想读时有锁,获取最新的状态,可以使用 select ... for update
,但是这个就是会处于阻塞状态了。
如何创建排他锁呢?分为显示和隐式。
在我们 insert
、delete
、update
增删改的时候都会隐式的创建排他锁。
在我们在未加锁的状态下 select ... for update
,也会创建排他锁。
在我们处理某些记录隐式或显示的创建了排他锁后,锁的级别可能是行级或可能是表级,这取决于在检索时是否使用到了索引或者主键,如果使用到了那么它就是行级的锁,没有使用到,它就是表级别的锁。
3.1.1. 证明
下面来证明显示和隐式的创建排他锁以及锁级别的升级。
先定义一个学生表和输入模拟数据:
create table if not exists student(
id int primary key auto_increment,
name varchar(32) not null,
age tinyint unsigned default 0 not null,
cc varchar(32) not null default 'cc',
index idx_name_age(name,age,cc)
);
insert into student (name,age)
values ('xxcheng',11),('jpc111',22),('www',23),('ccc',23);
3.1.1.1. 显示和隐式
我们明确一下如何判断是否成功创建了排他锁,那就是执行一遍 select ... for update
。如果阻塞了那么就说明有排他锁。
显示创建
创建两个事务,同时执行 select * from student for update;
,一个成功查询,一个阻塞一直到超时。
隐式创建
- 创建两个事务;
- A 事务先执行
select * from student for update;
,未阻塞说明当前无锁; - A 事务执行
commit
,再重新开启事务; - B 事务执行
update
操作; - A 事务重新执行一遍
select * from student for update;
,阻塞,说明有锁。
3.1.1.2. 行级锁升级表级
先明确如何判断当前是表级锁还是行级锁。通过查询不包括被修改记录所在行的 select...for update
语句阻塞来判断。前面提到了检索条件用到了索引或者主键,那么就是行锁,我们让索引失效就可以达到目的。
我们创建了一个 name
和 age
的联合索引,联合索引有最左匹配原则,我们调换顺序就可以完成测试,来查看是否达到预期。
下面我们用 id=1,name=xxcheng,age=11 记录来操作。
联合索引有效情况下
可以看到,在索引有效的情况下,当前 id 为 1 会阻塞,不想干的id 为 4 的记录不会阻塞,可以证明是行锁。
联合索引无效情况下
可以看到,索引失效的情况下,不相干的记录也被阻塞了,说明是表锁了。
3.2. 共享锁
又称 S 锁。
select...lock in share mode;
select...for share;#8.0版本新增
4. 颗粒模式分类
4.1. 全局锁
所有线程的事务都可以读,执行了全局锁的线程事务不可DDL、DML,其他线程事务DDL、DML 阻塞。
# 加锁
flush tables with read lock;
# 解锁
unlock tables;
4.2. 表级锁
4.2.1. 分类
- 表锁;
- 元数据锁;
- 意向锁;
- AUTO-INCR 锁;
4.2.2. 表锁
分为写锁(独占锁、排他锁)和读锁(共享锁)。
命令:
lock tables table_name read;
lock tables table_name write;
- 读锁:所有线程都可读不可写;
- 写锁:只能允许当前线程读写,其他线程不允许读写,普通读也不行。
4.2.3. 元数据锁
用于在数据表操作时数据的安全。是隐式的自动调用。
在 CURD 时,包括普通的 SELECT ,是读锁,无法修改字段,会阻塞;
在变更表结构时,会变成写锁,CURD 操作会阻塞;
多个事务同时处理,就会有阻塞的可能。
- T1 事务更新表内数据,创建了 MDL 读锁。如何事务一直未提交;
- T2 事务尝试修改表结构,但是有读锁的存在,就一直阻塞了,同时形成一条 MDL 的队列;
- T3 事务尝试查询数据,申请创建 MDL 读锁,但是前面队列中有读锁,会一直处于阻塞状态;
- T1 不提交,那么后面 CURD 事务就都阻塞了
4.2.4. 意向锁
用于快速判断表内是否有行级锁,防止表级锁和行级锁的冲突。
意向共享锁
select... lock in share mode;
意向排他锁
select...for update;
4.2.5. AUTO_INCR 锁
4.3. 行级锁
4.3.1. 分类
- 记录锁 record lock;
- 间隙锁 gap lock;
- 临键锁 next-key lock;
- 插入意向锁;
4.3.2. 记录锁
锁住单独某一行记录,有 X 锁和 S 锁之分。
4.3.3. 间隙锁
锁住一个区间范围,不让插入记录,防止幻读。区间范围的两端是开区间。
间隙锁也分读写,但是他们的效果是一样的,都是不让在其范围内添加记录。
多个间隙锁可能会导致死锁的问题:
4.3.4. 临键锁
记录锁 + 间隙锁的合集。既锁住当前记录,又防止在当前记录前插入新的记录。
4.3.5. 插入意向锁
在执行 INSERT 操作之前的一直间隙锁,它不是意向锁。
5. 死锁问题
5.1. 产生条件
- 两个及以上的事务;
- 每个事务都有锁,并且要申请新的锁;
- 申请新的锁的位置已经被其他事务所占用,只能阻塞等待;
5.2. 解决办法
5.2.1 改变获取锁的顺序
5.2.2 事务拆分
参考链接
本作品采用 知识共享署名-相同方式共享 4.0 国际许可协议 进行许可。