Skip to content

Latest commit

 

History

History
371 lines (191 loc) · 20.6 KB

03.MySQL事务与锁详解.md

File metadata and controls

371 lines (191 loc) · 20.6 KB

数据库事务

1 什么是数据库的事务?

1.1 事务的典型场景

<tx:advice id="txAdvice" transaction-manager="transactionManager">
    <tx:attributes>
        <tx:method name="save*" rollback-for="Throwable" />
        <tx:method name="add*" rollback-for="Throwable" />
        <tx:method name="send*" rollback-for="Throwable" />
        <tx:method name="insert*" rollback-for="Throwable" />
    </tx:attributes>
</tx:advice>

1.2 事务的定义

维基百科的定义:事务是数据库管理系统(DBMS)执行过程中的一个逻辑单位,由 一个有限的数据库操作序列构成。

这里面有两个关键点,第一个,它是数据库最小的工作单元,是不可以再分的。第 二个,它可能包含了一个或者一系列的 DML 语句,包括 insert delete update。

(单条 DDL(create drop)和 DCL(grant revoke)也会有事务)

1.3 哪些存储引擎支持事务

1.4 事务的四大特性

事务的四大特性:ACID。

第一个,原子性,Atomicity,也就是我们刚才说的不可再分,也就意味着我们对数 据库的一系列的操作,要么都是成功,要么都是失败,不可能出现部分成功或者部分失 败的情况。全部成功比较简单,问题是如果前面一个操作已经成功了,后面的操作失败了,怎 么让它全部失败呢?这个时候我们必须要回滚。

原子性,在 InnoDB 里面是通过 undo log 来实现的,它记录了数据修改之前的值(逻 辑日志),一旦发生异常,就可以用 undo log 来实现回滚操作。

第二个,一致性,consistent,指的是数据库的完整性约束没有被破坏,事务执行的 前后都是合法的数据状态。比如主键必须是唯一的,字段长度符合要求。

除了数据库自身的完整性约束,还有一个是用户自定义的完整性。

第三个,隔离性,Isolation,我们有了事务的定义以后,在数据库里面会有很多的 事务同时去操作我们的同一张表或者同一行数据,必然会产生一些并发或者干扰的操作, 那么我们对隔离性的定义,就是这些很多个的事务,对表或者行的并发操作,应该是透 明的,互相不干扰的。通过这种方式,我们最终也是保证业务数据的一致性。

最后一个叫做持久性,Durable,事务的持久性是什么意思呢?我们对数据库的任意 的操作,增删改,只要事务提交成功,那么结果就是永久性的,不可能因为我们系统宕 机或者重启了数据库的服务器,它又恢复到原来的状态了。这个就是事务的持久性。

持久性是通过 redo log 和 double write 双写缓冲来实现的,我们操作数据的时候,会先写到内存的 buffer pool 里面,同时记录 redo log,如果在刷盘之前出现异常,在 重启后就可以读取 redo log 的内容,写入到磁盘,保证数据的持久性。

当然,恢复成功的前提是数据页本身没有被破坏,是完整的,这个通过双写缓冲 (double write)保证。

原子性,隔离性,持久性,最后都是为了实现一致性。

1.5 数据库什么时候会出现事务

InnoDB 里面有一个 autocommit 的参数(分成两个级别, session 级别和 global 级别)。

show variables like 'autocommit';

它的默认值是 ON。autocommit 这个参数是什么意思呢?是否自动提交。如果它的 值是 true/on 的话,我们在操作数据的时候,会自动开启一个事务,和自动提交事务。

否则,如果我们把 autocommit 设置成 false/off,那么数据库的事务就需要我们手 动地去开启和手动地去结束。

手动开启事务也有几种方式,一种是用 begin;一种是用 start transaction。

如何结束一个事务?

第一种就是提交一个事务, commit;还有一种就是 rollback,回滚的时候,事务也会结束。

还有一种情况,客户端 的连接断开的时候,事务也会结束。

1.6 事务并发会带来什么问题?

无论是脏 读,还是不可重复读,还是幻读,它们都是数据库的读一致性的问题,都是在一个事务 里面前后两次读取出现了不一致的情况。

读一致性的问题,必须要由数据库提供一定的事务隔离机制来解决。就像我们去饭 店吃饭,基本的设施和卫生保证都是饭店提供的。那么我们使用数据库,隔离性的问题 也必须由数据库帮助我们来解决。

1.7 SQL92 标准

第一个隔离级别叫做:Read Uncommitted(未提交读),一个事务可以读取到其 他事务未提交的数据,会出现脏读,所以叫做 RU,它没有解决任何的问题。

第二个隔离级别叫做:Read Committed(已提交读),也就是一个事务只能读取 到其他事务已提交的数据,不能读取到其他事务未提交的数据,它解决了脏读的问题, 但是会出现不可重复读的问题。

第三个隔离级别叫做:Repeatable Read (可重复读),它解决了不可重复读的问题, 也就是在同一个事务里面多次读取同样的数据结果是一样的,但是在这个级别下,没有 定义解决幻读的问题。

最后一个就是:Serializable(串行化),在这个隔离级别里面,所有的事务都是串 行执行的,也就是对数据的操作需要排队,已经不存在事务的并发操作了,所以它解决 了所有的问题。

1.8 MySQL InnoDB 对隔离级别的支持

image-20220308230340342

InnoDB 支持的四个隔离级别和 SQL92 定义的基本一致,隔离级别越高,事务的并 发度就越低。唯一的区别就在于,InnoDB 在 RR 的级别就解决了幻读的问题。这个也是 InnoDB 默认使用 RR 作为事务隔离级别的原因,既保证了数据的一致性,又支持较高的 并发度。

1.9 两大实现方案

1.9.1 LBCC

1.9.2 MVCC

如果要让一个事务前后两次读取的数据保持一致, 那么我们可以在修改数据的时候给它建立一个备份或者叫快照,后面再来读取这个快照 就行了。这种方案我们叫做多版本的并发控制 Multi Version Concurrency Control (MVCC)。

MVCC 的核心思想是: 我可以查到在我这个事务开始之前已经存在的数据,即使它 在后面被修改或者删除了。在我这个事务之后新增的数据,我是查不到的。

问题:这个快照什么时候创建?读取数据的时候,怎么保证能读取到这个快照而不 是最新的数据?这个怎么实现呢?

InnoDB 为每行记录都实现了两个隐藏字段:

DB_TRX_ID,6 字节:插入或更新行的最后一个事务的事务 ID,事务编号是自动递 增的(我们把它理解为创建版本号,在数据新增或者修改为新数据的时候,记录当前事 务 ID)。

DB_ROLL_PTR,7 字节:回滚指针(我们把它理解为删除版本号,数据被删除或记 录为旧数据的时候,记录当前事务 ID)。

我们把这两个事务 ID 理解为版本号。

image-20220308231155771

第一个事务,初始化数据(检查初始数据)

image-20220308231245837

第二个事务,执行第 1 次查询,读取到两条原始数据,这个时候事务 ID 是2:

image-20220308232015618

第三个事务,插入数据:

image-20220308233556031

image-20220308234016228

MVCC 的查找规则:只能查找创建时间小于等于当前事务 ID 的数据,和删除时间大 于当前事务 ID 的行(或未删除)。

也就是不能查到在我的事务开始之后插入的数据,tom 的创建 ID 大于 2,所以还是 只能查到两条数据。

第四个事务,删除数据,删除了 id=2 jack 这条记录:

image-20220308233742665

此时的数据,jack 的删除版本被记录为当前事务 ID,4,其他数据不变。

image-20220308233651936

在第二个事务中,执行第 3 次查询:

查找规则:只能查找创建时间小于等于当前事务 ID 的数据,和删除时间大于当前事 务 ID 的行(或未删除)。

也就是,在我事务开始之后删除的数据,所以 jack 依然可以查出来。所以还是这两 条数据。

第五个事务,执行更新操作,这个事务事务 ID 是 5:

image-20220308234156847

image-20220308234214009

查找规则:只能查找创建时间小于等于当前事务 ID 的数据,和删除时间大于当前事 务 ID 的行(或未删除)。

因为更新后的数据 penyuyan 创建版本大于 2,代表是在事务之后增加的,查不出 来。

而旧数据 qingshan 的删除版本大于 2,代表是在事务之后删除的,可以查出来。

在 InnoDB 中,MVCC 是通过 Undo log 实现的。

Oracle、Postgres 等等其他数据库都有 MVCC 的实现。

需要注意,在 InnoDB 中,MVCC 和锁是协同使用的,这两种方案并不是互斥的。

2 MySQL InnoDB 锁的基本类型

所以我们把前面的两个行级别的锁(Shared and Exclusive Locks),和两个表级别的锁(Intention Locks)称为锁的基本模式。

后面三个 Record Locks、Gap Locks、Next-Key Locks,我们把它们叫做锁的算法, 也就是分别在什么情况下锁定什么范围。

2.1 锁的粒度

表锁:锁住一张表

行锁:锁住一行数据

2.2 共享锁

我们获取了 一行数据的读锁以后,可以用来读取数据,所以它也叫做读锁,注意不要在加上了读锁 以后去写数据,不然的话可能会出现死锁的情况。而且多个事务可以共享一把读锁。

如何加读锁?

我们可以用 select …… lock in share mode; 的方式手工加上一把读锁。

释放锁有两种方式,只要事务结束,锁就会自动事务,包括提交事务和结束事务

image-20220308235850999

2.3 排它锁

第二个行级别的锁叫做 Exclusive Locks(排它锁),它是用来操作数据的,所以又叫做写锁。只要一个事务获取了一行数据的排它锁,其他的事务就不能再获取这一行数据的共享锁和排它锁。

排它锁的加锁方式有两种,第一种是自动加排他锁。我们在操作数据的时候,包括 增删改,都会默认加上一个排它锁。

还有一种是手工加锁,我们用一个 FOR UPDATE 给一行数据加上一个排它锁,这个 无论是在我们的代码里面还是操作数据的工具里面,都比较常用。

image-20220309000320249

2.4 意向锁

也就是说,当我们给一行数据加上共享锁之前,数据库会自动在这张表上面加一个 意向共享锁。

当我们给一行数据加上排他锁之前,数据库会自动在这张表上面加一个意向排他锁。

反过来说:

如果一张表上面至少有一个意向共享锁,说明有其他的事务给其中的某些数据行加 上了共享锁。

如果一张表上面至少有一个意向排他锁,说明有其他的事务给其中的某些数据行加 上了排他锁。

image-20220309000715296

3 行锁的原理

3.1 没有索引的表(假设锁住记录)

我们先假设 InnoDB 的锁锁住了是一行数据或者一条记录。

image-20220309000905640

现在我们在两个会话里面手工开启两个事务。

在第一个事务里面,我们通过 where id =1 锁住第一行数据。

在第二个事务里面,我们尝试给 id=3 的这一行数据加锁,大家觉得能成功吗?

我们再来操作一条不存在的数据,插入 id=5。它也被阻塞了。实际上这里整张表都 被锁住了。所以,我们的第一个猜想被推翻了,InnoDB 的锁锁住的应该不是 Record。

3.2 有主键索引的表

image-20220309001119447

image-20220309001135895

第一种情况,使用相同的 id 值去加锁,冲突;使用不同的 id 加锁,可以加锁成功。

3.3 唯一索引(假设锁住字段)

image-20220309001304870

在第一个事务里面,我们通过 name 字段去锁定值是 4 的这行数据。

在第二个事务里面,尝试获取一样的排它锁,肯定是失败的,这个不用怀疑。

在这里我们怀疑 InnoDB 锁住的是字段,所以这次我换一个字段,用 id=4 去给这行 数据加锁,大家觉得能成功吗?

既然锁住的不是 record,也不是 column,InnoDB 里面锁住的到底是什么呢?在这 三个案例里面,我们要去分析一下他们的差异在哪里,也就是这三张表的结构,是什么 区别导致了加锁的行为的差异?其实答案就是索引。InnoDB 的行锁,就是通过锁住索引 来实现的。

1、为什么表里面没有索引的时候,锁住一行数据会导致锁表? 或者说,如果锁住的是索引,一张表没有索引怎么办? 所以,一张表有没有可能没有索引?

1)如果我们定义了主键(PRIMARY KEY),那么 InnoDB 会选择主键作为聚集索引。

2)如果没有显式定义主键,则 InnoDB 会选择第一个不包含有 NULL 值的唯一索 引作为主键索引。

3)如果也没有这样的唯一索引,则 InnoDB 会选择内置 6 字节长的 ROWID 作 为隐藏的聚集索引,它会随着行记录的写入而主键递增。

所以,为什么锁表,是因为查询没有使用索引,会进行全表扫描,然后把每一个隐 藏的聚集索引都锁住了。

2、为什么通过唯一索引给数据行加锁,主键索引也会被锁住?

而主键索引里面除了索引之外,还存储了完整的数据。所以我们通过辅助索引锁定 一行数据的时候,它跟我们检索数据的步骤是一样的,会通过主键值找到主键索引,然后也锁定。

image-20220309001614665

4 锁的算法

image-20220309001824450

这些数据库里面存在的主键值,我们把它叫做 Record,记录,那么这里我们就有 4 个 Record。

根据主键,这些存在的 Record 隔开的数据不存在的区间,我们把它叫做 Gap,间 隙,它是一个左开右开的区间。

最后一个,间隙(Gap)连同它左边的记录(Record),我们把它叫做临键的区间, 它是一个左开右闭的区间。

4.1 记录锁

第一种情况,当我们对于唯一性的索引(包括唯一索引和主键索引)使用等值查询,精准匹配到一条记录的时候,这个时候使用的就是记录锁。

image-20220309002121913

4.2 间隙锁

image-20220309003059890

4.3 临键锁

第三种情况,当我们使用了范围查询,不仅仅命中了 Record 记录,还包含了 Gap 间隙,在这种情况下我们使用的就是临键锁,它是 MySQL 里面默认的行锁算法,相当于 记录锁加上间隙锁。

image-20220309003142831

4.4 小结:隔离级别的实现

4.4.1 Read Uncommited

RU 隔离级别:不加锁。

4.4.2 Serializable

Serializable 所有的 select 语句都会被隐式的转化为 select ... in share mode,会 和 update、delete 互斥。

4.4.3 Repeatable Read

RR 隔离级别下,普通的 select 使用快照读(snapshot read),底层使用 MVCC 来实 现。

加锁的 select(select ... in share mode / select ... for update)以及更新操作 update, delete 等语句使用当前读(current read),底层使用记录锁、或者间隙锁、 临键锁。

4.4.4 Read Commited

RC 隔离级别下,普通的 select 都是快照读,使用 MVCC 实现。

加锁的 select 都使用记录锁,因为没有 Gap Lock。

除了两种特殊情况——外键约束检查(foreign-key constraint checking)以及重复 键检查(duplicate-key checking)时会使用间隙锁封锁区间。

所以 RC 会出现幻读的问题。

5 事务隔离级别怎么选?

RC 和 RR 主要有几个区别:

1、 RR 的间隙锁会导致锁定范围的扩大。

2、 条件列未使用到索引,RR 锁表,RC 锁行。

3、 RC 的“半一致性”(semi-consistent)读可以增加 update 操作的并发性。

在 RC 中,一个 update 语句,如果读到一行已经加锁的记录,此时 InnoDB 返回记 录最近提交的版本,由 MySQL 上层判断此版本是否满足 update 的 where 条件。若满 足(需要更新),则 MySQL 会重新发起一次读操作,此时会读取行的最新版本(并加锁)。

实际上,如果能够正确地使用锁(避免不使用索引去枷锁),只锁定需要的数据, 用默认的 RR 级别就可以了。

在我们使用锁的时候,有一个问题是需要注意和避免的,我们知道,排它锁有互斥 的特性。一个事务或者说一个线程持有锁的时候,会阻止其他的线程获取锁,这个时候 会造成阻塞等待,如果循环等待,会有可能造成死锁。

6 死锁

6.1 锁的释放与阻塞

回顾:锁什么时候释放?

事务结束(commit,rollback);客户端连接断开。

如果一个事务一直未释放锁,其他事务会被阻塞多久?会不会永远等待下去?如果 是,在并发访问比较高的情况下,如果大量事务因无法立即获得所需的锁而挂起,会占 用大量计算机资源,造成严重性能问题,甚至拖跨数据库。

6.2 死锁的发生和检测

image-20220309003610582

在第一个事务中,检测到了死锁,马上退出了,第二个事务获得了锁,不需要等待 50 秒。

为什么可以直接检测到呢?是因为死锁的发生需要满足一定的条件,所以在发生死 锁时,InnoDB 一般都能通过算法(wait-for graph)自动检测到。

那么死锁需要满足什么条件?死锁的产生条件:

因为锁本身是互斥的,(1)同一时刻只能有一个事务持有这把锁,(2)其他的事 务需要在这个事务释放锁之后才能获取锁,而不可以强行剥夺,(3)当多个事务形成等 待环路的时候,即发生死锁。

6.3 查看锁信息(日志)

image-20220309003715755

6.4 死锁的避免

1、 在程序中,操作多张表时,尽量以相同的顺序来访问(避免形成等待环路);

2、 批量操作单张表数据的时候,先对数据进行排序(避免形成等待环路);

3、 申请足够级别的锁,如果要操作数据,就申请排它锁;

4、 尽量使用索引访问数据,避免没有 where 条件的操作,避免锁表;

5、 如果可以,大事务化成小事务;

6、 使用等值查询而不是范围查询查询数据,命中记录,避免间隙锁对并发的影响。