目录
序言
很多ORM提供了FirstOrCreate方法。这个方法很便捷,使我们少写了很多代码。
e.g. Eloquent ORM 快速入门 | Eloquent ORM |《Laravel 5.8 中文文档 5.8》| Laravel China 社区
GORM 高级查询 | GORM - The fantastic ORM library for Golang, aims to be developer friendly.
但如果不仔细剖析一下,很容易踩坑哦。
FirstOrCreate的作用与本质
根据条件查询某一行,如果存在就返回该行,如果不存在就插入一行。
所以本质上就是两句SQL
i. SELECT fields FROM tbl WHERE conditions
ii. (如果查不到该行)
INSERT INTO tbl VALUES (conditions+attributes)
下面我试着阐述一个致命的问题,并发场景下,FirstOrCreate仅凭MySQL的常规操作难以保证业务的正确性。
FirstOrCreate在并发场景中的问题
下面讨论默认使用MySQL+innodb引擎
直接使用
Session A | Session B | |
T1 | SELECT fields FROM tbl WHERE conditions (行不存在) | |
T2 | SELECT fields FROM tbl WHERE conditions (行不存在) | |
T3 | INSERT INTO tbl VALUES ... (插入成功) | |
T4 | INSERT INTO tbl VALUES ... (插入成功) |
并发场景下因为时序的问题,查询语句得到的结果均为“行不存在”,进而两个session都进行了插入操作
解决思路
从插入语句下手,只允许一个session插入成功。可以利用唯一索引,如果成功插入了第一行,插入第二行的时候因为唯一索引限制插入失败。
但是这种解决方案的问题也很明显,首先就是需要建立唯一索引,可能无法很好的利用change buffer。
另外就是每碰到一个FirstOrCreate场景都加一下唯一索引,DBA听了都想打人。
通过大量的唯一索引保证业务是一种bad taste。业务代码能实现的话,尽量不要依赖存储层。
现实情况是,大多数的表只有主键索引是唯一索引,且主键是自增ID。而FirstOrCreate的插入语句又经常不指定主键ID,也就无法相互阻塞。
解决并发事务带来问题的两种基本方式——一致性读和锁定读,接下来我们一一试试这两种方式能否解决问题。
一致性读
利用事务“打包”查询语句和插入语句,使得查询语句与插入语句是原子性的。能否解决我们的问题呢?
先说结论:不能。
我们来看一下时序。下面表格在读提交、可重复读两种隔离级别现象一致。得到的结果和前面直接使用FirstOrCreate的时序图基本一样。
Session A | Session B | |
T1 | BEGIN; SELECT fields FROM tbl WHERE conditions (行不存在) | |
T2 | BEGIN; SELECT fields FROM tbl WHERE conditions (行不存在) | |
T3 | INSERT INTO tbl VALUES ... COMMIT; (插入成功) | |
T4 | INSERT INTO tbl VALUES ... COMMIT; (插入成功) |
SessionA和SessionB都是活跃中的事务,SessionA插入的行,SessionB不可见,而且SessionB插入操作申请到的自增ID值与SessionA申请到的自增ID是不一样的,成功躲过了主键唯一索引的校验。
那我们试试锁定读,看看能否解决问题。
锁定读
下面基于可重复读隔离级别绘制时序图
Session A | Session B | |
T1 | BEGIN; SELECT fields FROM tbl WHERE conditions for update (行不存在) | |
T2 | BEGIN; SELECT fields FROM tbl WHERE conditions for update (行不存在) | |
T3 | INSERT INTO tbl VALUES ... (Blocked) | |
T4 | INSERT INTO tbl VALUES ... (Deadlock found) |
糟了,怎么这回还死锁了?
我们来逐个分析每个语句。逐句分析前先复习一下可重复读隔离级别的加锁规则(by 丁奇)
原则 1:加锁的基本单位是 next-key lock。next-key lock 是前开后闭区间。
原则 2:查找过程中访问到的对象才会加锁。
优化 1:索引上的等值查询,给唯一索引加锁的时候,next-key lock 退化为行锁。
优化 2:索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock 退化为间隙锁。
一个 bug:唯一索引上的范围查询会访问到不满足条件的第一个值为止。
T1,因为没搜到符合条件的行,此时加的next-key lock退化为间隙锁。SessionA在相应区间持有间隙锁。
T2,同样的检索范围,也是加的next-key lock,且退化为间隙锁。SessionB在相应区间持有间隙锁。SessionB在T2加间隙锁不会被已有的SessionA间隙锁阻塞,因为间隙锁只阻塞“往这个间隙中插入一个记录”这个操作。
T3,SessionA想往该区间间隙插入记录,发现SessionA和SessionB持有间隙锁。SessionA是自身,操作允许进行。但SessionB是另一个活跃中的事务(这里强调活跃事务是想稍稍提及隐式锁的概念,隐式锁并不是实际存在的互斥锁结构,而是利用版本链实现的,详情可以读一下《从根儿上理解MySQL》),那么这时候“SessionA往该区间间隙插入记录”这个动作会被SessionB持有的间隙锁阻塞。
此时,SessionA仍旧只持有间隙锁,SessionB仍旧只持有间隙锁。(请忽略掉插入意向锁)
T4,SessionB想往该区间间隙插入记录,发现SessionA和SessionB持有间隙锁。SessionB是自身,操作允许进行。但SessionA是另一个活跃中的事务,那么这时候“SessionB往该区间间隙插入记录”这个动作会被SessionA持有的间隙锁阻塞。
此时,有趣的现象发生了。SessionA和SessionB都持有间隙锁,SessionA在等SessionB释放间隙锁,同时SessionB在等SessionA释放间隙锁,死锁啦!!!
该死的间隙锁,真是“多个香炉多只鬼”。
那我们改成读提交隔离级别,再试试
Session A | Session B | |
T1 | BEGIN; SELECT fields FROM tbl WHERE conditions for update (行不存在) | |
T2 | BEGIN; SELECT fields FROM tbl WHERE conditions for update (行不存在) | |
T3 | INSERT INTO tbl VALUES ... | |
T4 | INSERT INTO tbl VALUES ... | |
T5 | COMMIT; | |
T6 | COMMIT; |
没有了间隙锁的拘束又因为本来就没有符合条件的行,这时候其实什么都没有锁。
在读提交隔离级别下,间隙锁导致的死锁现象是没有了,但是插入了两行的问题依然存在。
为什么都用到了锁这种并发问题大杀器,依然没有办法解决问题?
i. 为了提高并发性,查询语句是可以并发进行的。并发的查询语句同时得到了“行不存在”的结果,进而导致了后续多个Session往相同的区间间隙插入数据。
ii. 锁的优化。间隙锁之间不互斥。读提交隔离级别,索引上的等值查询,向右遍历时且最后一个值不满足等值条件的时候,next-key lock 退化为间隙锁。又因为间隙锁之间不互斥,导致了这种加锁查询语句是可以并发进行的。
iii. sessionA修改了区间间隙,没有通知此时正在操作同一区间间隙的其他Session。既sessionA往区间间隙插入数据后,没有阻塞sessionB。因为sessionA和sessionB申请到的自增值不一样,在主键唯一索引上是不同的行,无需互斥。
解决方案
针对上述问题,我们分为两类解决方案:①串行化 ②写传播
先讲串行化,这个比较好理解。
串行化
只有一个事务能进行操作,活跃中的事务会阻塞其他事务。
隔壁内存数据库Redis就是这么玩的,Redis执行命令时使用单线程模型,任一时刻下只有一个进行中的读/写操作,很好的规避了并发与锁的问题。
但MySQL是需要与磁盘交互的,如果MySQL也改成单线程模型,并发度下降+磁盘操作时间较长,响应时间会明显增长。那如何把MySQL改成行的读操作互斥呢?在表级别加写锁。但是同一表的后续操作都会受这个表写锁影响,并发度大大下降。
不能降低MySQL的并发度,那要怎么做呢?利用Redis做分布式锁。
进行MySQL操作前,先抢占分布式锁。只有抢到了分布式锁才能接着进行FirstOrCreate操作,操作完成后才释放分布式锁。
这样就针对同一业务实现了串行化,不会影响其他需要使用到同一张表的业务的并发度。
写传播
写传播这个词大家可能比较陌生,这个词其实是源于CPU缓存一致性问题的解决方案。
这里稍作延伸,多核CPU中,不同的CPU核心各自独享L1/2 CPU缓存,如果此时两个核心都从内存读取了同一片缓存至L1缓存,然后两个核心都各自进行了写操作,写回内存的时候应该以哪个为准呢?这个问题称之为CPU缓存一致性问题。
为了只允许一个核心进行修改,“共享”状态的一个核心对Cache block进行修改时,会利用总线嗅探通知其余“共享”该cache block的核心修改状态为“已失效”状态。上述其实就是“写传播”的过程,要改数据的时候,使别的“共享”核心失效,已失效的核心如果也想修改,则需要同步最新的数据。
CPU缓存一致性问题其实跟上述FirstOrCreate问题有很多相似之处。不同的Session的查询同一个区间间隙就像不同的CPU核心共享了同一片Cache block;我们希望只插入一行数据正如希望只有一个CPU核心操作这一片Cache block,后续别的核心再操作时需要先同步数据。
CPU缓存一致性问题中“后续别的核心再操作时需要先同步数据” 类比为 MySQL中“后续别的Session再操作同一区间间隙需要先校验是否已有数据”,应该怎么实现呢?
MySQL中其实没有很好的解决办法,因为MySQL没有像总线嗅探这样的广播机制去告知别的事务区间间隙更新了。
举一反三
除了FirstOrCreate,有些ORM还会有UpdateOrCreate(先查询,如果存在则更新,否则插入)之类的方法。它们简化了我们书写业务逻辑的复杂度,但平时还是要多留个心眼,分析一下底层SQL运作。
笔者曾经因为FirstOrCreate并发(前端忘了做防抖),在商户的积分设置表中插入了两行一样的记录,继而导致该商户给C端发放了两倍的积分。希望大家都能引以为鉴,不要在这个方法上栽跟头。
参考资料
MySQL实战45讲_MySQL_数据库-极客时间 《20 | 幻读是什么,幻读有什么问题?》