Edited at

Mongoid 全リレーション種別で生成されるクエリを調べたみた

@koshi_life です。

Mongoidの各リレーションでどのようなクエリが発行されるか高速化するために興味があり、

Mongoidリレーションの公式ドキュメントを読んで、実験した内容の備忘です。


前提


  • Ruby 2.6.3

  • Rails 5.2.3

  • Mongo 4.0.3

  • Mongoid 7.0.2


Mongoidのリレーションは5種

記事投稿時点のMongoid(v7)では、

以下5種のリレーションがサポートされています。

mongoid-Relations より。

マクロ
リレーション種別
データ保存先のコレクション

has_one - belongs_to
1:1
両コレクション

embeds_one - embedded_in
1:1
親のみ

has_many - belongs_to
1:N
両コレクション

embeds_many - embedded_in
1:N
親のみ

has_and_belongs_to_many
N:N
両コレクション

embeds_one/embeds_many が Railsのリレーション にはなく Mongoid 特有の関連付けです。

Modelは分けたいが、参照/更新タイミングが一緒で別々のコレクションへのクエリを発行させたくない時に利用するリレーションと解釈しました。


リレーション定義例

適当な例として

フランチャイズチェーンのお店の売上、在庫、商品、オーナー、店長、バイトを管理するための各モデルを定義してみます。


関連図

qiita-20190520_1.png


お店モデル


app/models/store.rb

# お店

class Store
include Mongoid::Document

field :name, type: String

has_one :manager
embeds_one :owner

has_many :sales
has_many :stocks
has_and_belongs_to_many :part_timers
end



人関連モデル


app/models/owner.rb

# オーナー

class Owner
include Mongoid::Document

field :name, type: String

embedded_in :store
end



app/models/manager.rb

# 店長

class Manager
include Mongoid::Document

field :name, type: String

belongs_to :store

# Index貼るとよいかも (後述)
index({ store_id: 1 })
end



app/models/part_timer.rb

# バイト

class PartTimer
include Mongoid::Document

field :name, type: String

has_and_belongs_to_many :stores
end



商品、在庫、売上管理系モデル


app/models/product.rb

# 商品

class Product
include Mongoid::Document

field :name, type: String

has_one :product_quantity
end



app/models/product_quantity.rb

# 商品と数量の組み合わせを持つ抽象モデル

class ProductQuantity
include Mongoid::Document

field :quantity, type: Integer

belongs_to :product
end



app/models/stock.rb

# 在庫

class Stock < ProductQuantity
include Mongoid::Document

belongs_to :store

# Index貼るとよいかも (後述)
index({ _type: 1, store_id: 1, product_id: 1 })
end



app/models/sale.rb

# 売上

class Sale
include Mongoid::Document

field :price, type: Integer

belongs_to :store
embeds_many :sold_products

# Index貼るとよいかも (後述)
index({ store_id: 1 })
end



app/models/sold_product.rb

# 売った商品

class SoldProduct < ProductQuantity
include Mongoid::Document

field :unit_price, type: Integer

embedded_in :sale
end



サンプルデータ


sample_data.rb

module SampleData

STORE_DATA = [
Store.new(id: 's001', name: 'Store A', part_timer_ids: %w[p001 p004], owner: Owner.new(name: 'Owner A')),
Store.new(id: 's002', name: 'Store B', part_timer_ids: %w[p002], owner: Owner.new(name: 'Owner B')),
Store.new(id: 's003', name: 'Store C', part_timer_ids: %w[p003 p004], owner: Owner.new(name: 'Owner C'))
]

MANAGER_DATA = [
Manager.new(id: 'm001', name: 'Manager A', store_id: 's001'),
Manager.new(id: 'm002', name: 'Manager B', store_id: 's002'),
Manager.new(id: 'm003', name: 'Manager C', store_id: 's003')
]

PRODUCT_DATA = [
Product.new(id: 'p001', name: 'Product A'),
Product.new(id: 'p002', name: 'Product B'),
Product.new(id: 'p003', name: 'Product C')
]

SALE_DATA = [
Sale.new(
id: 'sa001001',
store_id: 's001',
price: 10,
sold_products: [SoldProduct.new(product_id: 'p001', quantity: 1, unit_price: 10)]
),
Sale.new(
id: 'sa001002',
store_id: 's001',
price: 40,
sold_products: [SoldProduct.new(product_id: 'p002', quantity: 2, unit_price: 20)]
),
Sale.new(
id: 'sa001003',
store_id: 's001',
price: 80,
sold_products: [
SoldProduct.new(product_id: 'p001', quantity: 2, unit_price: 10),
SoldProduct.new(product_id: 'p003', quantity: 2, unit_price: 30)
]
)
]

STOCK_DATA = [
Stock.new(id: 'st001001', product_id: 'p001', quantity: 10, store_id: 's001'),
Stock.new(id: 'st001002', product_id: 'p002', quantity: 10, store_id: 's001'),
Stock.new(id: 'st001003', product_id: 'p003', quantity: 10, store_id: 's001'),
Stock.new(id: 'st002001', product_id: 'p001', quantity: 20, store_id: 's002'),
Stock.new(id: 'st002002', product_id: 'p002', quantity: 20, store_id: 's002'),
Stock.new(id: 'st002003', product_id: 'p003', quantity: 20, store_id: 's002'),
Stock.new(id: 'st003001', product_id: 'p001', quantity: 30, store_id: 's003'),
Stock.new(id: 'st003002', product_id: 'p002', quantity: 30, store_id: 's003'),
Stock.new(id: 's003003', product_id: 'p003', quantity: 30, store_id: 's003')
]

PART_TIMER_DATA = [
PartTimer.new(id: 'p001', name: 'Part A', store_ids: %w[s001 s002]),
PartTimer.new(id: 'p002', name: 'Part B', store_ids: %w[s002]),
PartTimer.new(id: 'p003', name: 'Part C', store_ids: %w[s003]),
PartTimer.new(id: 'p004', name: 'Part D', store_ids: %w[s001 s003])
]

def initialize()
# Drop Collections
Store.collection.drop
Manager.collection.drop
Product.collection.drop
Sale.collection.drop
SoldProduct.collection.drop
Stock.collection.drop
PartTimer.collection.drop
# Insert Data
STORE_DATA.each(&:save!)
MANAGER_DATA.each(&:save!)
PRODUCT_DATA.each(&:save!)
STOCK_DATA.each(&:save!)
SALE_DATA.each(&:save!)
PART_TIMER_DATA.each(&:save!)
end
module_function :initialize
end



クエリの確認1

Storeモデルから各モデルへリレーション経由でアクセスした時のMongoへのクエリを確認。


ruby

# Storeの全要素を一括で取得 (Mongoクエリあり★☆★)

irb:001> stores = Store.all.to_a
=> [#<Store _id: s001, name: "Store A", part_timer_ids: ["p001", "p004"]>, #<Store _id: s002, name: "Store B", part_timer_ids: ["p002", "p001"]>, #<Store _id: s003, name: "Store C", part_timer_ids: ["p003", "p004"]>]
irb:002> store1 = stores[0]

# has_one (Mongoクエリあり★☆★)
irb:003> store1.manager
=> #<Manager _id: m001, name: "Manager A", store_id: "s001">

# has_many (Mongoクエリあり★☆★)
irb:004> store1.stocks

# has_many (Mongoクエリあり★☆★)
irb:005> s1_sales = store1.sales
=> [#<Sale _id: sa001001, price: 10, store_id: "s001">, #<Sale _id: sa001002, price: 40, store_id: "s001">, #<Sale _id: sa001003, price: 80, store_id: "s001">]
irb:006> s1_sale1 = s1_sales[0]

# embeds_many (Mongoクエリなし)
irb:007> s1_sale1.sold_products
=> [#<SoldProduct _id: 5ce29a8f48cca4564c626022, quantity: 1, product_id: "p001", _type: "SoldProduct", unit_price: 10>]

# embeds_one (Mongoクエリなし)
irb:008> store1.owner
=> #<Owner _id: 5ce29a8f48cca4564c62601f, name: "Owner A">

# has_and_belongs_to_many (Mongoクエリあり★☆★)
irb:009> store1.part_timers
=> [#<PartTimer _id: p001, name: "Part A", store_ids: ["s001", "s002"]>, #<PartTimer _id: p004, name: "Part D", store_ids: ["s001", "s003"]>]


Mongo クエリ内容

irb No
ruby
Mongoへのクエリ
インデックス利用有無

001
Store.all.to_a
{"find"=>"stores", "filter"=>{}, ...}

003
store1.manager
{"find"=>"managers", "filter"=>{"store_id"=>"s001"}, ...}

004
store1.stocks
{"find"=>"product_quantities", "filter"=>{"store_id"=>"s001", "_type"=>"Stock", ...}

005
store1.sales
{"find"=>"sales", "filter"=>{"store_id"=>"s001"}, ...}

009
store1.part_timers
{"find"=>"part_timers", "filter"=>{"$and"=>[{"_id"=>{"$in"=>["p001", "p004"]}}]}, ...}


確認できたこと


  • embeds_one/embeds_many は取得したドキュメント内に関連データが内包されているので追加で別コレクションへのクエリは発行されない

  • has_one/has_many-belongs_to のリレーションは、デフォルトではインデックスが効かないクエリ(フルスキャン)が発行されるので、使用頻度/データ量によってはインデックス作成を検討したほうがよい。

  • モデルを継承した場合は、継承元のコレクションに対して_type属性付きのドキュメントが作られる。(例だとStockコレクションではなくProductQuantitiesコレクションに_type:Stockで作成された)


クエリの確認2 (N+1問題は includes を用いる)

Mongoid::Criteria#includes を用いると一括でリレーションの関連データを取得することができる。

No.001 の rubyコードを以下のように変更すると、それ以降のNo.003,4,5,9 のMongoへのアクセスは発生しない。

irb:001> Store.all.includes(:manager, :stocks, :sales, :part_timers).to_a

=> [#<Store _id: s001, name: "Store A", part_timer_ids: ["p001", "p004"]>, #<Store _id: s002, name: "Store B", part_timer_ids: ["p002", "p001"]>, #<Store _id: s003, name: "Store C", part_timer_ids: ["p003", "p004"]>]

Mongoのクエリ内容

{"find"=>"stores", "filter"=>{}, ...}

{"find"=>"managers", "filter"=>{"store_id"=>{"$in"=>["s001", "s002", "s003"]}}, ...}
{"find"=>"product_quantities", "filter"=>{"store_id"=>{"$in"=>["s001", "s002", "s003"]}, "_type"=>"Stock"}, ...}
{"find"=>"sales", "filter"=>{"store_id"=>{"$in"=>["s001", "s002", "s003"]}}, ...}
{"find"=>"part_timers", "filter"=>{"_id"=>{"$in"=>["p001", "p004", "p002", "p003"]}}, ...}

includesで指定した各リレーション先のコレクションに対して、inであらかじめ全取得している。


クエリの確認3 pluck

Mongoid::Contextual::Mongo#pluck リレーション関係ないが、効率よいメソッドらしいので。確認してみる。

Sale.all.pluck(:id, :price)

{"find"=>"sales", "filter"=>{}, "projection"=>{"_id"=>1, "price"=>1}, ...}

射影(projection)でメモリ展開領域が少なくなるのが効率が良い!?と解釈。


参考