チュートリアル 第14章 ユーザーをフォローする - ステータスフィード - サブセレクト


前準備 - whereメソッド内の変数に、キーと値のペアを使うようにする

whereメソッドの第1引数であるSQL文において、Rails側の変数の内容を使う部分は、これまで?(疑問符)として与えてきました。以下のようなメソッド呼び出しがその例です。

Micropost.where("user_id IN (?) OR user_id = ?", following_ids, id)

whereメソッドは、実は「上記?の部分に、?ではなくRubyのシンボルを与える」という使い方ができます。以下のようなメソッド呼び出しがその例です。

Micropost.where(
  "user_id IN (:following_ids) OR user_id = :user_id",
  following_ids: following_ids,
  user_id: id
)

結果、app/models/user.rb内のfeedメソッドは以下のように変更できます。

app/models/user.rb#feed
  def feed
-   Micropost.where("user_id IN (?) OR user_id = ?", following_ids, id)
+   Micropost.where(
+     "user_id IN (:following_ids) OR user_id = :user_id",
+     following_ids: following_ids,
+     user_id: id
+   )
  end

このような変更をするからには、「following_idsもしくはuser_idを複数箇所で使う」という実装が発生するということなのでしょう。

「フィードを初めて実装する」の実装の問題点

「フィードを初めて実装する」の実装では、「投稿されたマイクロポストの数が膨大になった際にうまくスケールしない」という問題点があります。Railsチュートリアル本文には以下のようにあります。

フォローしているユーザーが5,000人程度になるとWebサービス全体が遅くなる可能性があります

現状の実装は、一体どのような点でスケールしないのでしょうか。「よりスケールする実装」というのは、一体どのような実装なのでしょうか。

現状の実装ではどういう処理がされているのか、現状の実装は何が問題なのか

「フィードを初めて実装する」の実装におけるfeedメソッドの実装内容は、以下のようなものでした。

def feed
  Micropost.where("user_id IN (?) OR user_id = ?", following_ids, id)
end

上記のコードは、最終的に以下のような動作をすることになります。

  1. following_idsメソッドにより、現在フォローしている全てのユーザーを得るためにRDBに問い合わせを行う
  2. 全てのMicropostオブジェクトを得るため、前述following_idsメソッドの戻り値を条件に、RDBのmicropostsテーブル全体を対象として問い合わせを行う

今回開発しているアプリケーションのユースケースでは、idが上記1.の集合に内包されているかどうかだけをチェックするため、RDBに2回問い合わせを行う現状の動作はどうにもまどろっこしいです。また、Railsが介入する必要がないであろうところにRailsが介入しているのはよろしくありません。

より効率的な方法はないのでしょうか。いや、こうした集合計算に特化した言語であるSQLなら、より効率的な方法はきっとあるはずです。

どういう処理だとよりいいのか - サブクエリ(サブセレクト)を用いたクエリの使用

今回行おうとしている処理の場合、「SELECT文の結果そのものを、(Railsに渡すことなく)次段のSELECT文の評価の対象とする」という形にすることによって、全てをRDBMS内で完結させることができます。RDBMSはこうした処理に最適化されているので、全てをRDBMS内で完結させることができれば、途中でRailsが介在する実装より高速な処理が実現できます。

例えば現在のユーザーのidが1である場合、このような処理を実現するためのSQL文は以下のようになります。

SELECT * FROM microposts
WHERE user_id IN (
  SELECT followed_id FROM relationships
  WHERE follower_id = 1
) OR user_id = 1

上記SQL文のように、「SELECT文の結果そのものを、次段のSQL文の評価の対象とする」処理は「サブクエリを用いたクエリ」と呼ばれます。()の内側のSQL文は「サブクエリ(もしくはサブセレクト)」と呼ばれます。

このようなサブクエリを用いたクエリにおいては、集合を組み立てるロジックはRDBMS内で完結します。

サブクエリを用いたクエリをwhereメソッドで使用する

Micropost.where(
  "user_id IN (:following_ids) OR user_id = :user_id",
  following_ids: following_ids,
  user_id: id
)

上記Micropost.whereの引数:following_idsは、前述「サブクエリを用いたクエリの使用」を踏まえて、以下のように書き換えることができます。

following_ids =
  "SELECT followed_id FROM relationships
  WHERE follower_id = :user_id"

結果、User#feedメソッドの実装は以下のように書き換えられる、ということになるわけです。

User#feed
def feed
  following_ids =
    "SELECT followed_id FROM relationships
    WHERE follower_id = :user_id"
  Micropost.where(
    "user_id IN (#{following_ids}) OR user_id = :user_id",
    user_id: id
  )
end

フィードの最終的な実装

ここまでの内容を踏まえると、app/models/user.rbに対する変更の内容は、以下のようになります。

app/models/user.rb#feed
  def feed
-   Micropost.where(
-     "user_id IN :following_ids, OR user_id = :user_id",
-     following_ids: following_ids,
-     user_id: id
-   )
+   following_ids =
+     "SELECT followed_id FROM relationships
+     WHERE follower_id = :user_id"
+   Micropost.where(
+     "user_id IN (#{following_ids}) OR user_id = :user_id",
+     user_id: id
  end

test/models/user_test.rbを対象としたテストも実行しておきましょう、

# rails test test/models/user_test.rb
Running via Spring preloader in process 428
Started with run options --seed 8307

  15/15: [=================================] 100% Time: 00:00:01, Time: 00:00:01

Finished in 1.29007s
15 tests, 64 assertions, 0 failures, 0 errors, 0 skips

無事テストが通りました。

開発環境において、Homeページにフィードが表示されている様子は以下のようになります。

本番環境において、Homeページにフィードが表示されている様子は以下のようになります。