并发下正确的FirstOrCreate数据库操作
并发下正确的FirstOrCreate数据库操作
FirstOrCreate
是开发过程中经常能遇到的一种操作,即:尝试查找,如果不存在则创建。如此简单的操作,但是实际操作起来比较容易踩坑。
现有的FirstOrCreate是如何做的?有什么问题?
在gorm框架中,有函数FirstOrCreate,但是我们可以发现其底层并不是用一句SQL实现的,而是拆分成了两句SQL,大概日志如下:
[INFO][2025-03-19T17:40:53.869+0800]_com_mysql_success||sql=SELECT * FROM `es_operator` WHERE `phone` = '123' ORDER BY `es_operator`.`id` LIMIT 1||proc_time=1.851||rows=0
[INFO][2025-03-19T17:40:53.872+0800]_com_mysql_success||sql=INSERT INTO `es_operator` (`phone`,`create_time`) VALUES ('123','0000-00-00 00:00:00')||proc_time=3.155||rows=1
这么操作的意思也很简单:尝试找到phone为123的数据,由于第一条结果为0,那就插入phone为123的数据。
对于并发比较敏感的同学应该很容易就可以看出来这样操作会在并发下存在重复插入数据的问题。
一个很直接的想法是Gorm框架为什么不将两步操作合并成一个事务中来解决并发的问题。
实际上是不能这么操作,因为简单的合并到一个事务中并不能解决并发下可能的重复插入问题,反而可能产生死锁的问题,具体看后文分解。
两步操作合并到一步可行吗?
由于索引会影响mysql的锁的范围,因此需要区分有无索引来分析。
- 数据库版本:
5.7.16-log
- 模拟事务的操作:两个会话都执行如下SQL,在第一个执行之后执行第二个,第二个预期会在
SELECT SLEEP(15);
的时候执行,从而完成达到如下图的时序。
START TRANSACTION;
SELECT * FROM es_operator WHERE phone = '123456678' FOR UPDATE;
SELECT SLEEP(15); //睡眠的目的是在第一个执行到这的时候等下第二个事务,从而可以方便测试后面Insert的情况
INSERT INTO es_operator (phone) VALUES ('123456678');
SELECT SLEEP(65);
COMMIT;
这里稍微解释下为什么SELECT
语句中有FOR UPDATE
,因为MySQL的隔离级别一般设置为可重复读,如果不加FOR UPDATE
,可重复读隔离级别下是用MVCC的,不会加锁,没有锁的话使用事务也没什么意义了。更具体的手动加锁见//对读取的记录加共享锁select ... lock in share mode;//对读取的记录加独占锁select ... for update;1
当然可以选择手动开启事务,然后一句话一句话执行,开始我没注意到可以这么操作,反应过来的时候已经开始写总结了,就偷懒不再来一遍了,因为最终结果都是一样的。
没有索引的情况
情况总结:由于没有索引,第一个事务的for update会直接开启表锁,因此第二个事务会直接等待第一个事务执行完之后才执行。 最后会导致数据库中有两条数据。
具体过程我们可以在事务执行过程中通过information_schema.INNODB_TRX
查看,可以看到另一个事务的状态(trx_state
)是LOCK WAIT
,在等待锁的释放。
mysql> SELECT * FROM information_schema.INNODB_TRX;
+----------+-----------+---------------------+-----------------------+---------------------+------------+---------------------+----------------------------------------------------------------+---------------------+-------------------+-------------------+------------------+-----------------------+-----------------+-------------------+-------------------------+---------------------+-------------------+------------------------+----------------------------+---------------------------+---------------------------+------------------+----------------------------+
| trx_id | trx_state | trx_started | trx_requested_lock_id | trx_wait_started | trx_weight | trx_mysql_thread_id | trx_query | trx_operation_state | trx_tables_in_use | trx_tables_locked | trx_lock_structs | trx_lock_memory_bytes | trx_rows_locked | trx_rows_modified | trx_concurrency_tickets | trx_isolation_level | trx_unique_checks | trx_foreign_key_checks | trx_last_foreign_key_error | trx_adaptive_hash_latched | trx_adaptive_hash_timeout | trx_is_read_only | trx_autocommit_non_locking |
+----------+-----------+---------------------+-----------------------+---------------------+------------+---------------------+----------------------------------------------------------------+---------------------+-------------------+-------------------+------------------+-----------------------+-----------------+-------------------+-------------------------+---------------------+-------------------+------------------------+----------------------------+---------------------------+---------------------------+------------------+----------------------------+
| 38207662 | LOCK WAIT | 2025-04-19 15:34:30 | 38207662:1622197:3:5 | 2025-04-19 15:34:30 | 2 | 32 | SELECT * FROM es_operator WHERE phone = '123456678' FOR UPDATE | starting index read | 1 | 1 | 2 | 1136 | 1 | 0 | 0 | REPEATABLE READ | 1 | 1 | NULL | 0 | 0 | 0 | 0 |
| 38207661 | RUNNING | 2025-04-19 15:34:27 | NULL | NULL | 2 | 31 | SELECT SLEEP(25) | NULL | 0 | 1 | 2 | 1136 | 4 | 0 | 0 | REPEATABLE READ | 1 | 1 | NULL | 0 | 0 | 0 | 0 |
+----------+-----------+---------------------+-----------------------+---------------------+------------+---------------------+----------------------------------------------------------------+---------------------+-------------------+-------------------+------------------+-----------------------+-----------------+-------------------+-------------------------+---------------------+-------------------+------------------------+----------------------------+---------------------------+---------------------------+------------------+----------------------------+
2 rows in set (0.01 sec)
执行的时序图:
有索引的情况
有唯一索引的情况
有唯一索引的情况下,我们可以利用唯一索引冲突在SQL中直接完成并发安全的写法,like:
INSERT INTO es_operator (phone) VALUES ('123456678');
有普通索引的情况
情况总结:普通索引不同于没有索引,第一个事务的for update
不会锁表,而是用行锁+间隙锁的方式锁(独占),由于不存在此行,因此全部是间隙锁,间隙锁不冲突,因此第二个事务同样可以添加间隙锁。但是会在执行插入的时候死锁,导致一个事务rollback,另一个事务成功执行,最后数据库中有一条数据。
在事务执行过程中通过information_schema.INNODB_TRX
查看,可以看到两个事务的trx_state
都是RUNNING
。
mysql> SELECT * FROM information_schema.INNODB_TRX;
+----------+-----------+---------------------+-----------------------+------------------+------------+---------------------+------------------+---------------------+-------------------+-------------------+------------------+-----------------------+-----------------+-------------------+-------------------------+---------------------+-------------------+------------------------+----------------------------+---------------------------+---------------------------+------------------+----------------------------+
| trx_id | trx_state | trx_started | trx_requested_lock_id | trx_wait_started | trx_weight | trx_mysql_thread_id | trx_query | trx_operation_state | trx_tables_in_use | trx_tables_locked | trx_lock_structs | trx_lock_memory_bytes | trx_rows_locked | trx_rows_modified | trx_concurrency_tickets | trx_isolation_level | trx_unique_checks | trx_foreign_key_checks | trx_last_foreign_key_error | trx_adaptive_hash_latched | trx_adaptive_hash_timeout | trx_is_read_only | trx_autocommit_non_locking |
+----------+-----------+---------------------+-----------------------+------------------+------------+---------------------+------------------+---------------------+-------------------+-------------------+------------------+-----------------------+-----------------+-------------------+-------------------------+---------------------+-------------------+------------------------+----------------------------+---------------------------+---------------------------+------------------+----------------------------+
| 38207684 | RUNNING | 2025-04-19 18:02:08 | NULL | NULL | 2 | 32 | SELECT SLEEP(15) | NULL | 0 | 1 | 2 | 1136 | 1 | 0 | 0 | REPEATABLE READ | 1 | 1 | NULL | 0 | 0 | 0 | 0 |
| 38207683 | RUNNING | 2025-04-19 18:02:06 | NULL | NULL | 2 | 31 | SELECT SLEEP(25) | NULL | 0 | 1 | 2 | 1136 | 1 | 0 | 0 | REPEATABLE READ | 1 | 1 | NULL | 0 | 0 | 0 | 0 |
+----------+-----------+---------------------+-----------------------+------------------+------------+---------------------+------------------+---------------------+-------------------+-------------------+------------------+-----------------------+-----------------+-------------------+-------------------------+---------------------+-------------------+------------------------+----------------------------+---------------------------+---------------------------+------------------+----------------------------+
2 rows in set (0.01 sec)
执行过程中的navicate截图:报错1213,是死锁的报错:
事务执行的时序图:
需要注意的是这里使用串行化隔离级别也是一样有这个问题的,原因在于使用串行化隔离级别相比于可重复读隔离级别,写锁是没什么区别的,只是读的时候也会加共享的读锁(MVCC的select是不加锁的)。
总结:我们应该怎么做?
从FirstOrCreate
的语义上可以分析出来,语句插入的数据肯定是希望满足where条件的唯一性,比如说要求:用户uid唯一(单列唯一),一个用户每天只能创建一个帖子(多列组合唯一)。因此使用唯一索引或联合唯一索引冲突来解决这个问题。
如果实在不想使用唯一索引冲突,比如说脑袋抽抽了,或者考虑到唯一索引没办法利用change buffer来优化写入速度(意义不大) 或者因为涉及多个表甚至多个库导致无法用到唯一索引等情况,最好从业务侧来解决这个问题了,比如说:1.使用分布式锁,保证全局只有一个实例。
从未有经过严谨实战的角度来说,只推荐使用唯一索引的方式来解决问题。
主要原因在于:
- 不出错,因为分布式锁还是有出错的可能性,因为场景都是数据严谨性要求特别高(一条重复都不能有)的场景。如果数据要求没那么严谨,也就没必要考虑重复的问题了,直接梭哈,有重复的再后期清理就对了。
- change buffer优化场景和能力有限且完全低效不了分布式锁带来的耗时:个人认为唯一索引的读取应该非常快且对于这种场景(先读后写),change buffer有多少优化效果也存疑。如果引入分布式锁,耗时反而上升!change buffer 详细见:09 普通索引和唯一索引,应该怎么选择?2。
及其不推荐在能使用唯一索引或联合唯一索引的情况下使用select xxx for update
来解决这个问题:
- 没有索引:会锁表 + 第二个事务也会执行成功,产生冗余数据!
- 有索引:第二个事务会死锁检测而rollback,因此影响mysql性能+业务报错!
FirstOrCreate
遇到的问题和解决方案对于InsertOrUpdate
也是同样的适用。
参考:
mysql SERIALIZABLE隔离级别死锁问题 - 简书
ORM提供的FirstOrCreate方法,你用对了吗?-CSDN博客
创建联合唯一索引:MySQL中添加唯一约束和联合唯一约束_51CTO博客_mysql添加唯一约束
↩//对读取的记录加共享锁 select ... lock in share mode;//对读取的记录加独占锁 select ... for update;
09 普通索引和唯一索引,应该怎么选择?
结论先行:在字段不能重复的前提下,如果业务在插入前可以保证关键字段不重复,那么使用普通索引好于唯一索引。
首先介绍
change buffer
,其也在innodb的buffer pool缓冲区中,其作用是延后写的时机:- 更新某一行数据:如果数据本身在buffer pool中,那么会直接更新该行数据,变成脏页。此时与change buffer 无关。
- 更新某一行数据:如果数据不在buffer pool中,那么会直接更新change buffer,而不用读该行数据,就可以直接提交了。
因此可以看到
change buffer
的作用就是在更新时内存中没有该行数据就可以避免读某行数据。
该行数据会在:1.后台定期merge操作的时候更新;2.读取改行数据到内存的时候更新。根据
change buffer
的作用总结:change buffer
的适用场合:写多读少 写了之后不会马上读。比如日志等功能。
如果写了之后马上读,那么change buffer
无法节省时间空间,还会增加维护change buffer
的消耗。那么可以回到正题了,在业务可以保证唯一性的前提下,使用普通索引更好的原因是因为普通索引可以利用
change buffer
,直接更新内存中的change buffer
和redo log
就可以提交了,而使用唯一索引,数据库就会检查唯一约束,此时会从数据库中读取数据(MySQL实际是按页来读取数据的),从而检查是否满足唯一约束,那么就无法利用change buffer加速了。
change buffer
与redo log
优化思路的不同点:change buffer可以优化随机读,而redo log是用于优化随机写。这里面介绍了
change buffer
,是小林中接近是一笔带过的内容,chang buffer
是buffer pool
的一部分,值得学习。 ↩