午前2時15分の緊急呼び出し:データベースが限界を迎えるとき
火曜日の午前2時15分、PagerDutyのアラートが鳴り響きました。ラップトップを開くと、プライマリのPostgreSQLインスタンス(AWSのdb.m5.2xlarge)のCPU使用率が99%に張り付いていました。アプリケーションのレスポンスタイムは、軽快な150msから驚愕の20秒へと膨れ上がっていました。単に遅いのではなく、実質的にオフラインの状態でした。
マーケティングメールが5万人の顧客に届いた直後だったのです。数千人のユーザーが一斉にダッシュボードにアクセスし、注文履歴やアクティビティログを取得するために複雑なSELECTクエリが実行されました。同時に、新規登録のユーザーが同じデータベースに対してINSERT操作を大量に送り込んでいました。単一のノードでは、重い書き込みと膨大な読み取りボリュームの複合的な負荷に耐えきれなかったのです。
ほとんどのリレーショナルデータベースには共通の限界点があります。単一ノードのCPUサイクルとI/Oスループットには限りがあるのです。その夜、垂直スケーリング(スケールアップ)だけではもはや不十分であることが明らかになりました。翌朝のピークが来る前に、リードレプリカ戦略を用いてトラフィックを分離する必要がありました。
リソース競合の罠
基本的なシングルインスタンス構成では、すべてのクエリが同じメモリとディスクI/Oを奪い合います。PostgreSQLやMySQLのような現代的なエンジンは効率的ですが、書き込みロックと読み取りパフォーマンスの根本的な対立は避けられません。
長時間実行される分析クエリは、共有ロックを保持したり、ソートのために膨大なRAMを消費したりすることがよくあります。そこへ高頻度の書き込み操作が発生すると、キューで待機することになり、アプリケーション全体に波及するボトルネックが生じます。より大きなサーバーにアップグレードすることもできますが、月額のクラウド費用を800ドルから1,600ドルへと倍増させても、パフォーマンスが10%しか向上しないという限界点にいつか到達します。これが垂直スケーリングの行き止まりです。
スケーリング手法の選択
私たちは負荷に対処するための主な3つの方法を検討しました。
- 垂直スケーリング(スケールアップ): RAMとCPUを増強します。最も簡単な解決策ですが、コストが高く、リサイズ中にダウンタイムが発生します。
- シャーディング: データを複数のプライマリノードに分散させます。強力ですが、アーキテクチャが極めて複雑になります。ペタバイト級のデータに達するまでは、ほとんどのチームにとって必要ありません。
- リードレプリカ: プライマリと同期する読み取り専用のコピーを作成します。これにより、一般的なWebトラフィックの約80%をメインノードからオフロードでき、高可用性のためのフェイルオーバー先としても機能します。
PostgreSQLでのリードレプリカの実装
PostgreSQLはストリーミングレプリケーションを利用します。プライマリサーバーが先行書き込みログ(WAL)レコードをレプリカにストリーミングし、レプリカがそれをリアルタイムで適用します。以下は、環境を安定させるために使用した設定です。
1. プライマリノードの設定
まず、postgresql.confを編集してレプリケーション接続を許可し、データの保持期間を定義します。
# レプリケーション機能を有効化
wal_level = replica
max_wal_senders = 10
max_replication_slots = 10
hot_standby = on
次に、pg_hba.confを更新して、レプリカの特定のIPアドレスからの接続を許可します。
# レプリカのIP(例: 10.0.0.5)からの接続を許可
host replication replication_user 10.0.0.5/32 md5
2. レプリカのプロビジョニング
セカンダリサーバーで、pg_basebackupツールを使用してプライマリからデータをクローンします。このコマンドにより、レプリカがプライマリのログストリームの追跡を開始するためのベースラインが作成されます。
bash
pg_basebackup -h primary-ip -D /var/lib/postgresql/15/main -U replication_user -P -R
-Rフラグが最も重要な部分です。これによりstandby.signalファイルが生成され、Postgresが読み取り専用モードで起動し、即座にプライマリに追従するよう指示されます。
MySQLでのリードレプリカの実装
MySQLは通常、レプリケーションにバイナリログ(binlog)を使用します。プライマリがすべての変更をログに記録し、レプリカがこれらのイベントを取得してローカルで再現します。
1. プライマリの設定
my.cnfまたはmysqld.cnfファイルに以下の行を追加します。
[mysqld]
server-id = 1
log-bin = mysql-bin
# 各ノードに一意のserver-idが必要
2. レプリカの設定
レプリカサーバーに一意のIDを割り当て、リレーログを有効にします。
[mysqld]
server-id = 2
relay-log = replica-relay-bin
read_only = 1
最後に、SQL経由でレプリカをプライマリに紐付けます。
sql
CHANGE MASTER TO
MASTER_HOST='primary-ip',
MASTER_USER='replica_user',
MASTER_PASSWORD='secure_password',
MASTER_LOG_FILE='mysql-bin.000001',
MASTER_LOG_POS=154;
START SLAVE;
インフラ構築は第一歩に過ぎない
レプリカを立ち上げるだけでは、パフォーマンスは自動的には改善しません。アプリケーションのコードを更新しなければ、プライマリデータベースのCPU使用率は100%のままで、レプリカはアイドル状態のままです。クエリを明示的にルーティングする必要があります。
最も信頼できる方法は、バックエンドで2つの異なるデータベース接続を定義することです。POST/PUT/DELETEリクエスト用のWriter(書き込み用)と、GETリクエスト用のReader(読み取り用)です。
Pythonによるロジック例 (SQLAlchemy)
単一のグローバルセッションを使用する代わりに、操作に基づいてエンジンを選択するルーターを実装します。
python
class DatabaseRouter:
def __init__(self, primary, replica):
self.primary = primary
self.replica = replica
def get_engine(self, is_write=False):
return self.primary if is_write else self.replica
# 使用例
# レコードの作成
engine = db_router.get_engine(is_write=True)
# データの取得
engine = db_router.get_engine(is_write=False)
レプリケーション遅延の現実
通常、レプリケーションは非同期です。つまり、プライマリでの書き込みとそのデータがレプリカに反映されるまでの間に、わずかな遅延(多くの場合10msから500ms)が発生します。これを「レプリケーション遅延(Replication Lag)」と呼びます。
ユーザーが設定を更新した直後に、レプリカから読み取るプロフィールページにリダイレクトされると、古いデータが表示される可能性があります。これは混乱を招き、サポートへの問い合わせにつながります。これを防ぐために、更新操作後の15秒間は「自分の書き込みを読み取る(Read-Your-Own-Writes)」クエリをプライマリにルーティングするようにします。
結果
リードレプリカ構成への移行は、私たちのインフラに劇的な変化をもたらしました。重いSELECTトラフィックを2つの小さなレプリカに移動したことで、プライマリデータベースのCPU使用率は99%から安定した15%に低下しました。ユーザーのクエリレイテンシも、数秒から100ms未満に短縮されました。
もしデータベースの負荷に悩んでいるなら、読み取りと書き込みの比率を確認してみてください。ほとんどのWebアプリケーションは80〜90%が読み取りです。そのボリュームをオフロードすることが, 最も持続可能な成長方法です。ハードウェア費用を節約できるだけでなく、何より夜にぐっすり眠れるようになるはずです。

