
PostgreSQLを使ったマルチテナントデータを見直す
こんにちは、Anti-Patternの塚本です。
突然ですが、みなさんマルチテナントアーキテクチャってご存知ですか?
複数の企業データを扱うシステムで、リソースや運用コストなどを最大限に抑えて管理することなのですが、これを検討するのは大変です。
このブログは、あるマルチテナントデータの見直しについて、書きたいと思います。
では、どんな方法があるのでしょう?
1.複数データベース(単一インスタンス)
テナント毎に物理的にインスタンスを分けたデータベース管理
2.単一データベース+複数スキーマ
物理的に一つのインスタンスで同じデータベースを、テナント毎にスキーマを分けて管理
3.単一データベース+単一スキーマ
物理的に一つのインスタンスで全テナントを、一つのデータベースで管理する
それぞれ、メリット・デメリットはありそうです。安全性を考えると、1.が良さそうですが、お金かかりそうですよね。また、システムの規模、テナント数とかにも左右されそうです。
私が携わったシステムでは、運用から数年でこんな症状が出始めました。
- DB全体の容量増加
- 運用コストの増加
- 性能劣化
そのため、マルチテナントデータの見直しに着手しました。
私が行ったのは、“単一データベース+複数スキーマ”から
“単一データベース+単一スキーマ”への変更です。
セキュリティの担保
複数スキーマの各テナントデータを、単一スキーマに統合するためにデータを寄せる必要があります。そのために、PostgreSQLの行セキュリティポリシー(ROW LEVEL SECURITY)を利用しました。
CREATE POLICY sample_table_policy_view ON sample_table
FOR ALL
USING (tenant_id = current_tenant_id())
WITH CHECK (tenant_id = current_tenant_id());
ALTER TABLE sample_table ENABLE ROW LEVEL SECURITY;
sample_tableには、tenant_id(テナントID)を設定しています。tenant_idはDBのroleと一致します。
そのため、テナントが接続した際にコンテキストのユーザ名(CURRENT_USER)からtenant_idをcurrent_tenant_id()で取得しできる仕組みです。
USINGでデータ参照のできる範囲、WITH CHECKでデータ参照、データ更新の範囲が抑止されています。
テナント毎のスキーマ定義で実現していたセキュリティは、これで担保することができました。
大量データの対応
複数スキーマを統合したことにより、データ量が肥大化したテーブルが幾つかできてしまいました。トランザクションデータが格納されるテーブルのため、更にデータが増加する可能性が高く、Indexだけは対応できません。そのため、テーブルのパーティショニングを利用しました。
https://www.postgresql.jp/document/10/html/ddl-partitioning.html
CREATE TABLE parent_table (
id bigserial,
tenant_id integer DEFAULT current_tenant_id() NOT NULL,
partition_id SMALLINT NOT NULL DEFAULT partition_id(current_tenant_id())
) PARTITION BY LIST (partition_id);
PARTITION BY LISTで分割キーを指定します。
CREATE POLICY partition_parent_table_view ON parent_table
FOR ALL
USING (tenant_id = current_tenant_id() AND
partition_id = partition_id(current_tenant_id()))
WITH CHECK (tenant_id = current_tenant_id() AND
partition_id = partition_id(current_tenant_id()));
ALTER TABLE parent_table ENABLE ROW LEVEL SECURITY;
まずは、親テーブル(継承元)に対してRLSを設定します。
-- 分割キー(partition_id)の値(0)格納するテーブル
CREATE TABLE partition_0 PARTITION OF parent_table FOR VALUES IN (0);
CREATE POLICY partition_parent_table_view ON partition_0
FOR ALL
USING (tenant_id = current_tenant_id() AND
partition_id = partition_id(current_tenant_id()))
WITH CHECK (tenant_id = current_tenant_id() AND
partition_id = partition_id(current_tenant_id()));
ALTER TABLE partition_0 ENABLE ROW LEVEL SECURITY;
-- 分割キー(partition_id)の値(1)格納するテーブル
CREATE TABLE partition_1 PARTITION OF parent_table FOR VALUES IN (1);
CREATE POLICY partition_parent_table_view ON partition_1
FOR ALL
USING (tenant_id = current_tenant_id() AND
partition_id = partition_id(current_tenant_id()))
WITH CHECK (tenant_id = current_tenant_id() AND
partition_id = partition_id(current_tenant_id()));
ALTER TABLE partition_1 ENABLE ROW LEVEL SECURITY;
-- 分割キー(partition_id)の値(2)格納するテーブル
CREATE TABLE partition_2 PARTITION OF parent_table FOR VALUES IN (2);
CREATE POLICY partition_parent_table_view ON partition_2
FOR ALL
USING (tenant_id = current_tenant_id() AND
partition_id = partition_id(current_tenant_id()))
WITH CHECK (tenant_id = current_tenant_id() AND
partition_id = partition_id(current_tenant_id()));
ALTER TABLE partition_2 ENABLE ROW LEVEL SECURITY;
分割キー(partition_id)の値が0の値を格納する、子テーブル(partition_0)を作成しRLSを設定します。分割キーは、partition_id(current_tenant_id())で取得します。同様に分割キー(partition_id)の値が1、2 の値を格納する子テーブルをそれぞれ作成します。
この設定で、tenant_idから計算されたパーティションテーブルへデータが格納されます。そして、行セキュリティポリシーに従ったデータ参照、更新が可能となりました。
そしてチューニング
データを一つのスキーマに寄せた後が大変でした。
実行計画を確認すると、ビックリする程、高コストだったり、Indexが効かない、設定しても逆にコストがかかったりと。
多段に ネストするクエリの実行計画・・・
一部を除き、何とかなるところまでチューニングを繰り返しました。
さらっと書いてますが、大変でした!
大変でしたが、収穫も大きく。
後輩が実行計画を読んで、クエリをチューニングし、Index設定をしている姿はちょっと感動しました。(初めての挑戦です)
それと、このブログに書いた内容は、PostgreSQL有識者の永安氏に大変お世話になってます。ありがとうございました!!