「負荷が高いからレプリカを増やす」という思考停止の罠
AWS AuroraやGoogle Cloud SQLなど、モダンなマネージドDBを使っていれば、クラウドコンソールから数クリック、あるいはTerraformで数行追加するだけで簡単にリードレプリカをプロビジョニングできます。「参照クエリが全体の8割以上を占めるのだから、SELECTをレプリカにオフロードすればマスターの負荷は劇的に下がるはずだ」――そう信じて参照クエリのルーティングを切り替えた瞬間、地獄への扉が開きます。
そもそも、マスターの負荷が高騰している根本原因は何でしょうか。インデックスが効いていないフルテーブルスキャン、ORMが無邪気に吐き出すN+1クエリ、過剰なページネーションによる巨大な一時テーブル生成……。これらを放置したままレプリカを導入しても、まったく同じ非効率なクエリがレプリカ側で並行して実行されるだけです。
安易なリードレプリカの導入は、パフォーマンス問題の根本治療ではなく、単なる「技術的負債の分散と隠蔽」に過ぎません。しかもその代償として、システムに「非同期レプリケーション遅延」という不可逆な分散システムの複雑性を持ち込むことになります。
現場を阿鼻叫喚に落とす3大ペナルティ
リードレプリカを導入したプロダクトが本番運用において直面する、典型的かつ致命的な3つのトラブルを見ていきましょう。
1. Read-Your-Own-Writes(自分の書き込みが読めない)違反
最も頻繁に発生し、ユーザー体験を最悪にするのが「Read-Your-Own-Writes(RYOW)」の破壊です。
典型的なフローを考えてみてください。ユーザーがプロフィール編集画面で自己紹介文を更新し、「保存」ボタンを押します。フロントエンドはマスターDBに対してUPDATE users SET bio = ...を発行し、成功レスポンスを受け取ってプロフィール表示画面へリダイレクトします。表示画面ではSELECT bio FROM usersが実行されますが、参照クエリであるためリードレプリカへルーティングされます。
この時、マスターからレプリカへの非同期レプリケーションにわずか30msの遅延があったとします。一方、モダンなSPAや高速な回線では、APIの完了から次の画面のデータフェッチまで10ms未満で到達することが珍しくありません。結果として、ユーザーの画面には「更新前の古い自己紹介文」が表示されます。
「保存が失敗したのか?」とパニックになったユーザーはどうするでしょうか。ブラウザの更新ボタン(F5)を猛烈に連打します。この連打リクエストはすべてリードレプリカに殺到し、レプリカの負荷をさらに引き上げ、レプリケーション遅延をミリ秒から秒単位へと拡大させる「遅延増幅ループ」へと発展します。
2. レプリケーション遅延の「スパイク」は絶対に防げない
「うちの環境はAuroraだから遅延は通常数ミリ秒程度。だから問題ない」と楽観視するエンジニアがいますが、これは平時のメトリクスしか見ていない証拠です。レプリケーション遅延は定常的に一定なのではなく、突発的に「跳ね上がる」特性を持っています。
- 夜間バッチや大量更新:
UPDATEやDELETEが数千〜数万行単位で走った瞬間、レプリカ側の再生スレッド(SQLスレッド)が追いつかなくなり、遅延が一気に数秒〜数十秒にスパイクします。 - DDL(テーブル定義変更): マイグレーション実行時、マスターで完了してもレプリカ側でメタデータロック(MDL)が発生してレプリケーションが一時停止します。
- ネットワーク瞬断・再同期: クラウド内部の物理ホスト移動やネットワークゆらぎによって、一時的な遅延増大は日常茶飯事です。
「通常は10msだから大丈夫」という甘い前提は、システムに遅延スパイクが発生した瞬間に、決済履歴の二重表示や在庫・残高の不整合といったクリティカルなインシデントを引き起こします。
3. コネクションプールの倍増とORMルーティングの闇
アーキテクチャ面でのオーバーヘッドも見逃せません。リードレプリカを導入すると、アプリケーションサーバーは「マスター用」と「レプリカ用」の2系統のコネクションプールを維持する必要があります。
サーバーインスタンスがスケールアウトするたびに、マスターだけでなくレプリカ側にも大量のコネクションが消費されます。さらに深刻なのが、ORMやフレームワークの「自動振り分け機能」に起因するバグです。
例えばSpringの@Transactional(readOnly = true)やRailsのマルチDB機能を利用している場合、開発者が不用意にreadOnly = trueを付与したサービス層の中で「直前にマスターへ書き込んだエンティティ」を参照し、レプリカから古いデータを拾ってビジネスロジックが誤動作するケースが後を絶ちません。
レプリカに頼る前にやるべき「単一マスターの極限チューニング」
「参照負荷を逃がす」という発想に至る前に、本当に単一マスターの限界を迎えているのかを冷静に問い直す必要があります。現代のNVMe SSDと十分なメモリ(RAM)を備えたPostgreSQLやMySQLは、適切に設計されていれば単一インスタンスで毎秒10,000〜30,000クエリ(QPS)を難なく処理できます。
レプリカを立てる前に、以下の泥臭い対策をやり切っているか点検してください。
- カバリングインデックスの徹底: テーブル本体(ヒープ領域)へのランダムアクセスを排除し、インデックスオンリースキャンでクエリを完結させる。
- Keysetペジネーションへの刷新:
OFFSET 100000のような巨大な読み飛ばしを捨て、主キーや作成日時によるカーソル検索に移行する。 - HTTPキャッシュヘッダーの活用: 変更頻度の低いマスターデータや公開コンテンツには
Cache-Control: public, max-age=60, stale-while-revalidate=300を適用し、そもそもDBまでリクエストを到達させない。 - Singleflightによるキャッシュスタンピード抑止: 同一キーに対する同時参照クエリをGoのプロセス内で1本に束ね、DBへの直撃を防ぐ。
それでもレプリカが必要な場合の「セッション一貫性」設計
事業の成長により、最適化を尽くした上でも参照クエリがマスターの限界を超えるケースはもちろん存在します。その場合、結果整合性を野放しにしてはいけません。最低限「自分が書いたデータは自分が必ず読める」状態、すなわちセッション一貫性(Session Consistency)を担保するアーキテクチャを敷く必要があります。
Cookie / ヘッダーを利用したSticky Masterルーティング
最も実用的で導入コストの低いアプローチが、「書き込みを行ったクライアントだけ、一定時間(TTL)マスターにルーティングを固定する」という手法です。
更新リクエスト(POST/PUT/DELETE)が成功した際、サーバーはレスポンスに「最終書き込みタイムスタンプ」をCookieやヘッダーとして付与します。その後のGETリクエストにおいて、最終書き込みから一定時間(例: 5秒以内)が経過していない場合は、強制的にマスターDBへクエリを向けます。
// Goによるセッション一貫性(Sticky Master)ルーティングのミドルウェア例
package middleware
import (
"context"
"net/http"
"strconv"
"time"
)
type dbContextKey struct{}
const (
MasterDB = "master"
ReplicaDB = "replica"
WriteCookieName = "last_write_ts"
StickyDuration = 5 * time.Second
)
func ReplicationLagSafeRouting(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
targetDB := ReplicaDB
// 1. 書き込み系メソッド(POST, PUT, DELETE, PATCH)は無条件でマスター
if r.Method != http.MethodGet && r.Method != http.MethodHead && r.Method != http.MethodOptions {
targetDB = MasterDB
// レスポンス時にクライアントへ最新の書き込みタイムスタンプをCookie付与
defer func() {
http.SetCookie(w, &http.Cookie{
Name: WriteCookieName,
Value: strconv.FormatInt(time.Now().UnixMilli(), 10),
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
MaxAge: int(StickyDuration.Seconds()),
})
}()
} else {
// 2. 参照系メソッド(GET)の場合、直近の書き込み履歴をチェック
if cookie, err := r.Cookie(WriteCookieName); err == nil {
if lastWriteMs, err := strconv.ParseInt(cookie.Value, 10, 64); err == nil {
lastWriteTime := time.UnixMilli(lastWriteMs)
// 直近5秒以内に書き込みを行っていれば、レプリカではなくマスターへ向ける
if time.Since(lastWriteTime) < StickyDuration {
targetDB = MasterDB
}
}
}
}
// コンテキストにルーティング対象を注入して後続のDBアクセス層へ渡す
ctx = context.WithValue(ctx, dbContextKey{}, targetDB)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
この設計を導入するだけで、ユーザーが自身の更新直後に遭遇する「データが消えた」「古いまま戻らない」という苦情を100%撲滅できます。全ユーザーの全GETクエリをマスターに向けるわけではなく、「直近数秒間に自身で更新を行ったユーザー」だけが一時的にマスターを参照するため、マスターの負荷上昇を極小限に抑えつつ整合性を完全に保護できます。
GTID / WAL LSNを利用した厳格な因果一貫性
さらに厳格な一貫性が求められるシステムでは、MySQLのGTID(Global Transaction Identifier)やPostgreSQLのWAL LSN(Log Sequence Number)を活用した手法が有効です。
書き込みトランザクションのコミット時に発行されたLSN/GTIDをレスポンスでクライアントに返し、クライアントが次リクエストでその識別子を送信します。レプリカ側でSELECT pg_wal_lsn_diff(pg_last_wal_replay_lsn(), $1)等を実行し、該当の書き込みがレプリカに反映済みであればレプリカから読み、未反映であれば数十ミリ秒待機するかマスターへフォールバックします。
まとめ: スケールアウトは「整合性破壊の覚悟」を持つ最後の手段
クラウドインフラの進化によって、リードレプリカのプロビジョニングはあまりにも手軽になりました。しかし、手軽に作れることと、安全に運用できることは全くの別問題です。
リードレプリカの本質は、「クエリ性能の向上」と引き換えに「ACIDな一貫性を放棄し、結果整合性の世界に足を踏み入れること」にあります。その覚悟とセッション一貫性の実装を持たずにスイッチを切り替えれば、現場は不整合データの調査とクレーム対応に追殺されることになります。
- まずは徹底したインデックス最適化、Keysetペジネーション、HTTP/メモリキャッシュで単一マスターの性能を限界まで引き出す。
- どうしても参照を逃がす場合は、安易な全自動ルーティングに頼らず、CookieやセッショントークンによるSticky Masterルーティングを必ず導入する。
- レプリケーション遅延スパイクは「必ず発生するもの」として、障害検知とフェイルオーバーの耐性を設計しておく。
銀の弾丸に見えるスケールアウトの誘惑に負けず、システムの整合性境界を自らの手でコントロールすることこそが、本番を支えるエンジニアの責務です。