MYSQL锁机制详解、示例SQL演示及脏读、不可重复读、幻读探究

MYSQL锁机制

MySQL 不同的存储引擎支持不同的锁机制。
myisam 和 memory 存储引擎采用的是 表级锁;
innodb 存储引擎既支持行级锁,也支持表级锁,但默认情况下采用行级锁。在不同事务隔离级别下,锁的范围也会有差异,故此处讨论锁机制前,先铺垫下存储引擎、隔离级别知识。

存储引擎

mysql存储引擎有好几种,我们最为常见的是myisam和innodb,开发中使用的是innodb,我们之所以myisam眼熟,有一部分原因是mysql5.5之前默认是存储引擎就是myisam,但是myisam是不支持事务的,所以绝大多数场景我们都是会使用innodb,而mysql在5.5以后也是将默认的存储引擎改为了innodb。

myisam 表锁

  1. MySQL 表级锁的锁模式
    MySQL 的表级锁有两种模式,表共享读锁(table read lock)和表独占写锁(table write lock)。
    对 myisam 表的读操作,不会阻塞其他用户对同一表的读请求,但会阻塞对同一表的写请求;对 myisam 表的写操作,则会阻塞其他用户对同一表的读和写操作;myisam 表的读操作和写操作之间,以及写操作之间时串行的。
    当一个线程获得对一个表的写锁户,只有持有锁的线程可以对表进行更新操作,其他线程的读、写操作都会等待,直到锁被释放。
  2. 加锁
    myisam 在执行查询语句(select)前,会自动给涉及的所有表加读锁,在执行更新操作(update、delete、insert等)前,会自动给涉及的表加写锁,这个过程并不需要直接用 lock table 命令给 myisam 表显示加锁。给 myisam 表显式加锁,一般是为了在一定程度模拟事务操作
    myisam 在自动加锁的情况下,总是一次获得 sql 语句所需要的全部锁,所以显示锁表的时候,必须同时取得所有涉及表的锁,这也正是 myisam 表不会出现死锁(deadlock)的原因。
    注意:在使用 lock tables 时,不仅需要一次锁定用到的所有表,而且,同一个表在 sql 语句中出现多少次,就要通过与 sql 语句中相同的别名锁定多少次,否则会报错。
  3. 并发插入(concurrent inserts)
    myisam 表的读和写是串行的,但这是就总体而言的。在一定条件下,myisam 表也支持查询和插入操作的并发进行。
    myisam 存储引擎有一个系统变量 concurrent_insert , 专门用以控制其并发插入的行为,其值分别可以为0,1,2。
    当 concurrent_insert 设置为 0 时,不允许并发插入。
    当 concurrent_insert 设置为 1 时,如果 myisam 表中没有空洞(即表的中间没有被删除的行),myisam 允许在一个进程读表的同时,另一个进程从表尾插入记录。这也是 MySQL 的默认设置。
    当 concurrent_insert 设置为 2 时,无论 myisam 表中有没有空洞,都允许在表尾并发插入记录。
  4. myisam 的锁调度
    myisam 存储引擎的读锁和写锁是互斥的,读写操作时串行的。当一个进程请求某个 myisam 表的读锁,同时另一个进程也请求同一表的写锁时,写进程会先获得锁。不仅如此,即使读请求先到锁等待队列,写请求后到,写锁也会插到读锁请求之前,这是因为 mysql 认为写请求一般比读请求重要。这也正是 myisam 表不太适合有大量更新操作和查询操作应用的原因,因为,大量的更新操作会造成查询操作很难获得读锁,从而可能永远阻塞。
    通过一些参数设置可以调节 MySQL 的默认调度行为:
    通过指定启动参数 low-priority-updates, 使 myisam 引擎默认给予读请求以优先的权利。
    通过执行命令 set low_priority_updates = 1, 使该连接发出的更新请求优先级降低。
    通过指定 insert、update、delete 语句的 low_priority 属性,降低该语句的优先级。
    上述方式都是要么更新优先,要么查询优先,MySQL 也提供了一种折中的办法调节读写冲突:
    给系统参数 max_write_lock_count 设置一个合适的值,当一个表的读锁达到这个值后,MySQL 就暂时将写请求的优先级降低,给读进程一定获得锁的机会。

myisam日常使用并不多,简单说下,主要内容放在innodb下的锁模式。

事务隔离级别

事务的隔离级别有:读未提交、读已提交、可重复读、串行化.这四个隔离级别都是自己的优点和局限性。

隔离级别 优点 缺点
读未提交 性能最佳 会出现脏读、不可重复读、幻读的问题
读已提交 性能出色,不会出现脏读这个不可容忍的问题,通常用于生产环境 会出现不可重复读、幻读的问题
可重复读 性能比较出色,解决了不可重复读,解决了部分幻读的问题,是mysql默认隔离级别 仍然会出现幻读的问题,有死锁的隐患
串行化 不会出现脏读、幻读、不可重复读问题 性能太差,不适合真实项目场景

(插曲)如何查看修改事务隔离级别

为了方便大家实操,这两个sql语句供大家使用。

修改事务隔离级别sql:
SET [SESSION|GLOBAL] TRANSACTION ISOLATION LEVEL [READ UNCOMMITTED|READ COMMITTED|REPEATABLE READ|SERIALIZABLE];
查询当前系统的隔离级别:
select @@global.tx_isolation;
查看当前会话的隔离级别:
select @@tx_isolation

上面说到,主要出现了三个问题:脏读、不可重复读、幻读,下面一一描述:
**

脏读

脏读:其实就是读到了别的事务回滚前的脏数据,或者说读到了其他事物尚未提交的数据,这就是幻读。
读未提交事务场景:小明账户100元,事物A给小明转账,增加100元,此时事务A尚未提交,线程B扣钱操作,查询到小明此时有两百元,逻辑判断可以执行扣除一百五十元的sql,因为事务A拥有数据的行锁,所以,A尚未提交这个阶段,B在等待锁的释放,小明的余额是200元,若后续事务A因为异常回滚,不给小明增加100元,name小明的账户就是100元,B再去扣除150元,小明的余额就是-50元,此时50元的差额谁来买单?故读未提交不可以在实际场景中使用。
此时问题来了B真的能扣除成功吗,事务A尚未提交,那么A就还拥有着行锁,若B执行的扣除操作是以乐观锁、或者悲观锁执行的话,其实就不会出现扣为-50元的问题,详情看sql:

sql举例:
在这里插入图片描述
读未提交隔离级别下,按照真实顺序执行:
开始事务,增加100元余额,尚未commit;
在这里插入图片描述
另一个会话,查询到余额为200元,执行乐观锁更新的sql,因为行锁被事务占有着,所以update在等待锁的释放。
在这里插入图片描述
当事务回滚时,可以看到,update语句并没有执行成功,即乐观锁的方式是可以避免出现这种数据不一致的情况的。
在这里插入图片描述

虽然当使用合适的锁机制即可解决类似上述的问题,但是查询事务未提交的数据,就有回滚的风险,此时若因为查询到未提交的数据而导致,执行逻辑变更,那么导致的问题,是很难预测的。(以上述例子举例,假如B的逻辑操作是,若小明余额大于100,赠送100积分,事务A最终回滚了,那么小明岂不是白得100积分,所以生产环境一般是不使用读未提交的。)

不可重复读

不可重复读:是指在一个事务内多次读取同一集合的数据,但是多次读到的数据是不一样的,这就违反了数据库事务的一致性的原则。但是,这跟脏读还是有区别的,脏读的数据是没有提交的,但是不可重复读的数据是已经提交的数据

场景:事务A 去查询小明的余额信息,发现余额是100元,后续其他线程执行更新sql,将小明余额更新为200元,然后事务A再去查询小明的余额,发现变为200元了,也就是事务A前后两次查询,结果却不一样。这也有一个专业名词叫做:一致性非锁定读

一致性非锁定读:是多版本并发控制(MVCC)来实现的一种场景,MVCC是在读已提交、可重复读两种隔离级别下作用,当开始事务后,执行select时,会生成快照数据(read view)。(而两个隔离级别的区别是,读已提交每次select都生成最新的read view,而可重复读,只有第一次会生成最新的readview,后续再select同一个记录,则直接读取之前生成的readview数据,这也是可重复读解决不可重复读的办法。)

实战演练:
读已提交隔离级别下,测试不可重复读:

先开启事务,查询小明余额信息在这里插入图片描述
另一个会话,执行更新操作在这里插入图片描述
这里可看到,再次查询,会查到事务提交的最新数据在这里插入图片描述

幻读

幻读:事务A 按照一定条件进行数据读取, 期间事务B 插入了相同搜索条件的新数据,事务A再次按照原先条件进行读取时,发现了事务B 新插入的数据 称为幻读
那么可重复读情况下通过生成、读取readview数据,会保证查到快照读的数据,不会查到最新数据,按理说是不会出现幻读的呀。这里有两点问题,一、虽然当前读查不到最新数据,但是若使用当前读,就能查到最新数据,出现幻读;二、即使查不到最新数据,但是若最新插入的数据满足你后续需要修改的条件,执行修改将未看到的数据修改了,这也是幻读带来的问题。故不可重复读没有完全解决幻读的问题。

实操场景(可重复读隔离级别)
此时表只有一条数据在这里插入图片描述开启事务,查询id<10的用户在这里插入图片描述开启另一个会话,插入一条数据
在这里插入图片描述
插入后,第二次select因为使用的是快照读,可重复隔离级别下,使用第一次的快照数据,故查到的还是只有小明,但是使用当前读,就能查到最新的数据,即没有完全解决幻读。
在这里插入图片描述
即便此时我们不使用当前读,直接执行更新操作,更新sql会将符合条件的最新数据都会更新,这也是幻读带来的影响。
在这里插入图片描述

扩展

串行化:事务A,当对某条数据执行修改、删除、或新增一条数据时,其他事务执行查询时,若刚好命中事务A操作的数据,就会等待A释放锁,才能读取到具体事务,这样效率确实难以接受。

mysql事务 串行化 隔离级别的锁机制是怎样的
在A、B客户端都开启事务,假如
① 如果A事务删除某条记录(尚未提交回滚),B事务无法读取A事务中删除且未提交的事务,这是因为A事务中加了写锁
② 如果A事务插入了一条数据(假如ID为4),那么B事务中,ID = 1、2、3都可以正常读,但是
select * from account where id <= 3;
就会等待

生产环境选择读已提交的原因

1.RR存在间隙锁会使死锁的概率增大;
2.在RR可重复读隔离级别下,条件列未命中索引会锁表!而在RC隔离级别下,只锁行;
在RR隔离级别下,走聚簇索引,进行全部扫描,最后会将整个表锁上;
在RC隔离级别下,其先走聚簇索引,进行全部扫描,但MySQL做了优化,在MySQL Server过滤条件,发现不满足后,会调用unlock_row方法,把不满足条件的记录放锁。
3.在RC隔离级别下,引入半一致性读(semi-consistent)特性增加了update操作的性能!

Innodb 锁机制

上面说了这么多,总算是进入正题了,下面我们来说下锁,以下锁机制,是在读已提交、可重复读两个隔离级别下探讨:

行锁

Innodb 实现了两种类型的行锁:

共享锁、排他锁

共享锁(S):允许一个事务去读一行,阻止其他事务获得相同数据集的排他锁。
排他锁(X):允许获得排他锁的事务更新数据,阻止其他事务获取相同数据集的共享读锁和排他写锁。
另外,为了允许行锁和表锁共存,事项多粒度锁机制,innodb 还有两种内部使用的意向锁,这两种意向锁都是表锁:

意向共享锁(IS):事务打算给数据行加行共享锁,事务在给一个数据行加共享锁前必须先取得该表的 IS 锁。
意向排它锁(IX): 事务打算给数据行加行排它锁,事务在给一个数据行加排它锁前必须先取得该表的 IX 锁。

如果一个事务请求的锁模式与当前的锁兼容,innodb 就将请求的锁授予该事务;反之,如果两者不兼容,该事务就要等待锁释放。
意向锁是 innodb 自动加的,不需要用户干预。对于 update、delete 和 insert 语句,innodb 会自动给涉及数据集加排它锁(X);对于普通 select 语句,innodb 不会加任何锁。
事务可以通过以下语句显式给记录集加共享锁或排它锁。
共享锁(S):select * from table_name where … lock in share mode.
排它锁(X): select * from table_name where … for update.
用 select… in share mode 获得共享锁,主要用在需要数据依存关系时来确认某行记录是否存在,并确保没有人对这个记录进行 update 或者 delete 操作。但是如果当前事务也需要对该记录进行更新操作,则有可能造成死锁,对于锁定行记录后需要进行更新操作的应用,应该使用 select… for update 方式获得排他锁。

兼容性:
排他锁
也就是说,当事务拥有数据A排他锁时,其他数据库连接是无法获取数据A的共享、排他锁的;当事务拥有数据A共享锁时,其他数据库连接无法获取数据A的排他锁,可以获取到共享锁。
那么问题来了,若有一个事务A得到了共享锁,此时B一个update请求在等待锁释放,另一个请求C又想获取共享锁,那么C此时可以获取到共享锁吗?答案当然是不可以,因为B要执行更新,自然会获取数据的意向排他锁,当拿到IX锁后,其他的请求想要去获取意向共享锁,因为B已经上了意向排他锁,自然是要在B执行更新之后才可以。

通常开发时,更多的是使用乐观锁、排他锁(悲观锁)的方式,来锁住行数据,完成原子判断及更新操作。
那么若使用共享锁,执行更新,如何产生死锁呢,上sql:

开启两个会话窗口,都开启事务(不开启事务的话,执行select lock in share mode;就直接释放共享锁了),都执行了select * from user lock in share mode;此时两个事务,都有这两条数据的共享锁,此时无法执行更新这两条数据的sql,需要等待其他拥有共享锁的事务释放锁,左侧先调用update,右侧再调用update,因为两个都拥有着共享锁,且都等待对方释放锁,故就会出现死锁现象。在这里插入图片描述

乐观锁、悲观锁

乐观锁:乐观锁( Optimistic Locking ) 是相对悲观锁而言的,乐观锁假设数据一般情况下不会造成冲突,所以在数据进行提交更新的时候,才会正式对数据的冲突与否进行检测,如果发现冲突了,则让返回用户错误的信息,让用户决定如何去做。
相对于悲观锁,在对数据库进行处理的时候,乐观锁并不会使用数据库提供的锁机制。一般的实现乐观锁的方式就是记录数据版本。
乐观并发控制相信事务之间的数据竞争(data race)的概率是比较小的,因此尽可能直接做下去,直到提交的时候才去锁定,所以update执行之前是不会争抢行锁的,并发小时,优先考虑乐观锁。

下面是乐观锁实践,select到update之间是没有加锁的,当①执行更新时,将之前查询出来的余额作为一个版本标志,来进行更新,若update成功更新一条数据,则select到update期间money没变,否则,update了0条数据,可以返回给用户请重试的信息,②就是乐观锁更新失败的情况,③是乐观锁的优化,扣除10元,不是一定要用户余额必须是当时查出来的余额,只要余额大于扣除的10元即可。

在这里插入图片描述
悲观锁:当我们要对一个数据库中的一条数据进行修改的时候,为了避免同时被其他人修改,最好的办法就是直接对该数据进行加锁以防止并发。

这种借助数据库锁机制在修改数据之前先锁定,再修改的方式被称之为悲观并发控制(又名“悲观锁”,Pessimistic Concurrency Control,缩写“PCC”)。

之所以叫做悲观锁,是因为这是一种对数据的修改抱有悲观态度的并发控制方式。我们一般认为数据被并发修改的概率比较大,所以需要在修改之前先加锁。

悲观并发控制实际上是“先取锁再访问”的保守策略,为数据处理的安全提供了保证。

但是在效率方面,处理加锁的机制会让数据库产生额外的开销,还有增加产生死锁的机会;

另外,还会降低并行性,一个事务如果锁定了某行数据,其他事务就必须等待该事务处理完才可以处理那行数据。但是在并发较大时,还是建议使用悲观锁,与其使用乐观锁,有太多的无效更新sql,对数据库造成压力,不如直接使用悲观锁,防止不必要的update执行。

开启事务,select for update,对这条数据加行锁,代码判断80>10后,直接执行更新sql,然后提交即可。
在这里插入图片描述

这里要明确的一点是:在事务中,select … for update会拿到排他行锁,而普通的select不会得到排他行锁,但是执行update的时候会得到这条数据的行锁,事务提交时,才会释放行锁。

表锁

下面要谈到表锁了,我准备读已提交和可重复读分开来讲,应该会更明确下

读已提交

!
在这里插入图片描述
这是某乎的头衔为数据库架构师的一个留言,大家觉得他说的有什么地方不太恰当的地方吗?innodb行锁依赖于索引不假,where 条件只有是索引才能应用行锁锁定,否则还会是表锁?
我个人不是很明白,为什么网上好些人要在innodb下来谈论行锁,表锁,我们主要使用的读已提交(RC)、可重复度(RR)下,锁的性质就有较大的区别,下面一一分析:

读已提交:在RC下,是不存在表锁的(Mysql优化,会释放不满足条件记录的锁),只有行锁,行锁是对聚簇索引来进行加锁,即便where后的条件不是索引列,也会查询出对应的聚簇索引①,对索引进行加锁,若查不到具体数据,就不执行任何加锁操作。这也是读已提交极少发生死锁的原因。

①:SQL会走聚簇索引的全扫描进行过滤,由于过滤是由MySQL Server层面进行的。因此每条记录,无论是否满足条件,都会被加上X锁。但是,为了效率考量,MySQL做了优化,对于不满足条件的记录,会在判断后放锁,最终持有的,是满足条件的记录上的锁,但是不满足条件的记录上的加锁/放锁动作不会省略。

实践出真知:

由浅入深:一、左侧根据主键Id作为where条件,加锁在聚簇索引也就是主键索引上,右侧执行update同一条数据需要等待锁的释放。
在这里插入图片描述即便是查询全表for update,也只是对查询到的数据的主键索引加排他锁,右侧执行插入还是不会受到任何影响,新插入的数据更新操作,自然也不会有任何问题。
在这里插入图片描述左侧开启事务,where查询一个非索引非唯一限制的字段,进行加锁,仍然只是对查询出来的数据加锁,右侧修改user_id为1的数据不受任何影响。
在这里插入图片描述左侧开启事务后,where查询一个普通字段,且条件是不存在的数据for update,右侧可以正常的执行更新操作,原理一样,对查询出的数据主键索引加排他锁,查不到数据,则不添加任何锁。在这里插入图片描述

读已提交仍有可能出现死锁的情况,就是事务A执行数据1更新,再执行数据2更新,此时事务B执行数据2更新,再执行数据1的更新,事务A在等数据2的行锁释放,事务B在等数据1的行锁释放,仍然会操作死锁,这种行级锁死锁出现的情况极低。

可重复读

下面我们讨论下可重复读隔离级别下的锁,一步一步来:

MySql只有在RR的隔离级别下才有gap lock和next-key lock。
原则1:加锁的基本单位是Next-key Lock(左开右闭区间);
原则2:查找到的对象才会加锁;
优化1:索引上的等值查询,给唯一索引加锁的时候,会退化为行锁;
优化2:索引上的等值查询,向右遍历最后一个值不满足等值条件时,next-key lock会退化为间隙锁。
一个不合理的地方:唯一索引上的范围查询会访问到不满足条件的第一个值为止。

先说下间隙锁、临键锁的简单概念,具体锁的效果,下面的实操大家体会哈

记录锁(Record Locks)

记录锁是 封锁记录,记录锁也叫行锁,例如:
SELECT * FROMtestWHEREid=1 FOR UPDATE;
它会在 id=1 的记录上加上记录锁,以阻止其他事务插入,更新,删除 id=1 这一行。
记录锁、间隙锁、临键锁都是排它锁

间隙锁(Gap Locks)

间隙锁是封锁索引记录中的间隔,或者第一条索引记录之前的范围,又或者最后一条索引记录之后的范围。
产生间隙锁的条件(RR事务隔离级别下;):

使用普通索引锁定;
使用多列唯一索引;
使用唯一索引锁定多行记录。
等情况,都会产生间隙锁

临键锁(Next-key Locks)

临键锁:是记录锁与间隙锁的组合,它的封锁范围,既包含索引记录,又包含索引区间。
临键锁针对的是普通索引,没命中数据,锁间隙,命中数据,锁住数据及数据所在的间隙范围。

数据
在这里插入图片描述

先看主键加锁

主键索引是唯一索引,所以当命中数据后,临键锁会退化为行锁,右侧执行update需要等待锁的释放
在这里插入图片描述
当未命中到数据时,临键锁会退化为间隙锁,左侧查询对id为15的数据加锁,因为没有该条数据,RR下,会锁住15所在的区间范围,因为存在的数据有1、2、3、10、20,30,15处在(10,20)范围内,故间隙锁会锁住(10,20)这个范围,右侧插入id为13的数据也要等待,左侧锁释放才能执行,否则就会超时异常
在这里插入图片描述
若想查询加锁的是id=40呢,锁住的范围就是(30,max),所以可重复读的锁的范围还是很大的,这也是容易造成死锁的原因。

此后sql依赖的数据
在这里插入图片描述mobile和name设置为唯一、普通索引
在这里插入图片描述
接下来测试唯一索引加锁(应该和主键索引的效果一样,因为主键索引也是唯一索引)

查询命中唯一索引列且存在数据时,退化为行锁,从右侧也可以看出来,更新加锁行,肯定不行,插入mobile为235成功,证明当前并没有间隙锁的存在在这里插入图片描述查询命中唯一索引列,但是没有命中具体数据,此时退化成间隙锁,锁住(321,456)的间隙,右侧的sql也能看出来,321、456执行更新都没问题,证明没有被加锁,插入mobile为345却等待锁超时了,因为345刚好在(321,456)加锁区间内,故不能插入。
在这里插入图片描述

下面看下普通索引的加锁情况,数据变更
在这里插入图片描述

对不存在的aba数据加锁,从右侧sql可以看出,只是对(aa,abc)的区间进行加锁了(间隙锁)。
在这里插入图片描述对存在的abb加锁,从右侧可以看出列name是普通索引,所以会采用临键锁的方式加锁,锁住(aa,abb]区间,故右侧sql执行插入name为aba的数据,锁会超时。
在这里插入图片描述

下面看下普通字段加锁情况,数据
在这里插入图片描述

左侧开启事务,对money为200的数据进行加锁,然后我们看下右侧sql执行的情况,无论是更新锁住这条数据,还是插入money(200,300)、(0,200)、(200,1000)都无法拿到锁,所以当where对非索引字段for update时,会直接锁表。
在这里插入图片描述

可重复读给出了这么多的锁目的就是来解决幻读的问题,为了防止多个事务把记录插入到同一范围中去,但是在可重复读的隔离级别下仍然还是会有一定的幻读问题。

Guess you like

Origin blog.csdn.net/kolbjbe/article/details/115383452
Recommended