0%

数据库锁与事务

本文介绍数据库的乐观锁、悲观锁、事务、MVCC

乐观锁

上锁流程

  1. 读取被操作对象,记录某个特征信息
  2. 对对象进行业务逻辑处理
  3. 数据回写前,检查特征信息是否有变化
  4. 如果一致,回写;如果不一致,重试上述流程

性质

乐观锁的本质是一种读写策略,没有真正的在被操作对象上加锁

对于操作方,有可能失败重试,反复读写,牺牲了操作方的性能(利他策略),但提升了被操作对象的并发度

应用场景

读多,极少修改,操作方重试概率低的场景

悲观锁

操作方对被操作对象加锁,等锁释放后其他操作方才能加锁(利己策略)

分类

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;insertupdatedelete

如何释放X锁:事务结束

加锁原则

最小知道原则

悲观锁的作用范围

  1. 行锁
  2. 页锁
  3. 表锁

锁升级

在RR隔离级别中,行锁是加在索引上,如果查询条件不走索引,会加表锁(升级)

实验:锁升级

1
2
3
4
5
6
7
# clinet 1
begin;
select * from actor where first_name = 1 for update;
# client 2
update actor set first_name = '2' where id = 1;
# client 1
commit;

死锁

死锁的条件

  1. 互斥:一把钥匙,只能一个人拿
  2. 不剥夺:一个人拿了钥匙,别人不能抢
  3. 请求和保持:有了一个资源,不放手,还要请求其他资源
  4. 循环等待:与另一个人的请求资源形成环

死锁的解决方案

打破任意一个条件即可

  1. 打破互斥条件:不用悲观锁,改为乐观锁
  2. 打破不剥夺:A获取资源B,还要获取资源C,如果申请失败,A释放资源B
  3. 打破请求和保持:保留和请求只留一个,如果需要持有两个资源,已持有一个,需放手后才能持有另一个
  4. 打破循环等待:按顺序抢占锁

如何定位死锁

  1. 数据库死锁日志:SHOW ENGINE INNODB STATUS;
  2. 线程dump文件:jmap -dump:format=b,file={filename} {pid}
  3. 进程堆栈信息: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锁死锁的解决方案
  1. 获取X锁失败后,主动释放S锁,重试
  2. 使用U锁,允许其他人加S锁,但不能加U和X
  3. 直接加X锁
实验1:S锁与X锁的阻塞情况
1
2
3
4
5
6
7
8
9
10
11
12
## S 锁不能加X锁的情况
# client 1
begin;
select * from actor where actor_id = 1 lock in share mode;
# client 2
select * from actor where actor_id = 1 for update;
# client 2 pending...
# client 1
commit;
# client 2 running or wait timeout exceeded
## X锁不能加S锁的情况
# 同理
实验2:数据库层面制造死锁并定位死锁
1
2
3
4
5
6
7
8
9
10
11
# client 1
begin;
select * from actor where actor_id = 1 lock in share mode;
# client 2
begin;
select * from actor where actor_id = 1 lock in share mode;
# client 1
select * from actor where actor_id = 1 for update;
# client 2
select * from actor where actor_id = 1 for update;
SHOW ENGINE INNODB STATUS;

mysql如何自动处理死锁

  1. 超时等待
  2. 死锁检测:谁触发死锁谁回滚当前事务,其他事务继续执行
死锁检测的原理

事务为顶点,锁为边(需求方指向持有方) 事务A持有L2锁,A需要L1锁,故有一条边由A指向B

事务B持有L1锁,B需要L2锁,故有一条边由B指向A

image-20220714030233959

事务

事务正确执行的四个要素:ACID

A:atomicity 原子性,要么全做,要么全不做

C:consistency 一致性,事务前后的数据是一致的

I:isolation 隔离性,多个并发事务同时读写时,防止多个事务并发执行时由于交叉执行导致数据不一致

D:durability 持久性 事务结束后,对数据的修改是永久的

事务的开始与结束

开始:以第一个DML(Data Manipulation Language,如select, delete, update, insert)语句的执行开始

结束:commit, rollback

事务的隔离

事务隔离解决的问题

ACID中的ACD对并发事务和单一事务是没有区别的,隔离性是防止并发事务执行时产生的影响,如果没有隔离性,并发事务会带来的问题有:

  1. 脏写:写了被其他事务覆盖,没生效
  2. 脏读:读到未提交的中间结果(中间结果可以回滚,不认为是正确的数据)
  3. 不可重复读:在一个事务内多次读结果不一致
  4. 幻读:一次事务内读到新数据

所以,需要解决以上问题

事务的隔离级别

隔离级别 脏读 不可重复读 幻读
读未提交(Read uncommitted)
读提交(read committed) ×
可重复读(repeatable read) × ×
串行化(Serializable) × × ×

MySQL 语句的默认事务隔离级别:rr 可重复读,通过SELECT @@transaction_isolation;查看

修改隔离级别:SET SESSION transaction_isolation = 'READ-UNCOMMITTED';

不同隔离级别通过锁的实现思路

隔离级别 解决的问题 实现思路
RC 脏读 写时加X锁
RR 不可重复读 读时加S锁
Serializable 幻读 事务之间串行执行

实验:不同隔离级别下的问题复现

  1. 脏读

    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;
  2. 不可重复读

    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锁解决了一些并发事务的问题,但是这种方式存在以下问题:

  1. 并发读写数据库时,读操作会阻塞写操作
  2. 加锁带来的资源消耗

如何解决上面的问题,也能保证事务的正确性呢

思路设想

我的实现思路:为每个事务建立一个副本,不加锁的操作(select)从副本中读数据,涉及到加锁操作在原表中通过悲观锁操作

难点:

  1. 副本的形式是什么,是拷贝完整的表,还是一部分?
  2. 创建副本时应该如何控制并发问题?
  3. 副本太多可能导致磁盘不足怎么办?

MySQL的实现思路:在有变更的操作时创建副本,事务找到它可见的最新副本,用于不加锁操作,涉及加锁操作,在原表中通过悲观锁操作

难点:副本形式,如何找到可见的最新副本

实现原理

MySQL的数据引擎对MVCC的实现原理

依赖于记录中的三个隐藏字段(row_id,trx_id, rollback_ptr),undo log,read view

trx_id:该记录最新修改的事务号

rollback_ptr:上一个版本指针

undo log:回滚日志

read view:可见性

image-20220714030233959

如上图,事务5来读取某一行数据,如那行数据的row_id=24,记录中trx_id > 5,对事务5不可见,通过rollback_ptr在undo log中找上个版本的记录,发现trx_id > 5,再找上个版本的记录,发现trx_id < 5,则该记录是事务5可见的最新版本,读数据

使用MVCC实现事务隔离级别,该怎么做

当前读:从数据表中,不同语句根据隔离级别,使用悲观锁处理冲突,如select ... in share modeselect ... for update

快照读:使用MVCC,从表或undo log中读数据,不需要使用悲观锁,如select ...

RC和RR都可以通过快照读来实现,解决对应的问题

MySQL 8.x 是通过MVCC实现的RC和RR,不是通过悲观锁

处理事务的注意事项

  1. 查询条件通过索引,避免无索引行升级为表锁
  2. 加锁时,缩小锁的范围,避免间隙锁
  3. 尽量减少锁定资源量和时间,将加锁操作放到最后执行