find_or_create_by造成的重复创建
最近一个新项目上线,踩了不少坑,其中一个就是 find_or_create_by
。
项目是一个学习系统。用户学习一门新课的时候,会创建一个学习进程(study_process
)。如果一次没学完,再次进入的时候,会继续之前的学习进程。
这个逻辑很简单也很常规
@study_process = current_user.study_processes.find_or_create_by!(processable: @course)
系统刚上线的两天里,用户量远超过过事前的估计,又由于很多地方的代码压根没怎么考虑性能,导致系统频繁崩溃。抢修了几天之后(主要是解决代码的性能问题),系统终于恢复正常,但是却发现数据库里有些数据出现了异常。
有部分用户对于同一个课程(course
)有 2 个、甚至 8 个学习进程(study_process
)。学习进程的创建只有上面的这一句代码,很快就把问题锁定在 find_or_create_by
这个方法上。
查了 API,有这么一段话
Please note this method is not atomic, it runs first a SELECT, and if there are no results an INSERT is attempted. If there are other threads or processes there is a race condition between both calls and it could be the case that you end up with two similar records.
http://api.rubyonrails.org/
换句话说,find_or_create_by
并非线程安全的。
但是,按理来说,出现重复创建的概率非常小,除非出现高并发。再查了一下重复数据的创建时间,全部集中在系统刚上线的那两天,也就是系统不稳定的那两天。
基本上可以确定问题的原因是,虽然用户量并没有非常大,但由于代码上的性能问题,还是导致出现了高并发,find_or_create_by
无法保证线程安全,出现了重复创建的问题。
定位好问题,解决办法就有了。首先当然是在数据库里把重复的记录删除,然后在代码上想办法避免这种情况再次发生。
虽然经过代码重构,基本上解决了性能问题,系统已经稳定。以目前的用户量和服务器配置,基本不会再出现类似的高并发问题,但是,find_or_create_by
依然是一个隐患。
既然在 Rails 层无法保证重复创建,自然就想到在数据库层加以限制。其实就是加一个唯一性索引就好了。但是项目的情况又有一点特殊。
在这个项目中,学习进程(study_process
)对于同一个课程(course
)应该是唯一的,但是还有其他不同类型的学习进程,例如复习,这种情况就不能做唯一性限制了。项目用到了多态关联,现在的需求变成了
只有在
processable_type
为Course
的时候,对processable_id
和learner_id
做唯一性索引的限制。
查了一通,对于 PostgreSQL
数据库,还真能实现。
添加一个 migration
class AddUniqueIndexToProcess < ActiveRecord::Migration[5.1]
def change
add_index :study_processes, [:processable_id, :learner_id], unique: true, where: "processable_type = 'Course'"
end
end
至此,问题解决。
总结
find_or_create_by
无法保证线程安全,高并发情况下可能会出现重复创建。如果要避免这个问题,解决办法是在数据库层加唯一性限制。