mysql-innodb的事务管理与锁

典型的事务场景:下单、转账

事物的定义:事务是DBMS执行过程中的一个逻辑单位,由一个有限的数据库操作序列构成

MYSQL中支持事务的数据引擎:innodb ndb

1、数据库事务的四大特性是什么?

原子性 Atomicity 由undo log保证

一致性 Consistent 数据完整性

隔离性 Isolation 不同事务之间处理同一段数据应当是隔离的互不干扰的

持久性 Durable redo log

原子性、隔离性和持久性最终都是为了实现一致性。

2、什么时候会出现事务、结束事务?

当我们执行单条语句的时候,会默认开启事务

mysql 参数 autocommit 默认为 on 开启状态,执行单条查询语句不需要显示的声明事务、提交事务

1
2
3
4
5
show global VARIABLE like 'autocommit'  --显示该参数的全局值

show session VARIABLE like 'autocommit' --显示当前会话该参数的值

set session autocommit=off --关闭autocommit后,需要手动提交

手动开启事务,两种方式

1
2
3
start TRANSACATION;    

begin;

事务的结束:

提交结束 commit;

回滚结束 rollback;

连接断开 会话结束 -> 事务结束

3、事务并发带来的问题有哪些?

  1. 脏读(读到未提交的数据):事务A执行一条查询,在此之前事务B修改了这部分数据但没提交,导致A读取到事务B没有提交的数据,事务B可能回滚导致事务A读取到的数据是脏数据。

  2. 不可重复读:事务A执行一条查询后,事务B对这部分数据执行了update/delete并提交了,导致事务A再次查询时与上一次的查询结果不一致,称为不可重复度。

  3. 幻读:事务A执行一条范围查询后,事务B在此范围insert了若干条数据,导致事务A再次执行该查询是记录数增多,产生幻读。与不可重复的区别是这里针对的是插入操作。

注意:幻读和不可重复度的区别是事务B对数据进行的操作不同,幻读是insert操作,不可重复读是update和delete操作

以上三个问题称为数据库的读一致性问题,必须由数据库自己提供一定的事务隔离机制来解决

4、SQL92 标准

许多数据库专家联合制定了一个标准,建议数据库厂商都按照这个标准提供一定的事务隔离级别,来解决事务并发问题。

看一下SQL92标准的官网:http://www.contrib.andrew.cmu.edu/~shadow/sql/sql1992.txt

在官网搜索_iso,会看到一张表格:

Level P1 P2 P3
READ UNCOMMITTED Possible Possible Possible
READ COMMITTED Not Possible Possible Possible
REPEATABLE READ Not Possible Not Possible Possible
SERIALIZABLE Not Possible Not Possible Not Possible

这里定义了四个隔离级别,右边的P1P2P3就是代表事务并发的三个问题,脏读,不可重复度,幻读。Possible表示在这个隔离级别下该问题有可能发生,Not Possible表示解决了该问题。

  • Read Uncommited 未提交读

顾名思义,事务可以读取到其他事物未提交的数据,使用这种隔离级别其实并未解决以上的任何问题

  • Read Commited 已提交读

只能读到其他事物已经提交了的数据,解决了脏读问题

  • Repeatable Read 可重复读

事务重复读取,保证重复读取数据一致,解决了不可重复度的问题

  • Serializable 串行化

事务串行化运行,没有并发自然没有不一性问题产生,但是严重影响效率,不推荐使用

不同的厂商或者数据库引擎在实现以上标准时会有一些差异。Oracle只实现了两种RC和Serializable,Innodb对以上的四种隔离级别都进行了实现,值得一提的是,innodb 对Repeatable Read 这一级别的实现同时也解决了幻读的问题,因此这一级别是innodb的默认事务隔离级别。

5、innodb是如何实现事务隔离级别的呢?

如果要解决读一致性的问题 ,保证一个事务前后两次读取数据一致,实现事务隔离级别,应该怎么做

方案1: MVCC 基于多版本的并发控制 生成一个数据请求时间点的一致性数据,并用这个快照来提供一定级别的一致性读取。

方案2 : LBCC 基于锁的并发控制

MVCC的实现原理

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

DB_ROW_ID 6字节:行标识

DB_TRX_ID 6字节:插入或更新行的最后一个事务ID,自动递增(理解为创建版本号)

DB_ROLL_PTR: 7字节:回滚指针(理解为删除版本号)数据被删除或记录为旧数据的时候记录当前的操作事务id。

mvcc核心思想,一个事务根据自己的事务id进行判断,只能查询到创建版本号比自己事务ID小的(在我之前插入或更新的数据) 和 删除版本号比我事务ID大的记录

下面通过一个简单的例子说明:

事务一:

1
2
3
4
5
--Transaction 1
begin;
insert into mvcctest values(NULL,"zzk");
insert into mvcctest values(NULL,"pjp");
commit;

此时的数据,创建版本是当前的事务id,删除版本号为空;

id name 创建版本 删除版本
1 zzk 1 undefined
2 pjp 1 undefined

事务二:执行第一次查询,读取到两条原始数据,这时事务id为2:

1
2
3
--Transaction 2
begin
select * from mvcctest ; -- (1) 第一次查询 注意此处没有提交,事务2没有结束

事务三,插入一条数据

1
2
3
4
-- Transaction 3
begin;
insert into mvctest values(NULL,"zpd");
commit;

此时的数据多了一条zpd,它的创建版号是3:

id name 创建版本 删除版本
1 zzk 1 undefined
2 pjp 1 undefined
3 zpd 3 undefined

然后事务二再进行第二次查询

1
2
3
4
--Transaction 2
begin
select * from mvcctest ; -- (1) 第一次查询 注意此处没有提交,事务2没有结束
select * from mvcctest ; -- (2) 第二次查询 注意此处没有提交,事务2没有结束

根据MVCC的原则:只能查询创建版本比自己小的数据,所以第二次查询也只能查询2条数据。

事务四:删除数据,删除了id=1 zzk这条记录

1
2
3
4
--Transaction 4
begin
delete from mvcctest where id = 1
commit;

这时候的数据,赵政康的删除版本被记录为事务id 4,其他不变:

id name 创建版本 删除版本
1 zzk 1 4
2 pjp 1 undefined
3 zpd 3 undefined

然后事务二执行第三次查询:

1
2
3
4
5
--Transaction 2
begin
select * from mvcctest ; -- (1) 第一次查询 注意此处没有提交,事务2没有结束
select * from mvcctest ; -- (2) 第二次查询 注意此处没有提交,事务2没有结束
select * from mvcctest ; -- (3) 第三次查询 注意此处没有提交,事务2没有结束

根据MVCC的查找原则:只能查询创建版本比自己小,删除版本比自己大、以及未删除的记录

也就是说在事务2开始之后被删除的数据依然可以被查出来,所以查询结果依然是2条数据。

事务五:执行更新操作

1
2
3
4
--Transaction 5
begin
update mvcctest set name='潘佳萍' where id = 2
commit;

此时的数据,更新数据的时候,旧数据的删除版本被记录为当前事务id,产生一条新数据,创建版本为当前事务id

id name 创建版本 删除版本
1 zzk 1 4
2 pjp 1 5
3 zpd 3 undefined
2 潘佳萍 5 undefined

然后事务二执行第四次查询:

1
2
3
4
5
6
--Transaction 2
begin
select * from mvcctest ; -- (1) 第一次查询 注意此处没有提交,事务2没有结束
select * from mvcctest ; -- (2) 第二次查询 注意此处没有提交,事务2没有结束
select * from mvcctest ; -- (3) 第三次查询 注意此处没有提交,事务2没有结束
select * from mvcctest ; -- (4) 第四次查询 注意此处没有提交,事务2没有结束

查找规则:只能查询创建版本小于自己,删除版本大于自己,或者未删除的数据,所以依然是2条数据。

通过以上演示我们可以看到,通过版本号的控制,无论其他事物是插入修改删除,事务2查询到的数据都没有变化。

在InnoDB中,MVCC是通过undo log实现的,需要注意的是,MVCC和锁是协同使用来实现隔离性的,这两种方案并不是互斥的。

Oracle、postgres等其他数据口都有MVCC的实现。

6、innodb中的锁

官网将锁分成了8类,这里我们将两个行级别的锁,和两个表级别的锁称锁的基本模式;后面三个Record Locks、Gap Locks、Next-Key Locks 称为锁的算法,也就是分别在上面情况下锁定什么范围。

6.1 共享锁

第一个行级别的锁就是我们在广网看到的Shard Locks(共享锁),我们获取了一行数据的共享锁之后,可以用他来读取数据,所以叫读锁。而且多个事务可以共享一把读锁。

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

释放读锁有两种方式:提交事务或断开连接

读锁可以重复的获取

6.2 排它锁

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

排它锁加锁两种方式:自动(增删改查操作自动加),手动加锁

手动加锁命令 在查询语句后面加上for update,就会给查询的数据加上一个写锁

释放排它锁的方式和共享锁一样。

6.3 意向锁

意向锁是数据库自己维护的。

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

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

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

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

那么这两个表级别锁存在的意义是什么?

第一个,有了表级别锁,在innodb中就可以支持更多粒度的锁;

第二个,如果没有意向锁的话,当我们想要一张表加上表锁的时候,是不是必须先判断有没有其他的事务锁定了其中的一行数据,那么这时候我们要扫描整张表才能确定能不能成功加上一个表锁,如果数据量大,加表锁的效率肯定很低

这是引入意向锁之后,只要判断这张表上面有没有意向锁,如果有加表锁操作直接返回失败。所以innodb中的意向表锁,我们可以把它理解成一个标志。

7、行锁的原理

行锁锁住的是什么?

首先我们准备三张表,一张没有索引的 t1,一张有主键索引的 t2,一张有唯一索引的 t3。

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

我们先来看一下 t1 的表结构,它有两个字段,int 类型的 id 和 varchar 类型的 name。 里面有 4 条数据,1、2、3、4。

Transaction 1 Transaction 2
begin;
SELECT * FROM t1 WHERE id =1 FOR UPDATE;
select * from t1 where id=3 for update; //blocked
INSERT INTO t1 (id, name) VALUES (5, ‘5’); //blocked

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

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

在第二个事务里面,我们尝试给 id=3 的这一行数据加锁。

这个加锁的操作被阻塞了。为什么第一个事务锁住了 id=1 的这行数据,我不能操作 id=3 的数据呢?

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

为什么在没有索引或者没有用到索引的情况下,会锁住整张表?这个问题我们先留 在这里。

有主键索引的表

我们看一下 t2 的表结构。字段是一样的,不同的地方是 id 上创建了一个主键索引。

里面的数据是 1、4、7、10。

Transaction 1 Transaction 2
begin;
select * from t2 where id=1 for update;
select * from t2 where id=1 for update; // blocked
select * from t2 where id=4 for update; // OK

第一种情况,使用相同的 id 值去加锁,冲突;使用不同的 id 加锁,可以加锁成功。 那么,既然不是锁定一行数据,有没有可能是锁住了 id 的这个字段呢?

我们继续往下验证。

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

我们看一下 t3 的表结构。字段还是一样的, id 上创建了一个主键索引,name 上

创建了一个唯一索引。里面的数据是 1、4、7、10。

Transaction 1 Transaction 2
begin;
select * from t3 where name= ‘4’ for update;
select * from t3 where name = ‘4’ for update; // blocked
select * from t3 where id = 4 for update; // blocked

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

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

在这里我们怀疑 InnoDB 锁住的是字段,所以换一个字段,用 id=4 去给这行数据加锁。又被阻塞了,说明锁住的是字段的这个推测也是错的,否则就不会出现第一个事务 锁住了 name,第二个字段锁住 id 失败的情况。

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

那么我们还有两个问题没有解决:

1、为什么表里面没有索引的时候,锁住一行数据会导致锁表?

或者说,如果锁住的是索引,一张表没有索引怎么办?

所以,一张表有没有可能没有索引?

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

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

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

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

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

大家还记得在 InnoDB 里面,当我们使用辅助索引的时候,它是怎么检索数据的吗? 辅助索引的叶子节点存储的是什么内容?

在辅助索引里面,索引存储的是二级索引和主键的值。比如name=4,存储的是name 的索引和主键 id 的值 4。

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

回表

8、锁的算法

t2 这张表有一个主键索引。

我们插入了 4 行数据,主键 id 分别是 1、4、7、10。

因为我们用主键索引加锁,我们这里的划分标准就是主键索引的值。

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

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

假设我们有 N 个 Record,那么所有的数据会被划分成多少个 Gap 区间?答案是 N+1,就像我们把一条绳子砍 N 刀,它最后肯定是变成 N+1 段。

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

8.1 记录锁

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

比如 where id = 1 4 7 10 。

我们使用不同的 key 去加锁,不会冲突,它只锁住这个 record。

8.2 间隙锁

第二种情况,当我们查询的记录不存在,没有命中任何一个 record,无论是用等值 查询还是范围查询的时候,它使用的都是间隙锁。

举个例子,where id >4 and id <7,where id = 6。

注意,间隙锁主要是阻塞插入 insert。相同的间隙锁之间不冲突。

Gap Lock 只在 RR 中存在,如果要关闭间隙锁,就是把事务隔离级别设置成 RC, 并且把 innodb_locks_unsafe_for_binlog 设置为 ON。

这种情况下除了外键约束和唯一性检查会加间隙锁,其他情况都不会用间隙锁。

8.3 临键锁

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

唯一性索引,等值查询匹配到一条记录的时候,退化成记录锁。

没有匹配到任何记录的时候,退化成间隙锁。

比如我们使用>5 <9, 它包含了记录不存在的区间,也包含了一个 Record 7。

临键锁,锁住最后一个 key 的下一个左开右闭的区间。

1
2
3
select * from t2 where id >5 and id <=7 for update; -- 锁住(4,7]和(7,10] 

select * from t2 where id >8 and id <=10 for update; -- 锁住 (7,10],(10,+∞)

为什么要锁住下一个左开右闭的区间?

就是为了解决幻读的问题。

9、innodb隔离级别的实现-

9.1 Read Uncommited

RU 隔离级别:不加锁。

9.2 Serializable

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

这两个很好理解,主要是 RR 和 RC 的区别?

9.3 Repeatable Read

RR 隔离级别下,普通的 select 使用基于MVCC的快照读(snapshot read)

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

9.4 Read Commited

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

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

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

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