如何使用 batch-loader gem 避免 N+1 Query
- Backend
- 07 Jun, 2020
我一開始看 batch-loader 在 github 上的說明,一直看不懂到底該怎麼用,所以這邊紀錄一下它的用法。
這是一個基本的使用方式,可以從 accounts 從 DB 拿出對應的 users:
def user_lazy(account)
BatchLoader.for(account).batch do |accounts, loader|
User.where(account: accounts).each do |user|
loader.call(user.account, user)
end
end
end
users = accounts.map do |account|
user_lazy(account)
end
首先,BatchLoader.for(account).batch{|..| ... } 會回傳一個 lazy object,也就是這個 code block 裡面的操作,並不會在呼叫 user_lazy method 時就立即被執行。
例如
users = accounts.map do |account|
user_lazy(account)
end
不會真的執行 user_lazy 裡面的User.where(account: accounts)。
而是等到真的需要使用到它的值,例如 puts users,才會真的對 DB query。
再來,
BatchLoader.for(account).batch do |accounts, loader|
User.where(account: accounts).each do |user|
loader.call(user.account, user)
end
end
batch code block 的第一個參數 accounts,是透過從 BatchLoader.for(**account**) 蒐集到的account ,組合出 **accounts** 來讓 DB 可以一次 query(User.where(account: **accounts**))。
而 batch code block
User.where(account: accounts).each do |user|
loader.call(user.account, user)
end
裡面的 loader.call(**user.account**, **user**) 做的事情。可以想像是把 User.where(account: accounts) 撈出來的 users ,拿來組出一個 hash,key 是 account ,value 是 user 。
而這個 hash 可以用來讓 BatchLoader.for(**account**) 利用 **account** 當作 key 從 hash 拿出所對應的 user:
BatchLoader.for(account).batch do |accounts, loader|
User.where(account: accounts).each do |user|
loader.call(user.account, user)
end
end
範例
假設你有個需求是根據一份名單,需要寫一段腳本讓使用者( user) 兌換兌換券(voucher)。
原始資料如下:
raw_data = [
{ account: 'shin', code: 'pmcHDn6aJAnTv7EU' },
{ account: 'en', code: 'RALyNNXKdx6WGkrs' },
]
兌換的功能由專屬的 service 來負責,執行的方式如下:
GiveVoucherService.new(user: user, voucher: voucher).execute
如果不考慮任何不良影響,可能會寫出這樣的程式碼:
data = raw_data.map do |d|
{
user: User.find_by(account: d[:account]),
voucher: Voucher.find_by(code: d[:code]),
}
end
data.each do |d|
GiveVoucherService.new(user: d[:user], voucher: d[:voucher]).execute
end
但是觀察 log 後,應該可以明顯發現這樣會有 N+1 Query。
如果要避免 N+1 Query,概念上就是先把資料一次從資料庫撈出來,再來組織要給 service 的資料。
accounts = raw_data.map{ |d| d[:account] }
codes = raw_data.map{ |d| d[:code] }
hash_account_to_user = User.where(account: accounts).index_by(&:account)
hash_code_to_voucher = Voucher.where(code: codes).index_by(&:code)
data = raw_data.map do |d|
{
user: hash_account_to_user[d[:account]],
voucher: hash_code_to_voucher[d[:code]],
}
end
如果是用 batch-loader 的話,要怎麼做呢:
data = raw_data.map do |d|
{
user: user_lazy(d[:account]),
voucher: voucher_lazy(d[:code]),
}
end
def user_lazy(account)
BatchLoader.for(account).batch do |accounts, loader|
User.where(account: accounts).each do |user|
loader.call(user.account, user)
end
end
end
def voucher_lazy(code)
BatchLoader.for(code).batch do |codes, loader|
Voucher.where(code: codes).each do |voucher|
loader.call(voucher.code, voucher)
end
end
end