【Rails】ランダムにデータ抽出する（アンチパターンに注意）

# はじめに
DBからランダムに1件データを抽出するというお話です。

この機能を実現するにあたっては、初学者でも比較的簡単にできると思います。
しかし、この前たまたま目にした記事が「これは怖い予感がする😨」という内容だったので、実際に怖さを検証してみました。

# どんな怖いコード？

以下が怖いコードの実例です。
```ruby
sample_post = Post.all.shuffle.first
```

全てのレコードを取得してからサンプリングしてるので、`時間がかかる` & `メモリを消費する` という問題があります。
（ちなみに`shuffle.first`は`sample`に置き換えられるという小さな問題もあります）

数百レコード程度であれば、目に見えるほどの影響はないかもしれません。
サンプル範囲が狭い場合であればこのようなやり方でも許容できるかもしれませんが、基本的には無駄な時間とメモリの消費は避けるべきです。

しかし実際にどのぐらい問題があるか体験したことがなかったので、検証してみようと思います。

# 検証してみる
100万件レコードがあれば結構な問題になるだろうと思ったので、100万件でテストしてみます

ローカルで使用した各環境は以下のとおりです
### 実行環境
* アプリケーション関連
    * Rails 7.0.2.2
    * Ruby 3.1.0
    * PostgreSQL 14.2
* ハード関連
    * macOS Monterey 12.3.1
    * Apple M1 Pro
    * メモリ 32GB

### サンプルデータ

```text
SELECT * FROM posts;
 id |   title   | content |         created_at         |         updated_at
----+-----------+---------+----------------------------+----------------------------
  1 | 1_1_test  | test    | 2022-07-03 22:37:23.549165 | 2022-07-03 22:37:23.549165
  2 | 1_2_test  | test    | 2022-07-03 22:37:23.549165 | 2022-07-03 22:37:23.549165
  3 | 1_3_test  | test    | 2022-07-03 22:37:23.549165 | 2022-07-03 22:37:23.549165
...
```

titleが`1_1_test` ~ `1000_1000_test`までの100万レコードで用意しました。
contentカラムはvarchar型で中身は全て`test`にしてます
（100万件のレコードを準備するやり方は別な記事で紹介しようと思います）

### 検証用コードの用意と実行
検証するサンプリング方法は3パターン用意しました。
```ruby
class Post < ApplicationRecord

  class << self
    def random_test
      Benchmark.bm 10 do |r|
        # DBの関数を使用(MySQLとかだとrandという関数になります)
        r.report 'random function' do
          order('random()').first
          put_memory_usage
        end

        # 全件数を取得してから、その件数以下の数字をランダムに選びlimitとoffsetで1件取得
        r.report 'offset' do
          rand_offset_number = rand(count)
          limit(1).offset(rand_offset_number).first
          put_memory_usage
        end

        # 全件取得してからサンプリング(怖い方法)
        r.report 'shuffle' do
          all.sample
          put_memory_usage
        end
      end
    end

    # 使用してるメモリ出力
    def put_memory_usage
      p "#{(ObjectSpace.memsize_of_all * 0.001 * 0.001).round(1)} MB"
    end
  end
end
```

### 結果発表おおおお（浜ちゃん風）
```bash
# 一応レコード数を確認
[1] pry(main)> Post.count
  Post Count (45.6ms)  SELECT COUNT(*) FROM "posts"
=> 1000000

# 検証
[2] pry(main)> Post.random_test
                 user     system      total        real
random function  Post Load (183.2ms)  SELECT "posts".* FROM "posts" ORDER BY random() LIMIT $1  [["LIMIT", 1]]
"63.0 MB"
  0.020798   0.003376   0.024174 (  0.233853)
offset       Post Count (14.7ms)  SELECT COUNT(*) FROM "posts"
  Post Load (24.8ms)  SELECT "posts".* FROM "posts" ORDER BY "posts"."id" ASC LIMIT $1 OFFSET $2  [["LIMIT", 1], ["OFFSET", 529166]]
"63.0 MB"
  0.012095   0.000161   0.012256 (  0.051814)
shuffle      Post Load (343.3ms)  SELECT "posts".* FROM "posts"
"925.0 MB"
  7.256302   2.916320  10.172622 ( 10.408513)
```

少しわかりやすく表示してみます

| 方法 | 処理時間 /s | メモリ消費 /MB |
|:-|:-:|:-:|
| DBのrandom関数使用  | 0.024174  | 63.0  |
| count + offset使用  | 0.012256  | 63.0  |
| 全件取得してからサンプリング  | 10.172622  | 925.0  |


CPUはM1 Proなので割と処理は早いと思いますが、それでもこれだけ時間がかかってます。
1レコードあたりのサイズはかなり小さいはずですが、メモリは結構食ってます。
（postsテーブルを想定すると、本来はcontentカラムがtext型でもっと中身があるはずなので、実際のユースケースではもっと膨大なメモリを消費するはずです。よほどメモリに余裕を持っておかないとアプリサーバーが落ちそうです）

# 少し紹介

ちなみにDBのrandom関数を使うのは一見問題なさそうに思えますが、SQLアンチパターンという書籍によると、アンチパターンとされています。

これは以下のような理由からです。
* ランダムにソートを行うため、インデックスのメリットを得られない
* 必要な行が最初の1行のみであるにも関わらず、データセット全体をソートしてしまう

こちらの記事に概略があるので、時間がある方は覗いてみると良いかもしれません。↓

https://zenn.dev/link/comments/472ca99fd484b8


もっと時間に余裕のある方は書籍を読むことをお薦めしておきます↓

https://www.amazon.co.jp/SQL%E3%82%A2%E3%83%B3%E3%83%81%E3%83%91%E3%82%BF%E3%83%BC%E3%83%B3-Bill-Karwin/dp/4873115892?&linkCode=ll1&tag=bookappkoki73-22&linkId=f8614fa162fddfb613468081e968ceea&language=ja_JP&ref_=as_li_ss_tl


# おまけ
10件でテストした時の結果です。

```bash
[1] pry(main)> Post.random_test
                 user     system      total        real
random function  Post Load (5.0ms)  SELECT "posts".* FROM "posts" ORDER BY random() LIMIT $1  [["LIMIT", 1]]
"62.9 MB"
  0.019082   0.002568   0.021650 (  0.055337)
offset       Post Count (0.6ms)  SELECT COUNT(*) FROM "posts"
  Post Load (0.9ms)  SELECT "posts".* FROM "posts" ORDER BY "posts"."id" ASC LIMIT $1 OFFSET $2  [["LIMIT", 1], ["OFFSET", 4]]
"63.0 MB"
  0.010359   0.000295   0.010654 (  0.013060)
shuffle      Post Load (0.4ms)  SELECT "posts".* FROM "posts"
"63.0 MB"
  0.008740   0.000120   0.008860 (  0.009192)
```

| 方法 | 処理時間 /s | メモリ消費 /MB |
|:-|:-:|:-:|
| DBのrandom関数使用  | 0.021650  | 62.9  |
| count + offset使用  | 0.010654  | 63.0  |
| 全件取得してからサンプリング  | 0.008860  | 63.0  |

データ件数が少ない場合は、クエリがシンプルなほうが早いという結果になってますね。