はじめに
こんにちは、FAT47と申します。
普段はチームブログの方でMySQL関連の記事を書いていますが、「はじめてのMySQL」が記事テーマになるイベントが開催中でしたので、Qiitaで記事を書いてみました。
私は15年ほどMySQLを触ってきていましたが、業務ではtoC向けサービスしか担当したことがなく、マルチテナントが必要な要件に出会うことはなかなかありませんでした。
はじめての事を考える良い機会ですので、今回はMySQLでのマルチテナント対応について考えてみました。
シングルテナントとマルチテナント
そもそもMySQLのマルチテナントとは何なのかを再確認しておきましょう。
-
マルチテナント
テナントが複数存在している状態で、toBなどのサービスやSaaSなどでよく見られます。
マルチテナントはモデルによって3つに分類されます。
マルチテナントのモデル分類
AWSのWell-Architectedフレームワークの資料では、以下の3つのモデルに分類されています。
Amazon Aurora MySQLを例にして紹介します。
サイロモデル
一つのAuroraクラスタ上には1つのテナントデータがいるモデルで、同一構成のAuroraクラスタがテナントの数だけ存在している構成となります。
メリット
- クラスタごとに独立しているため他のテナントによる性能悪化影響(ノイジーネイバー)がない
- テナントごとにスケールアップが可能
- 障害発生時の影響範囲が限定される
デメリット
- テナントが増えるたびにクラスタを増やすなど運用コストが高い
- 費用も高い
ブリッジモデル
1つのAuroraクラスタ上に、テナントごとにスキーマを分けて作成するモデルで、同一構成のスキーマがテナントの数だけ存在している構成となります。
メリット
- サイロモデルより新規テナント作成が容易
- クラスタの追加が不要なので、サイロモデルより費用が削減できる
デメリット
- テナント数に比例してスキーマ数(テーブルも)増加
- ノイジーネイバーの影響を受ける
- テナント数が増大ずるとマイグレーションに影響(テナント数*マイグレーション)
- 1秒かかるだけのマイグレーションでも、5000テナントあれば5000秒かかる
プールモデル
すべてのテナントを同一のAurora MySQLクラスタ上のスキーマで共有するモデル。テナントデータはテナントIDなどのカラムで管理。
メリット
- テーブル構成変更などのマイグレーションが比較的容易
- 新規テナント作成はテナントIDを追加するだけ
- サイロモデルより費用が削減できる
デメリット
- ノイジーネイバーの影響を受ける
- テナントIDごとに振り分けるアプリケーションの実装が必要
- 実装ミスやバグにより、他テナントの情報が表示されるリスク
- すべてのテナントを同一テーブルで管理するのでレコード数が増大しやすい
- 適切なインデックスやパーティション設計が重要
MySQLにおけるマルチテナントのプールモデルの懸念
MySQLでプールモデルのマルチテナントを構築しようとする場合、アプリケーション側で
WHERE tenant_id = 'tenant1'
のような処理を徹底して実装する必要があります。
ミスでWHERE句を間違って実装した場合などに、他のテナントの情報の表示や書き換えなどの重大なインシデントにつながる恐れがあります。
そのため、安全性を重視する場合はサイロモデルか、ブリッジモデルの採用を検討することになりますが、それぞれにデメリットがあるので十分考慮することが必要です。
一方、別のOSS RDBMSであるPostgreSQLの場合は、Row Level Security(RLS)という機能があり、プールモデルを安全に利用できる機能があります。
これはテーブルの行単位のアクセス制御を、DBのユーザーやロールのポリシーにもとづいておこなうことができる機能で、アプリケーション実装ミスによる誤ったテナントへのアクセスを防ぐことができます。
それでもMySQLでマルチテナントのプールモデルを実現したい
MySQLの機能のみでRLSのようなことは実現できないか調査したところ、
db tech showcase 2024のFindyさんの発表で、VIEWをつかった事例を見つけました。
https://speakerdeck.com/ham0215/mysqlnoviewwohuo-yong-sitaan-quan-namarutitenantonoshi-xian-fang-fa
こちらの資料を参考に、VIEWをつかったRLSのような挙動を検証してみました。
全体の構成イメージ図がこちらです。

DB接続ユーザーごとのテナントIDが管理されているマッピングテーブルが存在しており、
ユーザーはテナントIDのマッピング関数を呼び出すことで、自身のテナントIDを知ることができます。
アプリケーションからの読み書きはすべてVIEWテーブル経由でおこなうように徹底します。
VIEWテーブルにはWITH CHECK OPTIONによって、現在接続しているDBユーザーのテナントIDと一致していないデータにアクセスしようとするとエラーになる制約をつけています。
私はこの検証をするまでVIEWテーブルは参照しかできないと思い込んでいたのですが、普通に更新や削除処理など含めてCRUD操作に対応しているようです。
なお、VIEW経由の更新処理にはいろいろ制約もありますので、詳しくはこちらの公式ドキュメントもご参照ください。
https://dev.mysql.com/doc/refman/8.0/en/view-updatability.html
VIEWによるマルチテナント実装の構築
それでは、実際に動く状態のサンプルをつくっていきます。
最初に必要なスキーマを2つ作成します。
CREATE DATABASE coredb;
CREATE DATABASE viewdb;
DBユーザーとテナントIDをマッピングするテーブルをcoredb作成します。
USE coredb;
-- DBユーザーとテナントIDのマッピング
CREATE TABLE user_tenant_mapping (
db_user VARCHAR(50) PRIMARY KEY,
tenant_id INT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
マッピングテーブルにテスト用のデータを挿入します。
-- マッピングテーブルにテストデータ挿入
INSERT INTO user_tenant_mapping (db_user, tenant_id) VALUES
('app_user_alpha', 100),
('app_user_beta', 200),
('app_user_gamma', 300);
次にマッピングテーブルからテナントIDを確認する関数を作成します。
ここではDB接続ユーザー名とテナントIDを紐づける処理をしています。
-- マッピングテーブルからテナントIDを確認する関数
DELIMITER $$
CREATE FUNCTION current_tenant_id()
RETURNS INT
READS SQL DATA
BEGIN
DECLARE tenant_id_value INT DEFAULT NULL;
DECLARE current_username VARCHAR(100);
-- 現在のユーザー名を取得(@マークより前)
SET current_username = SUBSTRING_INDEX(USER(), '@', 1);
-- マッピングテーブルからテナントIDを取得
SELECT tenant_id INTO tenant_id_value
FROM user_tenant_mapping
WHERE db_user = current_username;
RETURN tenant_id_value;
END$$
DELIMITER ;
VIEWからアクセスする元となるテーブルをcoredbに作成します。
CREATE TABLE coredb.users (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
tenant_id INT NOT NULL,
username VARCHAR(100) NOT NULL,
email VARCHAR(255) NOT NULL,
password VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
VIEWのテーブルを作成します。
-- VIEWテーブルも作成
CREATE SQL SECURITY DEFINER VIEW viewdb.users AS
SELECT
id,
tenant_id,
username,
email,
password,
created_at
FROM
coredb.users
WHERE
tenant_id = coredb.current_tenant_id()
WITH CHECK OPTION;
テナントごとのDB接続ユーザーを作成し権限も設定します。
-- テナント用のアプリケーションユーザーを作成
CREATE USER 'app_user_alpha'@'%' IDENTIFIED BY 'pass';
CREATE USER 'app_user_beta'@'%' IDENTIFIED BY 'pass';
CREATE USER 'app_user_gamma'@'%' IDENTIFIED BY 'pass';
-- VIEWスキーマへのアクセス権限
GRANT SELECT, INSERT, UPDATE, DELETE ON viewdb.* TO 'app_user_alpha'@'%';
GRANT SELECT, INSERT, UPDATE, DELETE ON viewdb.* TO 'app_user_beta'@'%';
GRANT SELECT, INSERT, UPDATE, DELETE ON viewdb.* TO 'app_user_gamma'@'%';
-- 必要な関数の実行権限
GRANT EXECUTE ON FUNCTION coredb.current_tenant_id TO 'app_user_alpha'@'%';
GRANT EXECUTE ON FUNCTION coredb.current_tenant_id TO 'app_user_beta'@'%';
GRANT EXECUTE ON FUNCTION coredb.current_tenant_id TO 'app_user_gamma'@'%';
-- マッピングテーブルへの読み取り権限(関数内で使用)
GRANT SELECT ON coredb.user_tenant_mapping TO 'app_user_alpha'@'%';
GRANT SELECT ON coredb.user_tenant_mapping TO 'app_user_beta'@'%';
GRANT SELECT ON coredb.user_tenant_mapping TO 'app_user_gamma'@'%';
動作確認
最初にalphaユーザー(tenant_id=100)で接続します。
mysql -u app_user_alpha -p
SELECT coredb.current_tenant_id() AS my_tenant_id;
+--------------+
| my_tenant_id |
+--------------+
| 100 |
+--------------+
1 row in set (0.02 sec)
-- ユーザーalphaで2件レコード追加
INSERT INTO viewdb.users (tenant_id, username, email, password, created_at)
VALUES
(coredb.current_tenant_id(), 'alice', 'alice@example.com', 'hashed_password1', NOW()),
(coredb.current_tenant_id(), 'bob', 'bob@example.com', 'hashed_password2', NOW());
mysql> SELECT * FROM viewdb.users;
+----+-----------+----------+-------------------+------------------+---------------------+
| id | tenant_id | username | email | password | created_at |
+----+-----------+----------+-------------------+------------------+---------------------+
| 1 | 100 | alice | alice@example.com | hashed_password1 | 2025-07-06 15:06:57 |
| 2 | 100 | bob | bob@example.com | hashed_password2 | 2025-07-06 15:06:57 |
+----+-----------+----------+-------------------+------------------+---------------------+
2 rows in set (0.00 sec)
次にbeta(tenant_id=200)ユーザーで接続しなおします。
mysql -u app_user_beta -p
mysql> SELECT coredb.current_tenant_id() AS my_tenant_id;
+--------------+
| my_tenant_id |
+--------------+
| 200 |
+--------------+
1 row in set (0.00 sec)
-- ユーザーbetaで2件レコード追加
INSERT INTO viewdb.users (tenant_id, username, email, password, created_at)
VALUES
(coredb.current_tenant_id(), 'charlie', 'charlie@example.com', 'hashed_password3', NOW()),
(coredb.current_tenant_id(), 'diana', 'diana@example.com', 'hashed_password4', NOW());
ここで再度VIEWのusersテーブルをSELECTでWHERE条件付けずに確認してみます。
先程追加したtenant_id=100のレコードは見えず、自身のtenant_id=200のレコードだけ表示されていることがわかります。
mysql> SELECT * FROM viewdb.users;
+----+-----------+----------+---------------------+------------------+---------------------+
| id | tenant_id | username | email | password | created_at |
+----+-----------+----------+---------------------+------------------+---------------------+
| 3 | 200 | charlie | charlie@example.com | hashed_password3 | 2025-07-06 15:08:43 |
| 4 | 200 | diana | diana@example.com | hashed_password4 | 2025-07-06 15:08:43 |
+----+-----------+----------+---------------------+------------------+---------------------+
2 rows in set (0.01 sec)
最後にgamma(tenant_id=300)ユーザーで接続しなおします
mysql -u app_user_gamma -p
mysql> SELECT coredb.current_tenant_id() AS my_tenant_id;
+--------------+
| my_tenant_id |
+--------------+
| 300 |
+--------------+
1 row in set (0.00 sec)
-- ユーザーgammaで1件レコード追加
INSERT INTO viewdb.users (tenant_id, username, email, password, created_at)
VALUES
(coredb.current_tenant_id(), 'eve', 'eve@example.com', 'hashed_password5', NOW());
mysql> SELECT * FROM viewdb.users;
+----+-----------+----------+-----------------+------------------+---------------------+
| id | tenant_id | username | email | password | created_at |
+----+-----------+----------+-----------------+------------------+---------------------+
| 5 | 300 | eve | eve@example.com | hashed_password5 | 2025-07-06 15:09:35 |
+----+-----------+----------+-----------------+------------------+---------------------+
1 row in set (0.01 sec)
それでは、gammaユーザー(tenant_id=300)で接続している状態で、わざと異なるtenant_idを指定してINSERTしようとしてみましょう。
current_tennan_id()関数を使わず、tenant_idとして100を直接指定します。
INSERT INTO viewdb.users (tenant_id, username, email, password, created_at)
VALUES
(100, 'fuji', 'fuji@example.com', 'hashed_password6', NOW());
ERROR 1369 (HY000): CHECK OPTION failed 'viewdb.users'
ちゃんとCHECK OPTIONが効いてエラーになっていることがわかります。
まとめと注意点
- VIEWをつかうことでRow Level Securityのような動作を実装することはできる
- VIEW経由でのCRUD操作の制約に注意する必要がある
- プールモデルの場合、テナント共通で使うことになるテーブルのレコード数が多くなるので、適切なパーティショニングやインデックスの設計がより重要となる
最後に
MySQLでもRLS機能出しくれたら嬉しいです!!
イノベーションリリースでおねがいします。
参考資料
