3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Rails 7】Solid Cacheでランキング機能を効率化

Last updated at Posted at 2025-07-21

はじめに

こんにちは、こんばんは、初めまして。
プログラミングスクール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.ymldb/cache_schema.rbが新しく作成されます。
 

3,config.cache_storeの設定

config/environments/development.rbconfig/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に使ってもらうように設定を書き換えました。

まずは接続先の変更

config/cache.yml
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
config/database.yml
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関連のテーブルを探してつかってくれるようになります。

接続設定だけで実際にテーブルは用意できてないので以下のようにマイグレーションファイルを作成し、マイグレートします

db/migrate/xxxxxx_create_solidcache_tables.rb
# 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で表示します。

app/models/program.rb
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ほどです)

rails c
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時間おきにランキングが更新されるので新鮮さがあるのでとても満足しています。

自分はアルゴリズムの学習を少しかじった程度ですが、そのおかげでこういったことに気づいたりするのが早くなったと感じたのでアルゴリズム学習、おすすめです。

パフォーマンス向上やスケーラビリティを意識した実装は以外と楽しいので皆さんもクエリが走っている部分をよく観察して無駄に重くなってしまっていないか確認してみてください!楽しいですよ!

3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?