5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-11-03

始めまして、現在プログラミングスクールRUNTEQで学習中である初学者のmassanです。

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

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

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

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

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
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
db/schema.rb
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

10万件のコメントを付けて確認してみる!!(rails db:seed)

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

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

案1:全件取得してその中からランダムに一件取得

上記のランダム抽出機能を記述するために調べる始めると一番初めに出てきたアンチパターンのようなものが以下のようなコードでした


Board.find(2).comments.sample

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

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

ということでした

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

Image from Gyazo

案2:乱数を使ってidを決定しそれを使ってfind

「すべて取り出す」という操作が良くないなら1~レコード数までの乱数(r)を作成してその乱数を使ってコメントを検索すればいいのでは?という風に考えました。


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

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

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

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

id コメント名 紐づいている掲示板のid
1 コメント1 2
2 コメント2 3
3 コメント3 2

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

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


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

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

Image from Gyazo

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

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

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

Image from Gyazo

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

案3:紐づいているコメントのidだけ集めて抽出

案2と同時に思いついていた方法なのですが、まず、掲示板2に紐づいているコメントの ”idのみ”を全件取得 して、その中からランダムに1つ選んでそのidを使ってfindすればよいのでは?という方法です

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


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

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

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

Image from Gyazo

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

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

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

単純に取ってくるものが1/8になったから

ではないかと思います

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

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

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

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

しかし!!!

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

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


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

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

おまけで学んだこと

その1:同じクエリが複数回使われないようになるべく結果(データ)を変数に保存しようね。

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


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

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


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

その2:RailsのActiveRecord有能すぎ

メソッドチェーンは機械的にドット(.)までで一度処理してからその結果に対して次のドットまでの処理をするというようなイメージだったのですが

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


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の操作ですが、今回のように取り出すカラムが1/8になると実行時間も1/8になったり、レコードの最初の方より最後の方のデータを取得する方が時間がかかる場合がある
など、まだまだ知識不足の自分の脳みそや感覚でも理解できることがあったので、ほんの少しだけActiveRecordやDBの理解が進んだように感じました。

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

アルゴリズム学習はいいぞ!!

因みに今回いろいろと手を動かして実験みたいなことをしましたが

100ms = 0.1秒やからこの実験もほとんど気にしなくてよさそうなんやけどね!!

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

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

参考記事

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

5
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?