如何在递归方法中使用预加载的集合

时间:2016-01-26 16:32:14

标签: ruby-on-rails ruby ruby-on-rails-4 activerecord

我有以下自我参照关联:

class Action < ActiveRecord::Base
  # self referential association
  has_many :action_parents
  has_many :parents, through: :action_parents
  has_many :action_children, class_name: 'ActionParent', foreign_key: 'parent_id'
  has_many :children, through: :action_children, source: :action
  …
  def should_finish
    should_start + duration
  end

  def should_start
    # my_start is a field in db: if there are no parents (root) it will use this field
    return my_start if parents.empty?
    parents.map(&:should_finish).sort.last
  end
end

我的问题是should_finishshould_start互相调用这一事实,即使我预先加载父母,也会导致许多查询:

Action.includes(:parents).last.should_finish
# a new query every time it checks for parents

有关如何缓存actionsparents的想法吗?

编辑 - 让我给出一些背景信息:

# actions table:        actions_parents table:
# id | duration         task_id | parent_id
# 1  | 5                2       | 1
# 2  | 10               3       | 1
# 3  | 20               4       | 2
# 4  | 15               4       | 3
#
#                      |--------------|
#                      | action 2     |
#         |---------- >| duration: 10 |
#         |            |--------------|
#         |                     |
#  |--------------|             |--------->|--------------|
#  | action 1     |                        | action 4     |
#  | duration: 5  |                        | duration: 15 |
#  |--------------|             |--------->|--------------|
#         |                     |
#         |            |--------------|
#         |----------->| action 3     |
#                      | duration: 20 |
#                      |--------------|

PS:没有循环依赖。

假设我有一个my_start的{​​{1}}字段:

some day at 10:00:00

我认为可以使用# action | should_start | should_finish # ------------------------------------- # 1 | 10:00:00* | 10:00:05 # 2 | 10:00:05 | 10:00:15 # 3 | 10:00:05 | 10:00:25 # 4 | 10:00:25** | 10:00:40 # # * value from db since there is no parent # ** should_finish of parent with latest should_finish (action 3)

预加载所有操作

3 个答案:

答案 0 :(得分:1)

在我知道具体细节之前,我会抛弃一个狂野的,

假设父母结构中没有突出的循环,你无法通过缓存任何缓存整个表的任何东西来帮助自己,因为每次遇到父母时,你都会为每个动作实例点击不同的父母。没有缓存策略,包括rails one,将使您无需将整个数据集移动到缓存中。

事情是,你似乎想要做的事情对于关系数据库来说真的很难,而且似乎正是图数据库发明的原因(见What are graph databases & When to use a graph database&amp; Neo4j on Heroku

除了转到图形数据库或缓存整个动作表之外,最好的办法是优化查询(使用pluck)并可能将它们重写为PLSQL函数。

B计划是让您了解有关数据的知识,

  • should_startdurationshould_finish中的值会发生变化吗?它改变了很多吗?
  • 数据实时关键? (即可以随时收到稍微过时的信息)
  • 您构建数据的方式是否需要更友好的读取或写入友好?
  • 导致问题:使Action模型的数据库字段成为有意义,这样您每次查找时都不必遍历?
    • 即。你做的阅读操作比写作和
    • 更多
    • 您可以重新计算后台作业中的计算字段
  • 您是否经常在一个小时间窗口内访问should_startshould_finish
  • 你对Neo4j有多好:D
  • ....

编辑1

目前我看到的唯一解决方案是取消递归问题。试试这个:

在字符串/文本字段中存储父结构的ID,例如

  • 操作4会有[1,2,3]
  • 动作2&amp; 3将有[1]
  • 操作1会有[]

然后将ancestor_ids数组映射到id => action

的哈希值
def ancestry_hash
  @ancestry_hash ||= Hash[ Action.includes(:action_parents).where(id: ancestor_ids).map{|action| [action.id, action]} ]
end

然后重新实现递归查询以遍历此哈希而不是activerecord树,否则您将触发其他查询。类似的东西:

def should_finish(id = self.id)
  should_start(id) + ancestry_hash[id].duration
end

def should_start(id = self.id)
  # my_start is a field in db: if there are no parents (root) it will use this field
  action = ancestry_hash[id]
  return my_start if action.action_parents.empty?
  action.action_parents.pluck(:parent_id).map{ |parent_id| should_finish(parent_id) }.sort.last
end

我没有测试代码,但我希望你能得到这个想法,它应该足够接近这个

答案 1 :(得分:1)

问题:

简而言之,您有2个问题。一个是你实际上试图预加载超过你需要的东西(?!?),另一个是由于逻辑的递归性质,Rails并不急于加载你真正需要的东西。

为了进一步解释,请考虑一下:

my_action.parents.map(&:parents).flatten.map(&:parents)

Rails会:

  1. 首先抓住所有父母的行动
  2. 然后循环每个父母并抓住他们的父母
  3. 然后压扁那些祖父母&#39;到一个数组,遍历每个他们并获取他们的父母
  4. 请注意,在这种情况下,急切加载第一级父项并没有多大意义,因为您只是从一个Action实例开始 - 而不是一个集合。调用.parents将在一次传递中获取该操作的所有第一级父级(这无论如何都是急切加载)。

    那么当您开始使用集合(ActiveRelation)而不是实例时会发生什么?

    Action.some_scope.includes(:parents).map(&:parents)
    

    在这种情况下,范围中包含的所有操作的父项将被急切加载。调用.map(&:parents)将不会触发任何进一步的SQL调用,这就是使用includes()进行热切加载的重点。但是,有两件事情会破坏这一目的的全部目的 - 你正在做两件事:/

    首先,您的起点实际上并不是一系列操作,因为您正在立即调用.last。因此,所有父母对所有行为的取得都毫无意义 - 你只需要最后一次行动。一!因此,Rails足够聪明,可以缩小范围,并且只会急切地加载“最后”的父母。行动。但是,在这种情况下,热切加载并没有多大好处,因为调用.parents会在以后产生相同的单个查询。 (如果以后的操作需要更快地进行,那么提前加载会有很小的好处,在这种情况下实用性有限)。因此,无论是否使用.includes语句,您只需执行一次查询即可获取“最后一次”的父母。动作。

    更重要的是,你在每个父母身上递归调用.parents,而Rails绝对不知道你打算这样做。此外,递归调用本质上是不可预先获取的(不知道一些技巧),所以实际上没有任何方法可以告诉ActiveRecord,或使用&#39; vanilla&#39;对于这个问题的SQL,走链子并找出需要哪些父母,直到它已经完成(使得这一点没有实际意义)。所有这些都会导致N + 1情况的噩梦,正如您所经历的那样。

    一些解决方案:

    有几种方法可以缓解或消除N + 1问题 - 按实现复杂性顺序排列:

    • 预取最多N级父母(假设您知道max(N)是多少)

    Action.last.parents.includes(parents: {parents: :parents}) # grabs up to 4 levels

    • 完全跳过SQL,将所有操作加载到由关联的child_id键入的Action数组的散列中,并使用非ActiveRecord方法使用简单的Ruby聚合所需的内容。随着数据的增长,这种情况会迅速恶化,但对你来说可能已经足够了 - 至少目前是这样。

    • 使用可以提前确定祖先树的模式,并使用该实用程序来帮助进行此计算。 @bbozo给出的示例是一种方法 - 您还可以探索诸如祖先,acts_as_tree,awesome_nested_set,closure_tree等可能的宝石,以及可能帮助您解决此问题的其他方法。

    • 使用实际上在单个调用中执行递归计算的数据库特定函数。 PostgreSQL,Oracle和MS-SQL具有此功能,而MySQL和SQLite则没有。这可能会让您获得最佳性能,但仅使用ActiveRecord查询界面进行编写可能很复杂。

答案 2 :(得分:0)

你有没有想过记住这个?

在模型中

def should_start
  return my_start if parents.empty?

  @should_start ||= parents.map(&:should_finish).sort.last
end