【说明:资料来自https://ruby-china.org/topics/32364

ORM框架的性能小坑

在使用ActiveRecord这样的ORM工具时,常会嵌套遍历model。
例如,有两个model,Post、Comment,关系是一对多。

class Post < ApplicationRecord
has_many :comments
end class Comment < ApplicationRecord
belongs_to :post
end

总共有4个post。

> Post.count
(0.1ms) SELECT COUNT(*) FROM "posts"
=> 4

获取每个post的所有comment,我们可以:

> Post.all.map{|post| post.comments}
Post Load (0.3ms) SELECT "posts".* FROM "posts"
Comment Load (0.2ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ? [["post_id", 1]]
Comment Load (0.4ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ? [["post_id", 2]]
Comment Load (0.6ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ? [["post_id", 3]]
Comment Load (0.6ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ? [["post_id", 4]]

可以看到为了得到4条数据,我们执行了5(4 + 1)次的查询,这就是所谓N + 1查询问题。

发现问题

除了凭经验外,有一些gem也可以帮助我们提早发现N + 1查询问题。
例如收费的New Relic,免费的Bullet

解决问题

预加载

简单来说,就是提前加载model关系,让ActiveRecord预先加载所需要的数据。
ActiveRecord提供了以下三个方法预加载。

  • includes
  • preload
  • eager_load

他们的区别可以参考这里这里
以最常用的includes方法为例。

> Post.includes(:comments).map{|post| post.comments}
Post Load (0.2ms) SELECT "posts".* FROM "posts"
Comment Load (0.5ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN (1, 2, 3, 4)

得到的结果一样,但执行的查询只有两次。

傻瓜式预加载(Goldiloader)

传统预加载的“问题”

includes方法的确很惊艳,但……

第一,代码不够优雅。
例如,假设我们现在想找的是id在1到3之间的post的comment。
一般的我们的逻辑是,查找id在1到3之间的post,获取各post的comment然后合并。
而预加载后的逻辑是,查找id在1到3之间的post,关联comment,再获取各post的comment然后合并。
总觉得有点冗余。

> Post.where(id: 1..3).includes(:comments).map{|post| post.comments}
Post Load (0.5ms) SELECT "posts".* FROM "posts" WHERE ("posts"."id" BETWEEN ? AND ?) [["id", 1], ["id", 3]]
Comment Load (0.5ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN (1, 2, 3)

第二,不符合DRY。
既然我们都不喜欢N + 1,那就应该从源头上杜绝,而不是每次查询时都要主动includes一次。

Goldiloader

懒癌程序员的救星Goldiloader——几乎完美的解决了以上两个问题。

gem 'goldiloader'

bundle install以后,就可以用最直接(傻瓜)的方式点点点……

> Post.where(id: 1..3).map{|post| post.comments}
Post Load (0.2ms) SELECT "posts".* FROM "posts" WHERE ("posts"."id" BETWEEN ? AND ?) [["id", 1], ["id", 3]]
Comment Load (0.3ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN (1, 2, 3)
auto_include

Goldiloader默认自动加载所有关联数据,用auto_include: false可以方便地关闭自动加载。

class Post < ApplicationRecord
has_many :comments, auto_include: false
end
fully_load

以下的方法比较特殊,如果关系已经加载了,则会直接返回已缓存的值,如果没被加载,则会通过SQL查询。

  • first
  • second
  • third
  • fourth
  • fifth
  • forty_two
  • last
  • size
  • ids_reader
  • empty?
  • exists?

假设现在我们需要获取每个post的最新的comment。
但这不是我们想要的。

> Post.all.sum{|post| [post.id, post.comments.last&.content]}
Post Load (0.1ms) SELECT "posts".* FROM "posts"
Comment Load (0.1ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ? ORDER BY "comments"."id" DESC LIMIT ? [["post_id", 1], ["LIMIT", 1]]
Comment Load (0.1ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ? ORDER BY "comments"."id" DESC LIMIT ? [["post_id", 2], ["LIMIT", 1]]
Comment Load (0.1ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ? ORDER BY "comments"."id" DESC LIMIT ? [["post_id", 3], ["LIMIT", 1]]
Comment Load (0.1ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ? ORDER BY "comments"."id" DESC LIMIT ? [["post_id", 4], ["LIMIT", 1]]

添加选项full_load: true后,当调用上述方法时,Goldiloader会强制自动加载所需的关系。

class Post < ApplicationRecord
has_many :comments, fully_load: true
end

这才是我们想要的。

> Post.all.sum{|post| [post.id, post.comments.last&.content]}
Post Load (0.3ms) SELECT "posts".* FROM "posts"
Comment Load (0.2ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN (1, 2, 3, 4)

Goldiloader也不是万能的

has_one使用SQL limit时的隐患

Goldiloader是ActiveRecord的衍生工具,所以ActiveRecord预加载的副作用也一并继承了。
现在我们自定义一个has_one关系,用以获取最新的一条comment。

class Post < ApplicationRecord
has_many :comments, fully_load: true
has_one :latest_comment, -> { order(created_at: :desc) }, class_name: 'Comment'
end

遍历post获取最新的comment。

> Post.all.map{|post| post.latest_comment}

不使用Goldiloader或者预加载时,每条SQL自动回加上limit 1

Post Load (0.3ms)  SELECT "posts".* FROM "posts"
Comment Load (0.2ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ? ORDER BY "comments"."created_at" DESC LIMIT ? [["post_id", 1], ["LIMIT", 1]]
Comment Load (0.2ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ? ORDER BY "comments"."created_at" DESC LIMIT ? [["post_id", 2], ["LIMIT", 1]]
Comment Load (0.1ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ? ORDER BY "comments"."created_at" DESC LIMIT ? [["post_id", 3], ["LIMIT", 1]]
Comment Load (0.1ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = ? ORDER BY "comments"."created_at" DESC LIMIT ? [["post_id", 4], ["LIMIT", 1]]

使用Goldiloader或者预加载时,世界变清净了,但同时会有性能隐患,因为post的数据量可能非常大。

Post Load (0.5ms)  SELECT "posts".* FROM "posts"
Comment Load (0.2ms) SELECT "comments".* FROM "comments" WHERE "comments"."post_id" IN (1, 2, 3, 4) ORDER BY "comments"."created_at" DESC
其他限制

遇到以下的关系(方法),Goldiloader会自动关闭自动预加载。

  • limit
  • offset
  • finder_sql
  • group (due to a Rails bug)
  • from (due to a Rails bug)
  • joins (only Rails 4.0/4.1 - due to a Rails bug)
  • uniq (only Rails 3.2 - due to a Rails bug)

本文结束之前

N + 1查询问题是一个容易被忽略的问题。
发现解决它也不难,includes已经够用,Goldiloader更是锦上添花,对新手足够友好。
不过对于我这种被Rails“坑”习惯的斯德哥尔摩症候群患者来说,没有includes反而没安全感了>_<|||

参考文档

Rails 浅谈 ActiveRecord 的 N + 1 查询问题(copy)的更多相关文章

  1. 浅谈T-SQL中的子查询

    引言 这篇文章我们来简单的谈一下子查询的相关知识.子查询可以分为独立子查询和相关子查询.独立子查询不依赖于它所属的外部查询,而相关子查询则依赖于它所属的外部查询.子查询返回的值可以是标量(单值).多值 ...

  2. 浅谈T-SQL中的联接查询

    引言 平时开发时,经常会使用数据库进行增删改查,免不了会涉及多表联接.今天就简单的记录下T-SQL下的联接操作. 联接类型及其介绍 在T-SQL中联接操作使用的是JOIN表运算符.联接有三种基本的类型 ...

  3. 浅谈sql 、linq、lambda 查询语句的区别

    浅谈sql .linq.lambda 查询语句的区别 LINQ的书写格式如下: from 临时变量 in 集合对象或数据库对象 where 条件表达式 [order by条件] select 临时变量 ...

  4. 浅谈SQL优化入门:1、SQL查询语句的执行顺序

    1.SQL查询语句的执行顺序 (7) SELECT (8) DISTINCT <select_list> (1) FROM <left_table> (3) <join_ ...

  5. 浅谈oracle树状结构层级查询之start with ....connect by prior、level及order by

    浅谈oracle树状结构层级查询 oracle树状结构查询即层次递归查询,是sql语句经常用到的,在实际开发中组织结构实现及其层次化实现功能也是经常遇到的,虽然我是一个java程序开发者,我一直觉得只 ...

  6. 浅谈oracle树状结构层级查询测试数据

    浅谈oracle树状结构层级查询 oracle树状结构查询即层次递归查询,是sql语句经常用到的,在实际开发中组织结构实现及其层次化实现功能也是经常遇到的,虽然我是一个java程序开发者,我一直觉得只 ...

  7. oracle树形结构层级查询之start with ....connect by prior、level、order by以及sys_connect_by_path之浅谈

    浅谈oracle树状结构层级查询 oracle树状结构查询即层次递归查询,是sql语句经常用到的,在实际开发中组织结构实现及其层次化实现功能也是经常遇到的,虽然我是一个java程序开发者,我一直觉得只 ...

  8. 浅谈MySQL中优化sql语句查询常用的30种方法 - 转载

    浅谈MySQL中优化sql语句查询常用的30种方法 1.对查询进行优化,应尽量避免全表扫描,首先应考虑在 where 及 order by 涉及的列上建立索引. 2.应尽量避免在 where 子句中使 ...

  9. c#Winform程序调用app.config文件配置数据库连接字符串 SQL Server文章目录 浅谈SQL Server中统计对于查询的影响 有关索引的DMV SQL Server中的执行引擎入门 【译】表变量和临时表的比较 对于表列数据类型选择的一点思考 SQL Server复制入门(一)----复制简介 操作系统中的进程与线程

    c#Winform程序调用app.config文件配置数据库连接字符串 你新建winform项目的时候,会有一个app.config的配置文件,写在里面的<connectionStrings n ...

随机推荐

  1. 把disable maven nature后的项目,恢复菜单呈现出来(Convert to Maven Project)

    把disable maven nature后的项目,恢复菜单呈现出来(Convert to Maven Project) 有的时候需求把disable maven nature后的项目,再转换为mav ...

  2. 最长链(codevs 1814)

    题目描述 Description 现给出一棵N个结点二叉树,问这棵二叉树中最长链的长度为多少,保证了1号结点为二叉树的根. 输入描述 Input Description 输入的第1行为包含了一个正整数 ...

  3. MySQL的字符串连接函数CONCAT, CONCAT_WS,GROUP_CONTACT

    本文转载自de.cel<MySQL的字符串连接函数CONCAT, CONCAT_WS,GROUP_CONCAT>   在搜索Mysql中怎么实现把一列的多行数据合并成一行时,找到了grou ...

  4. UVA 861 组合数学 递推

    题目链接 https://vjudge.net/problem/UVA-861 题意: 一个国际象棋棋盘,‘象’会攻击自己所在位置对角线上的棋子.问n*n的棋盘 摆放k个互相不攻击的 '象' 有多少种 ...

  5. c++ 实现 key-value缓存数据结构

    c++ 实现 key-value缓存数据结构 概述 最近在阅读Memcached的源代码,今天借鉴部分设计思想简单的实现了一个keyvalue缓存. 哈希表部分使用了unordered_map,用于实 ...

  6. linux C 中的volatile使用

    一个定义为volatile的变量是说这变量可能会被意想不到地改变,这样,编译器就不会去假设这个变量的值了.精确地说就是,优化器在用到这个变量时必须每次都小心地重新读取这个变量的值,而不是使用保存在寄存 ...

  7. ModelAndView对象作用

    ModelAndView ModelAndView对象有两个作用: 作用一  :设置转向地址,如下所示(这也是ModelAndView和ModelMap的主要区别) ModelAndView mv = ...

  8. C++ std::tr1::bind使用

    1. 简述 同function函数相似.bind函数相同也能够实现相似于函数指针的功能.但却却比函数指针更加灵活.特别是函数指向类 的非静态成员函数时.std::tr1::function 能够对静态 ...

  9. Spark SQL数据载入和保存实战

    一:前置知识具体解释: Spark SQL重要是操作DataFrame,DataFrame本身提供了save和load的操作. Load:能够创建DataFrame. Save:把DataFrame中 ...

  10. lightoj 1138 - Trailing Zeroes (III)【二分】

    题目链接:http://lightoj.com/volume_showproblem.php? problem=1138 题意:问 N. 末尾 0 的个数为 Q 个的数是什么? 解法:二分枚举N,由于 ...