はじめまして。トライベック株式会社の下岡と申します。
現在、XD.GROWTHという BtoC / BtoBtoC 向けマーケティングオートメーション SaaS に携わってまして、昨年10月にその分析・集計用 DB 基盤を Hadoop + Impala から PostgreSQL + Citus に乗せ換えました。
「分散 DB に移行しました」と一行で書くとカッコいいのですが、実態は2年がかりでバッチや配信系を一個ずつ寄せていく、地味な引っ越しの連続でした。せっかくなので、なぜ Citus を選んだか・どう設計したか・運用で何にハマったかを、Qiita に出せる範囲で書き残しておきます。
XD.GROWTH ってなに
ざっくりだけ。
- BtoC / BtoBtoC 向けのマーケティングオートメーションツール
- マルチテナント構成の SaaS(テナントごとにデータを分離)
- 扱うのは「Web 行動ログ」「会員属性」「購買履歴」「メール / LINE / SMS / Webpush の配信ログ」と、ようするに BtoC のあらゆる行動データ
- 計測・分析・配信が 1 つのプロダクトに同居(ここ重要)
- バックエンドは Apache + PHP 8.x + PostgreSQL 構成
特にレポート・分析・ターゲティングまわりの裏側で、月数~十数億行クラスの行動ログ(データファイルは当時圧縮済みで 30TB 以上)に対して集計・絞り込みをかけ続ける必要があり、このバックエンドが今回の主役です。
なぜ Citus にしたか
それまでは Impala(Hadoop + Impala)でやっていました。
これが、よく動いてはいたものの、わりと重い代物で、具体的には次のような課題を抱えていました。
- ライセンス費が普通に痛い (ほんこれ)
- 普段使いの SQL クライアントや ORM がそのまま使えない(PHP からは Thrift 経由でアクセス)
- 集計クエリ以外の、細かい OLTP 寄りの操作には不向き
- 運用技術者の確保・育成の問題 (数が少ない&メインストリームではない)
このあたりを解消したくて、PostgreSQL の世界に寄せる方針が立ちました。
ただし、全テナントのログを 1 台の PostgreSQL に持つのは無理で、パフォーマンス的にも並列化が必須になると見込まれました。
選択肢としては次のような整理になりました。
| 選択肢 | 採用しなかった主な理由 |
|---|---|
| 普通の PostgreSQL でパーティション運用 | 単一ノードに収まらない |
| Aurora / Cloud SQL の read replica で凌ぐ | 書き込みが詰まる |
| Greenplum 等の他の分散 DB 製品 | ライセンス/運用コスト |
| Citus | サーバ台数次第でなんとかなりそう |
選んだ理由は次のとおりです。
- PostgreSQL の拡張として動くため、SQL もドライバもツールチェインも基本的にそのまま流用できる
- OSS で手元に持ち込める(マネージドの Azure Cosmos DB for PostgreSQL もあり)
- Coordinator + Worker のシンプルな構成で、運用イメージが立てやすい
- マルチテナント SaaS との相性が良い(後述)
- 運用技術者の確保が比較的容易
要するに「Postgres の延長線で、横にスケールできる」というのが大きかったです。
アーキテクチャ
最終的に組んだ構成はざっくり次のとおりです。
- Coordinator ノード: 1 台。クライアント接続を受けてクエリプランを作る
- Worker ノード: 複数台。実データ(シャード)を分散保持
- 検証用に同等構成のクラスタを別途 1 系統
- PostgreSQL 17 系 + Citus 13.x 系で運用、検証側で次バージョンのキャッチアップを進行
Coordinator の冗長化やバックアップは標準的な PostgreSQL の流儀(pg_basebackup + WAL 退避)に Citus メタデータの取り扱いを足す形で、特殊なことはしていません。Postgres の世界のまま運用できる、というのが Citus を選んだ大きな理由のひとつです。
インストール
ここに至るまでの構築手順を書いておきます。XD.GROWTH ではソースビルドからの拡張ロードを本番にもそのまま踏襲しています。
Citus 拡張のビルド
各ノード(Coordinator / Worker 全台)で次を実行します。
git clone https://github.com/citusdata/citus.git
cd citus
./configure
make
sudo make install
postgresql.conf に拡張をプリロードする設定を入れて、PostgreSQL を再起動します。
shared_preload_libraries = 'citus'
その上で、対象データベースで CREATE EXTENSION します。
postgres=# CREATE EXTENSION citus;
CREATE EXTENSION
postgres=# SELECT citus_version();
citus_version
------------------------------------------------------------
Citus 13.x.x on x86_64-pc-linux-gnu, ...
PostgreSQL のバージョンと Citus のバージョンの組み合わせ(対応マトリクス)は公式の README で確認しておきます。組み合わせが外れていると CREATE EXTENSION の段階で蹴られます。
Coordinator / Worker の登録
Coordinator 上で、自身と Worker を Citus クラスタに登録します。
-- Coordinator 自身を登録
postgres=# SELECT citus_set_coordinator_host('coordinator-host', 5432);
-- Worker を追加
postgres=# SELECT citus_add_node('worker1-host', 5432);
postgres=# SELECT citus_add_node('worker2-host', 5432);
-- ノード一覧の確認
postgres=# SELECT nodeid, groupid, nodename, nodeport, isactive
postgres-# FROM pg_dist_node;
nodeid | groupid | nodename | nodeport | isactive
--------+---------+------------------+----------+----------
1 | 0 | coordinator-host | 5432 | t
2 | 1 | worker1-host | 5432 | t
3 | 2 | worker2-host | 5432 | t
(3 rows)
ここまでで Coordinator + Worker が pg_dist_node に揃って表示されていれば、クラスタ構築としては完了です。
検証環境は Docker でサクッと
ローカルで挙動だけ確認したい場合は公式 Docker イメージで一発です。
docker run -d --name citus -p 5432:5432 \
-e POSTGRES_PASSWORD=mypass citusdata/citus:13.0
psql -U postgres -h localhost -c "SELECT citus_version();"
ただし 1 ノード構成では分散周りのハマりどころが再現しないので、本番手順の検証はマルチノード構成(公式の citusdata/docker にある docker-compose.yml 流用)でやるのが安全です。
テーブル設計
ここが Citus 移行で一番頭を使ったところで、後から後悔するのもここでした。
Citus には大きく次のテーブル形態があります。
| 種別 | 関数 | 用途 | XD.GROWTH での例 |
|---|---|---|---|
| 分散テーブル | create_distributed_table('t', 'col') |
行を分散キーで分割。大量データ向け | 行動ログ系全般 |
| 参照テーブル | create_reference_table('t') |
全 Worker に複製。更新頻度が極めて少ないマスター向け | コードマスタなど |
| メタデータ化 | citus_add_local_table_to_metadata('t') |
Coordinator のみで完結する管理表/中頻度更新マスター | 集計ジョブ管理表、業務マスタ |
| カラムナテーブル | USING columnar |
列指向ストレージ。圧縮率 60〜90% | アーカイブ系 |
これらをどう組み合わせるかが効いてきます。
分散キーは高カーディナリティな列を選ぶ
Citus でデータを各 Worker に振り分ける際、キーとなるカラム(分散キー)を1つ指定します。
行動ログ系のテーブルでは、分散キーにユーザーのセッションID session_id を採用しています。
CREATE TABLE t_realtime_accesslog (
site_id varchar(8) NOT NULL,
session_id varchar(64) NOT NULL,
ts timestamptz NOT NULL,
-- ...
) PARTITION BY RANGE (ts);
SELECT create_distributed_table('t_realtime_accesslog', 'session_id');
ポイントは 「カーディナリティが高い列を分散キーにする」 ということです。
Citus は分散キーのハッシュで shard を割り振るため、キーの取りうる値が多いほど Worker 間で均等に散ります。逆にカーディナリティの低い列――例えばテナント ID(site_id)のような数百〜数千程度の値しか取らない列――を分散キーにしてしまうと、巨大テナントだけが特定の Worker に偏ってホットスポット化する、というハマり方をします。
XD.GROWTH の場合、session_id は事実上ユニークで取りうる値が膨大なので、各 Worker に綺麗に散ってくれます。
co-location をちゃんと揃える
同じ分散キーで JOIN するテーブル群(行動ログ系・イベント系・ページビュー系など)は、最初から co-location を揃えておきます。要は
SELECT create_distributed_table('t_realtime_accesslog', 'session_id');
SELECT create_distributed_table('t_realtime_event', 'session_id',
colocate_with => 't_realtime_accesslog');
SELECT create_distributed_table('t_realtime_pageview', 'session_id',
colocate_with => 't_realtime_accesslog');
これを揃えていないと、JOIN のたびに Worker 間で中間結果のシャッフルが走って一気に遅くなり、Webアプリやバッチのレスポンスに大きく影響します。
最初に決めて、揃ったまま運用するのが鉄則で、後から作り直すとなるとデータ量に比例して涙が出ます。
co-location が効いているかは次のクエリで確認できます。
postgres=# SELECT logicalrelid, colocationid
postgres-# FROM pg_dist_partition
postgres-# WHERE logicalrelid IN ('t_realtime_accesslog'::regclass,
postgres(# 't_realtime_event'::regclass,
postgres(# 't_realtime_pageview'::regclass);
logicalrelid | colocationid
----------------------+--------------
t_realtime_accesslog | 2
t_realtime_event | 2
t_realtime_pageview | 2
colocationid が揃っていれば OK で、揃っていなかったらアウトです。
分散テーブル + RANGE パーティションの併用
時系列データ(行動ログ系)は 「分散テーブル + RANGE パーティション」 の二段構えにしています。
- 横軸(
session_id)で Worker に分散 - 縦軸(
ts)で日次/月次パーティション
CREATE TABLE t_realtime_accesslog (
site_id varchar(8) NOT NULL,
session_id varchar(64) NOT NULL,
ts timestamptz NOT NULL,
/* ... */
) PARTITION BY RANGE (ts);
CREATE TABLE t_realtime_accesslog_p20260301
PARTITION OF t_realtime_accesslog
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
SELECT create_distributed_table('t_realtime_accesslog', 'session_id');
古いパーティションは別途バッチで DETACH して削除、もしくは S3 にアーカイブしています。これで 「直近 N ヶ月だけが Worker のホットデータ」 という運用に持っていけて、ストレージコストの予測もしやすくなります。
参照テーブル化は「更新頻度が少ないマスター」だけにする
「マスタ系は迷わず参照テーブル化」――ではない、というのが運用してきての結論です。ここは設計を間違えると割と痛い目に遭います。
参照テーブル化(create_reference_table)は 全 Worker にデータを複製する仕組みのため、
- 読み取り側: JOIN が完全にローカル化されて速い
- 書き込み側: 更新コストが Worker 数に比例する
という強烈なトレードオフを抱えています。
なので、参照テーブル化の適用先は 「更新頻度が極めて少ない、ほぼ静的なマスターテーブル」だけに限定するのが安全です。コードマスタや国地域マスタなどはこれに該当します。
-- 滅多に変わらないコードマスタなら OK
SELECT create_reference_table('m_code');
そうでないテーブルは「メタデータ化」を選ぶ
更新頻度が中程度以上のマスター・管理表は、参照テーブルではなく メタデータ化(citus_add_local_table_to_metadata) を選ぶ方が安全です。
メタデータ化は、Coordinator にしか実体を持たないテーブルを Citus のメタデータに登録するだけの操作で、Worker への複製は走りません。Worker からの分散クエリには参加できますが、書き込みコストは普通の PostgreSQL のテーブルと変わりません。
SELECT citus_add_local_table_to_metadata('m_job_status');
失敗談: 参照テーブルへの高頻度書き込みでシステムが固まりかけた
実は、運用を始めてからこれをやらかしたことがあります。
「マスタっぽいから」という理由で参照テーブル化していた表に対して、高頻度かつ比較的大量に書き込みを行うバッチがあり、そのバッチが実行されるたびに全 Worker への複製が走って書き込みがどんどん詰まる、という状況に陥りました。
気付いた頃にはクラスタ全体が固まりかけていて、慌てて該当バッチを止め、当該テーブルを 参照テーブル → メタデータ化に切り替えることで事なきを得ました。
教訓は 「マスター = 参照テーブル」と短絡しないこと、これに尽きます。
- 本当にほぼ静的か?
- 運用フェーズで書き込みが増える可能性はないか?
- 増えた場合、どのバッチが書きにいくのか?
このあたりを最初の段階で握っておかないと、後から地味に効いてきます。
UNIQUE 制約は分散列を必ず含める
これは Citus の制約上避けられない話なのですが、
-- 通らない
postgres=# CREATE UNIQUE INDEX ON t_realtime_accesslog (event_id);
ERROR: cannot create constraint on "t_realtime_accesslog"
DETAIL: Distributed relations cannot have UNIQUE, ...
-- 通る
postgres=# CREATE UNIQUE INDEX ON t_realtime_accesslog (session_id, event_id);
CREATE INDEX
論理キー側に分散列(この例では session_id)を組み込むのが前提になります。
レガシーから引き取ったテーブルは、この制約に合わせてキー設計を見直す必要が出ることがあります。
timestamp ではなく timestamptz で揃える
何 TZ で保存されているのか曖昧な timestamp without time zone が混ざると、ぱっと見では分からない比較ズレが起きます。全テーブル timestamptz で固定するルールにしました。
Citus に限った話ではないですが、過去テーブルを引き取るタイミングで地味に効いてきます。
移行の進め方
「全部 Citus に一気に乗せる」をゴールにすると死ぬので、ワークロード単位で順に寄せていくやり方を取りました。
- 既存 Impala と並走 — 同じデータを両方に入れて結果が一致するかひたすら検証
- 重い参照系から順に切り替え — レポート、分析、検索
- 配信系(メール / SMS / LINE / Webpush)を切り替え
- Impala 側を停止
並走期間は長くて辛かったですが、結果論としては正解でした。
特に集計値の差異は 「並走させて両方の結果を毎日突合する」以外に正解の検出方法がないので、ここはケチらない方が良いです。
運用してみてどうだったか
良かったところは次のとおりです。
- クエリ性能が上がった(レポートの表示パフォーマンスがかなり改善した)
- コストが下がった(これは率直にデカい)
-
PostgreSQL のエコシステムがそのまま使える(
pg_stat_statementsもEXPLAIN ANALYZEも普通に効く。これが Impala との一番大きな違い)
しんどかったところはこのあたりです。
-
シャード再均衡の段取り は最初それなりに学習コストがある(
citus_rebalance_start()まわり) -
Worker ダウン時のメタデータ整合 は何度かやらかして、
pg_dist_placementを直接DELETEする儀式が定番化 -
巨大 CTE で
intermediate result size exceeds the limitに引っかかって、クエリを書き直すことになった - 検証は 1 ノードで素通りするのに本番で詰まる罠 を踏んだ
教訓は「マルチノード相当の検証を端折ると本番で必ず痛い目に遭う」ということに尽きます。Docker の単一ノード構成だと localhost 指定でも普通に動いてしまうため、本番で初めて事故るパターンです。
まとめ
- XD.GROWTH の DB 基盤を Impala → PostgreSQL + Citus に乗せ換え
- 分散キーは カーディナリティが高い列(XD.GROWTH では
session_id)が基本 - マスター系は 「更新頻度が極めて少ないなら参照テーブル、そうでないならメタデータ化」 で使い分け
- co-location を揃え、時系列は分散テーブル + パーティションの二段構え
- 移行は並走期間を長めに取って、ワークロード単位で順に切り替えるのが結局一番安全
「Postgres の世界に居ながら横に伸ばせる」という Citus の旨味は、水平分割しやすい大量データを抱えている SaaS だとピタッとハマります。同じような構成のシステムを抱えている方の参考になれば。