0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

XD.GROWTH の DB 基盤を Impala から PostgreSQL + Citus に移行した話

0
Last updated at Posted at 2026-05-15

はじめまして。トライベック株式会社の下岡と申します。

現在、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 に一気に乗せる」をゴールにすると死ぬので、ワークロード単位で順に寄せていくやり方を取りました。

  1. 既存 Impala と並走 — 同じデータを両方に入れて結果が一致するかひたすら検証
  2. 重い参照系から順に切り替え — レポート、分析、検索
  3. 配信系(メール / SMS / LINE / Webpush)を切り替え
  4. Impala 側を停止

並走期間は長くて辛かったですが、結果論としては正解でした。
特に集計値の差異は 「並走させて両方の結果を毎日突合する」以外に正解の検出方法がないので、ここはケチらない方が良いです。

運用してみてどうだったか

良かったところは次のとおりです。

  • クエリ性能が上がった(レポートの表示パフォーマンスがかなり改善した)
  • コストが下がった(これは率直にデカい)
  • PostgreSQL のエコシステムがそのまま使えるpg_stat_statementsEXPLAIN 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 だとピタッとハマります。同じような構成のシステムを抱えている方の参考になれば。


参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?