PostgreSQLを使ったマルチテナントデータを見直す

PostgreSQLを使ったマルチテナントデータを見直す

こんにちは、Anti-Patternの塚本です。

突然ですが、みなさんマルチテナントアーキテクチャってご存知ですか?

複数の企業データを扱うシステムで、リソースや運用コストなどを最大限に抑えて管理することなのですが、これを検討するのは大変です。

このブログは、あるマルチテナントデータの見直しについて、書きたいと思います。

では、どんな方法があるのでしょう?

1.複数データベース(単一インスタンス)

テナント毎に物理的にインスタンスを分けたデータベース管理

2.単一データベース+複数スキーマ

物理的に一つのインスタンスで同じデータベースを、テナント毎にスキーマを分けて管理

3.単一データベース+単一スキーマ

物理的に一つのインスタンスで全テナントを、一つのデータベースで管理する

それぞれ、メリット・デメリットはありそうです。安全性を考えると、1.が良さそうですが、お金かかりそうですよね。また、システムの規模、テナント数とかにも左右されそうです。

私が携わったシステムでは、運用から数年でこんな症状が出始めました。

  • DB全体の容量増加
  • 運用コストの増加
  • 性能劣化

そのため、マルチテナントデータの見直しに着手しました。

私が行ったのは、“単一データベース+複数スキーマ”から

“単一データベース+単一スキーマ”への変更です。

セキュリティの担保

複数スキーマの各テナントデータを、単一スキーマに統合するためにデータを寄せる必要があります。そのために、PostgreSQLの行セキュリティポリシー(ROW LEVEL SECURITY)を利用しました。

https://www.postgresql.jp/docs/10/ddl-rowsecurity.html

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有識者の永安氏に大変お世話になってます。ありがとうございました!!

http://pgsqldeepdive.blogspot.com/

https://www.slideshare.net/uptimejp/postgresql-6872500