第13回|スケールと外の世界

1台で足りなくなったときの引き出し(レプリケーション・パーティション・シャーディング)と、RDBMS を選ばないという判断。最終回として、スケール手法とデータストアの選択基準を俯瞰する。

この回のねらい

これまでの12回は、一貫して「PostgreSQL 1台をどう正しく・速く・安全に使い切るか」を扱ってきた。 最終回は視野を1段階広げ、「1台では足りなくなったとき、何をどの順で足すか」と「そもそも RDBMS が 最適でない仕事をどう見分けるか」を俯瞰する。ここで学ぶ手法(レプリケーション・パーティション・ シャーディング・OLTP/OLAP 分離・NoSQL/ベクトルDB)は、いずれも万能薬ではなく、それぞれ固有の コストと引き換えに特定の問題を解く道具である。最重要のメッセージは1つ、「早すぎる分散化を避ける」 ――索引・実行計画・運用という単一ノードの武器を使い切る前に分散へ逃げると、解けたはずの問題を 桁違いに難しくしてしまう。

到達目標

前提と準備

第1回で構築した PostgreSQL 16 系のデータベースに、共通スキーマとデータが投入済みであることを前提に する。今回の主役は約1,000万行の events(アクセスログ)である。パーティションの実験は既存の events をそのまま作り替えるため、心配なら別データベースか、events のダンプを取ってから進めるとよい。

ベクトルDBの節では pgvector 拡張を使う。導入済みでなくても本文は読み進められるが、手を動かすなら 次で有効化する(拡張のインストール自体は OS のパッケージ管理側の作業になる)。

-- pgvector を使う場合のみ
CREATE EXTENSION IF NOT EXISTS vector;

現在の events の規模を確認しておく。

psql -d postshop -c "SELECT count(*), min(occurred_at), max(occurred_at) FROM events;"
  count    |         min          |         max
-----------+----------------------+----------------------
 10000000  | 2023-01-01 00:00:... | 2024-12-31 23:59:...
(1 row)

この回で新しく出てくる用語

3つのスケール手法は名前が似ていて混同しやすいので、違いを先に一覧で置く。 回をまたいで使う語は用語集にもまとめている。

用語一行での説明
垂直スケールより強いマシンへ移行して1台の能力を上げること
水平スケール(スケールアウト)台数を増やして、負荷やデータを複数のサーバに分けること
パーティショニング論理的には1つの表を、1台の中で複数の子テーブルに分割すること
partition pruning条件に無関係なパーティションを、実行計画から丸ごと除外すること
レプリケーション同じデータを複数台のサーバに複製すること
シャーディング異なるデータを複数台に分割して配置すること
CAP定理ネットワーク分断時に、一貫性と可用性は同時には満たせないという定理
結果整合性更新が即座には行き渡らないが、時間が経てば全ノードが同じ値に収束する保証
OLTP / OLAP小さく多数のトランザクション処理と、大量行を舐める分析処理
Append複数の子テーブルの結果を順に連結する実行計画のノード
DO $$ ... $$関数を定義せずに手続き型のブロックをその場で実行する構文

本編

まず1台を使い切る ― スケールの前提

スケールの話に入る前に、順序を確認する。分散システムは、単一ノードでは決して得られない可用性や スループットを与える代わりに、整合性・運用・デバッグの難しさをまとめて背負い込む。したがって 最初の問いは常に「本当に1台では足りないのか」である。多くの「遅い」は分散不足ではなく、次のいずれか で解ける。

これらを使い切ったうえで、なお1台の CPU・メモリ・ディスク I/O が限界なら、まず垂直スケール (より強いマシンへの移行)を検討する。垂直スケールは分散に伴う整合性問題を一切生まないため、 コストが許す限り最も安価な選択肢である。以下で扱うのは、1台の中で表を分割するパーティションと、 台数を増やして負荷やデータを複数のサーバに分ける水平スケール(スケールアウト。レプリケーションと シャーディングがこれにあたる)で、いずれも垂直スケールでも足りなくなった段階の道具として読む。この 順序を飛ばすことが、本章の「つまずき」の筆頭に挙げる早すぎる分散化である。

スケール手法の全体像を、解く問題の軸で整理しておく。

手法主に解く問題台数主なコスト
パーティション1台の中の巨大な表の管理・スキャン量1台設計とキー選定
レプリケーション読み負荷の分散・可用性(冗長化)複数台(同じデータ)結果整合性(レプリケーション遅延)
シャーディング書き込み負荷・総データ量の分散複数台(異なるデータ)クロスシャード JOIN/トランザクション

パーティション ― 1台の中で大きな表を分割する

パーティショニングは、論理的には1つの表を、物理的には複数の子テーブル(パーティション)に分割する 仕組みである。台数は増やさない(1台の中の話)。PostgreSQL の宣言的パーティショニングでは、親 テーブルを「どの列をキーに、どう分割するか」だけ宣言し、実データは各パーティションに格納される。 events は時系列ログなので、occurred_at を使った**レンジ分割(月次)**が定石である。

既存の表は後からパーティション化できない(ALTER TABLE ... PARTITION BY は存在しない)。そこで 「既存表を退避 → パーティション親を作成 → 子を作成 → データを移送」という手順を踏む。

-- 1. 既存の events を退避する
ALTER TABLE events RENAME TO events_old;

-- 2. occurred_at のレンジでパーティション親を定義する
CREATE TABLE events (
  id           bigint      GENERATED BY DEFAULT AS IDENTITY,
  customer_id  bigint      REFERENCES customers(id),
  event_type   text        NOT NULL,
  occurred_at  timestamptz NOT NULL
) PARTITION BY RANGE (occurred_at);

親テーブルにはデータを直接持たせず、月ごとの子パーティションを作る。レンジは下限が含み・上限が 含まない([FROM, TO))ので、隣接する月を隙間なく・重なりなく並べられる。

CREATE TABLE events_2024_01 PARTITION OF events
  FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');   -- 1月ぶん
CREATE TABLE events_2024_02 PARTITION OF events
  FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');   -- 2月ぶん
CREATE TABLE events_2024_03 PARTITION OF events
  FOR VALUES FROM ('2024-03-01') TO ('2024-04-01');
-- ... 以下、必要な月ぶんを作る

24か月ぶんを手書きするのは現実的でない。DO ブロック(関数を定義せずに手続き型のコードをその場で 実行する構文)でまとめて生成する。format は書式から文字列を組み立てる関数で、%L はSQLのリテラル として、%s はそのままの文字列として値を埋め込む。

DO $$
DECLARE
  m date := '2023-01-01';
BEGIN
  WHILE m < '2025-01-01' LOOP
    EXECUTE format(
      'CREATE TABLE IF NOT EXISTS events_%s PARTITION OF events
         FOR VALUES FROM (%L) TO (%L)',
      to_char(m, 'YYYY_MM'), m, (m + interval '1 month')::date);
    m := (m + interval '1 month')::date;
  END LOOP;
END $$;

どのパーティションにも当てはまらない行が来ると INSERT は失敗する。想定外の日付を受け止める デフォルトパーティションを用意しておくと安全である。

CREATE TABLE events_default PARTITION OF events DEFAULT;

準備ができたら、退避した表からデータを移す。GENERATED BY DEFAULT AS IDENTITY は明示値の挿入を 許すので id をそのまま持ち込めるが、その場合はシーケンスを最大値に追いつかせる必要がある。

INSERT INTO events (id, customer_id, event_type, occurred_at)
SELECT id, customer_id, event_type, occurred_at FROM events_old;

-- IDENTITY のシーケンスを既存最大値に合わせる(次の自動採番が衝突しないように)
SELECT setval(pg_get_serial_sequence('events', 'id'),
              (SELECT max(id) FROM events));

DROP TABLE events_old;   -- 移送を確認してから

主キー・一意制約はパーティションキーを含む必要がある

パーティションテーブルに主キーや一意制約を張るとき、その制約はパーティションキー列を必ず含まねば ならない。含めずに PRIMARY KEY (id) を張ろうとすると、次のように拒否される。

ALTER TABLE events ADD PRIMARY KEY (id);
ERROR:  unique constraint on partitioned table must include all partitioning columns
DETAIL:  PRIMARY KEY constraint on table "events" lacks column "occurred_at"
        which is part of the partition key.

これは実装の都合ではなく必然である。一意性はパーティションをまたいで保証されず、各パーティション内 でしか担保されない。もし id だけで一意にできてしまうと、「どのパーティションを見れば重複が分かるか」 を決められない。だから一意制約はパーティションキーを含み、「同じキー値の行は必ず同じパーティションに 入る」ことを利用して各パーティション内の一意性で全体の一意性を成立させる。events に主キーを持たせる なら、複合キーにする。

ALTER TABLE events ADD PRIMARY KEY (id, occurred_at);

partition pruning ― 不要なパーティションを計画から消す

パーティション分割の最大の狙いが partition pruning(パーティションの刈り込み)である。WHERE がパーティションキーで範囲を絞っていると、プランナは無関係なパーティションを実行計画から丸ごと除外 する。3月ぶんだけを見るクエリを EXPLAIN すると、走査対象は events_2024_03 1つに絞られる。

EXPLAIN
SELECT count(*)
FROM events
WHERE occurred_at >= '2024-03-01' AND occurred_at < '2024-04-01';
 Aggregate  (cost=... rows=1 width=8)
   ->  Seq Scan on events_2024_03 events  (cost=... rows=... width=0)
         Filter: ((occurred_at >= '2024-03-01 00:00:00+09')
              AND (occurred_at <  '2024-04-01 00:00:00+09'))
(3 rows)

24か月ぶんのパーティションがあっても、計画に現れるのは1つだけである。対照的に、パーティションキーで 絞らないクエリ(例えば event_type だけで絞る)は、全パーティションを走査する。

EXPLAIN
SELECT count(*) FROM events WHERE event_type = 'purchase';
 Aggregate  (cost=...)
   ->  Append  (cost=...)
         ->  Seq Scan on events_2023_01 events_1  (...)
         ->  Seq Scan on events_2023_02 events_2  (...)
         ...
         ->  Seq Scan on events_2024_12 events_24  (...)
         ->  Seq Scan on events_default  events_25 (...)
(28 rows)

Append は複数の子テーブルの結果を順に連結するノードで、その下に全パーティションが並ぶのが 「刈り込みが効いていない」状態である。pruning は既定で有効 (enable_partition_pruning = on)で、宣言的パーティショニングでは追加設定なしに働く。定数で絞った 場合はプラン時に刈り込まれ、プリペアドステートメントのパラメータや実行時にしか値が定まらない場合は 実行時刈り込みが働き、計画に Subplans Removed: N と現れる。実行計画の読み方の詳細は第8回を参照。

分割のもう1つの恩恵 ― 古いデータの高速な廃棄

月次パーティションは、保持期間を過ぎた古いログを捨てる運用と相性がよい。DELETE は行を1件ずつ削除 して肥大化(bloat)を招くが、パーティションの DROP は表を丸ごと外すメタデータ操作で、ほぼ一瞬で 終わる。オンラインで安全に外すなら CONCURRENTLY を使う。

ALTER TABLE events DETACH PARTITION events_2023_01 CONCURRENTLY;  -- ロックを最小化して切り離す
DROP TABLE events_2023_01;                                       -- 切り離したものを破棄

月次のパーティション作成・古い月の廃棄を自動化するなら、拡張 pg_partman が定番である。パーティション の事前作成(プリメイク)や保持期間(retention)に基づく自動 DETACH/DROP を担う。本講座では概観に留める が、時系列データを継続運用するなら導入を検討する価値がある。

レプリケーション ― 同じデータを複数のサーバに

レプリケーションは、あるサーバ(プライマリ)の変更を別のサーバ(スタンバイ/レプリカ)へ複製し、 同じデータを複数台に持つ仕組みである。主な目的は2つ、可用性(プライマリが落ちてもスタンバイに 昇格させて継続する冗長化)と、読み込み負荷の分散である。PostgreSQL には方式が2系統ある。

観点ストリーミング(物理)レプリケーション論理レプリケーション
複製する単位WAL(クラスタ全体をバイト単位で複製)行の変更(テーブル単位で選べる)
対象の粒度クラスタ丸ごと。一部テーブルだけは不可発行(publication)で選んだテーブルのみ
複製先の版同一メジャーバージョン必須異なるメジャーバージョン間も可
複製先への書き込み不可(読み取り専用のホットスタンバイ)可(別クラスタとして書き込める)
主な用途HA(High Availability、冗長化)・読み取りレプリカ版アップグレード・特定テーブルの連携・DWH への配送

ストリーミングレプリケーションはプライマリの WAL(更新ログ)をスタンバイへ送り続け、スタンバイは それを適用してプライマリの複製を保つ。スタンバイは既定でホットスタンバイとして読み取りクエリを 受けられる(hot_standby = on)。論理レプリケーションは発行側で CREATE PUBLICATION、購読側で CREATE SUBSCRIPTION を宣言し、行単位の変更を論理的に転送する。

-- 発行側(プライマリ、wal_level = logical が必要)
CREATE PUBLICATION pub_events FOR TABLE events;

-- 購読側(別クラスタ)
CREATE SUBSCRIPTION sub_events
  CONNECTION 'host=primary dbname=postshop'
  PUBLICATION pub_events;

同期と非同期

プライマリがコミットを返す前にスタンバイの受信を待つかどうかで、同期・非同期が分かれる。

# プライマリの postgresql.conf(同期レプリケーションの例)
synchronous_standby_names = 'standby1'
synchronous_commit = on

「絶対に失えないデータか、多少のレイテンシと引き換えにするか」を要件で決める。多くのシステムは 非同期を基本に、重要な系だけ同期にする。

読み書き分離(read/write splitting)

ホットスタンバイを使うと、書き込みはプライマリ、読み込みはレプリカへ振り分けられる。読み取りの 多い EC のようなワークロードでは、レプリカを増やすほど読み取りスループットを水平に伸ばせる。

                     write (INSERT / UPDATE / DELETE)
  app -------------------------------------------------> primary
   |                                                         |
   |   read (SELECT)                                         |  WAL
   +----------------------------> replica 1 <----------------+
   |                                                         |
   +----------------------------> replica 2 <----------------+

app はアプリケーション、primary はプライマリ、replica 1/2 はレプリカである。primary 側から 各レプリカへ向かう <---- が WAL ストリーミングの流れを表す。要点は次の一文に尽きる。アプリは コマンドの種類で接続先を切り替え、更新系はプライマリ1台へ、参照系は複数のレプリカへ分散する。 プライマリからレプリカへは WAL が流れ続ける。ただし 非同期レプリケーションではレプリケーション遅延があり、プライマリに書いた直後にレプリカを読むと まだ反映されていないことがある(read-your-writes 問題)。「自分の直後の更新を確実に読む必要がある 処理はプライマリから読む」など、遅延を前提にした設計が要る。この遅延は次節の結果整合性そのものである。

シャーディング ― 複数台にデータを分散する

レプリケーションが「同じデータを複製する」のに対し、シャーディングは異なるデータを複数台に分割 配置する水平分割である。customer_id などのシャードキーで行き先ノードを決め、各ノードは 全体の一部だけを持つ。総データ量と書き込み負荷を台数で割れるため、単一プライマリの書き込み上限を 超えたい場合の最終手段になる。

代償は大きい。シャードをまたぐ操作が急に難しくなる。

したがってシャードキーは「大半のクエリが単一シャードで閉じる」ように選ぶのが鉄則で、ここを外すと 分散の利益を fan-out のコストが食い潰す。PostgreSQL でシャーディングを実装するなら、拡張 Citus (分散テーブルとクロスシャードクエリのプランニングを提供)や、アプリ層でのシャーディングが選択肢に なる。いずれにせよ、パーティションやレプリカで足りるうちはシャーディングに手を出さない。

CAP と結果整合性 ― 「壊れている」ではなく設計上の選択

複数ノードに同じデータを持つ分散システムには、避けられないトレードオフがある。CAP 定理は、 ネットワーク分断(Partition、ノード間の通信が途切れる状況)が起きたとき、システムは次の2つを同時 には満たせない、と述べる。

分断は現実の分散システムでは起こりうる前提なので、設計者は分断時に「一貫性を守って一部の応答を 拒む(CP)」か「応答を続ける代わりに一時的に古い値を許す(AP)」かを選ぶことになる。AP 寄りの 選択が生むのが結果整合性(eventual consistency)――更新は即座に全ノードへ行き渡らないが、時間が 経てば全ノードが同じ値に収束する、という保証である。前節のレプリケーション遅延はまさにこれで、 レプリカが一時的に古い値を返すのは故障ではなく、非同期レプリケーションという設計上の選択の帰結である。

初学者は結果整合性を「データがバグっている/壊れている」と受け取りがちだが、これは誤解である。 「いつ収束するか」「収束するまで何が見えうるか」が定義され、その範囲でアプリを設計するのが正しい 向き合い方である。逆に、強い一貫性が必要な処理(在庫の二重引き当て防止、残高の整合など)を結果 整合性の系に載せてはならない。整合性は要件ごとに選ぶものであり、「常に最強」を求めれば可用性や レイテンシで払わされる。

OLTP と OLAP ― 用途で分ける

同じ「データベースを使う仕事」でも、性質の異なる2種類がある。

この2つは求めるストレージ構造が正反対である。

行指向(row-oriented)列指向(columnar)
物理配置1行の全列をまとめて格納同じ列の値を連続して格納
得意1行・数行の読み書き(OLTP)特定列を全行集計(OLAP)
圧縮効きにくい同種の値が並ぶため高圧縮・高速スキャン
代表PostgreSQL のヒープ列指向 DWH(BigQuery, Redshift, ClickHouse, DuckDB 等)

PostgreSQL のヒープは行指向で、OLTP に最適化されている。ここで重い OLAP クエリ(全期間の全件集計 など)を本番で回すと、実害が出る。大量スキャンが共有バッファを本番データで温めていたキャッシュから 追い出し、ディスク I/O を占有する。さらに長時間走るクエリは古いスナップショットを保持し続け、 VACUUM が不要行を回収できず肥大化を招く(第9回・第12回)。 結果として、本来速いはずの OLTP のレイテンシが分析クエリに巻き込まれて悪化する。

対策は「集計を本番 OLTP から逃がす」ことである。段階に応じて選ぶ。

  1. まずは読み取りレプリカで集計する。本番プライマリのキャッシュと I/O を汚さずに済む。
  2. 定期的な分析なら ETL(Extract/Transform/Load)で列指向の**データウェアハウス(DWH)**へ データを移し、そこで集計する。分析専用に最適化された環境で、OLTP を一切圧迫しない。

「本番 DB で GROUP BY を1本流したら全体が重くなった」は、OLTP と OLAP を分けていないことの典型的な 症状である。

RDBMS を選ばない判断 ― NoSQL とベクトルDB

最後に、そもそも PostgreSQL(RDBMS)が最適でない仕事の見分け方に触れる。ここでも原則は同じで、 「新しくて速そうだから」ではなく「関係モデルで無理が出るから」選ぶ。

NoSQL をいつ選ぶか。 NoSQL は「関係モデル・SQL・強い一貫性の一部を手放す代わりに、別の何かを 得る」データストアの総称である。代表的な動機は次のいずれかである。

ただし「スキーマ柔軟性が欲しい」程度なら、PostgreSQL の jsonb 型が多くを吸収する。RDBMS の トランザクション・制約・SQL を保ったままドキュメント的な柔軟性を得られるため、NoSQL へ移る前に jsonb で足りないかを必ず確認する。NoSQL は銀の弾丸ではなく、失う一貫性・失う JOIN・失う SQL の 対価を払える場面でのみ選ぶ。

ベクトルDB(pgvector)をいつ選ぶか。 「意味の近さ」で検索したいとき――自然文の類似検索、推薦、 RAG(検索拡張生成)など――に使う。テキストや画像を機械学習モデルで数百〜数千次元の埋め込み (embedding)ベクトルに変換し、ベクトル間の距離が近いものを「意味的に近い」とみなして探す。 PostgreSQL では拡張 pgvector が vector 型と**近似最近傍探索(ANN)**の索引を提供するので、 別の専用DBを立てずに既存の関係データと同居させられる。

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE product_embeddings (
  product_id bigint PRIMARY KEY REFERENCES products(id),
  embedding  vector(768)                    -- 768次元の埋め込み(モデル依存)
);

-- HNSW インデックス(近似最近傍探索。cosine 距離で近い順を高速化)
CREATE INDEX ON product_embeddings USING hnsw (embedding vector_cosine_ops);

-- 商品42に意味的に近い商品を10件(<=> は cosine 距離)
SELECT product_id
FROM product_embeddings
ORDER BY embedding <=> (SELECT embedding FROM product_embeddings WHERE product_id = 42)
LIMIT 10;

厳密な全探索は次元数×行数に比例して重いので、HNSW(Hierarchical Navigable Small World、階層化した 近傍グラフをたどる方式)や IVFFlat(Inverted File with Flat compression、ベクトルを事前にクラスタへ 振り分けて候補を絞る方式)といった ANN 索引で「多少の取りこぼしを許して高速に近いものを返す」のが 実務の定石である。完全一致・範囲検索・集計は従来の B-tree(第7回) の仕事で、ベクトル索引はあくまで「意味の近さ」専用の別の道具だと切り分ける。

ハンズオン

課題1: events を月次パーティション化し、partition pruning を確認する

やること

  1. 既存の events(約1,000万行)を、occurred_at をキーにした月次のレンジパーティションへ作り替える。
  2. 特定月を範囲指定した集計クエリを EXPLAIN し、走査対象が1パーティションに刈り込まれることを確認する。
  3. パーティションキーで絞らないクエリでは全パーティションが走査されることを対比で確認する。

想定解答

本編の手順どおり、退避 → 親作成 → 子作成(DO ブロックで24か月+デフォルト)→ 移送を行う。

ALTER TABLE events RENAME TO events_old;

CREATE TABLE events (
  id           bigint      GENERATED BY DEFAULT AS IDENTITY,
  customer_id  bigint      REFERENCES customers(id),
  event_type   text        NOT NULL,
  occurred_at  timestamptz NOT NULL
) PARTITION BY RANGE (occurred_at);

DO $$
DECLARE m date := '2023-01-01';
BEGIN
  WHILE m < '2025-01-01' LOOP
    EXECUTE format(
      'CREATE TABLE IF NOT EXISTS events_%s PARTITION OF events
         FOR VALUES FROM (%L) TO (%L)',
      to_char(m, 'YYYY_MM'), m, (m + interval '1 month')::date);
    m := (m + interval '1 month')::date;
  END LOOP;
END $$;

CREATE TABLE events_default PARTITION OF events DEFAULT;

INSERT INTO events (id, customer_id, event_type, occurred_at)
SELECT id, customer_id, event_type, occurred_at FROM events_old;
SELECT setval(pg_get_serial_sequence('events','id'), (SELECT max(id) FROM events));

\d+ events で親と全パーティションの一覧を確認できる。次に刈り込みを見る。

EXPLAIN
SELECT count(*)
FROM events
WHERE occurred_at >= '2024-03-01' AND occurred_at < '2024-04-01';
 Aggregate
   ->  Seq Scan on events_2024_03 events
         Filter: ((occurred_at >= '2024-03-01 00:00:00+09')
              AND (occurred_at <  '2024-04-01 00:00:00+09'))
(3 rows)

計画に現れるのは events_2024_03 だけである。これが partition pruning が効いている証拠で、24か月ぶんの うち関係する1か月しか読まない。対して、パーティションキーを使わない条件は全パーティションを舐める。

EXPLAIN SELECT count(*) FROM events WHERE event_type = 'purchase';
 Aggregate
   ->  Append
         ->  Seq Scan on events_2023_01 events_1
         ->  Seq Scan on events_2023_02 events_2
         ...
         ->  Seq Scan on events_default  events_25

Append の下に全パーティションが並ぶ。両者の差が、そのまま「月次で切り、月で絞る」設計の効果である。 なぜこれで速くなるかは、読むべきデータブロック量そのものが激減するからで、索引以前にスキャン対象を 物理的に消せる点がパーティションの本質である。

課題2: 読み書き分離の構成を図で設計する

やること

読み取りの多い EC ワークロードを想定し、書き込みと読み込みを分離する構成を図と一文で設計する。 レプリケーション遅延をどう扱うかまで言及する。

想定解答

構成は「書き込みはプライマリ1台、読み込みは複数レプリカへ分散し、プライマリからレプリカへは WAL を 非同期ストリーミングする」である。

                              write
  app -------------------------------------------------> primary
   |                                                         |
   |   read                                                  |  WAL(非同期)
   +----------------------------> replica 1 <----------------+
   |                                                         |
   +----------------------------> replica 2 <----------------+

設計上の判断は次の3点である。第一に、更新系(INSERT/UPDATE/DELETE)は必ずプライマリへ送り、 参照系(SELECT)をレプリカへ振る。第二に、レプリカは読み取り専用のホットスタンバイなので、読み 負荷はレプリカ台数を増やすことで水平に伸ばせる。第三に、非同期レプリケーションにはレプリケーション 遅延があるため、「注文直後に自分の注文一覧を表示する」ような自分の書き込みを直後に読む処理は プライマリから読むことにして、read-your-writes 問題を避ける。可用性の観点では、プライマリ障害時に レプリカを昇格させてフェイルオーバーする。データ損失を1件も許さない系だけ同期レプリケーションにし、 その分のコミットレイテンシ増を受け入れる、という上乗せも設計に含めてよい。

よくあるつまずきと対処

症状原因対処
遅いのですぐ分散を検討してしまう索引・実行計画・運用という単一ノードの手を使い切っていないまず第7回索引・第8回計画・第12回監視、次に垂直スケール、最後に分散
ALTER TABLE events PARTITION BY ... が通らない既存表は後からパーティション化できない退避(RENAME)→ パーティション親を新規作成 → データ移送、の手順を踏む
PRIMARY KEY (id) が拒否される一意制約はパーティションキー列を含む必要があるPRIMARY KEY (id, occurred_at) のようにキー列を含めた複合キーにする
EXPLAIN で全パーティションが走査されるWHERE がパーティションキー(occurred_at)で絞っていない期間指定は必ず occurred_at の範囲で書く。関数で包むと刈り込みが効かないことがある
どのパーティションにも入らず INSERT が失敗する範囲外の日付でデフォルトパーティションが無いDEFAULT パーティションを用意する。またはその月のパーティションを追加する
レプリカが古い値を返す=壊れていると誤解する非同期レプリケーションの結果整合性(遅延)を故障だと捉えている遅延は設計上の選択。直後読みが必要な処理はプライマリから読む/必要なら同期にする
本番 DB で集計を回したら全体が重くなったOLTP に OLAP を混在させ、キャッシュ汚染・I/O 占有・長時間スナップショットが起きた集計は読み取りレプリカへ、または ETL で列指向 DWH へ逃がす

到達度チェックリスト

宿題(次回までの自習)

  1. 課題1で作った月次パーティションに対し、ALTER TABLE events DETACH PARTITION events_2023_01 CONCURRENTLY; で最古の月を切り離してから DROP TABLE する。同じ量を DELETE FROM events WHERE occurred_at < '2023-02-01'; で消す場合と比べ、所要時間と後片付け(VACUUM)の要否がどう違うかを 考える。
  2. events に対し、occurred_at >= '2024-06-01' AND occurred_at < '2024-07-01' の集計を、 パーティション化前(events_old があれば)と後で EXPLAIN (ANALYZE, BUFFERS) を取り、読んだ バッファ量(Buffers:)がどれだけ減ったかを比べる。
  3. 自分が関わっている(または想像上の)システムで、「本番 OLTP で回してしまっている集計クエリ」を 1つ挙げ、それを読み取りレプリカ/DWH のどちらへ、どう逃がすかを2〜3行で設計する。

次回への接続

本講座はここで一区切りとなる。全13回を貫いてきたのは、「まず1台の PostgreSQL を、正しいスキーマと型・ 制約で設計し(第1〜6回)、索引と実行計画で性能を出し(第7・8回)、トランザクションで整合性を 守り(第9回)、ロール・RLS・境界防御で安全を保ち(第10・11回)、監視と手順で運用する(第12回)」 という一本道である。最終回のスケールと分散は、その土台を使い切ってなお足りないときに初めて開く引き 出しであり、順序を守る限り強力な道具になる。逆に土台を飛ばして分散へ走れば、単一ノードなら索引1本で 片付いた問題を、結果整合性とクロスシャードの迷路にしてしまう。データベースの上達とは、より難しい 仕組みを増やすことではなく、いま抱えている問題を解く最小の道具を正しい順で選べるようになることである。

参考