インフィニットループ 技術ブログ

2022年03月08日 (火)

著者 : m-yamagishi

PHP の各種キャッシュ機構でメモリが枯渇した場合の挙動を調べてみた

こんにちは。やまゆです。
弊社の主軸であるソーシャルゲーム開発においては、マスターデータの存在は欠かせません。
マスターデータとは、例えば下記のようなものがあります。

  • 武器の名称・アイコン画像名・攻撃力・スキルID
  • ガチャでどのカードが何%の確率で排出されるのか
  • 敵を倒した時の報酬
  • ゲーム内イベントの開始日時と終了日時
caches

このように、全ユーザーで共有される設定データのことをマスターと呼びます。
これらのデータは膨大で、運営を続けていくとアイテムの追加やイベントの追加により、だんだんサイズが肥大化していきます。また、頻繁にアクセスされることが予測されるため、処理上のボトルネックになる可能性が高いです。
そのため、サーバサイドでマスターデータを処理する際は毎回データベースへアクセスすることはせず、別の場所にキャッシュしておいて、基本的にはそのキャッシュを読み込んで処理を実行します。
今回私の所属しているプロジェクトで、このあたりのキャッシュ戦略をどうするか調査しました。
一つの観点として、「キャッシュをメモリに載せていって、メモリが枯渇した場合どういった挙動を示すのか」を重点的に検証してみましたので、その結果を共有してみます。

前提条件

まずはキャッシュするデータの前提を羅列します。

  • マスターデータは運用中ある程度線形に大きくなっていく
    • 単体データのサイズが最大数MB
    • 1テーブルのレコード数が最大数千行
    • 全テーブルサイズ合計が最大数百MB
  • 1テーブルの全行を取得しなければならない場合がある
    • 逆に1テーブルでid指定の1レコードしか取得しない場合もある
  • メモリに載せる戦略の場合はメモリが不足する可能性がある
    • メモリが不足しても、対象 API 以外は影響せず安全に低いレイテンシで動作し続ける必要がある
  • ミドルウェアを増やすことは障害点の増加・監視対象の増加につながる
  • マスターのデプロイタイミングは、アプリコードのデプロイタイミングと異なる
    • 管理画面等経由でいつでも更新できる
  • リリース後は一度追加されたデータを更新しない(常に新規追加または削除のみされる)
  • データ構造を柔軟に設定できる
    • 事前にテーブル DDL を用意する必要がないこと(NoSQL 風)

今回はこれらの条件をクリア出来そうな四種類のキャッシュ機構を対象としました。

  • APCu
    • PHP から簡単にアクセスできる、オンメモリな KVS です
  • OPcache
  • memcached
    • Amazon ElastiCache でも使うことが出来る、シンプルな KVS です
  • Redis
    • Amazon ElastiCache でも使うことが出来る、高機能キャッシュサーバです

また、 TTL 付きのキャッシュの有効期限が切れて破棄された瞬間に、オリジン(DB等)に大量のアクセスが飛び、性能低下、システム遅延・停止などが起こる現象のことを Cache Stampede(Dog piling, Cache miss storm, Thundering Herd とも) と呼びますが、これが起こりえるかどうかの確認も重要です。

検証環境

  • docker compose による環境構築
  • Redis v6.0.16
  • memcached v1.6.12-alpine
  • PHP 8.1.1(php:8.1.1-cli-bullseye)
  • PHP extensions
    • APCu v5.1.21
    • OPcache v8.1.1
    • memcached v3.1.5(libmemcached v1.0.18, 3.1.5+2.2.0-5+deb11u1)
    • Redis v5.3.5

APCu

APCu は、PHP で動くインメモリの key-value ストアです。 キーは文字列型で、値は任意のPHPの変数を保存できます。 APCu はユーザーランドの変数のキャッシュのみをサポートしています。
APCu は APC から opcode のキャッシュ機能を除いたものです。
https://www.php.net/manual/ja/book.apcu.php

状態の確認方法

apcu_cache_info() 関数 があるのでそちらを利用しました。

ttl = 0
num_hits = 0
num_misses = 0
num_inserts = 0
num_entries = 0
expunges = 0
mem_size = 0.00 MB

ここで重要なのは expunges パラメータです。ここには 「メモリが枯渇したため、中身を空にした回数」 が記録されます。

メモリをあふれさせる方法

apcu_add((string)microtime(), str_repeat('0', $add), $ttl);

$add バイト数だけ 0 文字を埋める形で格納していきます。圧縮したりシリアライズしたりせず、文字列をそのままメモリに載せる形になります。
ini で設定した apc.shm_size (デフォルトでは 32M)まで格納できます。
また、 $ttl を指定していますが、 apc.ttl が 0 の場合は TTL を指定しても無視される ので注意が必要です。

apc.ttl int
あるキャッシュエントリのスロットを別のエントリが必要としている場合に、 キャッシュエントリがアイドル状態でいて良い秒数。 この値をゼロに設定すると、新しいエントリがキャッシュされない間は APCのキャッシュは潜在的に古いもので埋められる可能性があります。 キャッシュのメモリが不足した場合、ttl が0の場合、キャッシュは完全に削除されます。 0より大きな場合、APC は期限切れのエントリから削除しようとします。

メモリ割り当てを増やしていった結果

上にもある通り、 apc.ttl の設定によって、メモリあふれ時の挙動が異なりました。

apc.ttl = 0 の場合

apcu_add でメモリが足りなくなった瞬間、 APCu 上のデータを全て削除 して、 apcu_add した値が新しくキャッシュされました。 expunges が 1 増加します。

apc.ttl = 10 の場合

apcu_add でメモリが足りなくなった瞬間、 ttl 切れしているデータを全て削除 して、 apcu_add した値が新しくキャッシュされました。
また、 ttl 切れデータを消しても容量が不足している場合は、前述と同様 APCu 上のデータを全て削除 する挙動を示します。 expunges が 1 増加します。


このように、 APCu でメモリが枯渇した場合、 apc.ttl = 0 または ttl 以内のデータで埋まっている場合、全てのデータを消してしまうため、アプリケーション側で対策しなかった場合 Cache Stampede 現象が起こってしまうことが確認出来ました。

OPcache

OPcache はコンパイル済みのバイトコードを共有メモリに保存し、PHP がリクエストのたびにスクリプトを読み込み、パースする手間を省くことでパフォーマンスを向上させます。
https://www.php.net/manual/ja/intro.opcache.php

この他にも、不変な PHP データをインメモリキャッシュする機能もあるため、今回はそちらを利用しています。

状態の確認方法

opcache_get_status() 関数があるのでそちらを利用しました。

メモリをあふれさせる方法

// ダミーの PHP ファイルを生成する
$tmpfname = tempnam(sys_get_temp_dir(), 'OPCACHE_');
$handle = fopen($tmpfname, 'w');
fwrite($handle, '<?php return [' . str_repeat('[],', $add) . '];');
fclose($handle);
// OPcache は現在時刻よりも古い更新日時のファイルのみをキャッシュするため、一時ファイルの更新日時を少し戻す
touch($tmpfname, time() - 5);
// 明示的にコンパイルする
$result = opcache_compile_file($tmpfname);
// コンパイルされているかどうかを確認する
$cached = opcache_is_script_cached($tmpfname);

$add バイト数だけ [], 文字を繰り返す形で格納していきます。単純な文字列(0の繰り返しなど)の場合挙動が変わって適切にメモリ割り当てが増えなかったため、空配列を繰り返す形にしました。
ini で設定した opcache.memory_consumption (デフォルトでは 128M)まで格納できます。
ダミーファイルの他、実際に挙動を確認するための php ファイル等も格納するキャッシュに含まれます。

メモリ割り当てを増やしていった結果

# 通常の状態
opcache_enabled = 1
cache_full = 0
restart_pending = 0
restart_in_progress = 0
used_memory = 8.81 MB
free_memory = 119.19 MB
used_memory_percentage = 6.879 %
wasted_memory = 0.00 MB
current_wasted_percentage = 0.000 %
num_cached_scripts = 8
oom_restarts = 0
hash_restarts = 0
# メモリが枯渇している状態
opcache_enabled = 1
cache_full = 0
restart_pending = 0
restart_in_progress = 0
used_memory = 127.94 MB
free_memory = 0.06 MB
used_memory_percentage = 99.951 %
wasted_memory = 0.00 MB
current_wasted_percentage = 0.000 %
num_cached_scripts = 69
oom_restarts = 0
hash_restarts = 0

このように free_memory がほぼなくなった状態で新しいファイルをキャッシュしようとした場合、 新しいファイルの opcache_compile_file は true を返すが、 opcache_is_script_cached が false を返す 状態になりました。つまり、これ以上ファイルがキャッシュされない状態です。
TTL の概念が存在しないため Cache stampede 現象は起きなさそうですが、新しい PHP ファイルをキャッシュ出来なくなってしまうため、 全体のパフォーマンスが低下する ことが予想されます(max_wasted_percentage 設定によって古いキャッシュが解放される場合もあります)。
opcache.file_cache ディレクティブを有効にすることで、メモリが枯渇した場合ディスクにキャッシュ出来るようになることが書かれていますが、現環境でディレクトリを明示しても挙動は変わりませんでした。
また、 OPcache はプロセスを再起動するか opcache_reset() 関数を呼ぶまでキャッシュは解放されません。
単純に一つの PHP ファイルのキャッシュを解放したい場合は opcache_invalidate() 関数を利用出来ます。

Memcached

» memcached は、 ハイパフォーマンスな分散型メモリオブジェクトキャッシュシステムです。汎用的ですが、 データベース読み込みの負荷を軽減することで動的なウェブアプリケーションの速度を向上させることを意図しています。
この拡張モジュールは、libmemcached ライブラリを使用して memcached サーバーとの通信用の API を提供しています。 また、セッション ハンドラ (memcached) も用意しています。
https://www.php.net/manual/ja/intro.memcached.php

状態の確認方法

Memcached::getStats() メソッドがあるので、そちらを利用しました。

curr_connections = 2
total_connections = 82
bytes = 0
curr_items = 0
total_items = 74
evictions = 0
reclaimed = 74

メモリをあふれさせる方法

$memcached->set(str_replace(' ', '_', (string)microtime()), str_repeat('0', $add), $ttl);

‘0’ 文字列で埋めていますが、デフォルトで fastlz 可逆圧縮が入るため、増加するバイト数は事前に算出できません。
Memcached プロセスが割り当てられる限界まで格納できます。
docker-compose v2 で mem_limit を設定することで 32M の割り当てを最大としました。

メモリ割り当てを増やしていった結果

$memcached->set() メソッドを呼んだ段階で、 ttl 切れのアイテム分は随時解放されます。
ttl を長めにして $memcached->set() を続けると、 bytes の値が 30 MB 周辺になった段階で下記のようなエラーがポツポツ出てきます。

SERVER HAS FAILED AND IS DISABLED UNTIL TIMED RETRY

その後再度値を確認すると、中身が空になっていました。

Redis

PHP extension for interfacing with Redis

状態の確認方法

INFO memory コマンドを渡すことで、メモリに関する状態を取得することができます。

used_memory = 865216
used_memory_human = 844.94K
used_memory_rss = 14827520
used_memory_rss_human = 14.14M
used_memory_peak = 6067184
used_memory_peak_human = 5.79M
used_memory_peak_perc = 14.26%

used_memory_rss_human を見ることで現在キャッシュされている容量を確認出来ます。

メモリをあふれさせる方法

$redis->set((string)microtime(), str_repeat('0', $add), ['ex' => $ttl]);

$add バイト数だけ 0 文字を埋める形で格納していきます。圧縮したりシリアライズしたりせず、文字列をそのままメモリに載せる形になります。
Redis プロセスが割り当てられる限界まで格納できます。
今回は docker-compose v2 で mem_limit を設定することで 32M の割り当てを最大としました。

メモリ割り当てを増やしていった結果

$redis->set() メソッドを呼んだ段階で、 ttl 切れのアイテム分は随時解放されます。
ttl を長めにして $redis->set() を続けると、 rss の値が 32 MB 周辺になった段階で下記のようなエラーがポツポツ出てきます。

Notice: Redis::set(): Send of 238117 bytes failed with errno=104 Connection reset by peer
Fatal error: Uncaught RedisException: Connection refused
Fatal error: Uncaught RedisException: read error on connection to redis:6379

エラー後に再度値を確認すると、 rss の値が 10 MB 程度まで減っているため、強制的に一部のキャッシュがクリアされるような挙動を示します。


メモリがあふれた場合は致命的なエラーが出たり、そのエラー後に一部のキャッシュがクリアされたりするなど、想定しづらい挙動になります。 Cache stampede 現象も起こりえる結果となりました。


結果として、どのキャッシュ機構においても、メモリ溢れは危険ということが確認出来ました。
それぞれ状態を取得する機能があるので、監視ツールに送信して適正に管理する必要があります。
検証に使用した環境は infiniteloop-inc/php-cache-inspection – GitHub で確認可能です。
弊社では高負荷もさばけるエンジニアを募集しています。ご興味ある方のご応募をお待ちしています! → 採用情報

キャッシュ戦略考案時に参考にさせて頂いた資料

ブログ記事検索

このブログについて

このブログは、札幌市・仙台市の「株式会社インフィニットループ」が運営する技術ブログです。 お仕事で使えるITネタを社員たちが発信します!