TL; DR(長いので最初に結論)
- Railsアプリケーションでデータを作成時間順にソートする場合、idでソートする場合も、created_atでソートする場合も、それぞれメリットとデメリットがある。
- 特に、ソート列をidからcreated_atに単純に切り替えると、場合によっては重大なパフォーマンスの悪化を招く恐れがある
- 一概に「こっちを選んでおけば安心」という明快な回答はないので、それぞれのメリットとデメリットを理解した上で、要件にあったカラムを選ぼう。
- 適切な判断を下すためにはSQLやRDBMSに関する知識が必要。詳しくない人はしっかり勉強しよう。
はじめに
フィヨルドのkomagataさんが書いたこちらのブログ記事を拝見しました。
idの順番に依存しないコードを書こう - komagataのブログ
詳しい内容は直接上の記事を読んでもらいたいのですが、上記記事の主張を簡単にまとめると以下のようになります。
- Railsアプリで古い順 or 新しい順でソートするなら、idでソートするよりcreated_atのようなカラムを使おう
- なぜならidが整数値でなくuuidのような不定の文字列に仕様変更されると、不具合が発生するから
- Fixturesでテストを書いたときも、idがランダムにセットされるのでテストが失敗してしまう
ちなみに、uuid(Universally Unique Identifier)というのは、"550e8400-e29b-41d4-a716-446655440000"のような、将来にわたって重複や偶然の一致が起こらない識別子のことです。(参考: Wikipedia)
komagataさんのブログの内容は、僕個人の感覚としては「うん、まあそうですよね」と思いつつも、現実的な実装方法として あえて 「idでソート」することがよくあります。
また、「idでソートするのはよくない、代わりにcreated_atでソートしよう」という方針を盲目的に適用すると、パフォーマンスの悪いWebアプリを作ってしまう恐れもあります。
というわけで、この記事ではkomagataさんのブログへのリプライという形で、Webアプリケーション(特にRailsアプリケーション)のソートについて、いろいろ考えてみようと思います。
この記事の対象読者
この記事の対象読者は「プログラミング経験の浅い、Ruby on Rails開発者」です。
プログラマ歴の長い人や、RDBMSに精通している方にとっては、あまり新鮮な情報はないかもしれません。
また、komagataさんのブログ記事と同様、本記事の前提となるアプリケーションはRailsアプリケーションです。
インデックスのないカラムのソートは遅い(ことがある)
前述のとおり、idの代わりにcreated_atでソートすると、パフォーマンスが落ちる可能性があります。この理由は以下のとおりです。
- idは主キーなので、デフォルトでインデックスが付いている
- 一方、created_atはデフォルトではインデックスは付いていない
- 大量のデータをデータベース内でソートする場合は、インデックスが付いていないカラムを使うと遅くなることがある
- よって、ソート用のカラムをidからcreated_atに変えると遅くなる可能性がある
ここでいう「インデックス」とは、RDBMSで使われるインデックスのことを指します。
インデックスをよく知らない、という方はネット記事や書籍等で概要をチェックしてください。
データベース性能を向上させる「インデックス」を理解する:「データベーススペシャリスト試験」戦略的学習のススメ(26) - @IT
実際に確認してみる
「ソート用のカラムをidからcreated_atに変えると遅くなる」と書きましたが、数十〜数百件程度のデータでは速度の違いが体感できません。そこで、今回は250万件以上のデータが格納されているテーブル(とある実案件のinboxesテーブル)を使って、idとcreated_atの速度差を検証してみます。
なお、ここで使用するRDBMSはPostgreSQLです。
idでソートした場合
まず、idでソートした場合の確認です。ここでは以下のようなSQLを使います。
SELECT id, created_at
FROM inboxes
ORDER BY id
LIMIT 20
上のSQLはActiveRecordで表現すると、以下のようなコードになります。
Inbox.order(:id).limit(20).pluck(:id, :created_at)
EXPLAINコマンドを使って、実行計画とコストを見てみます。
# EXPLAIN SELECT id, created_at FROM inboxes ORDER BY id LIMIT 20;
QUERY PLAN
---------------------------------------------------------------------------------------------
Limit (cost=0.43..1.16 rows=20 width=12)
-> Index Scan using inboxes_pkey on inboxes (cost=0.43..94078.28 rows=2568731 width=12)
(2 rows)
コストは0.43..1.16
になっています。
また、実行計画上も高速なIndex Scanが使われています。
created_atでソートした場合
次に、created_atでソートした場合を確認します。SQLは以下のとおりです。
SELECT id, created_at
FROM inboxes
ORDER BY created_at
LIMIT 20
ActiveRecordで表すと次のようなコードになります。
Inbox.order(:created_at).limit(20).pluck(:id, :created_at)
では実行計画を見てみましょう。
# EXPLAIN SELECT id, created_at FROM inboxes ORDER BY created_at LIMIT 20;
QUERY PLAN
------------------------------------------------------------------------------
Limit (cost=121162.32..121162.37 rows=20 width=12)
-> Sort (cost=121162.32..127584.15 rows=2568731 width=12)
Sort Key: created_at
-> Seq Scan on inboxes (cost=0.00..52809.31 rows=2568731 width=12)
(4 rows)
コストは121162.32..121162.37
になっています。idでソートしたときに比べると、10万倍以上大きくなっています。
実行計画を見ると、テーブルを全件走査しているSeq Scanが使われています。
コストが大きくなっている理由のひとつは、このSeq Scanです。
実際にかかる時間を比べてみる
次に、(実行計画の確認ではなく)上のSQLを実際に実行して、データの表示にどれくらい時間がかかるか見てみましょう。
まず、idでソートする場合です。
# SELECT id, created_at FROM inboxes ORDER BY id LIMIT 20;
id | created_at
----+----------------------------
1 | 2013-03-14 02:20:28.31295
2 | 2013-03-14 02:20:28.34119
7 | 2013-03-23 04:56:05.424296
9 | 2013-03-23 04:56:05.440934
10 | 2013-03-23 04:56:05.449472
11 | 2013-03-23 04:56:05.457946
12 | 2013-03-24 20:37:05.349369
13 | 2013-03-24 20:37:05.377539
14 | 2013-03-24 20:37:05.380656
16 | 2013-03-24 20:37:05.458107
17 | 2013-03-24 20:47:39.732235
18 | 2013-03-24 20:47:39.735389
19 | 2013-03-24 20:47:39.738282
21 | 2013-03-24 20:47:39.803878
22 | 2013-03-24 20:58:20.398117
23 | 2013-03-24 20:58:20.402421
24 | 2013-03-24 20:58:20.406992
26 | 2013-03-24 20:58:20.41428
27 | 2013-03-24 21:09:59.443773
28 | 2013-03-24 21:09:59.447936
(20 rows)
Time: 15.406 ms
結果は15.406ミリ秒でした。
次に、created_atでソートする場合です。
# SELECT id, created_at FROM inboxes ORDER BY created_at LIMIT 20;
id | created_at
----+----------------------------
1 | 2013-03-14 02:20:28.31295
2 | 2013-03-14 02:20:28.34119
7 | 2013-03-23 04:56:05.424296
9 | 2013-03-23 04:56:05.440934
10 | 2013-03-23 04:56:05.449472
11 | 2013-03-23 04:56:05.457946
12 | 2013-03-24 20:37:05.349369
13 | 2013-03-24 20:37:05.377539
14 | 2013-03-24 20:37:05.380656
16 | 2013-03-24 20:37:05.458107
17 | 2013-03-24 20:47:39.732235
18 | 2013-03-24 20:47:39.735389
19 | 2013-03-24 20:47:39.738282
21 | 2013-03-24 20:47:39.803878
22 | 2013-03-24 20:58:20.398117
23 | 2013-03-24 20:58:20.402421
24 | 2013-03-24 20:58:20.406992
26 | 2013-03-24 20:58:20.41428
27 | 2013-03-24 21:09:59.443773
28 | 2013-03-24 21:09:59.447936
(20 rows)
Time: 696.141 ms
こちらは696.141ミリ秒でした。
idによるソートに比べると、created_atによるソートは約45倍遅くなっています。
このように、インデックスが付いていないカラムでソートすると、(対象のデータが増えれば増えるほど)遅くなる可能性があります。
とはいえ、どちらでも大差がない場合もある
このように書くと、「じゃあ、やっぱり毎回idでソートしよう」と思う人が出てくるかもしれません。
ですが、このあたりの話はそう単純なものではありません。
たとえば、あらかじめWHERE句やJOIN句でデータが絞り込まれていると(そしてその条件でインデックスが使われていると)、どちらのカラムでソートしても大した違いが出なくなることがあります。
試しに次のようなSQLを実行してみます。
(content_idで絞り込んで、idでソートする)
SELECT id, created_at
FROM inboxes
WHERE
content_id = 10
ORDER BY id
LIMIT 20
ActiveRecordで表すと次のようなコードになります。
content = Content.find(10)
content.inboxes.order(:id).limit(20).pluck(:id, :created_at)
実行計画は次の通りです。
# EXPLAIN SELECT id, created_at FROM inboxes WHERE content_id = 10 ORDER BY id LIMIT 20;
QUERY PLAN
------------------------------------------------------------------------------------------------------------
Limit (cost=362.05..362.10 rows=20 width=12)
-> Sort (cost=362.05..362.47 rows=168 width=12)
Sort Key: id
-> Index Scan using index_inboxes_on_content_id on inboxes (cost=0.43..357.58 rows=168 width=12)
Index Cond: (content_id = 10)
コストは362.05..362.10
になっています。
またIndex Scanでcontent_idを使った絞り込みが行われています。
実際に実行した秒数は以下のとおりです。
# SELECT id, created_at FROM inboxes WHERE content_id = 10 ORDER BY id LIMIT 20;
id | created_at
-------+----------------------------
42 | 2013-03-26 12:30:14.565752
44 | 2013-03-26 12:30:14.607234
45 | 2013-03-26 12:30:14.612068
46 | 2013-03-26 12:30:14.616097
20796 | 2013-10-24 14:07:38.903351
20797 | 2013-10-24 14:07:38.916944
20798 | 2013-10-24 14:07:38.942156
20799 | 2013-10-24 14:07:38.972237
20800 | 2013-10-24 14:07:39.104876
20801 | 2013-10-24 14:07:39.223855
20802 | 2013-10-24 14:07:39.437203
20803 | 2013-10-24 14:07:39.778828
20804 | 2013-10-24 14:07:39.796193
20805 | 2013-10-24 14:07:39.813212
20806 | 2013-10-24 14:07:39.847716
20807 | 2013-10-24 14:07:39.879232
20808 | 2013-10-24 14:07:39.903536
20809 | 2013-10-24 14:07:39.916426
20810 | 2013-10-24 14:07:39.93175
20811 | 2013-10-24 14:07:39.947177
(20 rows)
Time: 0.721 ms
かかった時間は0.721ミリ秒でした。
次に、先ほどのSQLでORDER BY句をcreated_atに変えてみます。
SELECT id, created_at
FROM inboxes
WHERE
content_id = 10
ORDER BY created_at
LIMIT 20
実行計画を見てみましょう。
# EXPLAIN SELECT id, created_at FROM inboxes WHERE content_id = 10 ORDER BY created_at LIMIT 20;
QUERY PLAN
------------------------------------------------------------------------------------------------------------
Limit (cost=362.05..362.10 rows=20 width=12)
-> Sort (cost=362.05..362.47 rows=168 width=12)
Sort Key: created_at
-> Index Scan using index_inboxes_on_content_id on inboxes (cost=0.43..357.58 rows=168 width=12)
Index Cond: (content_id = 10)
(5 rows)
コストは362.05..362.10
なので、先ほどと同じです。
Index Scanでcontent_idを使った絞り込みが行われている点も同じです。
これはつまり、どちらのSQLも「インデックスを使ってcontent_idをデータを絞り込んでから、(インデックスを使わずに)idまたはcreated_atで並び替え」という手順で処理されているため、コストが同じになっています。
実際にSQLを実行してみましょう。
# SELECT id, created_at FROM inboxes WHERE content_id = 10 ORDER BY created_at LIMIT 20;
id | created_at
-------+----------------------------
42 | 2013-03-26 12:30:14.565752
44 | 2013-03-26 12:30:14.607234
45 | 2013-03-26 12:30:14.612068
46 | 2013-03-26 12:30:14.616097
20796 | 2013-10-24 14:07:38.903351
20797 | 2013-10-24 14:07:38.916944
20798 | 2013-10-24 14:07:38.942156
20799 | 2013-10-24 14:07:38.972237
20800 | 2013-10-24 14:07:39.104876
20801 | 2013-10-24 14:07:39.223855
20802 | 2013-10-24 14:07:39.437203
20803 | 2013-10-24 14:07:39.778828
20804 | 2013-10-24 14:07:39.796193
20805 | 2013-10-24 14:07:39.813212
20806 | 2013-10-24 14:07:39.847716
20807 | 2013-10-24 14:07:39.879232
20808 | 2013-10-24 14:07:39.903536
20809 | 2013-10-24 14:07:39.916426
20810 | 2013-10-24 14:07:39.93175
20811 | 2013-10-24 14:07:39.947177
(20 rows)
Time: 0.681 ms
実行時間は0.681ミリ秒でした。
先ほどは0.721ミリ秒だったので、idによるソートの方が少し遅くなっていますが、0.1ミリ秒未満の違いなのでほとんど誤差のレベルです。
両者のメリットとデメリットをそれぞれ理解する
こういう話をしてしまうと、Rails初心者の方は「じゃあ、どっちでソートしたらいいの!?」と思ってしまうかもしれません。
その質問に対する答えは「ケースバイケース」となります。
一概に「idにすれば(またはcreated_atにすれば)OK」と言い切ることはできません。
ここから先では、それぞれの方法にどういうメリットとデメリットがあるのか、そして僕はどういった考えで使い分けているのかについて論じていきます。
idでソートする場合
メリット
- 主キーなのでデフォルトでインデックスが付いている(つまり速い)
- 主キーなので重複がない。つまり並び順が必ず一意になる
デメリット
- 「idが小さいほど古く、大きくなるほど新しい」というルールは絶対とは言い切れない(やろうと思えばRDBMSの設定をいじってidの開始値を変更したりすることもできる)
- uuidのようなランダムな文字列に変更されると、ソートしても古い/新しいの基準にならない(komagataさんの主張)
- Fixturesでテストを書くと、idがランダムに設定されるので、やはり古い/新しいの基準にならない(komagataさんの主張)
デメリットに挙げられている内容は「たしかにそのとおり」なのですが、実運用上は問題なかったりするケースが多い(そして、デメリットよりもメリットの方が大きい)です。
そのため、僕は特別重要な要件がない限り、「とりあえずidでソート」を選択することが多いです(もちろん、そうしない場合もあります。そのようなケースについては後述します)。
運用中のRailsアプリで、途中からidの開始値を変更したり、uuidに変更したりするのは、かなりレアケースです(僕の経験上は)。
実際にそういうことをする場合は、少なくとも「何らかの重要な決断」が発生しているはずなので、実施前の工数見積もりの段階で「ソートに問題が出ないか」ということも調査するんじゃないかなと思います。
Fixturesについてはすいません、僕はもっぱらFactoryBotを使ってテストデータを作るので、なんとも言えません😣
ただ、komagataさんもブログ記事で書いているとおり、あくまで「idはidentifier(ただの識別子)」です。
僕が「とりあえずidで」と気軽にidを指定するのは、マスタメンテのデータ一覧画面など、「何らかのルールでソートしたいが、これといって明確なルールが思い浮かばない場合」が多いです。
もし「発生時間順に並ぶこと」がシステム上、重要な要件になる場合は「ただの識別子であるid」でソートするのは避けるべきです。
その場合はcreated_at、もしくはそれ以外の時間を表すカラム(たとえば、メール送信日時 = mail_sent_atみたいなカラム)にインデックスを付けてソートします。
もっというなら、「発生時間順に並ぶこと」が重要な要件なのであれば、created_atの使用も避けた方がいいと思います。
created_atやupdated_atはあくまで「Railsが用意してくれている便利情報」なので、その情報に依存するよりも「メール送信日時」のような専用のカラムを追加して明示的に日時を管理する方が、より堅牢なアプリケーションになるはずです。
余談:マスタメンテ画面ですら、システムによって要件が異なる?
僕は先ほど、「マスタメンテ画面ぐらいなら、とりあえずidでソートすることが多い」と書きました。
ですが、komagataさんのブログではid順で問題が出る画面の例として「管理画面のユーザー一覧」を挙げています。
管理画面のユーザー一覧をid降順でソートしてて、最近追加されたユーザーは一番最初にきてたのuuidにしたら並びが変わった・困るとお客さんに言われることになる。
komagataさんのブログに出てくるようなシステムだと、マスタメンテ画面であっても「とりあえずidでソート」では都合が悪いのかもしれません。
このように要件はシステムによって異なるので、「適切な設計」は画一的に決められないことがわかります。
created_atでソートする場合
メリット
- (特殊な方法でcreated_atを更新していない限り)時間順に並ぶことが保証される
デメリット
- デフォルトではインデックスが付かないので、状況によっては遅くなる
- もちろん、created_atにインデックスを付ければこの問題は解決するが、そのためにはmigrationをわざわざ書かなければいけない。
- また、インデックスを増やすと、更新時の速度低下や空きストレージの消費問題など、別のデメリットを引き起こす点にも注意が必要。
- created_atだけで絶対に一意に並び順が決まる、とは言い切れない(値の重複がありうる)
idでソートする場合とcreated_atでソートする場合のメリットとデメリットは、だいたい反対の関係にあります。
すでに述べたとおり、対象のテーブルに何百万件もデータがあり、なおかつWHERE句やJOIN句でデータを絞り込んでいない(もしくは、絞り込んでいてもインデックスが使われていない)場合は、遅くなることがあります。
一方、数百件しかデータがない小さなテーブルや、WHERE句やJOIN句でインデックスをうまく使って対象データを制限できたりする場合は、パフォーマンスが問題にならない(体感できるほどの差異がない)点もすでに述べたとおりです。
ただし、idとは違って、created_atは値の重複が絶対にないとは言い切れない、という点に注意が必要です。
可能性は低いですが、偶然全く同じ日時のcreated_atが複数あると、ソート順が一意になりません。
特に、テストコード内でテスト用のデータを作成したりする場合は、全く同じ日時のcreated_atが複数発生する可能性があります。(Timecop gem等でシステム日時を変更している場合など)
例:こんなデータになっているとcreated_atでソートしても順番が一意に決まらない(特にテスト環境)
id | created_at
----+----------------------------
1 | 2013-03-14 01:00:00.000000
2 | 2013-03-14 01:00:00.000000
7 | 2013-03-14 01:00:00.000000
9 | 2013-03-14 01:00:00.000000
10 | 2013-03-14 01:00:00.000000
ですので、created_atだけでなく、一緒にidもソート順の条件に指定して、「必ず並び順が一意になること」を保証しておくのが良いと思います。
# created_atだけでなく、idも指定すれば、必ず順番が一意に決まる
Inbox.order(:created_at, :id).limit(20)
「idでソートする場合」の項でも書きましたが、created_atやupdated_atはあくまで「Railsが用意してくれている便利情報」です。
システムの要件上、発生日時順にデータが並ぶことが本当に重要な意味を持つのであれば、それ専用のカラムを追加することを検討してください。
まとめ
というわけで、この記事では時間順にソートする場合の条件として、どのカラムを使うべきか、という話をあれこれ議論してみました。
繰り返しになりますが、idが良いのか、created_atが良いのか、はたまた専用のカラムが良いのかは要件によるので「こうしておけば安心」という明快な回答はありません。
また、どんな要件であれ、「この場面ではこのカラムでソートするのが適切」という判断をするためには、インデックスや実行計画など、RDBMSの知識が必要になります。
「データベースやSQLって、実はあんまりよくわかってない」という人は、早かれ遅かれこうした壁にぶち当たるので、ちゃんと勉強しておきましょう!
あわせて読みたい
こちらもデータベースを利用したソート(ORDER BY句の挙動)に関する記事です。
「同じように作成したデータのはずなのに、開発環境とテストコード上(または本番環境)で並び順が異なってる!いったいなんで!?」と思ったことがある人はぜひ読んでみてください。