WEBアプリケーションエンジニアの小松です!
プロセス内キャッシュの挙動に馴染みがなかったので、どういう挙動なのか。
ネットワーク越しのキャッシュとの使い分け。
他言語との比較でRails特有の仕様なのかどうか。
という疑問が湧いたので調査し、それを記事にしました。
この記事は[Enigmo Advent Calendar 2025]の16日目の記事です。
- ローカルキャッシュとは何か
- 今回直面した疑問と調査内容
- 「ディスク IO を避けたいだけなら」プロセス内キャッシュが最も速い
- 実際に採用したコード
- Rails 特有の挙動
- Rails サーバーが複数台ある場合の挙動
- キャッシュとしての位置づけの違い
- この仕組みは Ruby 特有なのか
- まとめ
ローカルキャッシュとは何か
ローカルキャッシュとは、Ruby プロセス内のメモリに値を保持し、同じプロセス内であれば何度呼び出されても再計算や再読み込みを行わない仕組みのことを指す。
Ruby では次の構文がある。
@config ||= YAML.load_file("config/settings.yml")
この構文は最初の一回だけ YAML.load_file が実行され、以降はメモリに保持された @config が返される。
Rails プロセスが動いている限り、この値は保持され続ける。
今回直面した疑問と調査内容
実際に自分が直面した疑問は次のようなものだった。
-
Rails サーバーが複数ある場合、各プロセスごとにキャッシュされるということは、そもそも「キャッシュ」と言えるのか
-
Memcached や Redis など外部キャッシュと比べて本当に速いのか
-
毎回インスタンス変数に保存するだけで高速化されるように見えるが、仕組みとして本当に正しいのか
-
他言語ではどう実現しているのか
これらを順番に整理していった。
「ディスク IO を避けたいだけなら」プロセス内キャッシュが最も速い
Rails.cache(Memcached/Redis)のキャッシュも高速だが、必ずネットワーク越しの通信が発生する。
クラウド環境であれば数百マイクロ秒〜数ミリ秒のオーバーヘッドが加わる。
一方、プロセス内キャッシュは Ruby プロセスが持つメモリに直接アクセスするだけで、ネットワークもディスクも介さない。
最短経路でデータにアクセスできるという点では最速になる。
ただし、これは「ローカルに存在する静的データ」に限った話である。
更新頻度が高いデータには適さない。
実際に採用したコード
今回検討していたコードは次のような YAML 読み込み処理だった。
def contents(condition)
yaml = YAML.load_file('config/item_cate_desc.yml')
# 以下ロジック...
end
これでは毎回ファイルを読み込み、ディスク IO が発生するため遅い。
そこで、次のように改善した。
def item_cate_yaml
@item_cate_yaml ||= YAML.load_file('config/item_cate_desc.yml')
end
この1行によって、「最初の一回だけ読み込む」処理に変わる。
後はメモリに保持され続けるため、各リクエストで読み込む必要がない。
Rails 特有の挙動
Rails のコントローラでインスタンス変数を使っても、それはリクエストごとに新しく生成されるオブジェクトに所属するため、キャッシュとしては機能しない。
キャッシュとして効くのは、プロセスが生きている限り保持され続ける「クラスインスタンス変数」や「クラス変数」の方である。
PHP のようにリクエスト終了時にプロセスが破棄される言語とは異なり、Ruby(特に Rails のアプリサーバー)はプロセス常駐型のため、同じクラスインスタンス変数へ複数リクエストがアクセスする構造になっている。
この違いが理解しづらく、PHPer には馴染みがなく疑ってすらいたので、
railsアプリではクラスインスタンス変数の注意する #Ruby - Qiita
などの記事を参考にしてファクトチェックもしました。
Rails サーバーが複数台ある場合の挙動
ここについても疑問を持ったが、調べた結論は次のとおり。
-
各 Rails プロセス内で一度だけ読み込まれ、それぞれが独立してデータを保持する
-
よってプロセスを跨いだ共有キャッシュではない
-
ただし配置ファイル(YAML)が全サーバーで共通であれば問題はない
-
プロセス間の同期は不要で、むしろ高速
「複数サーバーだからキャッシュが効かない」という誤解があるが、ローカルキャッシュは各プロセス単位で成立するため問題ない。
キャッシュとしての位置づけの違い
データの性質に応じてどのキャッシュを選ぶべきか整理すると次のようになる。
| 種類 | 特徴 | 向いているケース |
|---|---|---|
| プロセス内キャッシュ | 最高速。プロセスごと独立。データ変更には弱い | 設定ファイル、マスターデータ |
| Rails.cache(Memcached/Redis) | 共有キャッシュ。通信が必要 | 変更頻度がありサーバー間で共通化したいデータ |
| DB キャッシュ | 一貫性は高いが IO コストあり | モデルデータ |
今回のような静的な YAML データであれば、間違いなくプロセス内キャッシュが適している。
この仕組みは Ruby 特有なのか
Ruby の ||= を使ったプロセス内キャッシュは極めて自然で扱いやすい。
もちろん他の言語でも似たことはできるが、次のように比較すると Ruby の簡潔さが際立つ。
Java
static 変数+ダブルチェックロックなど同期処理が必要で、明らかにコードが冗長。
Go
sync.Once を使う必要がある。
パッケージスコープの変数は設計上の制約も多い。
PHP
そもそも 1 リクエスト 1 プロセスのため、プロセス内キャッシュという概念が成立しない。
APCu など外部拡張に頼る必要がある。
Node.js
モジュールキャッシュにより Ruby に近い感覚で扱えるが、副作用の管理が必要で Ruby の手軽さとはやや異なる。
Ruby はプロセス常駐型で、かつクラスインスタンス変数が自然にキャッシュとして機能するため、他の言語と比較して特に扱いやすい。
まとめ
今回の検討で分かったのは、次のような点である。
-
Ruby の
@var ||= ...によるローカルキャッシュは、非常に手軽に使える最速のキャッシュ方式 -
複数サーバーでも問題なく、各プロセスが独立してキャッシュを保持する
-
Memcached や Redis より速いのは、ネットワーク通信が一切ないため
-
データの性質に応じてキャッシュ方式は使い分けるべき
-
他言語でも実現は可能だが、Ruby ほど自然で簡潔な形にはならない
静的な設定データを高速化したい場面では、最初に検討すべき手法と言える。
明日17日目はAIテクノロジーグループの太田さんです。