データベースのランダム読み出しは要注意(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

データベースのランダム読み出しは要注意(翻訳)

ORM(Object-Relation Mapper、またはObject-Relational Mapper)を初めて使ったとき、「ORMにrandom()メソッドがないのはどうしてなんだろうか?」と不思議に思ったものでした。こんなメソッドなら楽勝で追加できそうなものです。データベースのレコードをランダムに取り出したい理由はいろいろ考えられますが、順序をランダムにしたいレコード数がよほど少ないときでもない限り、SQLのORDER BY RANDOM()でやるべきではありません。本記事では、一見シンプルなSQL演算子によってどれほどパフォーマンスが低下するか、およびいくつかの修正方法について調査したいと思います。

ご存じの方もいらっしゃるかと思いますが、私はオープンソースを支援するのに最適なCodeTriageというサイトを運営しており、このサイトのデータベースパフォーマンス改善についていくつか記事を書きました(訳注: 以下はいずれも仮の日本語タイトルです)。

最近私はheroku pg:outliersコマンドを実行して最適化後の様子を調べていたところ、RANDOM()を含むたった2つのクエリがデータベース時間の32%を占めていたことに気づいて驚きました。

$ heroku pg:outliers
14:52:35.890252 | 19.9%          | 186,846    | 02:38:39.448613 | SELECT  "repos".* FROM "repos" WHERE (repos.id not in (?,?)) ORDER BY random() LIMIT $1
08:59:35.017667 | 12.1%          | 2,532,339  | 00:01:13.506894 | SELECT  "users".* FROM "users" WHERE ("users"."github_access_token" IS NOT NULL) ORDER BY RANDOM() LIMIT $1

これほど遅くなった原因を理解するために、最初のクエリをちょっと見てみましょう。

SELECT
  "repos".*
FROM "repos"
WHERE
  (repos.id not in (?,?))
ORDER BY
  random()
LIMIT $1

このクエリは週に一度実行され、オープンソースのリポジトリにアカウントを持っているがまだサイトに登録していないユーザーに、サイトに登録してissueを「トリアージ」するようユーザーに勧めます。ユーザーに送信されるメールには、ランダムなリポジトリを含む3つのおすすめリポジトリが含まれています。最終的にランダムな結果を得られたのですからRANDOM()の使い方としてはうまくいっているように思えますが、どこがまずいのでしょうか?

PostgreSQLへのリクエストORDER BY random() LIMIT 1ではレコードを1件しか要求していませんが、このクエリが行っているのはそれだけではありません。その1件を返す前にすべてのレコードを並べ替えているのです。

このクエリはArray#sampleのようなものだろうと考える人もいるかもしれませんが、実際にやっているのはArray#shuffle.firstです。私がこのコードを書いたときは、データベースに登録されているリポジトリがほんの数件どまりだったので、かなり高速でした。しかし今やリポジトリ数は2761件に増加しています。そのため、このクエリを実行するたびにデータベースはリポジトリごとに多数の行を読み込み、CPUパワーをリポジトリの並べ替えに使わなければなりません。

もうひとつのクエリでは、同じことがusersテーブルで起きているのがわかります。

=> EXPLAIN ANALYZE SELECT  "users".* FROM "users" \n
WHERE ("users"."github_access_token" IS NOT NULL) \n
ORDER BY RANDOM() LIMIT 1;

                                                      QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------
 Limit  (cost=1471.00..1471.01 rows=1 width=2098) (actual time=12.747..12.748 rows=1 loops=1)
   ->  Sort  (cost=1471.00..1475.24 rows=8464 width=2098) (actual time=12.745..12.745 rows=1 loops=1)
         Sort Key: (random())
         Sort Method: top-N heapsort  Memory: 26kB
         ->  Seq Scan on users  (cost=0.00..1462.54 rows=8464 width=2098) (actual time=0.013..7.327 rows=8726 loops=1)
               Filter: (github_access_token IS NOT NULL)
               Rows Removed by Filter: 13510
 Total runtime: 12.811 ms
(8 rows)

比較的小さなクエリを1つ実行するたびに13ms近くかかっています。

ではRANDOM()が犯人だとしたら、どう修正すればよいのでしょうか?これは驚くほど難しい質問です。アプリやデータへのアクセス方法によって大きく異なります。

私の場合、ランダムなIDを生成し、それを使ってコードを取り出すことで問題を修正しました。このインスタンスのIDは比較的連続していることがわかっていたので、@@max_idで最も大きなIDを取り出し、1からそのIDの間のランダムな数値を1つ得てから、IDが>= idになるレコードを取り出すクエリを実行するようにしました。

これで速くなったでしょうか?もちろん。以下は上のクエリをRANDOM()に置き換えた同じクエリです。

=> EXPLAIN ANALYZE SELECT  "users".* FROM "users"\n
WHERE ("users"."github_access_token" IS NOT NULL) \n
AND id >= 55 LIMIT 1;
                                                  QUERY PLAN
--------------------------------------------------------------------------------------------------------------
 Limit  (cost=0.00..0.17 rows=1 width=2098) (actual time=0.009..0.009 rows=1 loops=1)
   ->  Seq Scan on users  (cost=0.00..1469.36 rows=8459 width=2098) (actual time=0.009..0.009 rows=1 loops=1)
         Filter: ((github_access_token IS NOT NULL) AND (id >= 55))
 Total runtime: 0.039 ms

クエリ実行時間は13msから1msのコンマ以下にまで短縮されました。

: 連続性が要求される点に興味がおありの方は、私がRedditに書いたコメントをご覧ください。

この方法にはかなり重大な注意点がいくつかあります。私の実装では最大IDをキャッシュするので、この方法は私のユースケースには合いますが、皆さんのユースケースに合うとは限りません。次のようにすることで、これを完全にSQLで実行できます。

WHERE
  /* 略 */
  AND id IN (
    SELECT
    FLOOR(
      RANDOM() * (SELECT MAX(id) FROM issues)
    ) + 1
  )

いつものように、最適化の前後にはSQLクエリのベンチマークを取ってください。この実装ではID値が(連続せずに)まばらになっているとうまく扱えず、WHERE条件を使ってそれより大きな最大IDをランダムに選ぶことは考慮されていません。これを正しく行いたいのであれば、本質的には同じWHERE条件をメインクエリと同様にMAX(id)のサブクエリにも適用する必要があるでしょう。

私の場合、少々失敗があっても問題はありませんし、単に最も基本的なWHERE条件が適用されていることも認識していました。皆さんのニーズはここまで柔軟ではない可能性があります。

「ビルトイン機能で対処する方法ってあるの?」とお思いの方に耳寄りな情報です。Postgres 9.5で導入されたTABLESAMPLEがあることを知りました。情報をお寄せいただいた@HotFusionManに感謝します。

私の知る限り、TABLESAMPLEの使い方を紹介する最良の記事はTablesample In PostgreSQL 9.5です。欠点があるとすれば、「真のランダム」ではない(アプリで必要ならばですが)ことと、1件だけ取り出すのには使えないことです。私は、テーブルの1%だけをサンプリングするクエリを使ってこれを切り抜けられました。そして次のようにその1%からIDを取り出し、LIMITで最初のレコードだけを取り出しました。

SELECT
  *
FROM repos
WHERE
  id IN (
    SELECT
      id
    FROM repos
    TABLESAMPLE SYSTEM(1) /* 1 パーセント */
  )
LIMIT 1

Redditの/r/postgresスレで別の方法があることも教わりました。

この方法はうまくいき、大量のデータ(数百万行レベル)を返すクエリでORDER BY RANDOM()よりもはるかに高速になりましたが、その代わりデータ量の非常に少ないクエリが著しく遅くなります。

私がhttps://www.codetriage.comを最適化したとき、RANDOM()が使われているクエリをもうひとつ見つけました。これは、特定の1つのリポジトリについて開いているソースissueを検索するのに使われています。issueの保存方法が原因でIDの連続性がいまひとつなので、先ほどの手(ランダムなIDを>=でサンプリング)があまり通用しないようでした。データのランダム化にもっと確かな方法が必要であり、TABALESAMPLEならうまくいくかもしれないと考えました。

リポジトリによってはissueが数千件にのぼるものもありますが、リポジトリの50%は数件のissueしかありません。このクエリにTABLESAMPLEを導入したところ、小規模なクエリは目に見えて遅くなり、遅かったクエリは速くなりました。クエリで扱うissueの数は少ない方に偏っているので、トータルでは改善されませんでした。というわけで元のRANDOM()方式に戻しました。

追記: 念のため申し上げると、random()そのものは決して遅くないことは私もわかっていますし、本記事でもそのようなことは述べていません。遅いのはORDER BY random()の方です。速度の落ちるORDER BYを大量の記事に対して行うことが問題なのです。

追記2: いいえ、ランダムなオフセットは、IDを>=で指定する方法より速くありません。大量のレコードでオフセットを使う場合にも、パフォーマンスに深刻な問題が生じることがあります。

RANDOM()をもっと効率のよい方法で置き換えることに成功した方はいませんか?ご存知の方はぜひTwitter(@schneems)までお知らせください。

関連記事

Rails+PostgreSQLのパーティショニングを制覇する(翻訳)

ベテランRubyistがPythonコードを5倍速くした話(翻訳)

PostgreSQLの機能と便利技トップ10(2016年版)(翻訳)

Ruby on RailsによるWEBシステム開発、Android/iPhoneアプリ開発、電子書籍配信のことならお任せください この記事を書いた人と働こう! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833 コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。 これまでにRuby on Rails チュートリアル第2版の半分ほど、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れてそれぞれ一部を翻訳。 かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。 実は最近Go言語が好き。 仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

週刊Railsウォッチ

インフラ

BigBinary記事より

ActiveSupport探訪シリーズ