- 設計
リードレプリカ設計の注意点|レプリケーション遅延の対策と確認SQL
リードレプリカとは、プライマリ(書き込みを受けるデータベース)の内容を少し遅れて写し取る、読み取り専用のデータベースです。 読み取りをレプリカへ逃がすとプライマリの負荷を下げられますが、「書いた直後にレプリカで読むと、変更前の値が返る」という性質が必ず付いてきます。設計で決めるのは、どの読み取りをプライマリへ残し、どれをレプリカへ流すかです。
この記事では、振り分けの基準を画面・処理の種類別の早見表にし、PostgreSQL と MySQL でプライマリとレプリカを実際に組んで、遅延の再現、確認SQL、同期方式ごとの待ち時間を実行結果つきで並べます。
確認環境(確認日 2026-10-06):Ubuntu 24.04、PostgreSQL 18.4(ストリーミングレプリケーション、プライマリ1台+レプリカ1台)、MySQL 8.0.46(GTID レプリケーション、ソース1台+レプリカ1台)、Python 3.11+psycopg2 2.9.13。1台のマシンの中でポートを分けて2つのインスタンスを起動しています。MySQL 8.4 の列名と意味は、MySQL 8.4 リファレンスマニュアルの記載で確認しました。出力例のポート番号は既定の値(5432/5433、3306/3307)に置き換えています。
この記事の結論
- 更新した本人がすぐ読み返す処理だけプライマリへ、それ以外はレプリカへ流す。これが振り分けの基本形です。
- 遅延は「ふだんは数ミリ秒、大きな書き込みの直後だけ数秒」という形で現れます。平均ではなく、いちばん遅れた瞬間に画面がどう見えるかで設計します。
- 遅延の確認は、PostgreSQL はプライマリ側の pg_stat_replication、MySQL はレプリカ側の SHOW REPLICA STATUS が入口です。どちらも「止まっている」と「追いついている」の見分けに一手間が要ります。
- 同期レプリケーションにしても、既定の設定ではレプリカの読み取りは最新になりません。PostgreSQL で読み取りまで最新にするには synchronous_commit を remote_apply にします(そのぶんコミットが待たされます)。
読み取りの振り分け早見表
「レプリカで変更前の値が見えたら、だれが困るか」で分けます。
| 画面・処理の種類 | 例 | 流し先 | 理由 |
|---|---|---|---|
| 更新直後の完了画面・再表示 | プロフィール保存後の表示、投稿直後の自分の投稿一覧 | プライマリ | 本人は「いま保存した」ことを知っているので、変更前の値が出ると保存されていないように見える |
| 更新の前提になる読み取り | 在庫の残数を見て引き当てる、重複チェックをしてから登録する | プライマリ(同じトランザクション内) | 変更前の値で判断すると、二重登録や引き当て超過につながる |
| 採番・残高のような「最新でないと意味がない値」 | 次の受付番号、ポイント残高の更新前確認 | プライマリ | 値そのものを次の書き込みに使うため |
| ログイン直後の権限・設定の読み取り | 権限変更の直後、パスワード変更の直後 | プライマリ(変更後しばらく) | 変更前の権限で通ってしまうと困る |
| 他人が見る一覧・詳細 | 記事一覧、商品詳細、他の会員のプロフィール | レプリカ | 数秒前の内容でも、見ている人には区別がつかない |
| 検索・絞り込み・並べ替え | キーワード検索、条件つき一覧 | レプリカ | 読み取りの量が多く、数秒の遅れが結果の価値を変えない |
| 集計・レポート・ダッシュボード | 日次の件数、ランキング | レプリカ(集計専用の1台に分けるとよい) | 重い問い合わせをプライマリから離せる |
| CSV 出力・バッチの読み取り | 月次の一括出力 | レプリカ | 長時間の読み取りでプライマリを占有しない |
| バックアップ・データ抽出 | ダンプの取得 | レプリカ | プライマリへの負荷を避ける |
| 管理画面での更新結果の確認 | マスタ変更後の一覧 | プライマリ | 利用者が少なく、更新直後に見る割合が高い |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「リードレプリカ設計の注意点|レプリケーション遅延の対策と確認SQL」
迷ったときの判断基準
| 条件 | 選ぶもの |
|---|---|
| 読み取った値を、その後の書き込みの判断に使う | プライマリ |
| 読む人が、直前に自分でその行を更新している | プライマリ(更新後の一定時間だけ) |
| 読む人が、いつ更新されたかを知らない | レプリカ |
| 数秒前の内容で画面の意味が変わらない | レプリカ |
| どちらとも言えない | まずプライマリに置き、負荷を測ってからレプリカへ移す |
レプリケーション遅延が起きる流れ
プライマリは、コミットした変更をログ(PostgreSQL は WAL、MySQL はバイナリログ)としてレプリカへ送ります。レプリカは受け取ったログを順に適用します。「送る」「受け取る」「適用する」のあいだに時間差があり、これがレプリケーション遅延です。
sequenceDiagram
participant A as アプリ
participant P as プライマリ
participant R as レプリカ
A->>P: UPDATE(ニックネーム変更)
P-->>A: コミット完了
A->>R: SELECT(変更直後の再表示)
R-->>A: 変更前の値
P-)R: 変更内容を転送(少し後に反映)
A->>R: SELECT(数秒後)
R-->>A: 変更後の値
PostgreSQL 18 で再現する:書いた直後にレプリカで読む
構成と設定
プライマリとレプリカの postgresql.conf に足した設定は次のとおりです。レプリカは、停止中のプライマリのデータディレクトリをコピーし、standby.signal という空ファイルを置いて作りました。
# プライマリ(postgresql.conf)
port = 5432
wal_level = replica
max_wal_senders = 10
hot_standby = on
# レプリカ(postgresql.conf): データディレクトリに空の standby.signal を置く
port = 5433
primary_conninfo = 'host=127.0.0.1 port=5432 user=postgres application_name=replica1'
# プライマリの pg_hba.conf に追加(検証用。接続元と認証方式は環境に合わせる)
host replication all 127.0.0.1/32 trust
ふだんは追いついている
CREATE TABLE profiles (
user_id int PRIMARY KEY,
nickname text NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO profiles VALUES (1, 'taro');
プライマリで UPDATE し、すぐ後に別の接続でレプリカを読む操作を5回繰り返しました。上の行がプライマリの結果、下の行がレプリカの結果です。
taro-1
taro-1
taro-2
taro-2
…(省略)…
taro-5
taro-5
同じマシン内の小さな更新では、次のコマンドを打つまでのあいだに反映が終わっていました。「手元で試したら問題なかった」となりやすいのはこのためです。
大きな書き込みの直後は、何も設定しなくても遅れる
300万行をまとめて INSERT し、コミットが返った直後にレプリカで件数を数えました。
-- プライマリ
CREATE TABLE access_logs (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id int NOT NULL,
path text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO access_logs (user_id, path)
SELECT g % 1000, '/items/' || g FROM generate_series(1, 3000000) g;
INSERT 0 3000000
Time: 12235.445 ms (00:12.235)
-- レプリカ(0.5秒おきに実行)
SELECT to_char(clock_timestamp(), 'HH24:MI:SS.MS'), (SELECT count(*) FROM access_logs);
02:10:49.401|0
02:10:50.202|3000000
02:10:51.606|3000000
プライマリではコミット済みなのに、直後のレプリカでは 0 件でした。約0.8秒後に300万件がまとめて見えるようになっています。一括登録やバッチの直後は、この状態が起きます。
遅延を5秒に固定して観察する
観察しやすくするため、レプリカに「コミットの適用を5秒遅らせる」設定を入れました(検証のための設定です)。
# レプリカ(postgresql.conf)
recovery_min_apply_delay = '5s'
-- プライマリ
UPDATE profiles SET nickname = 'hanako', updated_at = now()
WHERE user_id = 1 RETURNING nickname, updated_at;
nickname | updated_at
----------+------------------------------
hanako | 2026-10-06 02:08:50.09284+09
-- レプリカ(直後)
SELECT nickname, updated_at FROM profiles WHERE user_id = 1;
nickname | updated_at
----------+-------------------------------
taro-5 | 2026-10-06 02:08:43.947342+09
プライマリは hanako、レプリカは変更前の taro-5 です。6秒後に同じ SELECT をレプリカで実行すると hanako が返りました。
レプリカは読み取り専用なので、書き込みはエラーになります。
ERROR: cannot execute UPDATE in a read-only transaction
遅延の確認SQL(PostgreSQL 18)
プライマリ側:pg_stat_replication
SELECT application_name, state, sync_state,
sent_lsn, write_lsn, flush_lsn, replay_lsn,
pg_wal_lsn_diff(sent_lsn, replay_lsn) AS replay_bytes_behind,
write_lag, flush_lag, replay_lag
FROM pg_stat_replication;
遅延5秒の設定で、UPDATE の2秒後に実行した結果です(縦表示)。
application_name | replica1
state | streaming
sync_state | async
sent_lsn | 0/1BDDCA0
write_lsn | 0/1BDDCA0
flush_lsn | 0/1BDDCA0
replay_lsn | 0/1BDDC20
replay_bytes_behind | 128
write_lag | 00:00:00.00022
flush_lag | 00:00:00.00089
replay_lag | 00:00:00.00089
| 列 | 意味 |
|---|---|
| state | streaming なら接続して転送中 |
| sync_state | async(非同期)/sync(同期)/potential/quorum |
| sent_lsn / write_lsn / flush_lsn / replay_lsn | 送った位置/レプリカが書いた位置/ディスクへ確定した位置/適用した位置 |
| replay_bytes_behind | 送った位置と適用した位置の差(バイト)。0 なら適用まで追いついている |
| write_lag / flush_lag / replay_lag | 直近の WAL が書かれる/確定する/適用されるまでにかかった時間 |
この結果では、flush_lsn は最新まで進んでいるのに replay_lsn が手前で止まっていて、差が128バイトあります。「レプリカに届いているが、まだ読めない」状態です。
レプリカ側:pg_last_xact_replay_timestamp
SELECT pg_is_in_recovery(),
pg_last_wal_receive_lsn(),
pg_last_wal_replay_lsn(),
pg_last_xact_replay_timestamp(),
now() - pg_last_xact_replay_timestamp() AS replay_delay;
pg_is_in_recovery | t
pg_last_wal_receive_lsn | 0/1BDDCA0
pg_last_wal_replay_lsn | 0/1BDDC78
pg_last_xact_replay_timestamp | 2026-10-06 02:08:43.950127+09
replay_delay | 00:00:08.242458
pg_last_xact_replay_timestamp() は「最後に適用したトランザクションが、プライマリでコミットされた時刻」です。現在時刻との差を遅延の目安にできます。
更新が無い時間は、差が広がって見える
プライマリへの書き込みを20秒止めてから、同じ式を実行しました。
SELECT pg_last_wal_receive_lsn() = pg_last_wal_replay_lsn() AS caught_up,
now() - pg_last_xact_replay_timestamp() AS replay_delay;
caught_up | t
replay_delay | 00:00:26.165795
追いついている(caught_up が t)のに、差は26秒と出ます。更新が無いあいだは「最後に適用した時刻」が進まないためです。監視に使うときは、受け取った位置と適用した位置が同じなら 0 とみなす形にします。
SELECT CASE
WHEN pg_last_wal_receive_lsn() = pg_last_wal_replay_lsn() THEN interval '0'
ELSE now() - pg_last_xact_replay_timestamp()
END AS replay_delay;
replay_delay
--------------
00:00:00
同じとき、プライマリ側の write_lag・flush_lag・replay_lag は3つとも空(NULL)でした。PostgreSQL 18 のドキュメントには、追いついて WAL の動きが無くなると、直近の計測値をしばらく表示した後 NULL になると書かれています。監視では NULL を「異常」ではなく「計測対象なし」と扱うことになります。
MySQL で再現する:SHOW REPLICA STATUS の Seconds_Behind_Source
構成
ソース(プライマリにあたる側)とレプリカを、GTID を有効にして起動しました。1台のマシンで2つ動かすため、データディレクトリ・ポート・ソケットを分けています。
# ソース
mysqld --datadir=/var/lib/mysql1 --port=3306 --socket=/var/lib/mysql1/mysql.sock --server-id=1 \
--gtid-mode=ON --enforce-gtid-consistency=ON &
# レプリカ(読み取り専用)
mysqld --datadir=/var/lib/mysql2 --port=3307 --socket=/var/lib/mysql2/mysql.sock --server-id=2 \
--gtid-mode=ON --enforce-gtid-consistency=ON \
--read-only=ON --super-read-only=ON &
-- ソースで、レプリケーション用のユーザーを作る
CREATE USER 'repl'@'127.0.0.1' IDENTIFIED BY 'repl-pass';
GRANT REPLICATION SLAVE ON *.* TO 'repl'@'127.0.0.1';
-- レプリカで、接続先を指定して開始する
CHANGE REPLICATION SOURCE TO
SOURCE_HOST = '127.0.0.1', SOURCE_PORT = 3306,
SOURCE_USER = 'repl', SOURCE_PASSWORD = 'repl-pass',
SOURCE_AUTO_POSITION = 1, GET_SOURCE_PUBLIC_KEY = 1;
START REPLICA;
大きな書き込みの直後
100万行を1回の INSERT で入れ、レプリカで1秒おきに件数と Seconds_Behind_Source を見ました。
-- ソース
CREATE TABLE appdb.access_logs (
id bigint AUTO_INCREMENT PRIMARY KEY,
user_id int NOT NULL,
path varchar(100) NOT NULL,
created_at datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
);
SET SESSION cte_max_recursion_depth = 2000000;
INSERT INTO appdb.access_logs (user_id, path)
WITH RECURSIVE g(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM g WHERE n < 1000000)
SELECT n % 1000, CONCAT('/items/', n) FROM g;
ソースでの INSERT は約5.3秒で終わりました。その後のレプリカです。
02:12:36.467000 0
Seconds_Behind_Source: 5
02:12:37.497000 0
Seconds_Behind_Source: 6
02:12:38.552000 0
Seconds_Behind_Source: 7
02:12:39.639000 0
Seconds_Behind_Source: 8
02:12:40.748000 0
Seconds_Behind_Source: 9
02:12:41.866000 1000000
Seconds_Behind_Source: 0
ソースでコミットが終わってから約5秒間、レプリカの件数は 0 のままでした。レプリカは同じ INSERT をもう一度実行するので、ソースで5秒かかった書き込みは、レプリカでもおおよそ同じだけかかります。
遅延を5秒に固定して観察する
-- レプリカ(検証のための設定)
STOP REPLICA SQL_THREAD;
CHANGE REPLICATION SOURCE TO SOURCE_DELAY = 5;
START REPLICA SQL_THREAD;
-- ソース
UPDATE appdb.profiles SET nickname = 'hanako', updated_at = NOW(6) WHERE user_id = 1;
SELECT nickname, updated_at FROM appdb.profiles;
nickname updated_at
hanako 2026-10-06 02:11:38.340570
-- レプリカ(直後)
SELECT nickname, updated_at FROM appdb.profiles;
nickname updated_at
taro-5 2026-10-06 02:11:37.202746
2秒後に SHOW REPLICA STATUS\G を実行し、遅延に関係する行を抜き出したものです。
Replica_IO_Running: Yes
Replica_SQL_Running: Yes
Seconds_Behind_Source: 2
SQL_Delay: 5
SQL_Remaining_Delay: 4
Replica_SQL_Running_State: Waiting until SOURCE_DELAY seconds after source executed event
Retrieved_Gtid_Set: c5db5bb7-c0df-11f1-9776-02fc00000001:1-11
Executed_Gtid_Set: c5db5bb7-c0df-11f1-9776-02fc00000001:1-10
| 行 | 見るところ |
|---|---|
| Replica_IO_Running | 受信スレッドが動いているか(Yes が正常) |
| Replica_SQL_Running | 適用スレッドが動いているか(Yes が正常) |
| Seconds_Behind_Source | 適用中のイベントが、ソースで記録された時刻からどれだけ遅れているか(秒) |
| Retrieved_Gtid_Set / Executed_Gtid_Set | 受け取った範囲/適用した範囲。差があれば「届いているが未適用」 |
| SQL_Delay / SQL_Remaining_Delay | SOURCE_DELAY で設定した秒数/残りの待ち秒数 |
Retrieved が 1-11、Executed が 1-10 なので、11番目のトランザクションは届いているが、まだ適用されていないと読めます。
Seconds_Behind_Source が NULL のとき
適用スレッドを止めた状態でソースを更新しました。
-- レプリカ
STOP REPLICA SQL_THREAD;
Replica_IO_Running: Yes
Replica_SQL_Running: No
Seconds_Behind_Source: NULL
レプリカの値は変更前のままですが、Seconds_Behind_Source は大きな数字ではなく NULL になります。MySQL 8.4 のマニュアルでも、適用スレッドが動いていないときは NULL、適用するイベントが無いときは 0 と説明されています。「0 でも NULL でもない数字がしきい値を超えたら通知」だけでは、止まっている状態を拾えません。NULL と Replica_SQL_Running: No も通知の条件に入れます。
レプリカへの書き込みは、次のエラーで止まります。
ERROR 1290 (HY000) at line 1: The MySQL server is running with the --super-read-only option so it cannot execute this statement
確認SQLの対応表
| 知りたいこと | PostgreSQL 18 | MySQL 8.0/8.4 |
|---|---|---|
| どちら側で実行するか | プライマリ(pg_stat_replication)とレプリカ(関数) | レプリカ(SHOW REPLICA STATUS) |
| 接続・転送が動いているか | state が streaming | Replica_IO_Running が Yes |
| 適用が動いているか | replay_lsn が進んでいるか | Replica_SQL_Running が Yes |
| 遅延(時間) | replay_lag、または now() − pg_last_xact_replay_timestamp() | Seconds_Behind_Source |
| 遅延(量) | pg_wal_lsn_diff(sent_lsn, replay_lsn) | Retrieved_Gtid_Set と Executed_Gtid_Set の差 |
| 追いついているときの表示 | replay_lag は NULL、関数の差は広がる | 0 |
| 止まっているときの表示 | pg_stat_replication に行が出ない/replay_lsn が進まない | NULL |
| 同期か非同期か | sync_state | Rpl_semi_sync_source_status(準同期プラグイン利用時) |
| レプリカかどうか | SELECT pg_is_in_recovery() が t | @@super_read_only、SHOW REPLICA STATUS に行がある |
遅延の対策:読み取り側でできること
| 対策 | しくみ | 向く場面 | 気をつける点 |
|---|---|---|---|
| 更新後の一定時間はプライマリから読む | 利用者ごとに最終更新時刻を覚え、N秒以内の読み取りをプライマリへ | 更新直後の完了画面・再表示 | N秒は「ふだんの遅延」ではなく「大きな書き込み時の遅延」に合わせる |
| 位置を渡して、追いつくまで待つ | 更新後の LSN や GTID を覚え、レプリカがそこまで適用したかを確かめてから読む | 一定時間方式では足りない、確実に読みたい処理 | 待つ上限を決め、超えたらプライマリへ切り替える |
| 更新の戻り値をそのまま表示する | UPDATE の結果(RETURNING など)や入力値を画面に使い、読み直さない | 保存後の完了画面 | 画面に必要な項目が戻り値でそろうこと |
| 重要な読み取りは最初からプライマリ | 画面・API 単位で接続先を固定する | 更新の前提になる読み取り | プライマリへ寄せすぎるとレプリカの効果が減る |
| 遅延が大きいレプリカを振り分けから外す | 監視値がしきい値を超えたら読み取り先から外す | レプリカが複数台ある構成 | 全台が外れたときの戻し先(プライマリ)を決めておく |
| 同期レプリケーション(適用まで待つ) | コミットを、レプリカでの適用完了まで待たせる | 台数が少なく、書き込みが少ない構成 | 書き込みが遅くなる。レプリカ停止時の動きを決める |
一定時間はプライマリから読む(Python の例)
# router.py : 更新した本人の読み取りだけ、一定時間プライマリへ流す振り分けの例
# 必要なもの: pip install psycopg2-binary
import time
import psycopg2
PRIMARY = "host=127.0.0.1 port=5432 dbname=appdb user=postgres"
REPLICA = "host=127.0.0.1 port=5433 dbname=appdb user=postgres"
STICKY_SECONDS = 10 # 更新後、この秒数は本人の読み取りをプライマリへ流す
primary = psycopg2.connect(PRIMARY)
replica = psycopg2.connect(REPLICA)
primary.autocommit = True
replica.autocommit = True
last_write_at = {} # user_id -> 最後に更新した時刻(本番ではセッションやキャッシュに置く)
def update_nickname(user_id, nickname):
with primary.cursor() as cur:
cur.execute(
"UPDATE profiles SET nickname = %s, updated_at = now() WHERE user_id = %s",
(nickname, user_id),
)
last_write_at[user_id] = time.monotonic()
def read_nickname(viewer_id, target_id):
"""viewer_id が target_id のプロフィールを読む。"""
wrote_recently = time.monotonic() - last_write_at.get(viewer_id, -1e9) < STICKY_SECONDS
conn, name = (primary, "primary") if wrote_recently else (replica, "replica")
with conn.cursor() as cur:
cur.execute("SELECT nickname FROM profiles WHERE user_id = %s", (target_id,))
return name, cur.fetchone()[0]
update_nickname(1, "taro-new")
print("直後 本人(1)が読む :", read_nickname(1, 1))
print("直後 他人(2)が読む :", read_nickname(2, 1))
time.sleep(6)
print("6秒後 他人(2)が読む :", read_nickname(2, 1))
time.sleep(5)
print("11秒後 本人(1)が読む :", read_nickname(1, 1))
遅延5秒のレプリカに対して実行した結果です。
直後 本人(1)が読む : ('primary', 'taro-new')
直後 他人(2)が読む : ('replica', 'taro')
6秒後 他人(2)が読む : ('replica', 'taro-new')
11秒後 本人(1)が読む : ('replica', 'taro-new')
本人は直後から新しい値、他人は数秒遅れて新しい値を見ます。他人は「いつ変わったか」を知らないので、この遅れは画面上の違和感になりません。アプリのサーバーが複数台あるときは、最終更新時刻をメモリではなく、セッションや共有のキャッシュに置きます。
位置を渡して、追いつくまで待つ
PostgreSQL:更新のコミット後に現在の LSN を取り、レプリカの適用位置がそこを超えたかを見ます。
-- プライマリ(UPDATE と SELECT は別々の文として実行する)
UPDATE profiles SET nickname = 'jiro2' WHERE user_id = 1;
SELECT pg_current_wal_lsn(); -- 0/1BDE168
-- レプリカ(1秒おき)
SELECT to_char(clock_timestamp(), 'HH24:MI:SS'),
pg_last_wal_replay_lsn(),
pg_last_wal_replay_lsn() >= '0/1BDE168'::pg_lsn AS ok,
(SELECT nickname FROM profiles WHERE user_id = 1);
02:10:28|0/1BDE140|f|stuck
02:10:29|0/1BDE140|f|stuck
…(省略)…
02:10:32|0/1BDE140|f|stuck
02:10:33|0/1BDE168|t|jiro2
02:10:34|0/1BDE168|t|jiro2
ok が t になった時点で、新しい値が読めています。(stuck は直前の検証で入っていた値です。)
MySQL:更新後の GTID の範囲を取り、レプリカで WAIT_FOR_EXECUTED_GTID_SET() を使います。第2引数は待つ秒数の上限で、戻り値 0 が「適用済み」、1 が「時間切れ」です。
-- ソース
UPDATE appdb.profiles SET nickname = 'jiro' WHERE user_id = 1;
SELECT @@global.gtid_executed; -- c5db5bb7-…:1-12
-- レプリカ(遅延5秒の設定)
SELECT WAIT_FOR_EXECUTED_GTID_SET('c5db5bb7-c0df-11f1-9776-02fc00000001:1-12', 1); -- 1(時間切れ)
SELECT nickname FROM appdb.profiles; -- hanako
SELECT WAIT_FOR_EXECUTED_GTID_SET('c5db5bb7-c0df-11f1-9776-02fc00000001:1-12', 10); -- 0(適用済み)
SELECT nickname FROM appdb.profiles; -- jiro
上限1秒では時間切れで変更前の hanako、上限10秒では約5秒待ってから 0 が返り、jiro が読めました。
同期・準同期・非同期の違い
| 方式 | コミットが返る条件 | レプリカで直後に読めるか | 書き込みの待ち | レプリカ停止時 |
|---|---|---|---|---|
| 非同期 | プライマリでの確定のみ | 読めないことがある | 最小 | 書き込みは続く |
| 準同期(MySQL のプラグイン) | レプリカ1台以上がログを受け取って保存 | 読めないことがある | 小 | 時間切れ後に非同期へ切り替わる |
| 同期:受領まで(PostgreSQL の on) | 同期レプリカが WAL をディスクへ確定 | 読めないことがある | 小〜中 | 書き込みが待ち続ける |
| 同期:適用まで(PostgreSQL の remote_apply) | 同期レプリカが適用を終え、読める状態 | 読める | 大 | 書き込みが待ち続ける |
PostgreSQL:synchronous_commit の値ごとの実測
レプリカを同期レプリカに指定し(synchronous_standby_names = 'replica1')、レプリカの適用遅延を5秒にしたまま、設定を変えながら UPDATE の時間と、直後のレプリカの値を見ました。時間は1回ずつの計測値です。
ALTER SYSTEM SET synchronous_standby_names = 'replica1';
SELECT pg_reload_conf();
-- 接続ごとに切り替えて試す
SET synchronous_commit = remote_apply;
| synchronous_commit | UPDATE にかかった時間 | 直後にレプリカで読んだ値 |
|---|---|---|
| off | 0.801 ms | 1つ前の値 |
| local | 1.567 ms | 1つ前の値 |
| remote_write | 1.859 ms | 1つ前の値 |
| on(既定) | 204.126 ms | 1つ前の値 |
| remote_apply | 5004.133 ms | 今回の値 |
on でも直後のレプリカは1つ前の値でした。on が待つのは「レプリカが WAL をディスクへ確定するまで」で、適用までは待たないためです。remote_apply にすると、レプリカの適用(ここでは5秒の遅延)を待ってからコミットが返り、直後の読み取りで今回の値が見えました。
同期レプリカを停止した状態でプライマリを更新すると、UPDATE は応答を返さず、5秒で打ち切りました。その後に確認すると、プライマリのその行は更新済みでした。待っているのは「レプリカからの返事」で、プライマリ側の確定は先に済んでいます。同期にする場合は、レプリカが止まったときに書き込みをどう続けるか(同期レプリカを複数にする、設定を外す手順を決める)を合わせて決めます。
MySQL:準同期レプリケーション
-- ソース
INSTALL PLUGIN rpl_semi_sync_source SONAME 'semisync_source.so';
SET GLOBAL rpl_semi_sync_source_enabled = 1;
SET GLOBAL rpl_semi_sync_source_timeout = 10000; -- ミリ秒
-- レプリカ
INSTALL PLUGIN rpl_semi_sync_replica SONAME 'semisync_replica.so';
SET GLOBAL rpl_semi_sync_replica_enabled = 1;
STOP REPLICA IO_THREAD; START REPLICA IO_THREAD;
SHOW STATUS LIKE 'Rpl_semi_sync_source_status'; -- ON
レプリカの適用遅延を5秒にしたまま更新すると、コミットは 0.009 秒で返り、直後のレプリカは変更前の値でした。準同期が保証するのは「レプリカがログを受け取って保存したこと」までで、適用は含みません。6秒後には新しい値が読めました。
次に、時間切れを3秒にして、レプリカの受信を止めた状態で更新しました。
commit took 3.011 s
Rpl_semi_sync_source_status OFF
Rpl_semi_sync_source_no_tx 1
3秒待ったあとコミットが返り、状態は OFF(非同期)に変わりました。レプリカの受信を再開すると ON に戻りました。MySQL 8.4 のマニュアルにも、時間切れで非同期へ戻り、レプリカが追いつくと準同期へ戻ると書かれています。
キャッシュ・シャーディングとの違いと選び方
| 手段 | 解決するもの | 読み取りの新しさ | 書き込みの負荷 | 導入の手間 |
|---|---|---|---|---|
| リードレプリカ | 読み取りの量、重い集計の分離 | 数ミリ秒〜数秒遅れる | 減らない | 接続先の振り分けが必要 |
| キャッシュ | 同じ読み取りの繰り返し | 有効期限のあいだ遅れる | 減らない | 無効化のルールが必要 |
| スケールアップ | 読み書き全体 | 最新 | 1台の上限まで受けられる | 小さい |
| シャーディング(分割) | 書き込みの量、データ量 | 最新(分割内) | 分散できる | 大きい。分割キーの設計と、分割をまたぐ問い合わせの扱いが必要 |
| 状況 | 選ぶもの |
|---|---|
| 同じ内容を多くの人が何度も読む(トップページ、マスタ) | キャッシュ |
| 条件がばらばらな検索や一覧が多い | リードレプリカ |
| 集計やCSV出力がプライマリを圧迫している | 集計専用のリードレプリカ |
| 書き込みが詰まっている | スケールアップ、次にシャーディング(レプリカでは減らない) |
| まず手間をかけずに余裕を作りたい | スケールアップ |
| 読み取りのすべてに最新の値が要る | プライマリのまま(レプリカやキャッシュは向かない) |
キャッシュもレプリカも、「少し前の値を返すことを受け入れる代わりに、読み取りを軽くする」点は同じです。冒頭の振り分け早見表は、キャッシュに載せてよい読み取りの判定にもそのまま使えます。
設計書に書いておく項目
| 項目 | 書く内容の例 |
|---|---|
| 振り分けの単位 | 画面・API ごとに「プライマリ/レプリカ」を一覧にする |
| 更新後にプライマリから読む時間 | 10秒(大きな書き込み時の遅延の計測値をもとに決める) |
| 遅延の監視方法 | 確認SQL、取得間隔、NULL の扱い |
| 通知のしきい値 | 例:30秒を超えたら通知、適用スレッド停止は即通知 |
| レプリカが使えないとき | 読み取りをプライマリへ戻す。戻す条件と手順 |
| 同期方式 | 非同期/準同期/同期のどれか、その理由 |
| レプリカで実行してよい処理 | 集計、CSV出力、バックアップ |
確かめて分かったこと
- 小さな更新では、手で試しても遅延は見えませんでした。 5回とも、次のコマンドを打つ前に反映が終わっています。遅延の確認には、大きな書き込みか、遅延を入れる設定が要ります。
- 遅延を入れなくても、一括 INSERT の直後はレプリカが 0 件でした。 PostgreSQL(300万行)で約0.8秒、MySQL(100万行)で約5秒、コミット後も見えない時間がありました。
- PostgreSQL の同期レプリケーションは、既定の on では直後のレプリカ読み取りが1つ前の値でした。 読み取りまで最新にするのは remote_apply です。
- now() − pg_last_xact_replay_timestamp() は、更新が無いだけで増えていきます。 追いついた状態で26秒と出ました。位置の比較と組み合わせます。
- MySQL の Seconds_Behind_Source は、適用スレッドが止まると NULL になります。 数値のしきい値だけでは拾えません。
- PostgreSQL で UPDATE と pg_current_wal_lsn() を1回の送信にまとめたところ、コミット前の位置が返りました。 その位置でレプリカを確かめると、適用済みと判定されたのに値は変更前でした。位置はコミットが終わってから、別の文で取ります。
- レプリカの max_wal_senders をプライマリより小さくすると、レプリカが起動しませんでした。 ログには「max_wal_senders = 5 is a lower setting than on the primary server, where its value was 10」と出ます。レプリカの設定値はプライマリ以上にそろえます。
よくある質問
Q. リードレプリカとは何ですか? A. プライマリの変更を少し遅れて写し取る、読み取り専用のデータベースです。読み取りを分けて負荷を下げる目的で使い、書き込みはプライマリだけが受けます。
Q. レプリケーション遅延はどのくらいを見込めばよいですか? A. ふだんの値ではなく、いちばん大きな書き込みの直後の値で見込みます。今回の構成では、小さな更新は体感できない速さで反映され、100万行の一括登録の直後は約5秒遅れました。自分の環境の一括処理で計測するのが確実です。
Q. 同期レプリケーションにすれば、レプリカで最新が読めますか? A. 方式によります。PostgreSQL の既定(on)と MySQL の準同期は「レプリカが受け取った」ところまでしか待たないため、直後の読み取りは変更前の値になることがあります。PostgreSQL の remote_apply は適用まで待ちます。
Q. 読み取りと書き込みの分離は、どこで行いますか? A. アプリケーションの中で接続先を2つ持ち、画面・API 単位で選ぶのが基本です。SQL の種類(SELECT かどうか)だけで自動的に分けると、更新の前提になる読み取りまでレプリカへ流れるため、振り分けの一覧を先に作ります。
Q. レプリカが遅れているかどうかを確認できないときは? A. PostgreSQL はプライマリで pg_stat_replication に行が出ているか、MySQL はレプリカで Replica_IO_Running と Replica_SQL_Running が Yes かを先に見ます。行が無い、または No の場合は、遅延ではなく停止です。
まとめ
- 更新した本人がすぐ読み返す処理だけプライマリへ、それ以外はレプリカへ。 振り分けは画面・API 単位の一覧にする。
- 読んだ値を次の書き込みに使う処理は、プライマリの同じトランザクション内で読む。
- 遅延は大きな書き込みの直後に現れる。小さな更新を手で試しても見えない。
- 確認は、PostgreSQL が pg_stat_replication と pg_last_xact_replay_timestamp()、MySQL が SHOW REPLICA STATUS の Seconds_Behind_Source。
- 「追いついている」と「止まっている」を見分ける条件(位置の一致、NULL、スレッドの状態)を監視に入れる。
- 同期レプリケーションでも、適用まで待つ設定でなければ直後の読み取りは最新にならない。
- 書き込みの負荷はレプリカでは減らない。何を解決したいかで、キャッシュ・スケールアップ・シャーディングと選び分ける。
参考・出典
- PostgreSQL 18 Documentation「Write Ahead Log」設定(synchronous_commit): https://www.postgresql.org/docs/18/runtime-config-wal.html
- PostgreSQL 18 Documentation「The Cumulative Statistics System」(pg_stat_replication): https://www.postgresql.org/docs/18/monitoring-stats.html
- MySQL 8.4 Reference Manual「SHOW REPLICA STATUS Statement」: https://dev.mysql.com/doc/refman/8.4/en/show-replica-status.html
- MySQL 8.4 Reference Manual「Semisynchronous Replication」: https://dev.mysql.com/doc/refman/8.4/en/replication-semisync.html
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「リードレプリカ設計の注意点|レプリケーション遅延の対策と確認SQL」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。