33讲MySQL调优之事务:⾼并发场景下的数据库事务调优
你好,我是刘超。
数据库事务是数据库系统执⾏过程中的⼀个逻辑处理单元,保证⼀个数据库操作要么成功,要么失败。谈到他,就不得不提
ACID属性了。数据库事务具有以下四个基本属性:原⼦性(Atomicity)、⼀致性(Consistent)、隔离性(Isolation)以及持久性(Durable)。正是这些特性,才保证了数据库事务的安全性。⽽在MySQL中,鉴于MyISAM存储引擎不⽀持事务,所以接下来的内容都是在InnoDB存储引擎的基础上进⾏讲解的。
我们知道,在Java并发编程中,可以多线程并发执⾏程序,然⽽并发虽然提⾼了程序的执⾏效率,却给程序带来了线程安全 问题。事务跟多线程⼀样,为了提⾼数据库处理事务的吞吐量,数据库同样⽀持并发事务,⽽在并发运⾏中,同样也存在着安全性问题,例如,修改数据丢失,读取数据不⼀致等。
在数据库事务中,事务的隔离是解决并发事务问题的关键, 今天我们就重点了解下事务隔离的实现原理,以及如何优化事务隔离带来的性能问题。
并发事务带来的问题
我们可以通过以下⼏个例⼦来了解下并发事务带来的⼏个问题:
1.数据丢失
2.脏读
事务隔离解决并发问题
以上 4 个并发事务带来的问题,其中,数据丢失可以基于数据库中的悲观锁来避免发⽣,即在查询时通过在事务中使⽤ select xx for update 语句来实现⼀个排他锁,保证在该事务结束之前其他事务⽆法更新该数据。
当然,我们也可以基于乐观锁来避免,即将某⼀字段作为版本号,如果更新时的版本号跟之前的版本⼀致,则更新,否则更新失败。剩下 3 个问题,其实是数据库读⼀致性造成的,需要数据库提供⼀定的事务隔离机制来解决。
我们通过加锁的⽅式,可以实现不同的事务隔离机制。在了解事务隔离机制之前,我们不妨先来了解下MySQL都有哪些锁机制。
InnoDB实现了两种类型的锁机制:共享锁(S)和排他锁(X)。共享锁允许⼀个事务读数据,不允许修改数据,如果其他事务要再对该⾏加锁,只能加共享锁;排他锁是修改数据时加的锁,可以读取和修改数据,⼀旦⼀个事务对该⾏数据加锁,其他事务将不能再对该数据加任务锁。
熟悉了以上InnoDB⾏锁的实现原理,我们就可以更清楚地理解下⾯的内容。
在操作数据的事务中,不同的锁机制会产⽣以下⼏种不同的事务隔离级别,不同的隔离级别分别可以解决并发事务产⽣的⼏个问题,对应如下:
未提交读(Read Uncommitted):在事务A读取数据时,事务B读取和修改数据加了共享锁。这种隔离级别,会导致脏读、不可重复读以及幻读。
已提交读(Read Committed):在事务A读取数据时增加了共享锁,⼀旦读取,⽴即释放锁,事务B读取修改数据时增加了
⾏级排他锁,直到事务结束才释放锁。也就是说,事务A在读取数据时,事务B只能读取数据,不能修改。当事务A读取到数据后,事务B才能修改。这种隔离级别,可以避免脏读,但依然存在不可重复读以及幻读的问题。
可重复读(Repeatable Read):在事务A读取数据时增加了共享锁,事务结束,才释放锁,事务B读取修改数据时增加了⾏级排他锁,直到事务结束才释放锁。也就是说,事务A在没有结束事务时,事务B只能读取数据,不能修改。当事务A结束事
务,事务B才能修改。这种隔离级别,可以避免脏读、不可重复读,但依然存在幻读的问题。
可序列化(Serializable):在事务A读取数据时增加了共享锁,事务结束,才释放锁,事务B读取修改数据时增加了表级排他锁,直到事务结束才释放锁。可序列化解决了脏读、不可重复读、幻读等问题,但隔离级别越来越⾼的同时,并发性会越来越低。
InnoDB中的RC和RR隔离事务是基于多版本并发控制(MVCC)实现⾼性能事务。⼀旦数据被加上排他锁,其他事务将⽆法加⼊共享锁,且处于阻塞等待状态,如果⼀张表有⼤量的请求,这样的性能将是⽆法⽀持的。
MVCC对普通的 Select 不加锁,如果读取的数据正在执⾏Delete或Update操作,这时读取操作不会等待排它锁的释放,⽽是直接利⽤MVCC读取该⾏的数据快照(数据快照是指在该⾏的之前版本的数据,⽽数据快照的版本是基于undo实现的,undo 是⽤来做事务回滚的,记录了回滚的不同版本的⾏记录)。MVCC避免了对数据重复加锁的过程,⼤⼤提⾼了读操作的性能。
锁具体实现算法
我们知道,InnoDB既实现了⾏锁,也实现了表锁。⾏锁是通过索引实现的,如果不通过索引条件检索数据,那么InnoDB将对表中所有的记录进⾏加锁,其实就是升级为表锁了。
⾏锁的具体实现算法有三种:record lock、gap lock以及next-key lock。record lock是专⻔对索引项加锁;gap lock是对索引项之间的间隙加锁;next-key lock则是前⾯两种的组合,对索引项以其之间的间隙加锁。
只在可重复读或以上隔离级别下的特定操作才会取得gap lock或next-key lock,在Select 、Update和Delete时,除了基于唯⼀索引的查询之外,其他索引查询时都会获取gap lock或next-key lock,即锁住其扫描的范围。
优化⾼并发事务
通过以上讲解,相信你对事务、锁以及隔离级别已经有了⼀个透彻的了解了。清楚了问题,我们就可以聊聊⾼并发场景下的事务到底该如何调优了。
结合业务场景,使⽤低级别事务隔离
在⾼并发业务中,为了保证业务数据的⼀致性,操作数据库时往往会使⽤到不同级别的事务隔离。隔离级别越⾼,并发性能就越低。
那换到业务场景中,我们如何判断⽤哪种隔离级别更合适呢?我们可以通过两个简单的业务来说下其中的选择⽅法。
我们在修改⽤户最后登录时间的业务场景中,这⾥对查询⽤户的登录时间没有特别严格的准确性要求,⽽修改⽤户登录信息只有⽤户⾃⼰登录时才会修改,不存在⼀个事务提交的信息被覆盖的可能。所以我们允许该业务使⽤最低隔离级别。
⽽如果是账户中的余额或积分的消费,就存在多个客户端同时消费⼀个账户的情况,此时我们应该选择RR级别来保证⼀旦有
⼀个客户端在对账户进⾏消费,其他客户端就不可能对该账户同时进⾏消费了。
避免⾏锁升级表锁
前⾯讲了,在InnoDB中,⾏锁是通过索引实现的,如果不通过索引条件检索数据,⾏锁将会升级到表锁。我们知道,表锁是会严重影响到整张表的操作性能的,所以我们应该避免他。
控制事务的⼤⼩,减少锁定的资源量和锁定时间⻓度
你是否遇到过以下SQL异常呢?在抢购系统的⽇志中,在活动区间,我们经常可以看到这种异常⽇志:
MySQLQueryInterruptedException: Query execution was interrupted
由于在抢购提交订单中开启了事务,在⾼并发时对⼀条记录进⾏更新的情况下,由于更新记录所在的事务还可能存在其他操
作,导致⼀个事务⽐较⻓,当有⼤量请求进⼊时,就可能导致⼀些请求同时进⼊到事务中。
⼜因为锁的竞争是不公平的,当多个事务同时对⼀条记录进⾏更新时,极端情况下,⼀个更新操作进去排队系统后,可能会⼀直拿不到锁,最后因超时被系统打断踢出。
在⽤户购买商品时,⾸先我们需要查询库存余额,再新建⼀个订单,并扣除相应的库存。这⼀系列操作是处于同⼀个事务的。以上业务若是在两种不同的执⾏顺序下,其结果都是⼀样的,但在事务性能⽅⾯却不⼀样:
这是因为,虽然这些操作在同⼀个事务,但锁的申请在不同时间,只有当其他操作都执⾏完,才会释放所有锁。因为扣除库存是更新操作,属于⾏锁,这将会影响到其他操作该数据的事务,所以我们应该尽量避免⻓时间地持有该锁,尽快释放该锁。
⼜因为先新建订单和先扣除库存都不会影响业务,所以我们可以将扣除库存操作放到最后,也就是使⽤执⾏顺序1,以此尽量减⼩锁的持有时间。
总结
其实MySQL的并发事务调优和Java的多线程编程调优⾮常类似,都是可以通过减⼩锁粒度和减少锁的持有时间进⾏调优。在
MySQL的并发事务调优中,我们尽量在可以使⽤低事务隔离级别的业务场景中,避免使⽤⾼事务隔离级别。
在功能业务开发时,开发⼈员往往会为了追求开发速度,习惯使⽤默认的参数设置来实现业务功能。例如,在service⽅法 中,你可能习惯默认使⽤transaction,很少再⼿动变更事务隔离级别。但要知道,transaction默认是RR事务隔离级别,在某些业务场景下,可能并不合适。因此,我们还是要结合具体的业务场景,进⾏考虑。
思考题
以上我们主要了解了锁实现事务的隔离性,你知道InnoDB是如何实现原⼦性、⼀致性和持久性的吗?
期待在留⾔区看到你的⻅解。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他⼀起讨论。
精选留⾔ <br />![](https://cdn.nlark.com/yuque/0/2022/png/1852637/1646315716350-a8190f79-b8b9-4861-8abc-9e6747a9187c.png#)许童童<br />binlog + redo log 两阶段提交保证持久性<br />事务的回滚机制 保证原⼦性 要么全部提交成功 要么回滚<br />undo log + MVCC 保证⼀致性 事务开始和结束的过程不会其它事务看到 为了并发可以适当破坏⼀致性<br />2019-08-08 18:42<br />作者回复<br />数据库基础⾮常扎实,赞<br />2019-08-09 09:15
胡峣
⽼师能否重点讲⼀下record lock、gap lock 以及 next-key lock?
2019-08-08 00:25
作者回复
好的,后⾯安排
2019-08-09 09:40
QQ怪
默认transaction⽤的是数据库默认的隔离级别不是⼀定是RR,只是⽤MySQL默认是RR
2019-08-08 20:31
作者回复
对的
2019-08-09 09:13
L.
⽼师,对于可重复读(Repeatable Read)的事务级别可以避免不可重复读的现象有个疑问:
对于事务A来说,它在获得共享锁期间修改了数据,⽐如把A改为B,修改完成后释放共享锁。在A获得共享锁期间,事务B看到的数据是A,释放共享锁后,事务B才获得排他锁,然后看到的数据是B。两次的数据不⼀样啊,还是没有避免不可重复读。。
。。不知道我理解的哪⾥不对,望⽼师指点。。。
2019-08-14 23:15
作者回复
粗略看好像没什么问题,但仔细看就能看出问题了。
事务A存在读和改两个操作,事务B也同样存在读和改两个操作。事务A在读时,由于是共享锁,事务B也能读到该数据,当事
务A进⾏修改就需要上排他锁了,此时事务B由于已经对该数据加了共享锁,事务A需要等待事务B释放共享锁才能获取排他锁来修改数据。同样事务B也在等待事务A释放共享锁。这种操作会导致死锁的出现。
我们⼀般⽤⼀个读的事务操作和⼀个读写事务操作来理解RR事务隔离级别。
2019-08-15 09:35
Liam
⽼师好,查询未加索引时⾏锁升级为表锁这⾥有个疑问,mvvc机制下select不是不加锁吗?除⾮是in share mode或for update
2019-08-12 09:01
作者回复
对的
2019-08-12 09:26
星星滴蓝天
⽼师能否多讲点innodb锁。最近我们⽼是出现锁等待的情况,⽼师可否给⼀些优化的思路
2019-08-09 22:24
作者回复
嗯嗯,后⾯会讲到死锁和锁等待的问题
2019-08-12 09:49
苏志辉
RR是基于MVVC的,⽽后者对于select不加锁,那么如果事务a有两次查询,事务b在a的两次查询之间做了修改,要保证可重 复读,a两次读取的都是b改之前的快照吗?
2019-08-09 09:16
LW
思考题:通过redo log和undo log实现
2019-08-08 15:23
作者回复
对的,redo log保证事务的原⼦性以及持久性,undo log保证事务的⼀致性。
2019-08-09 09:33
张学磊
MySQL通过事务实战原⼦性,⼀个事务内的DML语句要么全部成功要么全部失败。通过redo log和undo log实现持久性和⼀致性,当执⾏DML语句时会将操作记录到redo log中并记录与之相反的操作到undo log中,事务⼀旦提交,就将该redolog中的操作,持久化到磁盘上,事务回滚,则执⾏undo log中记录的操作,恢复到执⾏前的状态。
2019-08-08 14:29
作者回复
对的
2019-08-09 09:33
撒旦的堕落
这是因为,虽然这些操作在同⼀个事务,但锁的申请在不同时间,只有当其他操作都执⾏完,才会释放所有锁。 ⽼师 这个虽然降低了更新库存表那⾏锁持有时间 但是不是增加了订单表锁定的时间了么 还是说⼀个事务数据插⼊操作 并不会受到另⼀个事务数据插⼊操作的影响
2019-08-08 09:27
-W.LI-
⽼师好!之前听说不少互联⽹公司,把mysql数据库默认隔离级别设置为读已提交(不⼿动设默认是RR),来提⾼吞吐量。这样就 需要开发⼈员根据业务选择合适的隔离级别是么?
接着⽼师减库存的例⼦:
新建订单,减库存操作可以在,读已提交隔离级别下执⾏么?
我觉得新建订单和减库存只要保证原⼦⾏就好了。减库存是读当前操作,还是需要在RR下。
2019-08-08 08:54
我已经设置了昵称
执⾏顺序1那边,是否可以把查询条件放到事务外,减少事务⾥⾯的操作
2019-08-08 07:48