1. jnchito

    No comment

    jnchito
Changes in body
Source | HTML | Preview

TL; DR(長いので最初に結論)

  • Railsアプリケーションでデータを作成時間順にソートする場合、idでソートする場合も、created_atでソートする場合も、それぞれメリットとデメリットがある。
  • 一概に「こっちを選んでおけば安心」という明快な回答はないので、それぞれのメリットとデメリットを理解した上で、要件にあったカラムを選ぼう。
  • 適切な判断を下すためにはSQLやRDBMSに関する知識が必要。詳しくない人はしっかり勉強しよう。

はじめに

フィヨルドのkomagataさんが書いたこちらのブログ記事を拝見しました。

idの順番に依存しないコードを書こう - komagataのブログ

詳しい内容は直接上の記事を読んでもらいたいのですが、上記記事の主張を簡単にまとめると以下のようになります。

  • Railsアプリで古い順 or 新しい順でソートするなら、idでソートするよりcreated_atのようなカラムを使おう
  • なぜならidが整数値でなくuuidのような不定の文字列に仕様変更されると、不具合が発生するから
  • Fixturesでテストを書いたときも、idがランダムにセットされるのでテストが失敗してしまう

僕個人の感覚としては「うん、まあそうですよね」と思いつつも、現実的な実装方法として あえて 「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ミリ秒だったので少し遅くなっていますが、0.1ミリ秒未満の違いなのでほとんど誤差のレベルです。

じゃあ、どうすればいいの??

こういう話をしてしまうと、Rails初心者の方は「じゃあ、どっちでソートしたらいいの!?」と思ってしまうかもしれません。
その質問に対する答えは「ケースバイケース」となります。
一概に「idにすれば(またはcreated_atにすれば)OK」と言い切ることはできません。

ここから先では、それぞれの方法にどういうメリットとデメリットがあるのか、そして僕はどういった考えで使い分けているのかについて論じていきます。

idでソートする場合

メリット

  • 主キーなのでデフォルトでインデックスが付いている(つまり速い)
  • 主キーなので重複がない。つまり並び順が必ず一意になる

デメリット

  • 「idが小さいほど古く、大きくなるほど新しい」というルールは絶対とは言い切れない(やろうと思えばRDBMSの設定をいじってidの開始値を変更したりすることもできる)
  • uuidのようなランダムな文字列に変更されると、ソートしても古い/新しいの基準にならない(komagataさんの主張)
  • Fixturesでテストを書くと、idがランダムに設定されるので、やはり古い/新しいの基準にならない(komagataさんの主張)

デメリットに挙げられている内容は「たしかにそのとおり」なのですが、実運用上は問題なかったりするケースが多い(そして、デメリットよりもメリットの方が大きい)ため、僕は特別な理由がない限り「とりあえずidでソート」にすることが多いです。デメリットに挙げられている内容は「たしかにそのとおり」なのですが、実運用上は問題なかったりするケースが多い(そして、デメリットよりもメリットの方が大きい)ため、僕は特別な理由がない限り「とりあえずidでソート」にすることが多いです(もちろんそうしない場合もあります。その点については後述します)。

運用中のRailsアプリで、途中からidの開始値を変更したり、uuidに変更したりするのは、かなりレアケースです(僕の経験上は)。
実際にそういうことをする場合は少なくとも「何らかの重要な決断」が発生しているはずなので、実施前の工数見積もりの段階で「ソートに問題が出ないか」ということも調査するんじゃないかなと思います。

実際にそういうことをする場合は少なくとも「何らかの重要な決断」が発生しているはずなので、実施前の工数見積もりの段階で「ソートに問題が出ないか」ということも調査するんじゃないかなと思います。

Fixturesについてはすいません、僕はもっぱらFactoryBotを使ってテストデータを作るので、なんとも言えません😣

ただ、komagataさんもブログ記事で書いているとおり、あくまで「idはidentifier(ただの識別子)」です。
僕が「とりあえずidで」と気軽にidを指定するのは、マスタメンテのデータ一覧画面など、「何らかのルールでソートしたいが、これといって明確なルールが思い浮かばない場合」が多いです。

もし「発生時間順に並ぶこと」がシステム上、重要な要件になる場合は「ただの識別子であるid」でソートするのは避けるべきです。
その場合はcreated_at、もしくはそれ以外の時間を表すカラム(たとえば、メール送信日時 = mail_sent_atみたいなカラム)にインデックスを付けてソートします。

もっというなら、「発生時間順に並ぶこと」が重要な要件なのであれば、created_atの使用も避けた方がいいと思います。
created_atやupdated_atははあくまで「Railsが用意してくれている便利情報」なので、その情報に依存するよりも「メール送信日時」のような専用のカラムを追加して明示的に日時を管理する方が、より堅牢なアプリケーションになるはずです。

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だけでなく、idも指定すれば、必ず順番が一意に決まる
Inbox.order(:created_at, :id).limit(20)

「idでソートする場合」の項でも書きましたが、created_atやupdated_atはあくまで「Railsが用意してくれている便利情報」です。
システムの要件上、発生日時順にデータが並ぶことが本当に重要な意味を持つのであれば、それ専用のカラムを追加することを検討してください。

まとめ

というわけで、この記事では時間順にソートする場合の条件として、どのカラムを使うべきか、という話をあれこれ議論してみました。

繰り返しになりますが、idが良いのか、created_atが良いのか、はたまた専用のカラムが良いのかは要件によるので「こうしておけば安心」という明快な回答はありません。

また、どんな要件であれ、「ここではこのカラムでソートするのが適切」という判断をするためには、インデックスや実行計画など、RDBMSの知識が必要になります。
「データベースやSQLって、実はあんまりよくわかってない」という人は、早かれ遅かれこうした壁にぶち当たるので、ちゃんと勉強しておきましょう!