本文介绍数据库的乐观锁、悲观锁、事务、MVCC
乐观锁
上锁流程
- 读取被操作对象,记录某个特征信息
- 对对象进行业务逻辑处理
- 数据回写前,检查特征信息是否有变化
- 如果一致,回写;如果不一致,重试上述流程
性质
乐观锁的本质是一种读写策略,没有真正的在被操作对象上加锁
对于操作方,有可能失败重试,反复读写,牺牲了操作方的性能(利他策略),但提升了被操作对象的并发度
应用场景
读多,极少修改,操作方重试概率低的场景
悲观锁
操作方对被操作对象加锁,等锁释放后其他操作方才能加锁(利己策略)
分类
S锁
Shared Lock,共享锁,读锁
A加了S锁,其他人可以加S锁,不可加X锁
如何加S锁(哪些操作会加S锁):select * from user where ... lock in share mode;
如何释放S锁:事务结束
U锁(SQLServer)
更新锁,加了U锁,不能加U锁和X锁,可以加S锁(相当于加了S锁并预定了X锁)
X锁
Exclusive Lock,排他锁,写锁
加了X锁,其他人不能加任何锁
如何加X锁(哪些操作会加X锁):select * from user where ... for update;
,insert
,update
,delete
如何释放X锁:事务结束
加锁原则
最小知道原则
悲观锁的作用范围
- 行锁
- 页锁
- 表锁
锁升级
在RR隔离级别中,行锁是加在索引上,如果查询条件不走索引,会加表锁(升级)
实验:锁升级
1 | # clinet 1 |
死锁
死锁的条件
- 互斥:一把钥匙,只能一个人拿
- 不剥夺:一个人拿了钥匙,别人不能抢
- 请求和保持:有了一个资源,不放手,还要请求其他资源
- 循环等待:与另一个人的请求资源形成环
死锁的解决方案
打破任意一个条件即可
- 打破互斥条件:不用悲观锁,改为乐观锁
- 打破不剥夺:A获取资源B,还要获取资源C,如果申请失败,A释放资源B
- 打破请求和保持:保留和请求只留一个,如果需要持有两个资源,已持有一个,需放手后才能持有另一个
- 打破循环等待:按顺序抢占锁
如何定位死锁
- 数据库死锁日志:
SHOW ENGINE INNODB STATUS;
- 线程dump文件:
jmap -dump:format=b,file={filename} {pid}
- 进程堆栈信息:
jstack {pid}
本文只介绍数据库层面,其他层面捎带
死锁实验
有对象C,有一个操作:先对C加S锁,再对C加X锁
A对C加了S锁,B对C加了S锁,A对C加X锁失败,B对C加X锁也失败,A和B都不会主动释放C的S锁,产生了死锁
什么场景会先加S后加X呢?
防止产生S与X锁死锁的解决方案
- 获取X锁失败后,主动释放S锁,重试
- 使用U锁,允许其他人加S锁,但不能加U和X
- 直接加X锁
实验1:S锁与X锁的阻塞情况
1 | ## S 锁不能加X锁的情况 |
实验2:数据库层面制造死锁并定位死锁
1 | # client 1 |
mysql如何自动处理死锁
- 超时等待
- 死锁检测:谁触发死锁谁回滚当前事务,其他事务继续执行
死锁检测的原理
事务为顶点,锁为边(需求方指向持有方) 事务A持有L2锁,A需要L1锁,故有一条边由A指向B
事务B持有L1锁,B需要L2锁,故有一条边由B指向A
事务
事务正确执行的四个要素:ACID
A:atomicity 原子性,要么全做,要么全不做
C:consistency 一致性,事务前后的数据是一致的
I:isolation 隔离性,多个并发事务同时读写时,防止多个事务并发执行时由于交叉执行导致数据不一致
D:durability 持久性 事务结束后,对数据的修改是永久的
事务的开始与结束
开始:以第一个DML(Data Manipulation Language,如select, delete, update, insert)语句的执行开始
结束:commit, rollback
事务的隔离
事务隔离解决的问题
ACID中的ACD对并发事务和单一事务是没有区别的,隔离性是防止并发事务执行时产生的影响,如果没有隔离性,并发事务会带来的问题有:
- 脏写:写了被其他事务覆盖,没生效
- 脏读:读到未提交的中间结果(中间结果可以回滚,不认为是正确的数据)
- 不可重复读:在一个事务内多次读结果不一致
- 幻读:一次事务内读到新数据
所以,需要解决以上问题
事务的隔离级别
隔离级别 | 脏读 | 不可重复读 | 幻读 |
---|---|---|---|
读未提交(Read uncommitted) | √ | √ | √ |
读提交(read committed) | × | √ | √ |
可重复读(repeatable read) | × | × | √ |
串行化(Serializable) | × | × | × |
MySQL 语句的默认事务隔离级别:rr
可重复读,通过SELECT @@transaction_isolation;
查看
修改隔离级别:SET SESSION transaction_isolation = 'READ-UNCOMMITTED';
不同隔离级别通过锁的实现思路
隔离级别 | 解决的问题 | 实现思路 |
---|---|---|
RC | 脏读 | 写时加X锁 |
RR | 不可重复读 | 读时加S锁 |
Serializable | 幻读 | 事务之间串行执行 |
实验:不同隔离级别下的问题复现
脏读
1
2
3
4
5
6
7
8
9
10
11
12
13# clinet 2
begin;
update actor set first_name = 'Jans' where actor_id = 1;
# client 1
# client1
SET SESSION transaction_isolation = 'READ-UNCOMMITTED';
begin;
select * from actor where actor_id = 1;
# client 2
rollback;
# client 1
select * from actor where actor_id = 1;
commit;不可重复读
1
2
3
4
5
6
7
8
9# client1
SET SESSION transaction_isolation = 'READ-COMMITTED';
begin;
select * from actor where actor_id = 1;
# clinet 2
update actor set first_name = 'Jans' where actor_id = 1;
# client 1
select * from actor where actor_id = 1;
commit;
多版本并发控制 MVCC
Multi-Version Concurrency Control
在上小节中,通过S锁或X锁解决了一些并发事务的问题,但是这种方式存在以下问题:
- 并发读写数据库时,读操作会阻塞写操作
- 加锁带来的资源消耗
如何解决上面的问题,也能保证事务的正确性呢
思路设想
我的实现思路:为每个事务建立一个副本,不加锁的操作(select)从副本中读数据,涉及到加锁操作在原表中通过悲观锁操作
难点:
- 副本的形式是什么,是拷贝完整的表,还是一部分?
- 创建副本时应该如何控制并发问题?
- 副本太多可能导致磁盘不足怎么办?
MySQL的实现思路:在有变更的操作时创建副本,事务找到它可见的最新副本,用于不加锁操作,涉及加锁操作,在原表中通过悲观锁操作
难点:副本形式,如何找到可见的最新副本
实现原理
MySQL的数据引擎对MVCC的实现原理
依赖于记录中的三个隐藏字段(row_id,trx_id, rollback_ptr),undo log,read view
trx_id:该记录最新修改的事务号
rollback_ptr:上一个版本指针
undo log:回滚日志
read view:可见性
如上图,事务5来读取某一行数据,如那行数据的row_id=24,记录中trx_id > 5,对事务5不可见,通过rollback_ptr在undo log中找上个版本的记录,发现trx_id > 5,再找上个版本的记录,发现trx_id < 5,则该记录是事务5可见的最新版本,读数据
使用MVCC实现事务隔离级别,该怎么做
当前读:从数据表中,不同语句根据隔离级别,使用悲观锁处理冲突,如select ... in share mode
,select ... for update
快照读:使用MVCC,从表或undo
log中读数据,不需要使用悲观锁,如select ...
RC和RR都可以通过快照读来实现,解决对应的问题
MySQL 8.x 是通过MVCC实现的RC和RR,不是通过悲观锁
处理事务的注意事项
- 查询条件通过索引,避免无索引行升级为表锁
- 加锁时,缩小锁的范围,避免间隙锁
- 尽量减少锁定资源量和时间,将加锁操作放到最后执行