理解 Rails 的预加载
注:这篇文章是我几年前发表在Medium上的。我在这里重新发布,因为这样可能对某些人更有帮助。
如果你和我一样,没有什么比一堆机器生成的、热气腾腾的 SQL 代码更让人眼花缭乱的了。我的目标是写一篇 Rails 预加载指南,重点在于激励人心,而不是冗长的日志语句。希望我能为你提供我曾经从未得到的解释。
本文假设您了解模型关联,熟悉 JOIN 操作,并且了解N+1 查询问题。如果您对这些概念感到陌生,我建议您先从简单的 SQL(SELECT、JOIN 和 WHERE)入手。
(这才是简化操作的真正诀窍。虽然 Active Record 从记录创建对象的方式足够直观,但它的查询生成功能并不能让你免于理解查询在数据库中是如何执行的。)
我们开始吧!
为什么我们需要预加载?
换句话说:为什么 N+1 查询不好?因为数据库访问成本很高。查询有开销,事务有开销,而且与远程数据库服务器通信也很慢。你肯定不想在循环中这样做,但 ORM 很容易让你在这方面搬起石头砸自己的脚。
好消息是,如果我们告诉 Active Record 我们计划稍后使用的关联,它可以通过少量查询预加载关联的记录,这样任何循环都可以在内存中已有的记录上进行。
eager_load:一个查询,使用 JOIN
请看这个例子:
users = User.eager_load(:address)
cities = users.map do |user|
user.addresses.city
end
利用 SQL JOIN 的强大功能,Active Record 可以通过单个查询获取用户及其地址。而且由于eager_load使用了左连接,您将获得所有用户,而不仅仅是那些拥有地址的用户。
我就不赘述完整的 SQL 输出了,但你要知道 SELECT 子句中包含许多类似这样的条目"user.name" AS t0_r0。你可能已经注意到,这t0_r0是一个直角哭泣的表情符号,但你知道吗?它和它的编号伙伴们使得两个表中的每一列在查询过程中都可用。这意味着我们可以在链式WHERE或GROUP BY 子句中引用关联模型中的任何属性,例如:
users = User.eager_load(:address).where(address: { city: 'New Haven' })
streets = users.map do |user|
user.address.street
end
所以我们把所有信息都放在一个查询语句里了,而且我们的 WHERE 子句也运行正常。我们完成了吗?
未必如此:事实证明,这些工厂化eager_load查询不仅令人眼花缭乱,而且速度也可能比我们下一个选项生成的查询慢得多(甚至慢一个数量级以上!)。这部分是因为连接操作开销很大,部分是因为连接操作会引入重复数据,从而增加需要创建的对象数量。
如果连接操作没有在索引列上进行,Codd 可以帮到你。
preload每个表一个查询
这种方法很简单:AR 会为每个引用的表生成一个查询。由于没有连接操作,即使考虑到额外的查询开销,这种方法通常也更快。
preload但是,虽然我们第一个示例用替换后的语句可以正常运行eager_load,但第二个示例(需要addresses.city在生成的 WHERE 子句中使用)却会失败。由于用户查询仅对用户表执行 SELECT 操作,因此该查询的 WHERE 子句不能引用地址表中的列。(地址查询在这里不起作用,因为它单独执行。)
includespreload默认情况下,如有eager_load必要
这是最常用的方法,如果您读到这里,应该不需要太多解释。使用 `AR` 时includes,默认情况下,AR 会像使用 `AR` 一样分别加载每个表preload。但是,如果您在链式调用 `AR`或`AR`中指定关联表includes,`AR`将切换到eager_load`AR` 的“连接所有表”的行为。wheregroup
提示:如果您调用的where是groupSQL 字符串而不是哈希,则可以使用它references来触发连接。
joins: ˙\_(ツ)_/˙
不出所料,joins它还会生成一个使用 JOIN 的查询(这次是内连接)。
这里的主要优势在于您可以更好地控制生成的查询。请记住,` eager_loadand`includes会返回连接表中的每一列select。即使调用了 `!`函数,情况也是如此!
通常情况下,当结果集需要时,你会使用joins这种方法——例如,当你只想获取 INNER JOIN 所描述的记录时,或者当一个函数where依赖于关联表中的列,但你不想引入这些表中的其他列时。
因此,尽管joins它经常出现在关于预加载的文章中,但它主要用于限制加载的内容。它可以与某些特定加载方式结合使用select。我们来看一个例子:
users = User.joins(:address).select('addresses.city AS city')
cities = users.map do |user|
user.city
end
这里,city它就变成了用户实例上的一个新方法,因为我们在参数中使用了 AS 别名来进行选择。
提示:这将导致某些对象拥有同类其他对象所没有的方法。这可能会造成代码混乱——您可以通过在模型类中也定义额外的方法来缓解这个问题。这样,预加载就只是一个可选的加速功能,而不是调用正常工作的必要条件。不过,现在或许是引入查询对象的好时机。
请注意joins,使用“select + alias → new method”技巧是利用预加载值的唯一方法。user.address.city在该循环中调用其他方法会忽略内存中的值,并根据用户数量触发尽可能多的新查询。
其他优化
要弄清楚Active Record的哪些部分带来了哪些速度提升,可能会让人感到困惑。为了完整起见,我们来看看AR用来避免查询的几个不相关的技巧。
首先,AR使用代理对象来表示查询,只有在调用诸如all`getQuery( )` first、`getResult count()` 或 ` getResultSet()` 之类的方法时才会将其替换为结果集each。这种设计也使我们能够通过链式调用来构建查询。
AR还会缓存最近查询的结果。如果多次执行相同的查询,数据库只会被访问一次。(当日志显示“CACHE”而不是“LOAD”时,表示结果是从内存而不是数据库中读取的。)
遗憾的是,这两种方法都无法解决 N+1 问题,即应用程序代码中的循环会触发多个不同的查询。要解决这个问题,我们仍然需要预加载。
最后提示
- 通常情况下,获取相同数据的方法有很多种。了解每种方法意味着你可以选择速度最快或最易读的方法。
- 进行基准测试时,尽量使用与生产环境类似的设置。本地数据库的每次查询开销远低于远程数据库。
- 您还应该对打算进行 JOIN 操作的列建立索引,但请注意,索引并非万能的。索引会减慢 INSERT 和 UPDATE 操作的速度,甚至会减慢读取操作的速度(如果“存储顺序”足够,例如读取整个表)。
- 当预加载仍然不够用时,一个有用的模式是构建一个连接不同模型的哈希映射,并使用该映射“手动”查找关联的值。这在Presenter/View 对象中尤其有用。
- 此外,还有像goldiloader这样的库,它们试图实现比 AR 更强大的预加载自动化功能。
- 当您只需要某些列时,请使用
pluck.
推荐阅读
- Rails 指南:Active Record 查询接口:关联关系的预先加载
- BigBinary:预加载、急加载、包含和连接
- Wendi的博客:Rails中的preload、eager_load、includes、reflections和joins
- AkitaOnRails:Rails 2.1 入门教程 - 第一篇完整教程 - 第二部分
- APIDock:
references