🏃‍♀️

Railsでデータベースビュー(VIEW)を使用してパフォーマンス改善をおこなう

2022/12/26に公開

データベースビューとは、複数のテーブルからデータを結合して単一の仮想的なテーブルとして扱えるようにしたものです。
この記事では、データベースビューを使用することでパフォーマンス改善をおこなったときの内容を紹介します。

データベースビューを使用することにした背景

下記のAccountモデルでは複数のテーブルからデータを取得する必要があり、各モデル(Profile, Article, Notification)のテーブルに対してそれぞれSQLを実行していました。

class Account
  def initialize(customer_id)
    @customer_id = customer_id
  end

  # ニックネーム
  def nickname
    Profile.find_by(customer_id: @customer_id).nickname
  end

  # 記事の件数
  def article_count
    Article.where(customer_id: @customer_id).count
  end

  # 通知件数
  def notification_count
    Notification.where(customer_id: @customer_id).count
  end
end

このようなAccountモデルに定義されているメソッドの内容をカラムにもつ仮想的なテーブル(ビュー)を作れば、1度のSQL実行で全て欲しいデータが取得できそうだった。

データベースビューを作成する

マイグレーションファイルに生SQLを書いてビューを作ることもできるのですが、ビューをmigrationで管理する場合、gemのscenicが便利なのでこちらを使用しました。

https://github.com/scenic-views/scenic

今回は、ユーザーの[ニックネーム、通知件数、記事の件数]をカラムにもつテーブルを作るためにaccount_summaries ビューを作ります。

rails generate scenic:view account_summaries を実行すると2つファイルが作成され、

  • db/migrate/*_create_account_summaries.rb (マイグレーションファイル)
  • db/views/account_summaries_v01.sql (SQLを記述するファイル)

そのうちのaccount_summaries_v01.sqlに[ニックネーム、通知件数、記事の件数]をカラムに持つように既存のデータからSQLを組み立てます。下記がSQLの内容です。

SELECT
  customers.id AS customer_id,
  customer_profiles.nickname AS nickname,
  T1.articles_count AS articles_count,
  T2.notifications_count AS notifications_count,
FROM customers
LEFT JOIN customer_profiles ON customer_profiles.customer_id = customers.id

LEFT JOIN (
  SELECT
    customers.id AS customer_id,
    count(articles.id) AS articles_count
  FROM customers
  INNER JOIN articles ON articles.customer_id = customers.id
  GROUP BY customers.id
) AS T1 ON T1.customer_id = customers.id

LEFT JOIN (
  SELECT
    customers.id AS customer_id,
    count(notifications.id) AS notifications_count
  FROM customers
  INNER JOIN notifications ON notifications.customer_id = customers.id
  GROUP BY customers.id
) AS T2 ON T2.customer_id = customers.id

あとは、マイグレーション実行後にAccountSummaryモデルを定義すればAccountSummary.find(<ID>)でDBからデータを取得できるようになります。

$ bin/rails c                
[1] pry(main)> AccountSummary.find(1)
AccountSummary Load (54.9ms)  SELECT "account_summaries".* FROM "account_summaries" WHERE "account_summaries"."customer_id" = $1 LIMIT $2  [["customer_id", 1], ["LIMIT", 1]]
=> #<AccountSummary:0x000000014671c900
 customer_id: 1,
 nickname: "ニックネーム",
 articles_count: 5,
 notifications_count: 0>

データベースビューのモデルを使用した改善結果

下記が改善後のAccountモデルです。

[ニックネーム, 記事数, 通知件数]を取得するために各テーブルに対して3回SQL実行していたのをビューにまとめることで1回のSQL実行に抑えることができました。さらにAccountモデルが少しスッキリしたような気がします。

class Account
  delegate :nickname,
           :article_count,
           :notification_count, to: :account_summary

  def initialize(customer_id)
    @customer_id = customer_id
  end

  private

    def account_summary
      @_account_summary ||= AccountSummary.find(@customer_id)
    end
end

# データベースビューに対応するモデル
class AccountSummary
  # @!attribute [r] nickname
  #   @return [String] ニックネーム
  # @!attribute [r] notifications_count
  #   @return [Integer] 通知件数
  # @!attribute [r] articles_count
  #   @return [Integer] 記事件数

  # データベースビューに主キーを持たせるための設定
  self.primary_key = :customer_id
end

今回感じたメリット・デメリット

メリット

  • SQL実行回数を抑えられることによるパフォーマンスの改善が見込める
  • もともとあったAccountモデルの記述量を減らして見通しを良くできる

デメリット

  • 場合によってはSQLが複雑になり、メンテナンスが難しい巨大クエリができてしまいそう
  • テーブル構造の変更に対応できない。ビューが参照しているテーブルの構造が変更されるとそれに応じてビューもメンテナンスする必要がある

デメリットの2つめに関して、Railsを使用している場合はビューで参照されているカラムを削除することはできませんでした。下記がそのときのエラーの内容です。

rails aborted!
StandardError: An error has occurred, this and all later migrations canceled:

PG::DependentObjectsStillExist: ERROR:  cannot drop column nickname of table customer_profiles because other objects depend on it
DETAIL:  view account_summaries depends on column nickname of table customer_profiles
HINT:  Use DROP ... CASCADE to drop the dependent objects too.

なので、参照されているカラムが知らない間に削除されて事故ることはなさそうだったので、特にデメリットにはならないなとも思いました。

参考

https://en.wikipedia.org/wiki/View_(SQL)

https://techracho.bpsinc.jp/morimorihoge/2019_06_21/76521

Discussion