如何避免Rails关联模型中使用作用域导致的N+1查询问题
好问题!你已经通过预加载mandatory_fields和service_field解决了N+1查询问题,但当前在Ruby层面做筛选的方式,会加载所有mandatory_fields数据后再过滤,还是有优化空间的。我们可以直接在数据库层面完成过滤,既避免N+1,又减少不必要的数据加载,同时让代码更简洁。
下面分享几个更优的实现方案:
方案1:查询时直接在数据库层面过滤关联数据
利用joins(或left_joins)结合includes,在查询Task的同时就筛选出符合条件的mandatory_fields:
# 只返回包含目标字段的Task,同时预加载关联避免N+1 Task.includes(mandatory_fields: :service_field) .joins(mandatory_fields: :service_field) .where(service_fields: { name: :check_in_confirmed }) .limit(10)
如果需要保留所有Task(哪怕没有对应字段),把joins换成left_joins即可:
Task.includes(mandatory_fields: :service_field) .left_joins(mandatory_fields: :service_field) .where("service_fields.name = ? OR service_fields.id IS NULL", :check_in_confirmed) .limit(10)
这种方式的优势是:数据库只返回你需要的mandatory_fields数据,不需要在Ruby层面做额外筛选,性能更优。
方案2:添加自定义关联(推荐,代码更优雅)
如果这个筛选逻辑会被多次使用,建议在Task模型中添加一个专门的关联,直接指向目标字段:
class Task < ApplicationRecord has_many :mandatory_fields, class_name: "TaskField", foreign_key: :task_id # 新增关联:直接获取名为check_in_confirmed的mandatory_field has_one :check_in_confirmed_field, -> { by_name(:check_in_confirmed) }, class_name: "TaskField", foreign_key: :task_id end
之后查询时只需预加载这个自定义关联即可:
Task.includes(:check_in_confirmed_field).limit(10)
每个Task的check_in_confirmed_field属性就是对应的字段(没有则为nil),完全避免N+1,代码可读性也更高。
方案3:利用现有Scope结合预加载
你已经在TaskField和ServiceField定义了by_name scope,也可以用eager_load(它会自动做JOIN并预加载)来结合scope:
Task.eager_load(mandatory_fields: :service_field) .merge(TaskField.by_name(:check_in_confirmed)) .limit(10)
merge方法会把TaskField.by_name的查询条件合并到主查询中,同样能在数据库层面完成过滤,避免Ruby层面的筛选。
对比你当前的实现
你当前的写法:
Task.includes(:mandatory_fields => :service_field).limit(10).map{|t| t.mandatory_fields.select{|mf| mf.service_field.name == 'check_in_confirmed'}}
虽然解决了N+1,但会加载该Task下所有的mandatory_fields数据,再在Ruby层面筛选出需要的。如果mandatory_fields数量较多,会浪费内存和带宽。上面的方案都是直接在数据库层面过滤,只加载需要的数据,性能更优。
内容的提问来源于stack exchange,提问作者Ivan Rostovsky

