DBのViewを作ったらRailsプログラムが綺麗になった話

最近データベースというかSQLについて勉強しているんですが、奥が深いですね。この前の第9回中国地方DB勉強会のときに聞いたrank関数を使って、ランキング機能をリファクタリングしよう!と思って最近頑張ってます。というのも、複雑なクエリ(遅い)を業種数分(10回くらい)呼んでいたため、Herokuだと結構ギリギリの速度になることもあったので、なんとかしなければ!と思っていたのです。

とりあえず、私の開発環境を載せておきます。

Mac Yosemite
Ruby 2.2
Rails 4.2.1
PostgreSQL 9.3.4

ひとまずrank関数を使うところまで

まず、NewRelicを使ってActiveRecordが出力しているSQLを取得し、それを0xDBEのコンソールに貼り付けて、rank関数を使って業種でパーティションしてランキングを出すところまでしてみました。rank関数は、関数名の通り、ランキングを出力する関数です。詳細については、@soudai1025がアップしている資料を見てもらえばクエリ付きでわかりやすいと思います。

この資料に、以降で説明することもほぼ書かれていますので、通読することをお勧めします。

複雑なクエリを楽にするためにDBのViewを作る

書いたクエリをActiveRecordで表現してみると、かなり複雑で読みにくくなってしまったんですが、@soudai1025から、(DBの)Viewにしてはどうですか?とアドバイスをもらったので、Viewを作ってみました(以降、ViewはDBのViewのこと)。Viewはmigrationで作りましたが、railsでDBのViewを作る機能はないので、普通にSQLを書いてクエリを実行します。

class CreateViewFooRanking < ActiveRecord::Migration
  def up
    # Viewを作るためのクエリは50行くらいあったのでダミーで…。
    execute <<-SQL
      CREATE OR REPLACE VIEW foo_rankings AS
        SELECT *
        FROM (
          SELECT
            rank()
            OVER(
              PARTITION BY category_id
              ORDER BY count DESC
            ) AS rank,
            count,
            category_id,
            foo.id AS foo_id
            ...
          FROM foos
            INNER JOIN (
              SELECT ...
              FROM (
                SELECT ...
                FROM ...
                  LEFT OUTER JOIN (
                    SELECT ..
                    FROM ..
                  ) AS ..
              ) AS ..
            ) AS ..
        ) AS base
    SQL
  end

  def down
    execute <<-SQL
      DROP VIEW foo_rankings
    SQL
  end
end


このマイグレーションを実行後、0xDBEなどのSQLエディタで対象のViewに対してクエリを発行して、思った通りのデータが取れているかを確認します。

View用のModelを作る

View用のモデルは普通に作れるので、定義してみましょう。

class FooRanking < ActiveRecord::Base
  belongs_to :foo

  scope :top10, -> { where(arel_table[:rank].lteq(10)) }
  scope :default_order, -> { order(category_id: :asc, rank: :asc) }

  default_scope { default_order }
end

データを取得してみる

では、このモデルを使ってみましょう。

トップ10のデータを取得
FooRanking.preload(:foo).top10
1つのカテゴリのランキングを取得
FooRanking.preload(:foo).where(category_id: 1)
カテゴリごとの10位までを取得してカテゴリ毎に分ける
FooRanking.preload(:foo).top10.group_by(&:category_id)

ものすごく複雑だったランキングデータの取得が、1行のコードで取れるようになりました。
あぁ、快…感…。

Viewいいね!でもまだ遅いね!

しかし、Viewは結局のところアクセスされるたびにクエリを発行して擬似的なテーブルを作っているため、複雑なビューだと遅いんですね。
200msecかかってたクエリ10回=2秒
よりは、
500msecかかるView1回
のほうが、かかる時間は短いわけですが、500msecは割と気になる遅さです。Kaminariを使ってページネーションする場合、トータル件数の取得と現ページのデータの取得で2回クエリが発行されて、結果、合計1秒かかったり。

しかし、それをさらに速くする方法があったのです。

MATERIALIZED VIEWにしてさらに高速に!

マテリアライズドビュー(以降、マテビュー)は、Viewの結果をキャッシュしておくViewです。結果を保有しているため、問い合わせが高速です。500msecかかっていたクエリが、2msecになりました!素晴らしい。
作り方は簡単です。CREATE OR REPLACE VIEWにしていたところを、CREATE MATERIALIZED VIEWにするだけです。

CREATE MATERIALIZED VIEW foo_rankings AS ...

しかし、マテビューは結果をキャッシュするがゆえの欠点もありますし、そのあたりも書いておきます。

  • PostgreSQL 9.3以降のみで使える
  • 定期的にデータをリフレッシュしなければならない
  • リフレッシュ中はデータがないためロックされる
  • RSpecでテストする場合にデータ投入後にリフレッシュ必須

私の利用シーンでは、ランキングは月に1度の更新でよかったので、マテビューを使うほうがよさそう!と思ってこちらを採用しました。PostgreSQL9.4だと、リフレッシュ中のロックを緩やかにする機能があるらしいので、9.4にできる場合は9.4のほうがよさそうです。

まとめ

ActiveRecordで表現するにはあまりにも複雑な場合はViewを作りましょう。
読みやすいコードにすることが可能です。
要件次第ではマテビューを使うとかなりの高速化になります。


カテゴリー Ruby, Ruby on Rails | タグ | パーマリンク

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です