最近一个新项目上线,踩了不少坑,其中一个就是 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_typeCourse 的时候,对 processable_idlearner_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 无法保证线程安全,高并发情况下可能会出现重复创建。如果要避免这个问题,解决办法是在数据库层加唯一性限制。