Railsでのレコードのランダム抽出

始めまして、現在プログラミングスクールRUNTEQで学習中である初学者の[massan](https://qiita.com/massan-E/questions)です。

今回は自分が制作中のミニアプリで実装しようとしたコードについて深掘りした結果
RailsのActiveRecordやDBについての学びがあったので、自分の振り返りも兼ねて記事にしてみました。

# 今回やりたかったことと前提条件

今回自分が考えていたのは、Railsでのレコードのランダム抽出機能でした。
具体的には、ユーザーが掲示板を投稿し、その掲示板に対して他のユーザーが投稿したコメントの中からランダムで一件抽出する。というものです。（Xのポストに対する返信の中から一件ランダム抽出するような感じ）

前提条件となるそれぞれのテーブルの関係は以下です

```Ruby:app/models/board.rb
class Board < ApplicationRecord
  validates :title, presence: true, length: { maximum: 255 }
  validates :body, presence: true, length: { maximum: 65_535 }

  belongs_to :user
  has_many :comments, dependent: :destroy
end
```

```Ruby:app/models/comment.rb
class Comment < ApplicationRecord
  validates :body, presence: true, length: { maximum: 65_535 }

  enum condition:{ published: 0, hide: 1 }
  belongs_to :user
  belongs_to :board
end
```
<details>
    <summary>db/schema.rb</summary>

```Ruby:db/schema.rb
    ActiveRecord::Schema[7.0].define(version: 2024_10_23_152743) do
      create_table "boards", charset: "utf8mb4", force: :cascade do |t|
        t.string "title", null: false
        t.text "body", null: false
        t.bigint "user_id"
        t.datetime "created_at", null: false
        t.datetime "updated_at", null: false
        t.integer "condition", default: 0, null: false
        t.index ["user_id"], name: "index_boards_on_user_id"
      end
    
      create_table "comments", charset: "utf8mb4", force: :cascade do |t|
        t.bigint "user_id"
        t.bigint "board_id"
        t.text "body"
        t.datetime "created_at", null: false
        t.datetime "updated_at", null: false
        t.integer "condition", default: 0, null: false
        t.boolean "is_read", default: false, null: false
        t.index ["board_id"], name: "index_comments_on_board_id"
        t.index ["user_id"], name: "index_comments_on_user_id"
      end
    
      create_table "users", charset: "utf8mb4", force: :cascade do |t|
      # ===== 省略 =====
      end
    
      add_foreign_key "boards", "users"
      add_foreign_key "comments", "boards"
      add_foreign_key "comments", "users"
    end
```
</details>

### １０万件のコメントを付けて確認してみる！！（rails db:seed）
実際に動かしていろんなコードの処理速度を試したかったのでどうしようか考えた結果、DBに初期データを入れられるseedでboardsテーブルの最初のレコード（今回はid:2の掲示板　※以降「掲示板２」）に対して１０万件のコメントを作成して、それに対していろいろ実験してみることにしました。

```Ruby:db/seeds.rb
300.times do
  User.create!(
  # ===== 省略 =====
  )
end

user_ids = User.ids

100.times do |index|
  # ===== 省略　=====
end

100000.times do |index|
  board = Board.first
  user = User.find(user_ids.sample)
  user.comments.create!(user_id: user.id, board_id: board.id, body: "コメント#{index}", condition: "published", is_read: false)
end
```



# 案１：全件取得してその中からランダムに一件取得
上記のランダム抽出機能を記述するために調べる始めると一番初めに出てきたアンチパターンのようなものが以下のようなコードでした
```Ruby

Board.find(2).comments.sample

```

このコードを見た時にまず思ったのは

**掲示板２についたコメントを”すべて取得してから処理”を行っているので確かにあまりよくないだろうな**

ということでした

実際にクエリを確認してみると確かに紐づいたものを全件取得しています。

[![Image from Gyazo](https://i.gyazo.com/4328e1ca276329e9f33cc4193e6971e3.png)](https://gyazo.com/4328e1ca276329e9f33cc4193e6971e3)

# 案２：乱数を使ってidを決定しそれを使ってfind
「すべて取り出す」という操作が良くないなら１～レコード数までの乱数（r）を作成してその乱数を使ってコメントを検索すればいいのでは？という風に考えました。
```Ruby

r = rand(board.find(2).comments.count)
Board.find(2).comments.find(r)

```

しかしこの方法を単純に行うと一つ問題があります。
コメントのテーブルには今回の掲示板２に紐づいているもの以外に、ほかの掲示板に紐づいているものも「作成された順番でidが割り当てられて」保存されていきます。

なので純粋にこの方法で抽出すると掲示板２に紐づいていないものが抽出される可能性があります。

例えば掲示板２と掲示板３があり、19時に掲示板２にコメント１がつく(id:1)、その５分後に掲示板３にコメント２（id:2）がつく、またその５分後に掲示板２にコメント３がつく（id:3）
というような状況になると、掲示板２は全体で２件のコメントを持っていることになります。

| id | コメント名 | 紐づいている掲示板のid |
| ---- | ---- | ---- |
| 1 | コメント１ | 2 |
| 2 | コメント２ | 3 |
| 3 | コメント３ | 2 |

ですがこの状態で上記のコードを実行した場合、乱数で２が生成されると掲示板３に紐づいたコメントを取得することになってしまいます。

なのでoffsetを使って掲示板２に紐づいている一番最初のコメントのレコードから乱数回分飛ばしたものを取得することにします

```Ruby

Board.find(2).comments.offset(rand(Board.find(2).comments.count)).first

```

このコードもコンソールで確認してみましょう

[![Image from Gyazo](https://i.gyazo.com/a444cb8ef6225c74fb62d8cfa5e99f47.png)](https://gyazo.com/a444cb8ef6225c74fb62d8cfa5e99f47)

約1/4ほどの実行速度になっていますね

**しかしこのコードもまだ完璧ではありません。**

以下のように乱数が１０万件（テーブルの後ろの方）に近づくにつれて実行時間が増加してしまったりして安定性がありません。

[![Image from Gyazo](https://i.gyazo.com/4066b5da06484e5b064cff26d2681a49.png)](https://gyazo.com/4066b5da06484e5b064cff26d2681a49)

RubyやRailsのソースコードなどは確認していないので不確定ですが、これはおそらくoffsetで飛ばす時の処理で前から１つずつ数えていっているからなのではないかと思います（単純にidが２倍、すなわち前からの”距離”が約２倍になると実行時間も約２倍になっているため）

# 案３：紐づいているコメントのidだけ集めて抽出
案２と同時に思いついていた方法なのですが、まず、掲示板２に紐づいているコメントの **”idのみ”を全件取得** して、その中からランダムに１つ選んでそのidを使ってfindすればよいのでは？という方法です

SQLにおいてIDやインデックスを使用したデータの抽出は最適化されているので、どうにかそれらを使って抽出できないかな、と考えていた時に思いつた方法です。

```Ruby

Comment.find(Comment.where(board_id: 2).pluck(:id).sample)

```
※ここに来てやっと「 わざわざboardテーブルの関連付けから引っ張り出すのではなく、commentsテーブルのカラムにboard_idあるからそれで検索すればクエリ減らせるやん・・・ 」ということに気づきました。モヤモヤしていた方、すいません。

これもコンソールで確認してみましょう。

[![Image from Gyazo](https://i.gyazo.com/c5f4316ad6008a8ecce65b25c00bbd2d.png)](https://gyazo.com/c5f4316ad6008a8ecce65b25c00bbd2d)

圧倒的早さ！！！　
単純に全件取得している時と比べて実行時間が約１/８になっています！

何度かやってみましたが、毎回ほぼ同じことをやっているので実行時間はほとんど変わらずとても安定していました。

因みになぜ実行時間が１/８になったかということですが

**単純に取ってくるものが１/８になったから**

ではないかと思います

自分のアプリのcommentsテーブルには８つのカラムがありますが、案１のコードだとそのすべてのカラムの情報を取得していました

ですが今回の案３のコードではその８つのカラムの中のとくに情報が少な目なidというカラム１つのみに絞って全件取得しています

その結果、残りの７つのカラムの分の使用するメモリや問い合わせ回数が減り、結果的に実行時間が１/８になったのではないかと考えています。

やっとよさそうな記述が見つかりましたね！

### しかし！！！

このままだと「掲示板２（特定の掲示板）にコメントがついていない」場合にfindにnilを渡してしまうことになるのでエラーになります。

なので最後の仕上げにこんな風に書き換えます。

```Ruby

random_comment_id = Comment.where(board_id: 2).pluck(:id).sample
random_comment = random_comment_id ? Comment.find(random_comment_id) : nil

```

これでやっと完成です！！

# おまけで学んだこと

### その１：同じクエリが複数回使われないようになるべく結果（データ）を変数に保存しようね。

例えば以下のコードですが、全く同じクエリを発行する記述が２か所もあります
```Ruby

# |========== クエリ１回目 ============|　　　　　　　　　|=========== クエリ２回目 ===========|　　　　　　　　　　　　　　　　　　　
  Comment.where(board_id: 2).pluck(:id) ? Comment.find(Comment.where(board_id: 2).pluck(:id).sample) : nil

```

このような場合は一回目に呼び出した”結果（データ）”を変数に保存しておいて再利用することでクエリの発行回数(DBへの問い合わせ)を１/２にできます。

```Ruby

random_comment_id = Comment.where(board_id: 2).pluck(:id).sample
random_comment = random_comment_id ? Comment.find(random_comment_id) : nil

```

### その２：RailsのActiveRecord有能すぎ
メソッドチェーンは機械的にドット（.）までで一度処理してからその結果に対して次のドットまでの処理をするというようなイメージだったのですが

ActiveRecordは全体を考えて良しなにクエリ発行してくれてました・・・偉すぎ。

```Ruby

Board.find(2).comments.sample
# 関連づいてるコメント全件取得　→　それに対してsample　みたいなクエリになってる
# "SELECT `comments`.* FROM `comments` WHERE `comments`.`board_id` = 2" コレやった後に.sample


Board.find(2).comments.offset(rand(Board.find(2).comments.count)).first
# 上と同じようにBoard.find(2).commentsで全件取得してからそれに対して操作行ってると思ったら
# 取得せずに関連づいてるものの中からランダムで取得してた
# SELECT `boards`.* FROM `boards` WHERE `boards`.`id` = 2 LIMIT 1
# SELECT `boards`.* FROM `boards` WHERE `boards`.`id` = 2 LIMIT 1
# SELECT COUNT(*) FROM `comments` WHERE `comments`.`board_id` = 2       
# SELECT `comments`.* FROM `comments` WHERE `comments`.`board_id` = 2 ORDER BY `comments`.`id` ASC LIMIT 1 OFFSET 39631

```

# 最後に
Railsの場合、ActiveRecordさんがいろいろと良しなにやってくれている”おかげ”なのか”せい”なのかなんとなく難しそうなActiveRecordやDBの操作ですが、今回のように取り出すカラムが１/８になると実行時間も１/８になったり、レコードの最初の方より最後の方のデータを取得する方が時間がかかる場合がある
など、まだまだ知識不足の自分の脳みそや感覚でも理解できることがあったので、ほんの少しだけActiveRecordやDBの理解が進んだように感じました。

今回はいろいろな記事を調べたり、普段から行っているアルゴリズムの学習から得た感覚や知識（まだまだ初歩）をつかったり、納得いかない場合はAIと対話したりしてここで紹介したような記述を考えましたが、いつの日にかAIの力を借りずにこういうモノをパパっと考えられるようなエンジニアになりたいなと思いました。
#### アルゴリズム学習はいいぞ！！

因みに今回いろいろと手を動かして実験みたいなことをしましたが
### 100ms = 0.1秒やからこの実験もほとんど気にしなくてよさそうなんやけどね！！

企業レベルのレコード数でランダム機能使う時にでも思い出してください・・・ｗ

最後まで読んでいただいてありがとうございました！

## 参考記事
https://qiita.com/koki_73/items/2ebee06bf16c416aefa8
https://blog.44uk.net/2012/12/22/get-random-record-using-activerecord/