@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種のリレーションがサポートされています。
| マクロ | リレーション種別 | データ保存先のコレクション |
|---|---|---|
| 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は分けたいが、参照/更新タイミングが一緒で別々のコレクションへのクエリを発行させたくない時に利用するリレーションと解釈しました。
リレーション定義例
適当な例として
フランチャイズチェーンのお店の売上、在庫、商品、オーナー、店長、バイトを管理するための各モデルを定義してみます。
関連図
お店モデル
# お店
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
人関連モデル
# オーナー
class Owner
include Mongoid::Document
field :name, type: String
embedded_in :store
end
# 店長
class Manager
include Mongoid::Document
field :name, type: String
belongs_to :store
# Index貼るとよいかも (後述)
index({ store_id: 1 })
end
# バイト
class PartTimer
include Mongoid::Document
field :name, type: String
has_and_belongs_to_many :stores
end
商品、在庫、売上管理系モデル
# 商品
class Product
include Mongoid::Document
field :name, type: String
has_one :product_quantity
end
# 商品と数量の組み合わせを持つ抽象モデル
class ProductQuantity
include Mongoid::Document
field :quantity, type: Integer
belongs_to :product
end
# 在庫
class Stock < ProductQuantity
include Mongoid::Document
belongs_to :store
# Index貼るとよいかも (後述)
index({ _type: 1, store_id: 1, product_id: 1 })
end
# 売上
class Sale
include Mongoid::Document
field :price, type: Integer
belongs_to :store
embeds_many :sold_products
# Index貼るとよいかも (後述)
index({ store_id: 1 })
end
# 売った商品
class SoldProduct < ProductQuantity
include Mongoid::Document
field :unit_price, type: Integer
embedded_in :sale
end
サンプルデータ
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)でメモリ展開領域が少なくなるのが効率が良い!?と解釈。