午前2時の呼び出し:データベースが限界に達したとき
火曜日の午前2時15分、スマートフォンのバイブレーションでナイトスタンドから落ちそうになりました。私たちの eコマースプラットフォームは、単に遅いだけでなく、完全に麻痺していました。ダッシュボードを見ると、PostgreSQLのプライマリDBのCPU使用率が98%に張り付いており、レスポンスタイムの95パーセンタイルは5秒を超えていました。これは単なる一時的な不調ではなく、システム全体の全損状態でした。
ログを調査すると、予想通りではあるものの、受け入れがたい現実が明らかになりました。サイトの基本設定やセッションのメタデータを取得するためだけに、毎秒12,000件もの同一クエリをデータベースに投げ続けていたのです。これらの読み取り主体の操作により、データベースエンジンは、ほとんど変更されないデータのためにインデックスをスキャンし、ロックを管理することを強いられていました。スタック全体が崩壊する前に、即座にキャッシュを導入する必要がありました。
リレーショナルクエリの隠れたコスト
細かくチューニングされたSQLデータベースであっても、全く同じ行を毎秒1万回要求されれば苦戦します。各リクエストは、SQLのパース、権限の検証、バッファプールの管理といった一連のオーバーヘッドを発生させます。静的なJSONオブジェクトやセッション文字列を扱う場合、このプロセスは計算リソースの非常に高価な無駄遣いです。
私たちのアプリケーションは、ページを読み込むたびに、サイト全体の設定を含む150KBのシリアライズされたJSONオブジェクトを取得していました。これらの「大きなデータのかたまり(blob)」をRAMに移動させることで、データベースを本来得意とする業務、つまり複雑なトランザクションの処理やデータの永続性の確保に集中させることが可能になります。
Memcached vs. Redis:最適なツールの選択
障害対応の真っ只中、リードエンジニアが当然の疑問を口にしました。「なぜRedisを使わないのか?」と。Redisは現在業界で最も人気がありますが、私たちの特定のボトルネックに対しては、実はMemcachedの方が優れた選択肢でした。私たちがこの古くてシンプルなツールを選んだ理由は以下の通りです。
1. マルチスレッド・アーキテクチャ
Redisは基本的にシングルスレッドで動作します。驚異的に高速ではありますが、大量の単純なget/set操作を処理する場合、マルチコア・マシンではボトルネックになる可能性があります。一方、Memcachedは設計段階からマルチスレッド対応です。CPUコア数に応じて水平方向にスケーリングし、ほぼ線形のパフォーマンス向上を実現するため、単純なキーバリューのルックアップにおいては圧倒的な威力を発揮します。
2. メモリ管理とスラブ・アロケーション
Memcachedはメモリ管理に「スラブ・アロケーター」を使用します。RAMを特定のサイズの事前割り当て済みチャンク(スラブ)に分割するため、時間の経過に伴うメモリの断片化が実質的に発生しません。Redisはデータ型に柔軟性がありますが、高頻度でデータが入れ替わる環境ではメモリの断片化が発生することがあります。文字列やシリアライズされたオブジェクトのみを保存する場合、Memcachedの予測可能性は運用上の大きな利点となります。
3. 運用のシンプルさ
Memcachedは自分の役割に徹しています。Pub/Sub、地理空間インデックス、複雑なソート済みセットなどは提供しません。本質的には、RAM上の巨大な分散ハッシュテーブルです。このシンプルさゆえに、サイトに高負荷がかかっている際でも、調整すべき項目が少なく、設定ミスを起こす可能性も低くなります。
本番環境用Memcachedのセットアップ
システムを安定させるため、専用のMemcachedノードを用意しました。UbuntuやDebianであれば、初期設定は1分もかかりません。
sudo apt update
sudo apt install memcached libmemcached-tools -y
実際の重要な作業は /etc/memcached.conf で行います。デフォルトではセキュリティのために 127.0.0.1 にバインドされています。アプリケーションサーバーが別のインスタンスにある場合は、これをプライベートネットワークのIPに更新する必要があります。
高トラフィック向けに調整すべき主要なパラメータは以下の通りです:
-m 2048: RAMの上限をMB単位で設定します。本番ノードでは、高いヒット率を確保するために2GB(2048MB)に増やしました。-c 2048: 最大同時接続数をデフォルトの1024から増やします。-t 8: 使用するスレッド数です。インスタンスのCPUコア数に合わせました。
再起動して変更を適用します:
sudo systemctl restart memcached
sudo systemctl enable memcached
Pythonによる実践的な実装
アプリケーションを新しいキャッシュ層に即座に接続する必要がありました。バックエンドがPythonベースだったため、速度とスレッドセーフ性に定評のある pymemcache ライブラリを使用しました。
移行中、いくつかのレガシーなCSV設定ファイルをキャッシュ用にJSONに変換する必要がありました。ブラウザ上で変換処理ができる toolcraft.app/ja/tools/data/csv-to-json を利用することで、外部サーバーにデータを送ることなく、危機的な状況下で使い捨てのスクリプトを書く手間を省くことができました。
以下は、「ルックアサイド(Look-Aside)」キャッシュパターンを実装するために使用したロジックです:
from pymemcache.client import base
import json
# Memcachedクラスターに接続
client = base.Client(('10.0.0.5', 11211))
def get_site_settings(settings_id):
cache_key = f"settings_v2_{settings_id}"
cached_data = client.get(cache_key)
if cached_data:
# キャッシュヒット:即座にデータを返す
return json.loads(cached_data)
# キャッシュミス:PostgreSQLから取得
# 実際のアプリでは、SQLAlchemyやPsycopg2の呼び出しになります
db_data = {"theme": "dark", "version": "2.4.1", "api_limit": 5000}
# キャッシュに1時間(3600秒)保存
client.set(cache_key, json.dumps(db_data), expire=3600)
return db_data
現場で学んだ教訓
パッケージをデプロイするのは簡単ですが、大規模な環境で管理するには、私が長年苦労して学んだいくつかの追加の注意点が必要です。
1MBの制限を遵守する
Memcachedには、1アイテムあたりデフォルトで1MBという厳格な制限があります。2MBのJSONオブジェクトを保存しようとすると、クライアントはエラーを出さずに失敗することが多く、結果としてキャッシュヒット率が0%になります。オブジェクトが1MBを超える場合は、zlibなどを使用して圧縮するか、1キーあたり最大512MBまでサポートするRedisを検討すべきです。
サンダーリング・ハード問題を解決する
高トラフィックなキーの期限が切れると、多数のアプリケーションスレッドが同時にキャッシュミスを検知します。すると、すべてのスレッドが一斉にデータベースにアクセスして値を更新しようとします。これを防ぐために、私たちは「確率的早期再計算(probabilistic early recomputation)」を実装しました。キャッシュが完全に消滅するのを待つのではなく、期限切れの10%前になった時点でキャッシュアイテムをリフレッシュするようにしています。
最終的な結論
午前3時30分までにキャッシュが稼働しました。結果は劇的でした。データベースのCPU使用率は98%から安定した12%へと急落し、平均レスポンスタイムは5秒からわずか45ミリ秒にまで短縮されました。
データの永続性や、ハッシュやリストといった複雑なデータ構造が必要な場合、MemcachedはRedisの代わりにはなりません。しかし、膨大な読み取りトラフィックからデータベースを保護するための高速でマルチスレッドなバッファが必要な場合、Memcachedは依然として最も効率的なツールです。

