TL;DR
- レプリケーション = ソースのデータをレプリカにコピーし続ける仕組み。主な目的は負荷分散
- レプリケーションは非同期なので、ソースに書いたデータがレプリカに反映されるまでにラグがある
- 遅延の根本原因は「ソース(並列コミット)vs レプリカ(直列または限定的並列適用)の速度差」
- Railsでは「書き込み直後に読み取る」場面で踏みやすい。対処は書き込み直後はソースから読む
レプリカとは
MySQLのレプリケーションには2種類のサーバーが登場する。
ソース(source) レプリカ(replica)
──────────────── ─────────────────
書き込み・読み取りができる → 読み取り専用のコピー
メインのDBサーバー レプリケーションで同期
※ MySQL 8.0以降は「マスター/スレーブ」から「ソース/レプリカ」に用語が統一された
なぜレプリカが必要か
ソース1台だけだと全トラフィックが集中する。
書き込み ──┐
├──→ ソース1台 ← CPU・ディスクIOが1台に集中
読み取り ──┘
一般的なWebサービスは読み取りの方が書き込みより圧倒的に多い。読み取りをレプリカに逃がすことでソースの負荷を下げられる。
書き込み ──────────→ ソース
↓ レプリケーション
読み取り ──→ レプリカ1
読み取り ──→ レプリカ2
読み取り ──→ レプリカ3
| 用途 | 内容 |
|---|---|
| 読み取り負荷分散 | 参照クエリをレプリカに分散しソースのCPU・IOを下げる |
| バッチ処理/分析 | 重い集計クエリをレプリカに逃がしソースへの影響を防ぐ |
| バックアップ | バックアップ取得中の負荷をレプリカに閉じ込める |
| フェイルオーバー | ソース障害時にレプリカをソースに昇格させて高可用性を実現 |
なぜレプリケーション遅延が起きるのか
根本原因
ソースは複数スレッドで並列コミットできるが、レプリカの適用がそれに追いつけない。
ソース:
クライアントA ──→ コミット ┐
クライアントB ──→ コミット ├─ 並列
クライアントC ──→ コミット ┘
レプリカ(シングルスレッド方式):
バイナリログ: [A][B][C][D][E]...
SQLスレッド: A → B → C → D → E ← シリアルにしか処理できない
MTA方式(MySQL 8.0.27〜デフォルト)
SQLスレッドをマルチ化することで並列適用できるようになった。
コーディネータスレッド
↓ リレーログを読んで振り分け
┌───┬───┬───┬───┐
W1 W2 W3 W4 ← ワーカースレッド(デフォルト4本)
ただし同時刻にコミットされたトランザクションのみ並列化できる。高トラフィックでも遅延が解消しない場合はreplica_parallel_workersを増やすことを検討する。
遅延が起きる3つの具体的ケース
ケース1:高トラフィック × ワーカー不足
ソース: 100 tps でコミット
レプリカ: 60 tps でしか適用できない
↓
差分が積み上がり続ける → 遅延が拡大していく
ケース2:大きなトランザクション
# 100万件を1トランザクションで削除
User.where(inactive: true).delete_all
# コミット時に100万件分のROWイベントが一気に送信される
# → レプリカのSQLスレッドが長時間ブロックされる
# → その間、後続トランザクションの適用が止まる
ケース3:PKなしテーブルへのROWイベント適用
ROWイベントの適用手順:
1. 「どの行を更新するか」をバイナリログから読み取る
2. テーブルからその行を探す ← PKで検索
3. 更新を適用する
PKがない場合:
テーブルの全行をスキャンして一致する行を探す
→ データ量が増えるほど遅延が深刻になる
Railsでの対処法
パターン1:書き込み直後に読み取る(一番踏みやすい)
# 典型的な罠
def create
@order = Order.create!(order_params) # ソースに書き込み
redirect_to order_path(@order) # GET /orders/:id
end
def show
@order = Order.find(params[:id])
# ↑ レプリカに向いている場合、まだ存在しない可能性がある
# → ActiveRecord::RecordNotFound
end
対処法A:書き込み直後はソースから読む
def show
ActiveRecord::Base.connected_to(role: :writing) do
@order = Order.find(params[:id])
end
end
対処法B:セッションでフラグを立てて猶予期間中はソースから読む
# 書き込み後にセッションにフラグを立てる
def create
@order = Order.create!(order_params)
session[:wrote_at] = Time.current.to_f
redirect_to order_path(@order)
end
# ApplicationControllerで猶予期間中はソースから読む
class ApplicationController < ActionController::Base
around_action :handle_replica_lag
private
def handle_replica_lag
if within_replica_lag_window?
ActiveRecord::Base.connected_to(role: :writing) { yield }
else
yield
end
end
def within_replica_lag_window?
return false unless session[:wrote_at]
# 書き込みから2秒以内はソースから読む
Time.current.to_f - session[:wrote_at] < 2.0
end
end
パターン2:大きなバッチ処理でレプリカが詰まる
# NG: 大量件数を1トランザクションで処理
User.where(inactive: true).delete_all
# OK: in_batchesで小さいトランザクションに分割
User.where(inactive: true).in_batches(of: 1000) do |batch|
batch.delete_all
sleep(0.1) # レプリカが追いつく時間を与える
end
パターン3:PKなしテーブルの遅延
# NG: PKなしテーブル
create_table :event_logs, id: false do |t|
t.string :event_type
t.integer :user_id
t.datetime :occurred_at
end
# OK: 必ずPKを設定する
create_table :event_logs do |t| # デフォルトでidカラムが追加される
t.string :event_type
t.integer :user_id
t.datetime :occurred_at
end
まとめ
| パターン | 原因 | 対処 |
|---|---|---|
| 書き込み直後のRecordNotFound | 非同期レプリケーションのラグ | 書き込み直後はソースから読む |
| バッチ処理でレプリカが詰まる | 大きいトランザクション |
in_batchesで分割・sleepを挟む |
| PKなしテーブルの遅延増大 | ROW適用時のフルスキャン | 必ずPKを設定する |
| 高トラフィックで遅延が拡大 | ワーカースレッド不足 |
replica_parallel_workersを増やす |