🍼

【夢】ActiveRecordの遅延評価について

に公開

遅延評価について改めて確認する機会があったので、メモしておきます。

遅延評価とは??

実際にデータが必要になるタイミングで SQL クエリが生成され、データが取得されることを遅延評価(Lazy Evaluation)といいます。

実際に見てみる

どのタイミングでSQL発行されているのか見てみます。

実験
Rails.logger.debug("いち")
users = User.where(created_at: Time.zone.parse("2023-10-01 00:00:00")..Time.zone.parse("2025-5-22 23:59:59"))
Rails.logger.debug("に")
Rails.logger.debug("users: #{users}")
Rails.logger.debug("さん")
Rails.logger.debug(users.first)
Rails.logger.debug("よん")
log
いち
に
users: #<User::ActiveRecord_Relation:0x0000ffff99d75980>
さん
  User Load (0.4ms)  SELECT "users".* FROM "users" WHERE "users"."created_at" BETWEEN $1 AND $2 ORDER BY "users"."id" ASC LIMIT $3  [["created_at", "2023-10-01 07:00:00"], ["created_at", "2025-05-23 06:59:59"], ["LIMIT", 1]]
#<User id: 1, email: [FILTERED], created_at: "2025-04-21 20:46:17.078294000 -0700", updated_at: "2025-04-21 20:46:17.078294000 -0700">
よん

この結果から分かる通り、、、

  • 実際に具体的データが必要になったusers.firstのタイミングでSQL発行される
  • whereしたら、実際にはActiveRecord::Relationが返る
    • なんとなくSQL発行しそうですが、実際にはしていない!

これが遅延評価です。素敵な機能ですね。

SQLクエリ発行のタイミングとは??

実際にデータが必要になったタイミングとは、、、
ActiveRecordの中身を参照しようとした時!(データないと困るからね、、)

  • users.first
  • user.name
  • user.to_a (配列化)
  • users.each do |user| ....
  • User.exists?(name: "cinnamon")
  • User.count
    などなど、、、

ActiveRecord::Relation

どういうSQLを組み立てるのかという情報を持っており、データが必要になったタイミングでいい感じにデータ取得してくれるもの。。と思っています。。

自分でうまく言語化できなかったので、ChatGPTに聞きました。

まだ実行されていないSQLの設計図(クエリのレシピ)
実際のデータではなく、「どんなデータを取りに行くか」の準備状態のオブジェクト
そして、そのRelationオブジェクトは…
✅ 必要になったときに、実際にSQLを発行してデータを取ってくれる!

(ChatGPTって絵文字使いがちですよね、、✅ )

N+1問題...

某有名はN+1問題。
ループ処理内で関連オブジェクトにアクセスしようとすると、、そこでSQL発行されてしまい、ループの数だけSQL発行されてしまい、パフォーマンス悪化につながってしまいます。

実験
Rails.logger.debug("いち")
    users = User.where(created_at: Time.zone.parse("2025-5-01 00:00:00")..Time.zone.parse("2025-5-22 23:59:59"))
    Rails.logger.debug("に")
    users.each do |user| <- ここでuserが必要だ!となって、SQL発行される
      Rails.logger.debug("さん")
      Rails.logger.debug("user.daily_access: #{user.daily_accesses.first}") <- ここでdaily_accessが必要だ!となって、SQL発行される ✕ n回、、
      Rails.logger.debug("よん")
    end
log
いち
に
  User Load (0.3ms)  SELECT "users".* FROM "users" WHERE "users"."created_at" BETWEEN $1 AND $2  [["created_at", "2025-05-01 07:00:00"], ["created_at", "2025-05-23 06:59:59"]]
さん
  UserDailyAccess Load (0.2ms)  SELECT "user_daily_accesses".* FROM "user_daily_accesses" WHERE "user_daily_accesses"."user_id" = $1 ORDER BY "user_daily_accesses"."id" ASC LIMIT $2  [["user_id", 22], ["LIMIT", 1]]
user.daily_access: 
よん
さん
  UserDailyAccess Load (0.1ms)  SELECT "user_daily_accesses".* FROM "user_daily_accesses" WHERE "user_daily_accesses"."user_id" = $1 ORDER BY "user_daily_accesses"."id" ASC LIMIT $2  [["user_id", 27], ["LIMIT", 1]]
user.daily_access: 
よん
さん
  UserDailyAccess Load (0.1ms)  SELECT "user_daily_accesses".* FROM "user_daily_accesses" WHERE "user_daily_accesses"."user_id" = $1 ORDER BY "user_daily_accesses"."id" ASC LIMIT $2  [["user_id", 28], ["LIMIT", 1]]
user.daily_access: 
よん
さん
  UserDailyAccess Load (0.1ms)  SELECT "user_daily_accesses".* FROM "user_daily_accesses" WHERE "user_daily_accesses"."user_id" = $1 ORDER BY "user_daily_accesses"."id" ASC LIMIT $2  [["user_id", 29], ["LIMIT", 1]]
user.daily_access: 
よん
さん
  UserDailyAccess Load (0.1ms)  SELECT "user_daily_accesses".* FROM "user_daily_accesses" WHERE "user_daily_accesses"."user_id" = $1 ORDER BY "user_daily_accesses"."id" ASC LIMIT $2  [["user_id", 30], ["LIMIT", 1]]
user.daily_access: 
よん
さん
  UserDailyAccess Load (0.1ms)  SELECT "user_daily_accesses".* FROM "user_daily_accesses" WHERE "user_daily_accesses"."user_id" = $1 ORDER BY "user_daily_accesses"."id" ASC LIMIT $2  [["user_id", 32], ["LIMIT", 1]]
user.daily_access: 
よん
さん
  UserDailyAccess Load (0.1ms)  SELECT "user_daily_accesses".* FROM "user_daily_accesses" WHERE "user_daily_accesses"."user_id" = $1 ORDER BY "user_daily_accesses"."id" ASC LIMIT $2  [["user_id", 31], ["LIMIT", 1]]
user.daily_access: #<UserDailyAccess:0x0000ffff86582790>
よん
さん
  UserDailyAccess Load (0.1ms)  SELECT "user_daily_accesses".* FROM "user_daily_accesses" WHERE "user_daily_accesses"."user_id" = $1 ORDER BY "user_daily_accesses"."id" ASC LIMIT $2  [["user_id", 33], ["LIMIT", 1]]
user.daily_access: 
よん

includes をつけることで、関連データも一括で取得されるよう最適化されます!

実験
Rails.logger.debug("いち")
users = User.includes(:daily_accesses).where(created_at: Time.zone.parse("2025-5-01 00:00:00")..Time.zone.parse("2025-5-22 23:59:59"))
Rails.logger.debug("に")
users.each do |user| <- ここでuser取得のSQL発行されるのは変わりないですが、関連データも一括で取得する
  Rails.logger.debug("さん")
  Rails.logger.debug("user.daily_access: #{user.daily_accesses.first}") 
  Rails.logger.debug("よん")
end
log
いち
に
  User Load (0.4ms)  SELECT "users".* FROM "users" WHERE "users"."created_at" BETWEEN $1 AND $2  [["created_at", "2025-05-01 07:00:00"], ["created_at", "2025-05-23 06:59:59"]]
  UserDailyAccess Load (0.4ms)  SELECT "user_daily_accesses".* FROM "user_daily_accesses" WHERE "user_daily_accesses"."user_id" IN ($1, $2, $3, $4, $5, $6, $7, $8)  [["user_id", 22], ["user_id", 27], ["user_id", 28], ["user_id", 29], ["user_id", 30], ["user_id", 32], ["user_id", 31], ["user_id", 33]]
さん
user.daily_access: 
よん
さん
user.daily_access: 
よん
さん
user.daily_access: 
よん
さん
user.daily_access: 
よん
さん
user.daily_access: 
よん
さん
user.daily_access: 
よん
さん
user.daily_access: #<UserDailyAccess:0x0000ffff6bf5c290>
よん
さん
user.daily_access: 
よん

まとめ

  • あまり意識してないけどありがたい機能ですね〜
  • 意識しなさすぎて意図しないところで、データ取得されないようにしなくては

参考

Discussion