Railsでデータベースビュー(VIEW)を使用してパフォーマンス改善をおこなう
データベースビューとは、複数のテーブルからデータを結合して単一の仮想的なテーブルとして扱えるようにしたものです。
この記事では、データベースビューを使用することでパフォーマンス改善をおこなったときの内容を紹介します。
データベースビューを使用することにした背景
下記の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が便利なのでこちらを使用しました。
今回は、ユーザーの[ニックネーム、通知件数、記事の件数]をカラムにもつテーブルを作るために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.
なので、参照されているカラムが知らない間に削除されて事故ることはなさそうだったので、特にデメリットにはならないなとも思いました。
参考
Discussion