新手向 COSCUP 2023
一年 365 天歡迎餵食,
請多指教 ヽ(●´∀`●)ノ
新手向 COSCUP 2023
連接物件導向程式 & (關聯式)資料庫之間的技巧
ORM tools:幫助開發者與(關聯式)資料庫互動的工具
兩種意思:
eg. Default ORM tool in Rails
不了解工具
導致問題
工具的設計有缺點
導致問題
SQL Query
Rails ORM
SQL Query
Rails ORM
實際如何執行?
額外的程式邏輯?
https://sagarjunnarkar.github.io/blogs/2019/09/15/activerecord-scope/
class Post < ApplicationRecord
scope :recent_published,
-> { where(published: true).order('published_at DESC').first }
end
Post.recent_published
Post Load (0.3ms) SELECT "posts".* FROM "posts" WHERE "posts"."published" = ? ORDER BY published_at DESC LIMIT ? [["published", 1], ["LIMIT", 1]]
Post Load (0.1ms) SELECT "posts".* FROM "posts" LIMIT ? [["LIMIT", 11]]
=> <ActiveRecord::Relation ...>
https://guides.rubyonrails.org/active_record_querying.html#scopes
Scoping allows you to specify commonly-used queries which can be referenced as method calls on the association objects or models. With these scopes, you can use every method previously covered such as where
, joins
and includes
. All scope bodies should return an ActiveRecord::Relation
or nil
to allow for further methods (such as other scopes) to be called on it.
Model.scope_a.scope_b.scope_c
class Order < ApplicationRecord
scope :created_before,
->(time) { where(created_at: ...time) if time.present? }
end
回傳 nil 的使用情境:
在用 mongoid 7.x 時遇到這樣的狀況
criteria = Model.my_scope # no sorting
criteria.first.id
# => 64b63169f8a9b80348a14bc7
criteria.last.id
# => 64c366a23932ad003b9c5e46
criteria.first.id
# => 64c366a23932ad003b9c5e46
criteria.last.id
# => 64c366a23932ad003b9c5e46
但只要有指定 sorting 就不會有事 👻
在沒有 cache 的情況下重下 query 發現:
Mongoid::QueryCache.uncached do
test_criteria = Model.my_scope
puts test_criteria.first.id
puts test_criteria.last.id
puts test_criteria.first.id
end
DEBUG -- : MONGODB | ... "sort"=>{"_id"=>1} ...
DEBUG -- : MONGODB | ... "sort"=>{"_id"=>-1} ...
DEBUG -- : MONGODB | ... "sort"=>{"_id"=>-1} ...
?!!!
經過一番研究跟翻 code,
發現 #last 會讓 Mongo::Collection::View 被改動
test_criteria = Model.my_scope
test_criteria.view.sort
# => nil
test_criteria.last
# ...
test_criteria.view.sort
# => {"_id"=>-1}
原來是踩到 bug (鬆一口氣)
這次想多講講 ActiveRecord 有趣的機制:
以及初步可能會遇到的 Performance Issue:
Bank.first
Bank Load (0.5ms) SELECT `banks`.* FROM `banks` ORDER BY `banks`.`id` ASC LIMIT 1
Bank.first
CACHE Bank Load (0.0ms) SELECT `banks`.* FROM `banks` ORDER BY `banks`.`id` ASC LIMIT 1
Default 是 false,可用 enable_query_cache! 打開設定來做測試
Q
ActiveRecord::Base.connection.query_cache_enabled
# => false
ActiveRecord::Base.connection.enable_query_cache!
ActiveRecord::Base.connection.query_cache_enabled
# => true
Query Cache 少了多次對 DB 查詢的開銷,但並未減少 ActiveRecord Result 轉換成 ActiveRecord Relation 的開支
Memoization 仍然是有意義的
Q
def published_post
@posts ||= Post.published
end
Q
ActiveRecord::Base.connection.query_cache
# => {"SELECT \"users\".* FROM \"users\" ORDER BY \"users\".\"id\" DESC LIMIT 1"=> {[]=> #<ActiveRecord::Result... > },
# "SELECT ... "=> {[]=> #<ActiveRecord::Result... > }
ActiveRecord::Base.connection.query_cache['SELECT .... '][[]]
#=> #<ActiveRecord::Result:0x0000000280c3f140
# @column_types= { ... },
# @columns= [ ... ],
# @hash_rows=nil,
# @rows=
# [["column_1_result",
# "column2_result"],
# ["column_1_result",
# "column2_result"]]]
# >
拿到的資料有可能不是最新的,但有些操作需要確保資料是即時的
Bank.first
# Bank Load (0.5ms) SELECT `banks`.* FROM `banks` ORDER BY `banks`.`id` ASC LIMIT 1
Bank.first
# CACHE Bank Load (0.0ms) SELECT `banks`.* FROM `banks` ORDER BY `banks`.`id` ASC LIMIT 1
ActiveRecord::Base.uncached { Bank.first }
# Bank Load (0.5ms) SELECT `banks`.* FROM `banks` ORDER BY `banks`.`id` ASC LIMIT 1
Query Cache 越長越大造成 memory 負擔過重
卻沒有清除 Cache 的機制 🤢
做個實驗:
# 不使用 prepare statement 的情況下
(1..100000).each {|i| puts TestModel.limit(i) }
# => ...
ActiveRecord::Base.connection.query_cache.keys.count
# => 100000
# 使用 prepare statement 的情況下
(1..100000).each {|i| puts TestModel.limit(i) }
# => ...
ActiveRecord::Base.connection.query_cache['SELECT ...'].keys.count
# => 100000
QueryCache
SQL Query & Result
QueryCache
https://code.jjb.cc/turning-off-activerecord-query-cache-to-improve-memory-consumption-in-background-jobs
在未來的 Rails 版本,可望 QueryCache 會改為 LRU(Least Recently Used Cache)
https://github.com/rails/rails/pull/48110
將來可以設定 QueryCache Max Size 或關閉 QueryCache
https://github.com/rails/rails/pull/48110
development:
adapter: mysql2
query_cache: 200 # default: 100
development:
adapter: mysql2
query_cache: false
PREPARE test_prepare (INT) AS
SELECT id, name FROM stores LIMIT $1;
-- PREPARE
EXECUTE test_prepare(3);
-- id | name
-- ----+---------
-- 1 | Store A
-- 2 | Store B
-- 3 | Store C
SELECT * FROM pg_prepared_statements;
-- name | statement | ...
-- --------------+--------------------------------------+--------
-- test_prepare | PREPARE test_prepare (INT) AS +| 20...
-- | SELECT id, name FROM banks LIMIT $1; |
https://www.interdb.jp/pg/pgsql03.html
statement 會多次重複使用的情況下:
Simple Query:
Rails:
development:
adapter: mysql2
prepared_statements: true
# when prepared_statements is set to true (default in postgresql adapter)
ActiveRecord::Base.connection.prepared_statements
# => true
User.last
# => User Load (2.6ms) SELECT "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT $1 [["LIMIT", 1]]
# when prepared_statements is set to false
ActiveRecord::Base.connection.prepared_statements
# => false
User.last
# => User Load (2.7ms) SELECT "users".* FROM "users" ORDER BY "users"."id" DESC LIMIT 1
使用 Prepared Statement 有可能會遇到這個錯誤:
ActiveRecord::Base.transaction do
retried ||= false
# ...
rescue ActiveRecord::PreparedStatementCacheExpired => e
raise e if retried
retried = true
end
如果 transaction 內沒有不可重試的操作,可以加上 retry
https://github.com/rails/rails/pull/41718
User.limit(10)
# enumerate_columns_in_select_statements is off
# => SELECT * FROM users LIMIT 10
# enumerate_columns_in_select_statements is on
# => SELECT "first_name,last_name,email ..." FROM users LIMIT 10
N+1 Queries
Started GET "/" for ::1 at 2023-07-29 20:44:10 +0800
Processing by PagesController#index as HTML
[1m[36mOrder Load (6.3ms)[0m [1m[34mSELECT "orders".* FROM "orders" LIMIT $1[0m [["LIMIT", 10]]
↳ app/controllers/pages_controller.rb:4
[1m[36mDistributor Load (1.1ms)[0m [1m[34mSELECT "merchants".* FROM "merchants" WHERE "merchants"."id" = $1 LIMIT $2[0m [["id", "..."], ["LIMIT", 1]]
↳ app/controllers/pages_controller.rb:5
[1m[36mDistributor Load (2.8ms)[0m [1m[34mSELECT "merchants".* FROM "merchants" WHERE "merchants"."id" = $1 LIMIT $2[0m [["id", "..."], ["LIMIT", 1]]
↳ app/controllers/pages_controller.rb:5
[1m[36mDistributor Load (1.6ms)[0m [1m[34mSELECT "merchants".* FROM "merchants" WHERE "merchants"."id" = $1 LIMIT $2[0m [["id", "..."], ["LIMIT", 1]]
↳ app/controllers/pages_controller.rb:5
[1m[36mDistributor Load (3.6ms)[0m [1m[34mSELECT "merchants".* FROM "merchants" WHERE "merchants"."id" = $1 LIMIT $2[0m [["id", "..."], ["LIMIT", 1]]
↳ app/controllers/pages_controller.rb:5
[1m[36mDistributor Load (2.0ms)[0m [1m[34mSELECT "merchants".* FROM "merchants" WHERE "merchants"."id" = $1 LIMIT $2[0m [["id", "..."], ["LIMIT", 1]]
↳ app/controllers/pages_controller.rb:5
[1m[36mDistributor Load (2.7ms)[0m [1m[34mSELECT "merchants".* FROM "merchants" WHERE "merchants"."id" = $1 LIMIT $2[0m [["id", "..."], ["LIMIT", 1]]
↳ app/controllers/pages_controller.rb:5
[1m[36mDistributor Load (6.1ms)[0m [1m[34mSELECT "merchants".* FROM "merchants" WHERE "merchants"."id" = $1 LIMIT $2[0m [["id", "..."], ["LIMIT", 1]]
↳ app/controllers/pages_controller.rb:5
[1m[36mDistributor Load (4.4ms)[0m [1m[34mSELECT "merchants".* FROM "merchants" WHERE "merchants"."id" = $1 LIMIT $2[0m [["id", "..."], ["LIMIT", 1]]
↳ app/controllers/pages_controller.rb:5
[1m[36mDistributor Load (2.5ms)[0m [1m[34mSELECT "merchants".* FROM "merchants" WHERE "merchants"."id" = $1 LIMIT $2[0m [["id", "..."], ["LIMIT", 1]]
↳ app/controllers/pages_controller.rb:5
[1m[36mDistributor Load (5.1ms)[0m [1m[34mSELECT "merchants".* FROM "merchants" WHERE "merchants"."id" = $1 LIMIT $2[0m [["id", "..."], ["LIMIT", 1]]
↳ app/controllers/pages_controller.rb:5
Rendering pages/index.html.erb
Rendered pages/index.html.erb (17.5ms)
Completed 200 OK in 164ms (Views: 19.2ms | ActiveRecord: 73.4ms)
在取得 main query 相關的 relation 的 data 時,額外執行了 N 次查詢的情況
@user = User.find(params[:id])
@posts = user.posts # 1
@posts.each do |post|
puts post.comments.count # n
end
多次 DB Query 導致增加 DB loading & 拉長 response 時間
Started GET "/" for ::1 at 2023-07-29 20:44:10 +0800
Processing by PagesController#index as HTML
[1m[36mOrder Load (6.3ms)[0m [1m[34mSELECT "orders".* FROM "orders" LIMIT $1[0m [["LIMIT", 10]]
↳ app/controllers/pages_controller.rb:4
[1m[36mMerchant Load (1.1ms)[0m [1m[34mSELECT "merchants".* FROM "merchants" WHERE "merchants"."id" = $1 LIMIT $2[0m [["id", "..."], ["LIMIT", 1]]
↳ app/controllers/pages_controller.rb:5
[1m[36mMerchant Load (2.8ms)[0m [1m[34mSELECT "merchants".* FROM "merchants" WHERE "merchants"."id" = $1 LIMIT $2[0m [["id", "..."], ["LIMIT", 1]]
↳ app/controllers/pages_controller.rb:5
[1m[36mMerchant Load (1.6ms)[0m [1m[34mSELECT "merchants".* FROM "merchants" WHERE "merchants"."id" = $1 LIMIT $2[0m [["id", "..."], ["LIMIT", 1]]
↳ app/controllers/pages_controller.rb:5
[1m[36mMerchant Load (3.6ms)[0m [1m[34mSELECT "merchants".* FROM "merchants" WHERE "merchants"."id" = $1 LIMIT $2[0m [["id", "..."], ["LIMIT", 1]]
↳ app/controllers/pages_controller.rb:5
Rendering pages/index.html.erb
Rendered pages/index.html.erb (17.5ms)
Completed 200 OK in 164ms (Views: 19.2ms | ActiveRecord: 73.4ms)
There's nothing wrong
with my code
老生常談卻依舊不停發生(?
controller
model
view
def index
@posts = Post.limit(20)
end
<% @posts.each do |post| %>
<%= post.category.name %>
<%= post.post_liked %> Likes
<% end %>
def post_liked
liked_users.count
end
在這裡 preload
在這裡注意該 preload 的 association
preload
With preload, Active Record loads each specified association using one query per association.
Payment.preload(:wallet).limit(10)
# Payment Load (1.3ms) SELECT `payments`.* FROM `payments`
# Wallet Load (0.8ms) SELECT `wallets`.* FROM `wallets` WHERE `wallets`.`id` IN (1, 2, 3, 5, 6)
https://guides.rubyonrails.org/active_record_querying.html#preload
eager_load
With eager_load, Active Record loads all specified associations using a LEFT OUTER JOIN associations.
Payment.eager_load(:wallet).limit(10)
# SQL (1.8ms) SELECT `payments`.`id` AS t0_r0, ...
# `wallets`.`id` AS t1_r0, `wallets`.`type` AS t1_r1, ...
# FROM `payments`
# LEFT OUTER JOIN `wallets` ON `wallets`.`id` = `payments`.`wallet_id`
# LIMIT 10
https://guides.rubyonrails.org/active_record_querying.html#eager-load
includes
Specify associations args to be eager loaded to prevent N + 1 queries. A separate query is performed for each association, unless a join is required by conditions.
Payment.preload(:wallet)
# Payment Load (1.3ms) SELECT `payments`.* FROM `payments`
# Wallet Load (0.8ms) SELECT `wallets`.* FROM `wallets` WHERE `wallets`.`id` IN (1, 2, 3, 5, 6)
Payment.includes(:wallet).where(wallet: { type: 'activated' }).limit(10)
# SQL (0.8ms) SELECT `payments`.`id` AS t0_r0, `payments`.`type` AS t0_r1,...
# `wallets`.`id` AS t1_r0, `wallets`.`type` AS t1_r1,...
# FROM `payments`
# LEFT OUTER JOIN `wallets` ON `wallets`.`id` = `payments`.`wallet_id`
# WHERE `wallets`.`type` = 'aaa'
# LIMIT 10
https://github.com/rails/rails/pull/47253/files
輔助檢查:
N + 1 Queries
Unused eager loading
Unnecessary COUNT queries (改用 counter_cache)
https://github.com/flyerhzm/bullet
Bullet.enable = true
Bullet.alert = true
Bullet.bullet_logger = true
Bullet.console = true
Bullet.rails_logger = true
Bullet.add_footer = true
config/environments/development.rb
https://github.com/flyerhzm/bullet
GET /
USE eager loading detected
Order => [:merchant]
Add to your finder: :includes => [:merchant]
Call stack
.../app/controllers/pages_controller.rb:5:in `index'
...
log/bullet.log
Bullet.bullet_logger = true
GET /
USE eager loading detected
Order => [:merchant]
Add to your finder: :includes => [:merchant]
Call stack
.../app/controllers/pages_controller.rb:5:in `index'
...
log/development.log
Bullet.rails_logger = true
alert messege box
Bullet.alert = true
html div
Bullet.add_footer = true
browser console
Bullet.console = true
N+1 Queries Detected!!!
(uniform notifier)
alert
slack
console
rollbar