MySQLにおけるマルチテナントアーキテクチャ:苦い失敗経験から数千ユーザー規模のSaaS構築まで

MySQL tutorial - IT technology blog
MySQL tutorial - IT technology blog

SaaSにおける「データ分離」の課題

最初のSaaSプロジェクトを手掛けた際、忘れられない苦い経験があります。開発スピードを優先し、全店舗のデータを1つのデータベースに詰め込み、各テーブルをtenant_idカラムで区別していました。

顧客数が500に達するまでは順調でした。しかしある日、チームメンバーが商品価格を更新する際にWHERE tenant_id = ?という条件を書き忘れてしまいました。わずか1秒で、顧客Aの全注文データが顧客Bの名前に書き換わってしまったのです。その夜は200GBのデータをリストアするために徹夜しました。この教訓:最初から正しいマルチテナントアーキテクチャを選択しなければ、砂の上に家を建てるようなものです。

なぜマルチテナンシーで悩むのか?

SaaSの世界では、各顧客は「テナント」と呼ばれます。最大の課題は、テナントAがテナントBのデータを盗み見ないようにすることだけではありません。絶対的なセキュリティ、メンテナンスの容易さ、そして運用コスト(CPU、RAM)の3つのバランスを取る必要があります。

実際、あらゆるケースに完璧なアーキテクチャは存在しません。10社の大手顧客向けか、1万店舗の小規模ショップ向けかによって、アプローチは全く異なります。

MySQLにおける3つの実戦的なデータ分離手法

1. Database-per-Tenant(顧客ごとにデータベースを分離)

これは独立性を最優先する手法です。新規顧客が増えるたびに、システムが自動的にCREATE DATABASE db_tenant_idを実行します。

  • メリット: 最高レベルのセキュリティ。顧客の要望に応じて個別にバックアップが可能です。テナントAのデータベースが破損しても、テナントBは正常に稼働し続けます。
  • デメリット: リソース消費が激しいです。MySQLでは、データベース数が1,000を超えると、バッファプール(InnoDB Buffer Pool)の管理やファイル記述子の制限が問題になることがよくあります。また、1,000個のデータベースに対して同時にマイグレーションを実行するのは、時間的に大きな挑戦となります。

2. Schema-per-Tenant(同一インスタンス、別スキーマ)

MySQLでは、DATABASESCHEMAは実質的に同じものです。しかし、PostgreSQLなどの管理システムでは、これらはより明確に区別されています。MySQLの場合、この手法は1つのデータベースインスタンスを共有しつつ、テーブル接頭辞(Prefix)を使用したり、論理的なクラスタに分割して管理したりすることで適用されます。

3. Shared Database, Shared Schema(すべてを共有)

スタートアップはサーバーコストを抑えるためにこの方法を選ぶことが多いです。全テナントが同じテーブルセットを使用し、識別子カラムで区別します。

CREATE TABLE orders (
    id INT AUTO_INCREMENT PRIMARY KEY,
    tenant_id INT NOT NULL,
    customer_name VARCHAR(255),
    total_amount DECIMAL(10, 2),
    INDEX (tenant_id) -- フルテーブルスキャンを防ぐためにインデックスは必須
);

パフォーマンスに関する注意点: ordersテーブルが2,000万〜3,000万行を超えると、クエリ速度が目に見えて低下します。この時、tenant_id単体のインデックスだけでは不十分です。最適化のために複合インデックスが必要になります。

WHERE条件の「書き忘れ」を防ぐ解決策

私が経験したような初歩的なミスを防ぐために、SQLを最適化・修正する以下の2つのテクニックを適用できます。

テクニック1:MySQL Viewの活用

顧客ごとに個別のViewを作成します。バックエンドのコードは、元のテーブルではなくViewに直接接続します。この方法は安全ですが、データベース内のViewのリストが膨大になり、手動での管理が困難になります。

テクニック2:セッション変数による行レベルセキュリティ(RLS)のシミュレーション

MySQLにはPostgresのような本格的なRLSはありませんが、接続時にセッション変数を設定することで「回避」できます。

SET @current_tenant_id = 101;

その後、自動フィルタリングロジックを持つ共通Viewを介してクエリを実行します:

CREATE VIEW v_tenant_orders AS
SELECT * FROM orders WHERE tenant_id = @current_tenant_id;

この方法により、バックエンドのコード層が非常にクリーンになります。SELECT * FROM v_tenant_ordersを呼び出すだけで、データが混ざる心配は一切ありません。

システム規模拡大時の最適化のコツ

データが増えるにつれ、「Noisy Neighbor(特定の顧客がリソースを使いすぎて他者に影響を与える現象)」が発生します。私が実践している対処法は以下の通りです:

  • 複合インデックス(Composite Index): 常にtenant_idをインデックスの先頭に配置します。例:(tenant_id, status, created_at)
  • テーブルパーティショニング(Table Partitioning): PARTITION BY LIST(tenant_id)を使用します。MySQLは50GBのテーブル全体をスキャンする代わりに、そのテナント의データが含まれるパーティションのみをスキャンします。
  • データベースシャーディング(Database Sharding): サーバーのCPU使用率が頻繁に80%に達するようになったら、テナントを異なる物理サーバーに分散させます。グループ1-500はアジアサーバー、501-1000は欧州サーバーといった形です。

あなたのプロジェクトにはどの方向性が適しているか?

私の経験に基づき、2つのシナリオを検討してみてください:

  1. Shared Schema(手法3)を選択: 一般的なアプリケーションで、コスト最適化と迅速なスケールが必要な場合。クエリに自動的にtenant_idを挿入してくれるGlobal ScopesをサポートするORMライブラリを活用しましょう。
  2. Database-per-Tenant(手法1)を選択: 顧客が銀行や政府機関の場合。彼らは厳格なセキュリティ基準を満たすために、データが物理ディスク上で分離されていることを要求します。

私は通常、ハイブリッド(混合)ソリューションを優先します。無料トライアルの顧客は1つの大きな共有データベースに収容し、高額な料金を支払うVIP顧客は専用の個別データベースに「引っ越し」させます。これにより、コストのバランスを取りつつ、最も重要な顧客を維持することができます。

SaaSのデータベース設計は長い道のりです。データが混乱してから解決策を探すのではなく、事前に対策を講じましょう。この共有が、皆さんの不必要な「失敗」を避ける助けになれば幸いです!

Share: