浅谈事务隔离级别与锁机制

数据库中通常会存在并发执行多个事务的情况,而这些并发的事务如果操作的有相同的数据,就会导致脏写、脏读等问题。MySQL为了解决数据库并发事务导致的脏写、脏读、不可重复读、幻读等问题,设计了事务隔离等级锁机制MVCC(多版本并发控制)机制,通过一整套的机制来解决多事务并发问题。

一、数据库事务

1.1 事务特性

事务是由一组SQL语句组成的逻辑处理单元,事务具有以下四大特性,称为事务的ACID特性:

  • 原子性:事务是一个原子操作单元,其内部的SQL语句要么全部执行成功,要么都不执行
  • 一致性:在事务开始和完成时,数据都必须保证一致状态。所有相关的数据规则都应用于事务的修改,以保证数据的完整性。比如:银行卡转账操作,操作前后,两个银行卡账户的总额应该是一致的。
  • 隔离性:事务之间的操作是隔离的,某个事务处理过程的中间状态对外部是不可见的,同时外部状态对该事务也是不可见的。
  • 持久性:事务完成之后,对数据的修改是永久性的,即使出现系统故障也能够保证数据不丢失。

1.2 并发事务问题

多个事务同时执行时,难免会出现一些事务的并发问题,主要包括下面几种情况:

脏写

当多个事务选择同一行进行更新或删除操作,由于每个事务都不直到其他事务的存在,就导致最后的操作覆盖了前面其他事务的操作

脏读

一个事务对某条记录做了修改,但该事务还没有提交,在事务提交之前,数据处于不一致状态;这时有另外一个事务也读取到了该记录,此时拿到的就是“脏数据”。简单来说就是事务A读取到了事务B已修改但未提交的数据,还在已修改的基础上做了进一步操作。如果此时事务B回滚,事务A之前读取到的就是无效的数据,不符合一致性要求。

不可重复读

一个事务在事务内的不同时间,执行了相同的查询语句,而得到的结果却发生了变化,这种现象就是“不可重复读”,不符合隔离性。

幻读

事务A读取到了事务B提交的新增数据,不符合隔离性。这里说的事务A读取并不是简单的快照读,而是insert/update/delete操作中的读

注意:不可重复读,侧重的是相同的读操作,也就是两次读操作的结果不一致。而幻读侧重于先读,后写,比如同时开始了事务A和事务B,然后事务B中插入了一条id=5的记录,而在事务A中查询时,发现没有该记录,于是就也插入一条id=5的记录,但事务A插入失败了。这才是幻读的真正场景。

二、事务隔离级别

脏读、不可重复读、幻读都是数据库的读一致性问题,可以通过一定的事务隔离机制来控制,InnoDB存储引擎提供了四种隔离级别,这四种隔离级别与并发问题的关系如下表所示:

隔离级别脏读(DirtyRead)不可重复读(Norepeatable Read)幻读(Phantom Read)
读未提交(Read uncommitted)可能可能可能
读已提交(Read committed)不可能可能可能
可重复读(Repeatable read)不可能不可能可能
可串行化(Serializable)不可能不可能不可能

读已提交采用MVCC机制实现,可重复读采用MVCC+锁来实现,而串行化是通过对所有操作加锁(包括读操作)来实现。

注意:事务的隔离等级越严格,并发的问题就越少,但付出的代价也就越大;因为事务隔离实质上就是使事务在一定程度上“串行化”。同时不同的应用对事务的读一致性和事务隔离等级也是不同的,比如很多应用对“不可重读”和“幻读”并不敏感,但更关注数据并发访问的能力
查看当前数据库事务等级:

show variables like 'tx_isolation';

设置数据库事务等级:

set tx_isolation='REPEATABLE-READ';

Mysql默认的事务隔离级别是可重复读

三、数据库中锁

在数据库中,数据也是一种共享资源。保证数据并发访问的一致性、有效性是所有数据库都需要解决的一个问题,而锁冲突也是影响数据库并发访问性能的一个重要因素。

3.1 锁分类

3.1.1 从性能上

乐观锁和悲观锁,乐观锁通过版本比对实现,不需要加锁阻塞,而悲观锁需要进行线程阻塞。

3.1.2 从数据操作类型上

读锁、写锁和意向锁,而意向锁又分为意向读锁和意向写锁。

  • 读锁

    读锁属于共享锁(Shared),针对同一份数据,多个读操作可以同时进行而不会相互影响,而对于写操作,则会进行阻塞。
    读锁通常出现在一致性锁定读的情况下,通过下面的SQL可以获取一把共享锁:

    select ... Lock in share mode
    
  • 写锁

    写锁属于排他锁(eXclusive),当前事物的写操作没有完成前,其他事务不能对锁定行加任何锁,加锁会被阻塞。
    一致性锁定读中,可以通过下面的SQL加排它锁:

    select ... for update;
    

    可重复读的事务隔离级别下,为了解决不可重复读,对数据的修改操作,也会加排它锁,阻塞其他事务中的修改。

  • 意向锁

    在Innodb存储引擎中,意向锁是一个表级别的锁,而它又分为两种:
    1、意向共享锁(IS Lock):事务想要获得一张表中某几行的共享锁
    2、意向排它锁(IX Lock):事务想要获得一张表中某几行的排它锁
    Innodb引擎支持的是行级别的锁,因此意向锁虽然是表锁,但它并不会阻塞除全表扫描以外其他的任何请求。
    那为什么需要设计意向锁?(个人理解)
    比如事务A需要加表锁,那么它就需要判断表中的每一行记录是否有锁。而引入意向锁后,其他事务在加行锁之前,需要先获取意向锁,比如对行加读锁,就需要先获取表的意向读锁。此时事务A再来加表锁时,就会判断该表上是否有意向锁,有意向锁的情况下,就不能加表锁了。

3.1.3 从数据操作粒度上

行锁、页锁和表锁,在InnoDB中,页锁并不支持,这里只是做一个比较。

  • 行锁

    每次操作锁定一行数据。开销大,加锁慢;会出现死锁;但是锁粒度最小,发生锁冲突的概率最低,并发度最高。

  • 页锁

    在意向锁中体现页锁的概念,在意向锁中,如果对一个记录进行加锁,则需要对粗粒度的对象进行加锁,依次是数据库加锁、数据表加锁、数据页加锁和记录加锁。
    但Innodb的意向锁实现比较简介,其意向锁即为表级别的锁。

  • 表锁

    每次操作需要锁住整张表,开销小,加锁快;不会出现死锁,锁粒度大,发生冲突的概率最高,并发度最低;一般用在数据迁移的场景。
    注意:这里说表锁不会出现死锁,应该是针对MyISAM存储引擎说的,该引擎不支持事务,自然不会死锁。但在Innodb引擎中,是有可能会出现表锁的死锁的(个人观点)
    手动增加表锁:

    lock table 表名 read(write);
    

    查看表上是否有锁:

    show open tables;
    

    删除表锁:

    unlock tables;
    

    对MyISAM表的读操作(加读锁) ,不会阻塞其他进程对同一表的读请求,但会阻塞对同一表的写请求。只有当读锁释放后,才会执行其它进程的写操作。

    对MylSAM表的写操作(加写锁) ,会阻塞其他进程对同一表的读和写操作,只有当写锁释放后,才会执行其它进程的读写操作。

    在InnoDB引擎中,如果加了表锁后,并不是需要一直等到事务执行完才释放,而是每比对完成一行记录,就释放该记录的锁。

3.1.4 从锁范围上

间隙锁(Gap Lock)和临建锁(Next-key Locks)。

  • 间隙锁(Gap Lock)

    间隙锁,顾名思义,锁的就是两个记录之间的间隙,但不包含记录本身
    account这个表记录为例:
    间隙就有(3,15)、(15,20)、(20,∞)这三个间隙
    现在事务A要执行下面的SQL:

    update account set name = 'lizhi' where id >10 and id < 18;
    

    上面的SQL跨越了上面前两个区间,所以其他事务在(3,20]整个区间内,不能插入或修改任何数据,最后的20也是包含在内的。
    所以,在可重复读的事务隔离级别下,间隙锁在某些情况下,可以解决幻读的问题。
    注:间隙锁只有在可重复读隔离界别下才生效

  • 临建锁(Next-key Locks)

    Next-key Locks其实是行锁于间隙锁的组合。像上面间隙锁的例子中,(3,20]的整个区间可以叫做间隙锁。
    在无索引的情况下,行锁可能会升级为表锁。(可重复读隔离级别会升级为表锁,读已提交隔离级别不会升级为表锁)
    在Innodb引擎中,行锁一般都是加在索引上,如果对非索引字段进行修改,可能就会导致行锁升级为表锁。
    比如,事务A执行下面的代码:

    update account set balance = 9000 where name = 'lizhi';
    

    其他所有事务对该表的操作都会被阻塞

    注:InnoDB中的行锁是针对索引加的锁,不是针对记录加锁。并且索引不能失效,否则行锁就会变为表表锁

3.2 行锁分析

通过检查InnoDB_row_lock状态变量来分析系统上的行锁的争夺情况

show status like 'innodb_row_lock%';

相关变量说明:

Innodb_row_lock_current_waits: 当前正在等待锁定的数量
Innodb_row_lock_time: 从系统启动到现在锁定总时间长度
Innodb_row_lock_time_avg: 每次等待所花平均时间
Innodb_row_lock_time_max: 从系统启动到现在等待最长的时间
Innodb_row_lock_waits: 系统启动到现在总共等待的次数

对于这五个状态变量,比较重要的是:平均等待时长、等待总次数、等待总时长
尤其是当等待次数很高,而且每次等待时长也不小的时候,就需要分析系统中为什么会出现如此多的等待。

3.3 查看锁信息

查看INFORMATION_SCHEMA系统库锁相关数据表

-- 查看事务
select * from INFORMATION_SCHEMA.INNODB_TRX;

-- 查看锁
select * from INFORMATION_SCHEMA.INNODB_LOCKS;

-- 查看锁等待
select * from INFORMATION_SCHEMA.INNODB_LOCK_WAITS;

-- 释放锁,trx_mysql_thread_id可以从INNODB_TRX表里查看到
kill trx_mysql_thread_id

-- 查看锁等待详细信息
show engine innodb status; 

3.4 锁优化

对于锁优化的建议:
1、尽可能让所有数据检索都通过索引来完成,避免无索引行锁升级为表锁
2、合理设计索引,尽量缩小锁的范围
3、尽可能减少检索条件范围,避免间隙锁
4、尽量控制事务大小,减少锁定资源量和时间长度,涉及事务加锁的SQL尽量放在事务最后执行
5、尽可能使用低级别事务隔离

四、行锁与事务隔离级别分析

4.1 读未提交

现在有事务A和事务B两个事务,事务A将balance从450改为400,事务B可以看见balance改了400,出现了脏读。
此时,事务A回滚了,而事务B再将balance减去50,按照我们的理解,读取的是400,再减去50,结果应该是350。
但结果依然是400,因为事务A回滚后,数据库中balance的值仍然为450,而事务B在进行更新是,会从数据库中取出最先的数据再进行更新。

4.2 读已提交

事务A和事务B,事务A将balance从450改为了400,但此时事务B中查看到的balance依然是450,脏读被解决了。事务A提交后,事务B再次查询,发现balance变成了400,事务B中两次相同的查询,却得到了不同的结果,说明依然存在“不可重复读”问题。

4.3 可重复读

事务A和事务B,事务A将balance从450改成了400,而事务B在事务A提交前后,查询到的balance值都是450,说明可重复读解决了脏读和不可重读的问题。接着事务B将balance再减去50,发现最终的结果为350,这是因为在可重复读的隔离级别下,使用MVCC(一致性不加锁读)机制,select操作不会更新版本后,读的是历史版本数据。但insert、update、delete操作会更新版本好,是当前读。
重新打开事务A,然后插入一条新的id=4数据,但是在事务B中,并不能看到新插入的记录,此时,事务B也插入一条id=4的记录却失败了,这就表明幻读现象依然存在。

4.4 可串行化

事务A和事务B,事务A查看id=1的记录,然后事务B更新id=1的记录时,则会处于等待状态,在可串行化的隔离等级下,读也会加锁。
如果事务A执行的是一个范围查询,那么该范围所有的行,包括所有行的间隙都会被加锁,此时事务B在想插入或修改数据,都会被阻塞,所以就避免了幻读。

注:上面的几个例子可以通过开启两个MySQL客户端,设置不同的事务隔离级别,然后同时开启两个事务来进行验证,这里就不把具体的截图展示出来。

  • 4
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值