Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

Rails開発者が陥りやすい!データベースパフォーマンスを低下させる定番イディオム3選

RailsのActiveRecordを初めて目にしたときのことは、今でも鮮明に覚えています。当時は2005年、PHPアプリのためにSQLクエリを手作業で書いていた頃でした。それまで退屈な雑務だったデータベース操作が、ある日突然簡単になり、あえて言えば「楽しい」ものへと変わったのです。

……しかし、その後すぐにパフォーマンス問題に気づき始めました。

遅かったのはActiveRecordそのものではありません。私が、実際に実行されているクエリへの意識を怠ってしまっただけです。そして調べてみると、RailsのCRUDアプリでごく普通に使われる定番クエリの中に、デフォルトのままでは大規模データセットに対してスケールしにくいものがいくつもあることが分かったのです。

本記事では、その中でも特に影響の大きい3つのパターンを取り上げます。その前にまず、自分のDBクエリがスケールできるかどうかを見極める方法から説明しましょう。

パフォーマンスの測定方法

どんなDBクエリでも、データセットが十分に小さければ高速に動作します。したがって、パフォーマンスを正確に把握するには、本番環境相当の規模のデータベースでベンチマークを取ることが不可欠です。この記事の例では、約22,000件のレコードを持つfaultsテーブルを使用します。

使用するデータベースはPostgreSQLです。PostgreSQLでは、explainコマンドを使ってパフォーマンスを測定します。例えば次のようになります。

# explain (analyze) select * from faults where id = 1;
                                     QUERY PLAN
--------------------------------------------------------------------------------------------------
 Index Scan using faults_pkey on faults  (cost=0.29..8.30 rows=1 width=1855) (actual time=0.556..0.556 rows=0 loops=1)
   Index Cond: (id = 1)
 Total runtime: 0.626 ms

この出力には、クエリ実行のコスト見積もり(cost=0.29..8.30 rows=1 width=1855)と、実際にかかった時間(actual time=0.556..0.556 rows=0 loops=1)の両方が表示されます。

より読みやすい形式が好みであれば、結果をYAML形式で出力するよう指定することも可能です。

# explain (analyze, format yaml) select * from faults where id = 1;
              QUERY PLAN
--------------------------------------
 - Plan:                             +
     Node Type: "Index Scan"         +
     Scan Direction: "Forward"       +
     Index Name: "faults_pkey"       +
     Relation Name: "faults"         +
     Alias: "faults"                 +
     Startup Cost: 0.29              +
     Total Cost: 8.30                +
     Plan Rows: 1                    +
     Plan Width: 1855                +
     Actual Startup Time: 0.008      +
     Actual Total Time: 0.008        +
     Actual Rows: 0                  +
     Actual Loops: 1                 +
     Index Cond: "(id = 1)"          +
     Rows Removed by Index Recheck: 0+
   Triggers:                         +
   Total Runtime: 0.036
(1 row)

ここでは、まず「Plan Rows」「Actual Rows」の2つに注目してください。

  • Plan Rows:最悪の場合、クエリに応答するためにDBが走査しなければならない行数
  • Actual Rows:クエリ実行時に、DBが実際に走査した行数

上記の例のようにPlan Rowsが1であれば、そのクエリはおそらく良好にスケールします。逆に、Plan Rowsがテーブル内の全行数と一致している場合は「フルテーブルスキャン」が発生しており、データ量の増加に伴ってパフォーマンスが劣化することを意味します。

測定方法が分かったところで、代表的なRailsの書き方がどのような結果をもたらすのか、順番に見ていきましょう。

COUNT(件数取得)

Railsのビューでは、次のようなコードを非常によく見かけます。

Total Faults <%= Fault.count %>

これにより、次のようなSQLが生成されます。

select count(*) from faults;

これをexplainに渡して、何が起こるか確認してみましょう。

# explain (analyze, format yaml) select count(*) from faults;
              QUERY PLAN
--------------------------------------
 - Plan:                             +
     Node Type: "Aggregate"          +
     Strategy: "Plain"               +
     Startup Cost: 1840.31           +
     Total Cost: 1840.32             +
     Plan Rows: 1                    +
     Plan Width: 0                   +
     Actual Startup Time: 24.477     +
     Actual Total Time: 24.477       +
     Actual Rows: 1                  +
     Actual Loops: 1                 +
     Plans:                          +
       - Node Type: "Seq Scan"       +
         Parent Relationship: "Outer"+
         Relation Name: "faults"     +
         Alias: "faults"             +
         Startup Cost: 0.00          +
         Total Cost: 1784.65         +
         Plan Rows: 22265            +
         Plan Width: 0               +
         Actual Startup Time: 0.311  +
         Actual Total Time: 22.839   +
         Actual Rows: 22265          +
         Actual Loops: 1             +
   Triggers:                         +
   Total Runtime: 24.555
(1 row)

驚きましたか? この単純なカウントクエリは、22,265行——つまりテーブル全体を走査しています。PostgreSQLでは、COUNTは常にレコードセット全体をループ処理するのです。

where条件を追加すれば対象レコードの件数を絞り込めます。要件によっては、許容できるパフォーマンスレベルまで減らせるかもしれません。

この問題のもうひとつの回避策は、カウント値をキャッシュすることです。Railsなら設定だけで対応できます。

belongs_to :project, :counter_cache => true

さらに、「レコードが1件でも存在するかどうか」だけを確認したい場合は別の代替手段があります。Users.count > 0の代わりにUsers.exists?を使うのです。生成されるクエリははるかに効率的です。(この点をご指摘くださった読者のGerry Shaw氏に感謝します。)

ORDER BY(ソート)

一覧ページ。ほぼすべてのWebアプリに存在する画面ですね。データベースから最新の20件を取得して表示する。これ以上シンプルな処理はありません。

レコードを取得するコードは、おそらく次のようになるでしょう。

@faults = Fault.order(created_at: :desc)

生成されるSQLはこうです。

select * from faults order by created_at desc;

では、分析してみましょう。

# explain (analyze, format yaml) select * from faults order by created_at desc;
              QUERY PLAN
--------------------------------------
 - Plan:                             +
     Node Type: "Sort"               +
     Startup Cost: 39162.46          +
     Total Cost: 39218.12            +
     Plan Rows: 22265                +
     Plan Width: 1855                +
     Actual Startup Time: 75.928     +
     Actual Total Time: 86.460       +
     Actual Rows: 22265              +
     Actual Loops: 1                 +
     Sort Key:                       +
       - "created_at"                +
     Sort Method: "external merge"   +
     Sort Space Used: 10752          +
     Sort Space Type: "Disk"         +
     Plans:                          +
       - Node Type: "Seq Scan"       +
         Parent Relationship: "Outer"+
         Relation Name: "faults"     +
         Alias: "faults"             +
         Startup Cost: 0.00          +
         Total Cost: 1784.65         +
         Plan Rows: 22265            +
         Plan Width: 1855            +
         Actual Startup Time: 0.004  +
         Actual Total Time: 4.653    +
         Actual Rows: 22265          +
         Actual Loops: 1             +
   Triggers:                         +
   Total Runtime: 102.288
(1 row)

見てください。このクエリが実行されるたびに、DBは全22,265行を毎回ソートしています。これは由々しき事態です。

デフォルトでは、SQL内のすべてのorder by句は、その場でリアルタイムにレコードセットをソートさせます。キャッシュもなければ、自動的に救ってくれる魔法もありません。

解決策はインデックスの活用です。このような単純なケースであれば、created_atカラムにソート済みインデックスを追加するだけで、クエリは大幅に高速化します。

Railsのマイグレーションでは次のように書きます。

class AddIndexToFaultCreatedAt < ActiveRecord::Migration
  def change
    add_index(:faults, :created_at)
  end
end

これにより、次のSQLが実行されます。

CREATE INDEX index_faults_on_created_at ON faults USING btree (created_at);

末尾の(created_at)がソート順序の指定です。デフォルトは昇順(ASC)となっています。

この状態で先ほどのソートクエリを再実行すると、ソートステップが消えていることが分かります。DBはインデックスから事前ソート済みのデータを読み出すだけになったのです。

# explain (analyze, format yaml) select * from faults order by created_at desc;
                  QUERY PLAN
----------------------------------------------
 - Plan:                                     +
     Node Type: "Index Scan"                 +
     Scan Direction: "Backward"              +
     Index Name: "index_faults_on_created_at"+
     Relation Name: "faults"                 +
     Alias: "faults"                         +
     Startup Cost: 0.29                      +
     Total Cost: 5288.04                     +
     Plan Rows: 22265                        +
     Plan Width: 1855                        +
     Actual Startup Time: 0.023              +
     Actual Total Time: 8.778                +
     Actual Rows: 22265                      +
     Actual Loops: 1                         +
   Triggers:                                 +
   Total Runtime: 10.080
(1 row)

なお、複数のカラムでソートする場合は、同じカラム構成・順序でソートされた複合インデックスを作成する必要があります。Railsマイグレーションでは次のように書けます。

add_index(:faults, [:priority, :created_at], order: { priority: :asc, created_at: :desc })

クエリが複雑になってきたら、こまめにexplainで確認する習慣をつけましょう。早い段階で、頻繁に。クエリのごく小さな変更が、PostgreSQLがソートにインデックスを使えなくなる原因になることがあるからです。

LIMIT と OFFSET(ページネーション)

一覧ページで、データベース内の全アイテムを表示することはまずありません。代わりにページネーションを行い、一度に10件・30件・50件程度だけを表示するのが一般的です。その最も一般的な方法が、limitoffsetの組み合わせです。Railsでは次のように書きます。

Fault.limit(10).offset(100)

生成されるSQLはこうなります。

select * from faults limit 10 offset 100;

これをexplainで実行すると、奇妙なことに気づきます。走査される行数が110、つまりlimit+offsetと等しいのです。

# explain (analyze, format yaml) select * from faults limit 10 offset 100;
              QUERY PLAN
--------------------------------------
 - Plan:                             +
     Node Type: "Limit"              +
     ...
     Plans:                          +
       - Node Type: "Seq Scan"       +
         Actual Rows: 110            +
         ...

offsetを10,000に変更するとどうなるでしょう。走査行数は10,010に跳ね上がり、クエリは64倍遅くなります。

# explain (analyze, format yaml) select * from faults limit 10 offset 10000;
              QUERY PLAN
--------------------------------------
 - Plan:                             +
     Node Type: "Limit"              +
     ...
     Plans:                          +
       - Node Type: "Seq Scan"       +
         Actual Rows: 10010          +
         ...

ここから導かれるのは、少し不穏な結論です。ページネーションでは、後ろのページほど読み込みが遅くなるということです。上記の例で1ページあたり100件と仮定すると、100ページ目は1ページ目より13倍遅いことになります。

では、どう対処すればよいのでしょうか?

率直に言えば、完全な銀の弾丸はまだ見つかっていません。まず検討すべきは、そもそもデータセットのサイズを縮小できないかという点です。数百・数千ページにも及ぶ一覧が必要かどうか、設計を見直してみましょう。

レコードセットを減らせない場合は、offset/limitをwhere句に置き換えるのが有効です。

# 日付の範囲を使う方法
Fault.where("created_at > ? and created_at < ?", 100.days.ago, 101.days.ago)

# ...あるいはIDの範囲でもOK
Fault.where("id > ? and id < ?", 100, 200)

まとめ

本記事を通じて、PostgreSQLのexplain機能を活用し、DBクエリに潜むパフォーマンス問題を早期に発見することの重要性をお伝えできたなら幸いです。最も単純に見えるクエリでさえ、深刻なパフォーマンス問題を引き起こす可能性があります。だからこそ、日頃からチェックする習慣が成果を生むのです。:)

  1. 【Excel】自動的に更新されるデータベースの作り方|4つの実践テクニック

    この記事では、Excelで自動的に更新されるデータベースを作成するための、実用的な4つの方法をわかりやすく解説します。売上や天気予報など、常に変化するデータを扱う業務では、元データが更新されたときにデータベース側も自動的に反映される仕組みが非常に重要です。手作業でのコピーや修正の手間を省き、ミスも防げます。それでは、具体例とともに各方法を見ていきましょう。 Excelで自動更新されるデータベースを作成する4つの方法 方法1:Webからデータを抽出して自動更新データベースを作成する 課題:Web上の「ニューヨーク(アメリカ)の14日間天気予報」を抽出し、自動的に更新されるExcelデータベースを

  2. Google Chromeの新拡張機能仕様「Manifest v3」が広告ブロッカーを無効化する恐れ

    Chrome体験向上の裏で、拡張機能開発者との対立が浮上 GoogleはChromeブラウザのユーザー体験を向上させるため、拡張機能に関する新しい計画を進めています。しかしこの方針は、多くの拡張機能開発者の強い反発を招いています。この計画が実行されれば、Chromeのパフォーマンスとセキュリティは確かに向上するものの、ウェブ閲覧中に広告や悪意のあるリンクをブロックするために作られた拡張機能が事実上使えなくなる恐れがあるのです。 どの拡張機能が影響を受けるのか? Googleの提案によって影響を受けると見られているのは、トラッカーブロッカー「Ghostery」、オープンソースの広告ブロッカー