PostgreSQLサーバー1台では足りなくなるのはいつか?
以前、急成長を続ける広告トラッキングログ・システムを直接担当したことがあります。毎日、2,000万〜3,000万件の新しいレコードがデータベースに追加されていました。当初、チームはハードウェアのアップグレード(垂直スケーリング)を選択し、RAMを128GBに増設、当時最高速のNVMe SSDを使用しました。しかし、eventsテーブルが20億行に達した頃、事態は悪化し始めました。単純なCOUNT文やレポート用のJOINに数分かかるようになり、CPUは常に100%の負荷状態に陥ったのです。
実際問題として、ハードウェアのアップグレードには常に限界があり、コストは指数関数的に増大します。この時点で最も現実的な選択肢は、水平シャーディング(Horizontal Sharding)、つまり横方向への拡張です。1台の巨大なサーバーにすべてを詰め込む代わりに、データを5台、10台、あるいは20台の小さなサーバーに分散させます。Citusは、アプリケーションのロジックをすべて書き換えることなく、PostgreSQLを分散データベースへと変貌させてくれる強力な助っ人です。
Citusはなぜこれほど信頼されているのか?
CitusはPostgreSQLのフォーク(派生版)ではありません。実際には一つの拡張機能(extension)です。最大の利点は、JSONBや全文検索から、PostGISやTimescaleDBといった他の拡張機能まで、Postgresのエコシステムをそのまま維持できることです。
Citusのモデルは主に2つのコンポーネントで動作します:
- Coordinator Node(コーディネーターノード): 「司令塔」の役割を果たします。メタデータを保持し、アプリケーションからのクエリを受け取り、解析してワーカーノードに命令を振り分けます。
- Worker Nodes(ワーカーノード): 実際の「作業員」です。データの断片(シャード)を保存し、並列計算を実行します。
クエリを送信すると、コーディネーターがそれを細分化し、複数のワーカーに同時に実行させます。その後、結果が統合されて返されます。ユーザー体験としては単一のサーバーを使っているのと変わりませんが、実際の処理能力はクラスター全体の力を集結させたものになります。
DockerでCitusクラスターを素早く構築する
すぐに試してみたい場合は、Docker Composeを使用して、1つのコーディネーターと2つのワーカーで構成されるCitusクラスターを構築できます。これは、本番環境への導入を検討する前にテストを行う最も簡単な方法です。
# docker-compose.yml のサンプルファイル
version: '3'
services:
db_master:
image: citusdata/citus:12.1
ports: ["5432:5432"]
environment: &id001
POSTGRES_USER: admin
POSTGRES_PASSWORD: secret
worker_1:
image: citusdata/citus:12.1
environment: *id001
depends_on: [db_master]
worker_2:
image: citusdata/citus:12.1
environment: *id001
depends_on: [db_master]
docker-compose up -dコマンドで起動した後、コーディネーターノードにアクセスして、ワーカーをシステムに登録します。
-- コーディネーターに接続してノードを追加
SELECT citus_add_node('worker_1', 5432);
SELECT citus_add_node('worker_2', 5432);
-- ノードの状態を確認
SELECT * FROM citus_get_active_worker_nodes();
シャーディング戦略:高速化のための正しいキー選択
Citusを使用する際、最も重要な決定は配布列(Distribution Column / シャードキー)の選択です。選択を誤ると、データが偏り(データスキュー)、一部のサーバーが過負荷になる一方で、他のサーバーがアイドル状態になってしまいます。
user_activitiesテーブルの場合、私は通常user_idをシャードキーに選びます。その理由は、実際のクエリの大部分が特定のユーザーでフィルタリングを行うからです。これにより、1人のユーザーの全データが同一のワーカーに収まり、ノード間のネットワーク通信を必要とせずにJOINや集計(Aggregation)を非常に高速に行うことができます。
-- コーディネーター上でテーブルを作成
CREATE TABLE user_activities (
id bigserial,
user_id int NOT NULL,
action text,
created_at timestamp DEFAULT now(),
PRIMARY KEY (user_id, id) -- シャードキーは必ず主キーに含まれている必要があります
);
-- テーブルの分散を有効化
SELECT create_distributed_table('user_activities', 'user_id');
システムは自動的にテーブルを32個のシャード(デフォルト)に分割し、利用可能なワーカーに均等に配置します。
ヒント:リファレンステーブル(Reference Tables)の活用
データベースには、categoriesやcountriesのように、あまり変更されないマスターテーブルが存在します。これらもシャーディングしてしまうと、ワーカー間でのJOINに膨大なネットワークリソースを消費します。
最適な解決策は、リファレンステーブル(Reference Table)を使用することです。Citusはこのテーブルの全コピーをすべてのワーカーに配置し、ローカルでのJOINを可能にします。
CREATE TABLE categories (id int PRIMARY KEY, name text);
-- このテーブルをクラスター内の全ノードに複製
SELECT create_reference_table('categories');
監視とデバッグ(Monitoring)
データが実際にどこにあるかを知るには、以下のコマンドでCitusのメタデータを確認できます。
SELECT shardid, shardsize, nodename
FROM citus_shards
WHERE table_name::text = 'user_activities';
私が非常に気に入っている機能の一つがEXPLAIN ANALYZEです。コーディネーターでこれを実行すると、各タスクがどのワーカーに送信され、実行に何ミリ秒かかったかの詳細が表示されます。
EXPLAIN (VERBOSE ON)
SELECT count(*) FROM user_activities WHERE user_id = 1001;
もし結果にCustom Scan (Citus Adaptive)と表示され、特定の1つのノードのみをターゲットにしていれば、シャードキーの選択が非常に適切である証拠です。おめでとうございます!
実運用における3つの「手痛い」教訓
Citusを本番環境で長期間運用した結果、3つの重要な注意点を導き出しました:
- 一意性制約(Unique Constraints): Citusは、シャードキーが制約の一部である場合にのみ一意性を保証します。
user_idでシャーディングしている場合、emailだけに一意インデックスを作成することはできません。 - 分散トランザクション(Distributed Transactions): Citusはこれを適切にサポートしていますが、複数のシャードにまたがる長時間のトランザクションはデッドロックを引き起こしやすくなります。クエリは可能な限り単一のシャードキー内に収まるように設計してください。
- ハードウェアの均一性: スペックの異なるサーバーを混ぜないでください。クラスターの速度は最も遅いノードに引きずられます(ストラグラー現象)。
分散アーキテクチャへの移行は、技術的に難しいわけではありません。難しいのはスキーマ設計の考え方を変えることです。シャードキーをマスターすれば、Postgresでの数十TBのデータ管理はこれまで以上に快適になるでしょう。
Postgresの低速化に頭を悩ませていませんか?ぜひ下のコメント欄で教えてください。一緒に解決策を考えましょう!

