码字不易,转载请附原链,搬砖繁忙回复不及时见谅,技术交流请加QQ群:909211071
一切都源自一场面试
由于初出茅庐不知天高地厚,在简历上写了熟练掌握MySQL事务,今天去面了某大厂,被面试官虐的体无完肤,下面是当时的对话:
面试官:我看你写熟练掌握MySQL事务,那我们简单聊一下事务的隔离模式吧,先说一下有哪几种隔离模式?
我:四种隔离级别由低到高分别是读未提交(Read Uncommitted)、读已提交(Read Committed)、可重复度(Repeatable Read),串行(Serializable)。
面试官:那你能不能聊一下每个模式下解决了什么问题,存在什么问题呢?
我:由上到下应该是分别解决了脏读,呃,幻读,可重复读,每个阶段存在的问题,呃,这个记不太清了。。。
面试官:ok,了解了,我们今天的面试先到这,后面有消息会通知你。
恶补
回来好好翻了翻《InnoDB技术内幕》,又看了几篇大佬的文章,同时模拟了每种隔离模式的各种场景,终于对四种隔离模式有了深入的了解。 (由于看很多文章上面放了很多SQL测试截图,对于当初不太懂隔离模式的我,看起来是比较懵逼的,并且截图会占太大篇幅,影响各位看官的整体阅读理解,所以就不贴拉,但是还是建议小伙伴们亲自开2个事务实际操作一下,相信很快就能理解)
各个隔离模式表现
- 读未提交(Read Uncommitted):可以看到其他事务未提交的内容和已提交的内容,脏读、不可重复读、幻读
- 读已提交(Read Committed):可以看到其他事务已提交读内容,不可重复读、幻读
- 可重复读(Repeatable Read):事务开始时和事务结束时读到的数据完全相同、幻读(部分解决,详细原因下面有)
- 串行(Serializable):事务必须逐步执行,后来的会排队、不存在脏读、幻读、不可重复读
实现的原理
数据库通过视图来实现隔离模式,访问的时候以视图的逻辑结果为准。
- 读未提交:直接返回记录上的最新值,没有视图概念
- 读已提交:在每个SQL语句开始执行的时候创建视图
- 可重复读:在事物启动时创建视图,整个事务提交之前都用这个视图
- 串 行:直接用加锁的方式来避免并行访问
隔离级别下的问题
前提条件:同时开启A和B两个事务
脏读:
- 事务查询id为1的数据,num字段为1
- B事务将id为1的数据num更新为2,但未commit
- 事务查询id为1的数据,num字段变为2
- B回滚,则A读到的数据为脏数据
不可重复读:
- 事务查询id为1的数据,num字段为1
- B事务将id为1的数据num更新为2,commit
- 事务查询id为1的数据,num字段变为2
- A事务前后两次读到的同一条记录num字段不同,不可重复读
幻读:
- A事务查询id >=1 and id <=3的数据,得到id=1和id=3两条数据(注意:没有id=2的数据)
- B事务插入id=2的数据
- A事务再查询id >=1 and id <=3的数据,发现多了一条id=2的数据,即两次查询数据的行数不同
各种隔离模式下会出现的问题
- 读未提交:脏读、不可重复度、幻读
- 读已提交:不可重复读、幻读
- 可重复读:幻读
MVCC
划重点:快照读,无需加锁,同时解决了不可重复读、部分幻读的问题,当事务内部update之后,会引发当前版本号升级,还是会出现幻读。
解决幻读:
- 事务A:查询num>=2 and num<=4的记录,但是不加for update!不加for update!不加for update!
- 事务B:insert into table ('', 3),不会被阻塞,不会被阻塞,不会被阻塞!然后commit
- 事务A:查询num>=2 and num<=4的记录,和之前查询相同
- 事务A:提交
- 事务A:查询num>=2 and num<=4的记录,可以查询到num=3的记录,此时返回3条结果
版本号升级现象:
- 事务A:查询num>=2 and num<=4的记录,但是不加for update!不加for update!不加for update!
- 事务B:insert into table ('', 3),不会被阻塞,不会被阻塞,不会被阻塞!然后commit
- 事务A:查询num>=2 and num<=4的记录,和之前查询相同
- 事务A:update table set name = 'mysql' where nume = 2; (版本号升级)
- 事务A:查询num>=2 and num<=4的记录,可以查询到num=3的记录,此时返回3条结果
- 事务A:提交
快照读和当前读
MySQL的读操作分为快照读和当前读:
快照读:提高读-写或写-读场景的并发性,只对写操作进行加锁,读操作读取当前事务的可见版本,通过MVCC实现,每个事务读取的是当前事务的可见版本(视图)。简单的说,MVCC机制对数据库的任何修改的提交都不会直接覆盖之前的数据,而是产生一个新的版本与老版本共存,可重复读模式就是通过MVCC的版本号,使事务开始时和结束时读同一个视图的数据,以此达到不用加锁,同时解决了不可重复读、幻读(版本号升级除外)的问题。
当前读:读和写都加锁,读取的是当前版本,通过next-key实现。当我们开启一个事务,并且在事务中查询时加select...lock in share mode或for update时,以及insert、update、delete操作,会使用next-key锁定查询范围里对应的行数,并且该锁是排他锁,其他事务排队等待。
小应用
电商场景下的库存处理,用读已提交模式(因为需要在当前事务内检测到其他事务对库存的更改),采用CAS+版本号的方式,失败自旋10次重试,全失败返回错误,否则返回成功。
$returnData = [
'is_success' => true,
'lack_goods_id_list' => [],
];
$goodsIdList = array_column($agentOrderList, 'goods_id');
$sql = 'select id, stock, version from t_goods where id in (%s)';
$sql = sprintf($sql, implode(',', $goodsIdList));
$rows = $this->modelGoods->sqlquery($sql);
if(!$rows){
$returnData['is_success'] = false;
return $returnData;
}
$hasLack = false;
$goodsList = array_column($rows, null, 'id');
foreach($agentOrderList as $agentOrder){
$goodsId = $agentOrder['goods_id'];
$agentCount = $agentOrder['agent_count'];
$stock = $goodsList[$goodsId]['stock'];
$version = $goodsList[$goodsId]['version'];
//先判断库存是否充足
if($stock < $agentCount){
$returnData['lack_goods_id_list'][] = $goodsId;
$hasLack = true;
$returnData['is_success'] = false;
continue;
}
if($hasLack){
continue;
}
$maxAttempt = 10;
$currentIsSuccess = false;
$changeSql = 'update t_goods set stock = stock - %d, version = version + 1 where id = %d and version = %d';
$changeSql = sprintf($changeSql, $agentCount, $goodsId, $version);
while(--$maxAttempt){
$changeResult = $this->modelGoods->sqlquery($changeSql);
if($changeResult){
$currentIsSuccess = true;
break;
}
$where = [
'id' => $goodsId,
];
$row = current($this->modelGoods->select($where, [], 0, 1, $fields));
$version = $row['version'];
}
if(!$currentIsSuccess){
$returnData['is_success'] = false;
return $returnData;
}
}
return $returnData;