単一ノードのデータベースが限界に達したとき
私は通常、信頼性の高いスキーマが必要なプロジェクトではすぐにPostgreSQLを選択します。PostgreSQLは非常に強力なツールです。しかし、急成長するアプリケーションはいずれ限界に達します。プライマリデータベースが2TBまで膨れ上がったり、月額2,000ドルも払って64コアの巨大なRDSインスタンスを運用しているのに、ピーク時にはCPU使用率が90%に張り付いていたりすることもあるでしょう。
これこそが典型的な垂直スケーリング(スケールアップ)の限界です。より大きなインスタンスに資金を投入し続けることはできますが、費用対効果はすぐに低下します。アプリケーション層での手動シャアリングは一つの解決策ですが、メンテナンスが非常に困難で、ロジックの書き換えも必要になります。Citusはよりスマートな道を提供します。これはPostgreSQLを分散エンジンに変えるオープンソースの拡張機能で、データとクエリの負荷をノードのクラスター全体に分散させます。
私がCitusを好む理由は、それがフォークではないからです。標準的な拡張機能であるため、PostgreSQLを優れたものにしている機能を失うことはありません。JSONBによる半構造化データ、PostGISによる位置情報サービス、全文検索などを利用しながら、数十台のサーバーまでスケールアウトできます。
インストール:数分でクラスターを立ち上げる
分散構成を試す最も効率的な方法は、Docker Composeを使用することです。本番環境ではベアメタルやVMにpostgresql-16-citus-12.1をインストールすることになりますが、Dockerを使えばアーキテクチャを即座に可視化できます。
Citusクラスターは、1つのCoordinator(コーディネーター)と複数のWorker(ワーカー)で構成されます。
Coordinatorはメタデータとクエリのルーティングを管理します。Workerは実際のデータシャードを保存し、重い処理を担います。Coordinatorを指揮者、Workerをオーケストラと考えると分かりやすいでしょう。
セットアップをテストするために、以下のdocker-compose.ymlを保存してください:
version: '3.8'
services:
coordinator:
image: citusdata/citus:12.1
ports:
- "5432:5432"
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=mypassword
worker1:
image: citusdata/citus:12.1
depends_on: [coordinator]
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=mypassword
worker2:
image: citusdata/citus:12.1
depends_on: [coordinator]
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=mypassword
docker-compose up -dでクラスターを起動します。コンテナが正常に動作したら、Coordinatorに入ってWorkerを登録します。これは標準的なSQLクライアントから実行可能です。
-- コーディネーターがデータの送信先を把握できるようにノードを登録する
SELECT citus_add_node('worker1', 5432);
SELECT citus_add_node('worker2', 5432);
-- クラスターがアクティブであることを確認する
SELECT * FROM citus_get_active_worker_nodes();
アーキテクチャ:データを正しくシャード化する
拡張機能をインストールするだけで魔法のようにパフォーマンスの問題が解決するわけではありません。Citusにデータをどのように分割するかを伝えるための**Distribution Column**(分散列、またはシャードキー)を選択する必要があります。この選択が、負荷がかかったときのクラスターのパフォーマンスを左右します。
例えば、異なる企業の数百万件のイベントを追跡するSaaSプラットフォームを想像してください。この場合、tenant_idやuser_idが論理的な選択肢となります。共通のIDでシャード化することで、特定の顧客に関するすべてのデータが同じ物理ノードに保存されるようになります。
1. スキーマの定義
まずはCoordinator上でテーブルを作成します。主キーに関する要件に注意してください。Citusでは、シャードキーがいかなるユニーク制約の一部であることも求められます。
CREATE TABLE users (
user_id bigserial PRIMARY KEY,
email text,
created_at timestamptz DEFAULT now()
);
CREATE TABLE events (
event_id bigserial,
user_id bigint,
event_type text,
payload jsonb,
created_at timestamptz DEFAULT now(),
PRIMARY KEY (user_id, event_id)
);
2. ワークロードの分散
次に、create_distributed_table関数を使用します。国コードのリストのような小さなルックアップテーブルには、代わりにcreate_reference_tableを使用してください。リファレンステーブルはすべてのWorkerに複製されるため、結合(JOIN)が非常に高速になります。
-- コロケーション(共配置)を有効にするため、両方のテーブルをuser_idでシャード化する
SELECT create_distributed_table('users', 'user_id');
SELECT create_distributed_table('events', 'user_id');
コロケーション(共配置)はここでの秘策です。ユーザーとそのイベントが同じWorkerに存在するため、PostgreSQLはローカルで結合を実行できます。これにより、分散システムにおけるレイテンシの主な原因となる、ネットワーク経由でのギガバイト単位のデータの「シャッフル」を防ぐことができます。
3. 透過的なデータロード
アプリケーションのINSERT文を変更する必要はありません。Coordinatorにデータを送信すると、user_idがハッシュ化され、行が適切なシャードにルーティングされます。50台の異なるサーバーに書き込んでいても、単一のデータベースを操作しているように感じられます。
並列処理:パフォーマンス向上の実感
本当の成果は、巨大なデータセットに対して集計クエリを実行したときに現れます。1つのCPUコアが5億行を処理する代わりに、Citusはクエリを断片に分割し、すべてのWorkerで同時に実行します。
EXPLAIN ANALYZE
SELECT count(*) FROM events
WHERE event_type = 'checkout';
出力で「Citus Adaptive Executor」を確認してください。Workerが10台あれば、そのスキャンに対して実質的に10倍のI/O帯域幅と処理能力が得られます。以前は30秒かかっていたクエリが、3秒で終わるかもしれません。
データの偏り(スキュー)への対処
特定の顧客が他よりもはるかに大きく、「ホット」なシャードが発生することがあります。これはcitus_shardsをクエリしてサイズ分布を確認することで監視できます。特定のWorkerが苦戦している場合は、rebalance_table_shards()関数を使用します。これにより、データベースをオンラインに保ったままノード間でデータを移動し、ダウンタイムなしでシステムのバランスを維持できます。
高度なモニタリング
私は常にcitus_stat_statementsを有効にすることをお勧めします。これは、分散クエリのパフォーマンスを追跡する、標準のpg_stat_statementsの拡張版です。クエリが単一ノードにヒットしているか(高速)、クラスター全体へのブロードキャストを必要としているか(低速)を特定するのに役立ちます。
-- 最も重い分散クエリを特定する
SELECT query, calls, total_exec_time
FROM citus_stat_statements
ORDER BY total_exec_time DESC
LIMIT 5;
データベースのスケーリングが「一度設定すれば終わり」になることは稀です。しかし、Citusは手動シャアリングの複雑さを取り除き、トラフィックの増加に合わせてスケールアウトすることを可能にします。PostgreSQLインスタンスが悲鳴を上げ始めているなら、分散モデルへの移行が最も論理的な次の一手です。

