ORM提供的FirstOrCreate方法,你用对了吗?

目录

序言

FirstOrCreate的作用与本质

FirstOrCreate在并发场景中的问题

直接使用

一致性读

锁定读

解决方案

串行化

写传播

举一反三


序言

很多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 | 幻读是什么,幻读有什么问题?》

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值