はじめに
こんにちは、こんばんは、初めまして。
プログラミングスクールRUNTEQで学習し、現在就活フェイズに入ったmassanです。
RUNTEQのアウトプットリレーという企画に参加しており、6月末からは隔週で記事を書いています。
今回の記事はこちら
今回も僕のRUNTEQでの卒業制作、「Music Hour」で実装したことについてアウトプットがてら記事にしていきます
Music Hourについてはこちらの記事にざっくりと書いてあるのでご覧ください
今回はタイトル通りRails7系でsolid cacheを使ってみたのでざっくりどんな感じで実装したか、どんな効果があったかアウトプットしていきたいと思います。
対象読者
- MVCやRailsの基本的な動作が理解できる
- キャッシュという機能についてある程度理解している
- solid cacheに興味がある
今回は従来のキャッシュ機能で用いられてきたRedisなどとsolid cacheの違いや性能差についてはあまり詳しく触れず、Railsのキャッシュについても解説をしていません。
solid cacheの導入方法などをメインにしているのであしからず
環境
- Windows11
- Ubuntu 24.04 LTS (WSL2)
- Docker
- Ruby 3.3.6
- Rails 7.2.1
今回の目標
今回は、特定の投稿に対し1~5ポイントの間でポイントを付けて、そのポイントをユーザー同士で競ってランキングをつける「ポイントランキング」 を実装しようとしています。
このランキングを表示する方法としては
- 毎回DBの該当するテーブルに直接問い合わせて集計、表示する
- ランキング用のテーブルを作成し、定期的にcronジョブを実行したり、何かしらの方法でそのテーブルにランキングを保存、必要な時にそのテーブルから取り出す
- 一定期間で最初の表示のみDBへの問い合わせと集計を行い、キャッシュに保存しておく。二度目以降はキャッシュから読み出す。
の3つが候補でした。
1つ目は毎回複雑なクエリが走ってしまい、レコードが多くなってくるとパフォーマンスが低下しますし、2つ目は新たにcronジョブとテーブルやモデルを追加するのが面倒なので、スケーラビリティも考え、3つ目のキャッシュで実装することにしました。
というわけで今回の目標は、「ランキングの集計と表示をキャッシュを用いて実装する」です!
Solid Cache
Railsガイドには以下のように書いてあります
Solid Cacheは、データベース上に構築されるActive Supportキャッシュストアです。 従来のハードディスクよりずっと高速な最新のSSD(ソリッドステートドライブ)を活用して、より大きなストレージ容量とシンプルなインフラストラクチャを備えたコストパフォーマンスの高いキャッシュを提供します。
SSDはRAMより若干遅いのですが、ほとんどのアプリケーションではその差はわずかであり、RAMよりも多くのデータを保存できるため、キャッシュを頻繁に無効化する必要がないことで補われています。その結果、平均キャッシュミスが少なくなり、応答時間も速くなります。
従来のRedisのようなキャッシュサーバーを使ってキャッシュを保存するのではなく、最近のストレージがHDDからSSDに移行してきたという背景からDBの中にキャッシュ保存用の領域を作ってそこにキャッシュを保存するというようなRailsの機能。という理解で僕はおちつきました。
メモリ上でキャッシュの保存まで行っているRedisとは違い、ストレージ内にキャッシュの保存を行っているため、速度はRedisほど早くはありませんが、大容量かつ再起動してもデータが残るなどの特徴もあるようです。
要するに今まで気にしないといけなかった容量とかパフォーマンスとかをあまり気にしなくてよくなってより便利になったキャッシュってことらしい。
その他お世話になった参考資料:
https://note.com/ruby_dev/n/n5c66a36d024d
https://qiita.com/yokoto/items/52a05bca505a30d64130
https://qiita.com/yamashun/items/bf9a3d29de749cf18f2e
実装
PaaSにRenderを使用しているのですが、Redisを使用しようとすると新しくサーバーを立ち上げなければならず、月々の金額が上がってしまうので、今回はDBの中にsolid cache用のテーブルを作成し、追加のDBやサーバーは作成しない方向で進めていきます。
solid cache 導入
まずは専用の設定を追加していきます。MusicHourが初期にRails8から7系にバージョンダウンしてたせいで若干面倒だったんですが、とりあえず以下のREADME通りにやっていけば簡単に導入できました。
1,Gemfileにsolid_cacheのgemを入れてbundle install
gem "solid_cache"
2,各種設定をしてくれるsolid_cache
が提供してるコマンド実行
docker環境ではもちろんdocker内のbash内で実行してくださいね
bin/rails solid_cache:install
config/cache.yml
とdb/cache_schema.rb
が新しく作成されます。
3,config.cache_storeの設定
config/environments/development.rb
とconfig/environments/production.rb
にsolid_cache使うぜ?って設定を追加します。
config.cache_store = :solid_cache_store
README通りにDBの設定を変更
production:
primary: &primary_production
<<: *default
database: app_production
username: app
password: <%= ENV["APP_DATABASE_PASSWORD"] %>
cache:
<<: *primary_production
database: app_production_cache
migrations_paths: db/cache_migrate
ちなみに自分はローカルでも試したかったので以下のプルリクのようにしました。
本番環境用のものも変えてありますが実はこのままじゃ本番環境で動きません。
4,DBに適用
ここまでやったらrails db:prepare
をします。prepareの挙動なのかsolid_cacheの特有のものなのかわからないんですが、マイグレーションファイルがないんですがおそらくdb/cache_schema.rb
から読み込んできてDBとテーブル作ってくれます。
いつものDBコンテナの中にテーブルができてるわけじゃなくて、そもそも別のDBサーバーなのかDBサーバー内で二つに分かれてるのかわかりませんが、普通に使ってるDBとは別の切り離されたところに専用のテーブルが作成されてました。DBのコンテナのbashに入って
cache:
<<: *primary_production
database: app_production_cache
migrations_paths: db/cache_migrate
ここで設定したdatabaseに書いた名前のデータベースにアクセスしたらテーブルが存在してました。
ここまで出来たらsolid_cache使えるようになってるのでrails c
で試してみます。
myapp(dev)> Rails.cache.write("test", "test_value", expires_in: 60.minutes)
SolidCache::Entry Upsert (4.3ms) INSERT INTO "solid_cache_entries" ("key","value","key_hash","byte_size","created_at") VALUES ('\x646576656c6f706d656e743a74657374', '\x0011029aa596c26f17da41ffffffff746573745f76616c7565', -8888549517541498432, 181, CURRENT_TIMESTAMP) ON CONFLICT ("key_hash") DO UPDATE SET "key"=excluded."key","value"=excluded."value","byte_size"=excluded."byte_size" RETURNING "id" /*application='Myapp'*/
=> true
myapp(dev)> Rails.cache.read("test")
SolidCache::Entry Load (0.5ms) SELECT "solid_cache_entries"."key", "solid_cache_entries"."value" FROM "solid_cache_entries" WHERE "solid_cache_entries"."key_hash" IN (-8888549517541498432) /*application='Myapp'*/
=> "test_value"
myapp(dev)>
read, write, fetchについてはこちらの記事を参考にしました:https://qiita.com/shuhei_takada/items/4da6f852bb72acb29aef
expires_in: 60.minutes
の部分はキャッシュが自動で削除される時間を設定してます。
動いてますね、おK。
PaaS(Render)でも動くようにする
Renderなどのpaasでは上記のように2つDBサーバー作る?というか2つDBを用意できなかった(やり方がわからなかった)ので現在使ってるDB内に必要なテーブル作成してそれをsolid cacheに使ってもらうように設定を書き換えました。
まずは接続先の変更
default: &default
store_options:
# Cap age of oldest cache entry to fulfill retention policies
# max_age: <%= 60.days.to_i %>
max_size: <%= 256.megabytes %>
namespace: <%= Rails.env %>
development:
database: primary
<<: *default
test:
<<: *default
production:
database: primary
<<: *default
default: &default
adapter: postgresql
encoding: unicode
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
host: db
username: postgres
password: password
development:
primary: &primary_development
<<: *default
database: myapp_development
test:
<<: *default
database: myapp_test
production:
primary: &primary_production
<<: *default
adapter: postgresql
encoding: unicode
url: <%= ENV['DATABASE_URL'] %>
上記のように編集すればアプリケーションで使っているDBの中でsolid cache関連のテーブルを探してつかってくれるようになります。
接続設定だけで実際にテーブルは用意できてないので以下のようにマイグレーションファイルを作成し、マイグレートします
# NOTE: Renderではdatabaseを複数持てないのでsolid_cacheのschemaからコピペ
# https://github.com/rails/solid_cache/blob/v1.0.6/lib/generators/solid_cache/install/templates/db/cache_schema.rb
# https://madogiwa0124.hatenablog.com/entry/2025/01/25/230015
class CreateSolidcacheTables < ActiveRecord::Migration[7.2]
def change
create_table "solid_cache_entries", force: :cascade do |t|
t.binary "key", limit: 1024, null: false
t.binary "value", limit: 536870912, null: false
t.datetime "created_at", null: false
t.integer "key_hash", limit: 8, null: false
t.integer "byte_size", limit: 4, null: false
t.index [ "byte_size" ], name: "index_solid_cache_entries_on_byte_size"
t.index [ "key_hash", "byte_size" ], name: "index_solid_cache_entries_on_key_hash_and_byte_size"
t.index [ "key_hash" ], name: "index_solid_cache_entries_on_key_hash", unique: true
end
end
end
ファイルの作成と保存ができたら
rails db:migrate
https://madogiwa0124.hatenablog.com/entry/2025/01/25/230015
https://github.com/massan-E/MusicHour/pull/272/files
config/database.yml
の、paasのDBとつなぎこむときにやるurl: <%= ENV['DATABASE_URL'] %>
のような項目を設定してるとdatabase:
の項目は上書きされる「らしい」です
ここまで出来たらデプロイし、本番環境のコンソールで先ほどのようにrails c
をつかって
Rails.cache.write("test", "test_value", expires_in: 60.minutes)
のように実行してみるときちんと動作するようになっていると思います。
上記では同じDB上にsolid cache用のテーブルを用意してしまっていますが、こうすると少しパフォーマンスが下がってしまう可能性があります。
使用例:ランキングの集計結果をキャッシュする
solid cacheの導入がしたい方は上記のようにすると導入が完了します
ここからはMusic Hourでどのようにsolid cacheを使って実装をしたか軽くアウトプットしていきます。
ポイントランキングを集計するクエリは以下のように10万件のレコードを用意して検証し、良さそうなものを見つけておきました。
今回のポイントランキングにかかわるMusic Hourのテーブルなども書いてあるので一度確認してみてください(殴り書きのメモなので読みにくくてすみません・・・)
というわけでProgramモデルに以下のようにメソッドをいくつか追加して、これをコントローラーで呼び出して変数に格納、viewで表示します。
class Program < ApplicationRecord
# 省略
belongs_to :user
has_many :letters, dependent: :nullify
# 省略
# ランキングを週間か月間で設定できる
enum ranking_period: { not_set: 0, weekly: 1, monthly: 2 }
# 省略
# 日付範囲を計算するメソッド
# ランキングの更新を週または月の1日目早朝5時~次の週、月の一日目4時59分までにする(要するにランキングの更新は5時更新)
def calculate_date_range(base_time, period_type)
return nil unless %w[weekly monthly].include?(period_type)
base_date = base_time.to_date
range_config = {
"weekly" => {
start: ->(date) { date.beginning_of_week + 5.hours },
end: ->(date) { date.end_of_week.end_of_day + 5.hours }
},
"monthly" => {
start: ->(date) { date.beginning_of_month + 5.hours },
end: ->(date) { date.end_of_month.end_of_day + 5.hours }
}
}
# デフォルトは月間
config = range_config[period_type]
start_time = config[:start].call(base_date)
end_time = config[:end].call(base_date)
start_time..end_time
end
# 現在のランキング期間の日付範囲を取得するメソッド
def current_ranking_date_range
return nil if not_set?
calculate_date_range(Time.zone.today, ranking_period)
end
# 指定した日付のランキング期間の日付範囲を取得するメソッド
def ranking_date_range_for(date)
return nil if not_set?
calculate_date_range(date, ranking_period)
end
# スターランキングを取得するメソッド
def star_rankings(limit = 10, date_range = current_ranking_date_range)
return [] if not_set?
letters
.where.not(user: nil)
.where(publish: true)
.where(stared_at: date_range)
.group(:user_id)
.limit(limit)
.order(Arel.sql("SUM(star) DESC"))
.sum(:star)
end
def cached_star_rankings
return [] if not_set?
cache_key = "star_rankings_program_#{id}_#{ranking_period}"
Rails.cache.fetch(cache_key, expires_in: 60.minutes) do
star_rankings
end
end
def clear_star_rankings_cache
cache_key = "star_rankings_program_#{id}_#{ranking_period}"
Rails.cache.delete(cache_key)
end
# 省略
いろいろと追加してありますが要するに番組(program)のインスタンスに対して
@program.cached_star_rankings
と実行すれば、番組に紐づいているお便りをユーザー別にまとめて、そのお便りそれぞれのstarを集計し、どのユーザーが一番starを獲得したかのランキングを集計するクエリが走り、その結果がキャッシュに保存される
といった感じです。
Rails.cache.fetch
cached_star_rankings
メソッドの中でキャッシュ機能を利用しているのは
Rails.cache.fetch(cache_key, expires_in: 60.minutes) do
star_rankings
end
の部分です。
Rails.cache.fetch
は指定したキーのキャッシュが存在していなければ書き込み、存在していれば読み取ってそれを返します。
expires_in: 60.minutes
の部分はキャッシュの有効期限を設定しています。
60分で失効するので、60のうちに2回以上同じランキングが表示される場合にキャッシュから読みだされるといった感じですね
これを用いているcached_star_rankings
メソッドを使ってコントローラーでランキングを取得しておくと、ランキングを表示するリクエストが飛んできたときに、初回の表示は時間がかかりますが、二回目以降はかなり時間が短縮されます。
コンソールで実行し、実行時間を計測してみた結果が以下です。(関連付けされているレコードは7000ほどです)
myapp(dev)> program = Program.find(17)
Program Load (1.2ms) SELECT "programs".* FROM "programs" WHERE "programs"."id" = 17 LIMIT 1 /*application='Myapp'*/
=>
#<Program:0x00007d2dcdfba428
...
myapp(dev)> program.cached_star_rankings
SolidCache::Entry Load (1.3ms) SELECT "solid_cache_entries"."key", "solid_cache_entries"."value" FROM "solid_cache_entries" WHERE "solid_cache_entries"."key_hash" IN (8487624975138040347) /*application='Myapp'*/
Letter Sum (220.4ms) SELECT SUM("letters"."star") AS "sum_star", "letters"."user_id" AS "letters_user_id" FROM "letters" WHERE "letters"."program_id" = 17 AND "letters"."user_id" IS NOT NULL AND "letters"."publish" = TRUE AND "letters"."stared_at" BETWEEN '2025-05-31 20:00:00' AND '2025-06-30 19:59:59.999999' GROUP BY "letters"."user_id" ORDER BY SUM(star) DESC LIMIT 10 /*application='Myapp'*/
SolidCache::Entry Upsert (2.1ms) INSERT INTO "solid_cache_entries" ("key","value","key_hash","byte_size","created_at") VALUES ('\x646576656c6f706d656e743a737461725f72616e6b696e67735f70726f6772616d5f31375f6d6f6e74686c79', '\x001101b8748484871fda41ffffffff04087b0f690669236902ae0a691d69022909691c6902d00f691b69024206691a6902b50e69196902e306691969025c0c691869020b10691869025c126918', 8487624975138040347, 261, CURRENT_TIMESTAMP) ON CONFLICT ("key_hash") DO UPDATE SET "key"=excluded."key","value"=excluded."value","byte_size"=excluded."byte_size" RETURNING "id" /*application='Myapp'*/
=> {1=>30, 2734=>24, 2345=>23, 4048=>22, 1602=>21, 3765=>20, 1763=>20, 3164=>19, 4107=>19, 4700=>19}
myapp(dev)> program.cached_star_rankings
SolidCache::Entry Load (0.4ms) SELECT "solid_cache_entries"."key", "solid_cache_entries"."value" FROM "solid_cache_entries" WHERE "solid_cache_entries"."key_hash" IN (8487624975138040347) /*application='Myapp'*/
=> {1=>30, 2734=>24, 2345=>23, 4048=>22, 1602=>21, 3765=>20, 1763=>20, 3164=>19, 4107=>19, 4700=>19}
初回は合計で223.8ms
程かかってしまっていますが、二回目はキャッシュからロードし、0.4ms
にまでなっていますね。これなら安心です!
まとめ
今回はsolid cacheを用いて重そうなクエリの実行回数を抑えてパフォーマンス、スケーラビリティを意識しながらランキング機能を実装してみました。
DBにランキング用のテーブルを作成するより簡単ですしcronで定期的に実行するよりも安上がりでほぼ1時間おきにランキングが更新されるので新鮮さがあるのでとても満足しています。
自分はアルゴリズムの学習を少しかじった程度ですが、そのおかげでこういったことに気づいたりするのが早くなったと感じたのでアルゴリズム学習、おすすめです。
パフォーマンス向上やスケーラビリティを意識した実装は以外と楽しいので皆さんもクエリが走っている部分をよく観察して無駄に重くなってしまっていないか確認してみてください!楽しいですよ!