MySQL—一文让你彻底明白事务原理和锁机制

事务

什么是事务

​是指作为单个逻辑工作单元执行的一系列操作,要么完全地执行,要么完全地不执行。 事务处理可以确保除非事务性单元内的所有操作都成功完成,否则不会永久更新面向数据的资源。通过将一组相关操作组合为一个要么全部成功要么全部失败的单元,可以简化错误恢复并使应用程序更加可靠。一个逻辑工作单元要成为事务,必须满足所谓的ACID(原子性、一致性、隔离性和持久性)属性。事务是数据库运行中的一个逻辑工作单位,由DBMS中的事务管理子系统负责事务的处理。

​举个例子加深一下理解:同一个银行转账,A转1000块钱给B,这里存在两个操作,一个是A账户扣款1000元,两一个操作是B账户增加1000元,两者就构成了转账这个事务。

  • 两个操作都成功,A账户扣款1000元,B账户增加1000元,事务成功
  • 两个操作都失败,A账户和B账户金额都没变,事务失败

最后思考一下,怎么样会出现A账户扣款1000元,B账户金额不变?如果你是把两个操作放在一个事务里面,并且是数据库提供的内在事务支持,那就不会有问题,但是开发人员把两个操作放在两个事务里面,而第二个事务失败就会出现中间状态。现实中自己实现的分布式事务处理不当也会出现中间状态,这并不是事务的错,事务本身就是规定不会出现中间状态,是事务实现者做出来的方案有问题。

4个特性

  • 原子性(Atomic):事务必须是原子工作单元;对于其数据修改,要么全都执行,要么全都不执行。通常,与某个事务关联的操作具有共同的目标,并且是相互依赖的。如果系统只执行这些操作的一个子集,则可能会破坏事务的总体目标。原子性消除了系统处理操作子集的可能性。
  • 一致性(Consistency):事务的一致性指的是在一个事务执行之前和执行之后数据库都必须处于一致性状态。这种特性称为事务的一致性。假如数据库的状态满足所有的完整性约束,就说该数据库是一致的。
  • 隔离性(Isolation):由并发事务所作的修改必须与任何其它并发事务所作的修改隔离。事务查看数据时数据所处的状态,到底是另一个事务执行之前的状态还是中间某个状态,相互之间存在什么影响,是可以通过隔离级别的设置来控制的。
  • 持久性(Durability):事务结束后,事务处理的结果必须能够得到固化,即写入数据库文件中即使机器宕机数据也不会丢失,它对于系统的影响是永久性的。

事务并发控制

我们从另外一个方向来说说,如果不对事务进行并发控制,我们看看数据库并发操作是会有那些异常情形,有些使我们可以接受的,有些是不能接受的,注意这里的异常就是特定语境下的,并不一定就是错误什么的。假设有一个order表,有个字段叫count,作为计数用,当前值为100

  • 第一类丢失更新(Update Lost):此种更新丢失是因为回滚的原因,所以也叫回滚丢失。此时两个事务同时更新count,两个事务都读取到100,事务一更新成功并提交,count=100+1=101,事务二出于某种原因更新失败了,然后回滚,事务二就把count还原为它一开始读到的100,此时事务一的更新就这样丢失了。
  • 脏读(Dirty Read):此种异常时因为一个事务读取了另一个事务修改了但是未提交的数据。举个例子,事务一更新了count=101,但是没有提交,事务二此时读取count,值为101而不是100,然后事务一出于某种原因回滚了,然后第二个事务读取的这个值就是噩梦的开始。
  • 不可重复读(Not Repeatable Read):此种异常是一个事务对同一行数据执行了两次或更多次查询,但是却得到了不同的结果,也就是在一个事务里面你不能重复(即多次)读取一行数据,如果你这么做了,不能保证每次读取的结果是一样的,有可能一样有可能不一样。造成这个结果是在两次查询之间有别的事务对该行数据做了更新操作。举个例子,事务一先查询了count,值为100,此时事务二更新了count=101,事务一再次读取count,值就会变成101,两次读取结果不一样。
  • 第二类丢失更新(Second Update Lost):此种更新丢失是因为更新被其他事务给覆盖了,也可以叫覆盖丢失。举个例子,两个事务同时更新count,都读取100这个初始值,事务一先更新成功并提交,count=100+1=101,事务二后更新成功并提交,count=100+1=101,由于事务二count还是从100开始增加,事务一的更新就这样丢失了。
  • 幻读(Phantom Read):幻读和不可重复读有点像,只是针对的不是数据的值而是数据的数量。此种异常是一个事务在两次查询的过程中数据的数量不同,让人以为发生幻觉,幻读大概就是这么得来的吧。举个例子,事务一查询order表有多少条记录,事务二新增了一条记录,然后事务一查了一下order表有多少记录,发现和第一次不一样,这就是幻读。

图解
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
解决方案:这些解决方案是通过不同的隔离级别实现的,而隔离级别实现的原理是通过锁,锁在下文中会详细介绍
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

隔离级别

看到上面提到的几种问题,你可能会想,我擦,这么多坑怎么办啊。其实上面几种情况并不是一定都要避免的,具体看你的业务要求,包括你数据库的负载都会影响你的决定。不知道大家发现没有,上面各种异常情况都是多个事务之间相互影响造成的,这说明两个事务之间需要某种方式将他们从某种程度上分开,降低直至避免相互影响。这时候数据库事务隔离级别就粉墨登场了,而数据库的隔离级别实现一般是通过数据库锁实现的。

  • 读未提交(Read Uncommitted): 该隔离级别指即使一个事务的更新语句没有提交,但是别的事务可以读到这个改变,几种异常情况都可能出现。极易出错,没有安全性可言,基本不会使用。
  • 读已提交(Read Committed): 该隔离级别指一个事务只能看到其他事务的已经提交的更新,看不到未提交的更新,消除了脏读和第一类丢失更新,这是大多数数据库的默认隔离级别,如Oracle,Sqlserver。
  • 可重复读(Repeatable Read): 该隔离级别指一个事务中进行两次或多次同样的对于数据内容的查询,得到的结果是一样的,但不保证对于数据条数的查询是一样的,只要存在读改行数据就禁止写,消除了不可重复读和第二类更新丢失,这是Mysql数据库的默认隔离级别。
  • 串行化(Serializable): 意思是说这个事务执行的时候不允许别的事务并发执行.完全串行化的读,只要存在读就禁止写,但可以同时读,消除了幻读。这是事务隔离的最高级别,虽然最安全最省心,但是效率太低,一般不会用。

下面是各种隔离级别对各异常的控制能力:

级别\异常第一类更新丢失脏读不可重复读第二类丢失更新幻读
读未提交YYYYY
读已提交NNYYY
可重复读NNNNY
串行化NNNNN

事务启动时机

begin/start transaction 命令不是一个事务的起点,在执行他们之后的第一个操作innodb表的语句时,事务才真正启动,也正是此时会创建事务的一致性视图
start transaction with consistent snapshot 这个命令开始立即启动一个事务,而此时也会创建对用事务的一致性视图

MVCC

多版本并发控制:通过一种可见性算法来实现数据库的并发控制,实现在读取数据时不加锁,避免因为写锁而造成读数据的并发阻塞问题

隐藏列
innodb中每一行都以隐藏列data_trx_id,data_roll_ptrdata_trx_id表示最近修改行数据的事务ID;data_roll_prt指向该行回滚段的指针,该行上所有旧版本,在undo中通过链表的形式组织;row_id如果链表没有指定主键,innodb会使用rowId创建一个聚簇索引;其中还有一个flag标记删除, 判断该行记录是否被删除, delete操作在引擎层面其实做的是逻辑删除,整个MVCC的关键就是通过这两个隐藏列来实现的
在这里插入图片描述

mysql中事务从开始到提交的前都会被保存到trx_sys的事务链表中, 这里报错的都是未提交的事务, 事务一旦被提交, 则会从事务链表中清除掉
在这里插入图片描述

两种读形式

  • 快照读:读取的是当前事务的可见版本,不必加锁,简单的select操作就是快照锁
  • 当前读:读取的是当前版本,比如特殊的操作,更新/插入删除

readView
读视图, 不同的隔离级别下会在不同的时机创建读视图; RC级别下, 每个sql执行前都会创建自己的数据读视图; RR级别下, 事务启动之后创建视图
在这里插入图片描述
其实就是一种数据结构:trx_ids: 当前系统活跃(未提交)的事务版本号集合,low_limit_id: 创建当前ReadView时, 当前系统活跃的事务的最大版本号+1,up_limit_id: 创建当前ReadView时, 当前系统活跃的事务的最小版本号,cerator_trx_id: 创建当前ReadView的事务的版本号

总结: 一个数据版本对于一个事务视图来说, 自己的更新总是可见之外, 还有三种情况:

  • 版本未提交, 不可见
  • 版本已提交, 但是在视图创建之后提交的, 不可见
  • 版本已提交, 而且是在视图创建之前提交的, 可见

一般可以分为两类,一个是悲观锁,一个是乐观锁,悲观锁一般就是我们通常说的数据库锁机制,乐观锁一般是指用户自己实现的一种锁机制,比如hibernate实现的乐观锁甚至编程语言也有乐观锁的思想的应用。

悲观锁

悲观锁按照使用性质划分:

  • 共享锁(Share locks称为S锁) :也称为读锁,事务A对对象T加S锁,其他事务也只能对T加S锁,多个事务可以同时读,但是不能有写操作,直到A释放S锁。
  • 排他锁(Exclusivelocks称为X锁) :也成为写锁,事务A对对象T加X锁以后,其他事务不能对T加任何锁,只有事务A可以读写对象T直到A释放X锁
  • 更新锁(称为U锁):用来预定要对此对象施加X锁,它允许其他事务读,但不允许再施加U锁或X锁;当被读取的对象将要被更新时,则升级为X锁,主要是用来防止死锁的。因为使用共享锁时,修改数据的操作分为两步,首先获得一个共享锁,读取数据,然后将共享锁升级为排它锁,然后再执行修改操作。这样如果同时有两个或多个事务同时对一个对象申请了共享锁,在修改数据的时候,这些事务都要将共享锁升级为排它锁。这些事务都不会释放共享锁而是一直等待对方释放,这样就造成了死锁。如果一个数据在修改前直接申请更新锁,在数据修改的时候再升级为排它锁,就可以避免死锁。

悲观锁按照作用范围划分:

行锁: 锁的作用范围是行级别,数据库能够确定那些行需要锁的情况下使用行锁,如果不知道会影响哪些行的时候就会使用表锁。举个例子,一个用户表user,有主键id和用户生日birthday当你使用update … where id=?这样的语句数据库明确知道会影响哪一行,它就会使用行锁,当你使用update … where birthday=?这样的的语句的时候因为事先不知道会影响哪些行就可能会使用表锁。
在InnoDB的事务中,行锁再需要的时候才加上的,但并不是不需要就立即释放,而是要等到事务结束时才释放,如果事务中需要锁多行, 那应该把最可能造成锁冲突, 影响并发度的锁尽量往后放

表锁: 锁的作用范围是整张表。
lock talbes … read/write 可以使用unlock tables 主动释放锁, 也可以再客户端关闭的时候自动释放. 还没有出现更细颗粒度的锁时, 表锁是常用的处理并发的方式
元数据锁 MDL(metadata lock):不需要显示地使用, 在访问一个表的时候会被自动加上, 作用是保证读写的准确性.。对表的操作包括两种:对一张表进行 增删改查操作, 此时会加 MDL 读锁;对一张表的 结构进行变更操作(加字段/修改字段/删除字段/添加索引等)时, 此时会加 MDL 写锁。
元数据锁之间的互斥关系:MDL 读锁之间不互斥, 因此可以有多个线程同时对一张表进行增删改查;读写锁, 写锁之间是互斥的, 用来保证表结构操作的安全性. 因此表持有写锁时, 该表的其他操作都被阻塞
注意:大表操作的时候, 需要格外小心, 以免导致整个库宕掉; 一定要避开当前mysql中正在提交事务的表进行DDL;在alter table 的时候, 可以设定等待时间, 在这个时间内能能到MDL写锁则进行执行DDL语句, 如果不能拿到就先放弃不要阻塞后面的业务语句;alter等待时间设置语法: alter table t_test wait N add name varchar(32) not null default ‘’;

乐观锁

乐观锁:顾名思义,就是很乐观,每次自己操作数据的时候认为没有人回来修改它,所以不去加锁,但是在更新的时候会去判断在此期间数据有没有被修改,需要用户自己去实现。既然都有数据库提供的悲观锁可以方便使用为什么要使用乐观锁呢?对于读操作远多于写操作的时候,大多数都是读取,这时候一个更新操作加锁会阻塞所有读取,降低了吞吐量。最后还要释放锁,锁是需要一些开销的,我们只要想办法解决极少量的更新操作的同步问题。换句话说,如果是读写比例差距不是非常大或者你的系统没有响应不及时,吞吐量瓶颈问题,那就不要去使用乐观锁,它增加了复杂度,也带来了额外的风险。

乐观锁实现方式:

  • 版本号(记为version):就是给数据增加一个版本标识,在数据库上就是表中增加一个version字段,每次更新把这个字段加1,读取数据的时候把version读出来,更新的时候比较version,如果还是开始读取的version就可以更新了,如果现在的version比老的version大,说明有其他事务更新了该数据,并增加了版本号,这时候得到一个无法更新的通知,用户自行根据这个通知来决定怎么处理,比如重新开始一遍。这里的关键是判断version和更新两个动作需要作为一个原子单元执行,否则在你判断可以更新以后正式更新之前有别的事务修改了version,这个时候你再去更新就可能会覆盖前一个事务做的更新,造成第二类丢失更新,所以你可以使用update … where … and version=”old version”这样的语句,根据返回结果是0还是非0来得到通知,如果是0说明更新没有成功,因为version被改了,如果返回非0说明更新成功。

  • 时间戳(timestamp):和版本号基本一样,只是通过时间戳来判断而已,注意时间戳要使用数据库服务器的时间戳不能是业务系统的时间。

  • 待更新字段:和版本号方式相似,只是不增加额外字段,直接使用有效数据字段做版本控制信息,因为有时候我们可能无法改变旧系统的数据库表结构。假设有个待更新字段叫count,先去读取这个count,更新的时候去比较数据库中count的值是不是我期望的值(即开始读的值),如果是就把我修改的count的值更新到该字段,否则更新失败。java的基本类型的原子类型对象如AtomicInteger就是这种思想。

  • 所有字段:和待更新字段类似,只是使用所有字段做版本控制信息,只有所有字段都没变化才会执行更新。

乐观锁几种方式的区别: 新系统设计可以使用version方式和timestamp方式,需要增加字段,应用范围是整条数据,不论那个字段修改都会更新version,也就是说两个事务更新同一条记录的两个不相关字段也是互斥的,不能同步进行。旧系统不能修改数据库表结构的时候使用数据字段作为版本控制信息,不需要新增字段,待更新字段方式只要其他事务修改的字段和当前事务修改的字段没有重叠就可以同步进行,并发性更高。

死锁

死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。这些永远在互相等待的进程称为死锁进程

产生死锁的必要条件:

  • 互斥使用,即当资源被一个线程使用(占有)时,别的线程不能使用
  • 不可抢占,资源请求者不能强制从资源占有者手中夺取资源,资源只能由资源占有者主动释放。
  • 请求和保持,即当资源请求者在请求其他的资源的同时保持对原有资源的占有。
  • 循环等待,即存在一个等待队列:P1占有P2的资源,P2占有P3的资源,P3占有P1的资源。这样就形成了一个等待环路。

死锁产生的原因:

  1. 系统资源的竞争
    通常系统中拥有的不可剥夺资源,其数量不足以满足多个进程运行的需要,使得进程在 运行过程中,会因争夺资源而陷入僵局

  2. 进程推进顺序非法
    进程在运行过程中,请求和释放资源的顺序不当,也同样会导致死锁。例如,并发进程 P1、P2分别保持了资源R1、R2,而进程P1申请资源R2,进程P2申请资源R1时,两者都 会因为所需资源被占用而阻塞。

  3. 信号量使用不当也会造成死锁。
    进程间彼此相互等待对方发来的消息,结果也会使得这 些进程间无法继续向前推进。

如何避免死锁?

三种用于避免死锁的技术:

  • 加锁顺序:
    一个线程需要一些锁,那么它必须按照确定的顺序获取锁。它只有获得了从顺序上排在前面的锁之后,才能获取后面的锁。
    例如,线程2和线程3只有在获取了锁A之后才能尝试获取锁C(获取锁A是获取锁C的必要条件)。因为线程1已经拥有了锁A,
    所以线程2和3需要一直等到锁A被释放。然后在它们尝试对B或C加锁之前,必须成功地对A加了锁。

  • 加锁时限 :
    另外一个可以避免死锁的方法是在尝试获取锁的时候加一个超时时间,这也就意味着在尝试获取锁的过程中若超过了这个时限该线程则放弃对该锁请求。
    并会进行回退并释放所有已经获得的锁,然后等待一段随机的时间再重试。这段随机的等待时间让其它线程有机会尝试获取相同的这些锁,
    并且让该应用在没有获得锁的时候可以继续运行(加锁超时后可以先继续运行干点其它事情,再回头来重复之前加锁的逻辑)。

  • 死锁检测:
    每当一个线程获得了锁,会在线程和锁相关的数据结构中(map、graph等等)将其记下。除此之外,每当有线程请求锁,也需要记录在这个数据结构中。
    当一个线程请求锁失败时,这个线程可以遍历锁的关系图看看是否有死锁发生。如果检测到死锁,就释放所有锁,回退,并且等待一段随机的时间后再重试

遇到死锁怎么办?

我们先了解下死锁定理:

​ ①如果资源分配图中没有环路,则系统没有死锁;

​ ②如果资源分配图中出现了环路,则系统可能有死锁。

从上面的死锁定理中我们可以知道只要打破死锁的环路就可以解开死锁,以下是处理死锁的两种名方法:

1)抢占资源:挂起某些死锁进程,并抢占它的资源,将这些资源分配给其他的死锁进程。但应防止被挂起的进程长时间得不到资源,而处于资源匮乏的状态。

2)终止(或撤销)进程:终止或撤销系统中的一个或多个死锁进程,直至打破死锁状态

进入死锁的线程进行等待, 直到超时自动退出. 超时参数可以通过innodb_lock_wait_timeout(默认50s)设置
发起死锁检测, 发现死锁后, 主动回滚掉其中一条事务, 让其他的事务得以执行, innodb_deadlock_deteck 设置为on, 这也是默认值, 表示开启这个逻辑

加锁的规则

  1. 原则上:加锁的基本单位是next-key lock,是一个前开后闭区间,锁是加在索引上的
  2. 原则上:查找过程中访问的对象才会加锁
  3. 优化:索引上的等值查询,该唯一索引加锁的时候,next-key lock退化为行锁
  4. 优化:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock退化为间隙锁
  5. 优化:唯一索引上的范围查询会访问到不满足条件的第一值为止

参考文章: https://www.cnblogs.com/xiaofengwang/p/11291944.html

常见的问题

幻读是什么?

指的是一个事务在前后两次查询范围内,后一次看到了前一次没看到的数据,当前读不是幻读,幻读是’指新插入的行’,
存在问题: 语义不一致,数据不一致,数据和binlog日志
解决幻读: 引入间隙锁,锁的是两个值之间的间隙,间隙锁冲突只是—往间隙插入记录,间隙锁和行锁并称—next-key lock
带来的问题: 间隙锁的引入,可能会导致同样的语句锁住更大的范围,这其实是影响了并发度的

next-key lock 间隙锁与行锁

锁案例
等值查询间隙锁、非唯一索引等值锁、主键索引范围锁、非唯一索引范围锁、唯一索引范围锁bug、非唯一索引存在等值、limit语句锁、子主题
结论

锁是加在索引上的
lock in share mode查询使用覆盖索引、
for update系统认为需要更新数据,
会给主键索引加行锁、
在delete limit加锁后满足limit值后循环结束
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值