- 設計
マルチテナントDB設計|テナント分離3方式の比較とRLSのSQL例
マルチテナントのDB設計とは、1つのシステムを複数の利用組織(テナント)で共用しながら、各テナントのデータが互いに見えないように分ける設計のことです。 分け方は「行で分ける(tenant_id 列+RLS)」「スキーマで分ける」「データベースで分ける」の3方式があり、どれを選ぶかで運用の手間と分離の強さが変わります。
この記事では、3方式の比較と選び方を先に示したうえで、PostgreSQL の RLS(Row Level Security:行単位のアクセス制御)を実際に動かし、設定しても他テナントの行が見えてしまう3つの経路(所有者ロール・BYPASSRLS・接続プールの設定残り)を出力ごと再現します。RLS を持たない MySQL での代替の書き方も並べます。
確認環境:Ubuntu 24.04、PostgreSQL 18.4(クライアントは psql 16.15)、MySQL 8.0.46。SQL と出力はすべてこの環境で実行したものです(確認日 2026-10-06)。出力中のデータベース名は appdb に統一し、エラー行の先頭に付くファイル名と行番号は省いています。題材は架空の文具店向け受注システムです。
この記事の結論
- まず tenant_id 列+RLS の行分離で始める。テーブルは1組、マイグレーションは1回、接続プールも1つで済みます。
- スキーマ分離・DB分離へ上げるのは、テナント単位のバックアップ復元や性能の隔離が要件になった時だけにします。
- RLS は設定して終わりではなく、FORCE ROW LEVEL SECURITY を付ける・アプリ用ロールを所有者と分ける・テナント指定をトランザクション単位にするの3点までがセットです。
- 主キーと外部キーは (tenant_id, id) の複合にしておくと、どの方式・どの製品でも他テナントの行を指す参照を制約で止められます。
テナント分離3方式の早見表
| 項目 | 行分離(tenant_id+RLS) | スキーマ分離 | DB分離 |
|---|---|---|---|
| 分ける単位 | 行(全テナントが同じテーブル) | スキーマ(テナントごとに同じ名前のテーブル一式) | データベース(テナントごとに別DB) |
| テナントの指定方法 | 設定値(set_config)をポリシーが参照 | search_path を切り替え | 接続先を切り替え |
| マイグレーションの回数 | 1回 | テナント数ぶん | テナント数ぶん |
| 接続プール | 1つを共用 | 1つを共用(切り替えは接続ごと) | テナントごとに必要 |
| テナント単位の復元 | 行を抜き出して戻す作業が要る | スキーマ単位で扱える | DB単位で扱える |
| 性能の隔離 | 同じテーブル・同じインデックスを共用 | テーブルは別、サーバー資源は共用 | DBごとに設定や配置を分けられる |
| テナント横断の集計 | 1つのSQLで書ける | スキーマをまたぐ UNION が要る | DBをまたぐ仕組みが要る |
| 分離が外れる主な経路 | ポリシーの設定漏れ・設定値の残り | search_path の残り | 接続先の取り違え |
| 向く規模 | 多数の小〜中規模テナント | 数十〜数百程度のテナント | 少数の大口テナント |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「マルチテナントDB設計|テナント分離3方式の比較とRLSのSQL例」
方式を選ぶ4つの軸(テナント数・復元単位・マイグレーション回数・接続数)
方式選びは「分離が強いほどよい」ではなく、運用で毎回支払う手間と引き換えです。次の4軸で要件を確認すると、どこまで上げる必要があるかが決まります。
| 軸 | 確認する問い | 行分離で足りる | スキーマ分離を検討 | DB分離を検討 |
|---|---|---|---|---|
| テナント数 | 何テナントまで増えるか | 数百〜数万以上でも同じ構成 | 数百程度まで | 数十程度まで |
| 復元単位 | 1テナントだけを過去の時点へ戻す要件があるか | 無い、または行の抜き出しで対応できる | ある(スキーマ単位で戻したい) | ある(他テナントに一切触れずに戻したい) |
| マイグレーション回数 | 列追加1回につき、DDL を何回流せるか | 1回で済ませたい | テナント数ぶん流す仕組みを用意できる | テナント数ぶん+版のずれを管理できる |
| 接続数 | DB への同時接続をいくつ用意できるか | プール1つで足りる | プール1つ+切り替え処理 | テナント数 × プールサイズを確保できる |
表のテナント数は目安の例です。スキーマ分離と DB 分離で「テナント数ぶん」となる手間は、掛け算で効いてきます。たとえばテーブル30個・テナント200のスキーマ分離ではテーブルが 6,000 個になり、列を1つ足す DDL も 200 回流すことになります。DB 分離でプールサイズを 10 にすると、テナント200で最大 2,000 接続です。
条件から方式を決める表
| 条件 | 選ぶ方式 |
|---|---|
| テナント単位の復元も性能隔離も、要件に書かれていない | 行分離(tenant_id+RLS) |
| テナントが多く、1テナントあたりのデータは小さい | 行分離(tenant_id+RLS) |
| テナントごとにテーブル構成を変える要望がある | スキーマ分離 |
| 1テナントだけを過去の時点に戻す運用が決まっている | スキーマ分離または DB 分離 |
| 特定のテナントだけ負荷が大きく、他へ影響させたくない | そのテナントだけ DB 分離(残りは行分離のまま) |
| テナントごとに置き場所やサーバーを指定される | DB 分離 |
全テナントを同じ方式にそろえる必要はありません。 行分離を基本にして、条件に当てはまるテナントだけを別 DB に切り出す混在構成にすると、手間の掛け算を小さく保てます。その時に効くのが、次の節の複合主キーです。
行分離の基本形:tenant_id 列と複合主キー
行分離では、テナントに属するすべてのテーブルに tenant_id 列を持たせ、主キーを (tenant_id, id) の複合にします。
CREATE TABLE tenants (
id integer PRIMARY KEY,
name text NOT NULL
);
CREATE TABLE orders (
tenant_id integer NOT NULL REFERENCES tenants (id),
id bigint GENERATED ALWAYS AS IDENTITY,
item text NOT NULL,
amount integer NOT NULL,
PRIMARY KEY (tenant_id, id)
);
INSERT INTO tenants VALUES (1, 'あおば商店'), (2, 'みどり文具');
INSERT INTO orders (tenant_id, item, amount) VALUES
(1, 'ノート', 1200), (1, 'ペン', 300),
(2, 'ファイル', 800), (2, 'のり', 150), (2, 'はさみ', 500);
複合主キーにする理由は3つあります。
| 理由 | 内容 |
|---|---|
| インデックスの先頭が tenant_id になる | テナントで絞る検索が主キーのインデックスで済む |
| 子テーブルの外部キーも (tenant_id, 親id) にできる | 他テナントの親行を指す参照を制約で止められる |
| テナントの切り出しが楽になる | tenant_id で抜いた行を別 DB に入れても、主キーが衝突しない |
PostgreSQL 18 で RLS を設定する
ロールを3つに分ける
RLS の効き方はロールによって変わるので、最初に役割を分けます。
-- スーパーユーザーで実行
CREATE ROLE app_owner LOGIN; -- テーブルの所有者(DDL を流す)
CREATE ROLE app_user LOGIN; -- アプリが接続に使う
CREATE ROLE app_batch LOGIN BYPASSRLS; -- テナント横断の集計用(後で挙動を確認)
CREATE DATABASE appdb OWNER app_owner;
CREATE POLICY でテナントの行だけを通す
app_owner でテーブルを作ったあと、RLS を有効にしてポリシーを付けます。
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.tenant_id', true)::integer);
GRANT SELECT ON tenants TO app_user, app_batch;
GRANT SELECT, INSERT, UPDATE, DELETE ON orders TO app_user, app_batch;
current_setting の第2引数 true は「設定が無くてもエラーにせず NULL を返す」指定です。app.tenant_id は自分で決めた名前の設定値で、アプリがリクエストごとにテナントIDを入れます。
set_config でテナントを指定して確かめる
app_user で接続し、テナントを指定しない場合と、テナント1を指定した場合を比べます。
SELECT * FROM orders; -- テナント未指定
BEGIN;
SELECT set_config('app.tenant_id', '1', true); -- 第3引数 true = このトランザクションの間だけ
SELECT * FROM orders;
SELECT count(*) FROM orders WHERE tenant_id = 2;
INSERT INTO orders (tenant_id, item, amount) VALUES (2, '付箋', 200);
ROLLBACK;
tenant_id | id | item | amount
-----------+----+------+--------
(0 rows)
tenant_id | id | item | amount
-----------+----+--------+--------
1 | 1 | ノート | 1200
1 | 2 | ペン | 300
(2 rows)
count
-------
0
(1 row)
ERROR: new row violates row-level security policy for table "orders"
未指定なら0行、テナント1を指定すればテナント1の2行だけが返り、WHERE でテナント2を名指ししても0件です。他テナントの行を INSERT しようとするとエラーで止まります。更新と削除も確かめます。
BEGIN;
SELECT set_config('app.tenant_id', '1', true);
UPDATE orders SET amount = 0 WHERE tenant_id = 2;
DELETE FROM orders WHERE tenant_id = 2;
UPDATE orders SET tenant_id = 2 WHERE item = 'ペン';
ROLLBACK;
UPDATE 0
DELETE 0
ERROR: new row violates row-level security policy for table "orders"
他テナントの行への UPDATE と DELETE は、エラーではなく「0件」で終わります。 見えない行は対象にならないためです。一方、自分の行の tenant_id を他テナントに書き換える操作はエラーになります。ポリシーに WITH CHECK を書かなかった場合、USING と同じ式が書き込み後の行にも適用されるためです(PostgreSQL 18 ドキュメント 5.9 Row Security Policies)。
RLS を設定しても他テナントの行が見える3つの経路
ここからが本題です。上の設定だけでは、次の3つの経路で他テナントの行が見えます。すべて実際に再現した出力です。
| 経路 | 何が起きるか | 直し方 |
|---|---|---|
| テーブルの所有者ロールで接続 | ポリシーが適用されず全行が見える | FORCE ROW LEVEL SECURITY を付け、アプリは所有者以外のロールで接続 |
| BYPASSRLS 属性のロール・スーパーユーザー | 常に全行が見える(FORCE でも変わらない) | アプリの接続に使わない。属性を一覧で点検する |
| 接続プールでの設定残り | 前のリクエストのテナントIDのまま次の処理が動く | 設定をトランザクション単位にする |
経路1:所有者ロールは RLS の対象外(FORCE ROW LEVEL SECURITY で直す)
テーブルを作った app_owner で、テナントを指定せずに件数を数えます。
SELECT tenant_id, count(*) FROM orders GROUP BY tenant_id ORDER BY 1;
SELECT relname, relrowsecurity, relforcerowsecurity FROM pg_class WHERE relname = 'orders';
tenant_id | count
-----------+-------
1 | 2
2 | 3
(2 rows)
relname | relrowsecurity | relforcerowsecurity
---------+----------------+---------------------
orders | t | f
(1 row)
RLS は有効(relrowsecurity が t)なのに、全テナントの行が見えています。所有者は通常ポリシーの対象外だからです。FORCE を付けて、同じ SQL をもう一度流します。
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
SELECT relname, relrowsecurity, relforcerowsecurity FROM pg_class WHERE relname = 'orders';
SELECT tenant_id, count(*) FROM orders GROUP BY tenant_id ORDER BY 1;
relname | relrowsecurity | relforcerowsecurity
---------+----------------+---------------------
orders | t | t
(1 row)
tenant_id | count
-----------+-------
(0 rows)
ENABLE と FORCE は別の設定で、両方が t になって初めて所有者にもポリシーが効きます。 マイグレーションツールとアプリが同じロールで接続する構成では、ここが抜けやすい箇所です。
経路2:BYPASSRLS 属性のロールは FORCE でも全行が見える
FORCE を付けた状態のまま、app_batch で同じ件数を数えます。
SELECT tenant_id, count(*) FROM orders GROUP BY tenant_id ORDER BY 1;
SELECT rolname, rolsuper, rolbypassrls FROM pg_roles WHERE rolname LIKE 'app_%' ORDER BY 1;
tenant_id | count
-----------+-------
1 | 2
2 | 3
(2 rows)
rolname | rolsuper | rolbypassrls
-----------+----------+--------------
app_batch | f | t
app_owner | f | f
app_user | f | f
(3 rows)
スーパーユーザーと BYPASSRLS 属性を持つロールは、常に RLS を通り抜けます。テナント横断の集計には便利な属性ですが、アプリの通常の接続には使わないと決め、上の pg_roles の問い合わせを定期点検に入れておくと安心です。
経路3:接続プールでテナント指定が残る
接続プールは、リクエストが終わっても DB との接続を切らずに次のリクエストへ渡します。set_config の第3引数を false(セッション単位)にすると、設定が接続に残ります。1本の接続で2つのリクエストが続く様子を再現します。
-- リクエストA(テナント1):セッション単位で設定
SELECT set_config('app.tenant_id', '1', false);
SELECT count(*) FROM orders;
-- ここで接続がプールに戻り、次のリクエストに渡る
-- リクエストB(本来はテナント2):テナント指定の処理を通らなかった
SELECT current_setting('app.tenant_id', true) AS leftover;
SELECT tenant_id, item FROM orders ORDER BY id;
set_config
------------
1
(1 row)
count
-------
2
(1 row)
leftover
----------
1
(1 row)
tenant_id | item
-----------+--------
1 | ノート
1 | ペン
(2 rows)
リクエストBはテナントを指定していないのに、テナント1の行が返りました。エラーにならず、もっともらしい結果が返る点が見つけにくさの理由です。
直し方は、テナント指定を必ずトランザクションの中で、第3引数 true で行うことです。COMMIT または ROLLBACK で設定が消えます。
BEGIN;
SELECT set_config('app.tenant_id', '2', true);
SELECT count(*) FROM orders;
COMMIT;
SELECT current_setting('app.tenant_id', true) AS after_commit,
current_setting('app.tenant_id', true) IS NULL AS is_null;
count
-------
3
(1 row)
after_commit | is_null
--------------+---------
| f
(1 row)
設定が消えたあとは NULL ではなく空文字になる
上の最後の出力で is_null が f になっている点に注目してください。一度でも設定した接続では、設定が消えたあとの値は NULL ではなく空文字です。 そのまま最初のポリシーで問い合わせると、次のエラーになります。
SELECT count(*) FROM orders;
ERROR: invalid input syntax for type integer: ""
新しい接続では NULL(0行が返る)、使い回した接続では空文字(エラー)と、接続の経歴で挙動が変わります。RESET app.tenant_id や DISCARD ALL のあとも同じく空文字でした。どちらの状態でも0行になるよう、NULLIF で空文字を NULL に変える形にポリシーを書き直します。
DROP POLICY tenant_isolation ON orders;
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer)
WITH CHECK (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
書き直したあと、設定が消えた接続で同じ問い合わせを流した結果です。
count
-------
0
(1 row)
ビューを作るときの確認点
集計用のビューを所有者ロールで作ると、ビューの中の問い合わせは所有者の権限で動きます。RLS を有効にしただけ(FORCE なし)の notes テーブルとビューで確かめます。
-- app_owner で実行
CREATE TABLE notes (
tenant_id integer NOT NULL,
id integer NOT NULL,
body text,
PRIMARY KEY (tenant_id, id)
);
INSERT INTO notes VALUES (1, 1, 'テナント1のメモ'), (2, 1, 'テナント2のメモ');
ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON notes
USING (tenant_id = NULLIF(current_setting('app.tenant_id', true), '')::integer);
GRANT SELECT ON notes TO app_user;
CREATE VIEW notes_v AS SELECT * FROM notes;
GRANT SELECT ON notes_v TO app_user;
app_user でテナント1を指定し、テーブルとビューを順に読みます。
BEGIN;
SELECT set_config('app.tenant_id', '1', true);
SELECT * FROM notes;
SELECT * FROM notes_v;
COMMIT;
tenant_id | id | body
-----------+----+-----------------
1 | 1 | テナント1のメモ
(1 row)
tenant_id | id | body
-----------+----+-----------------
1 | 1 | テナント1のメモ
2 | 1 | テナント2のメモ
(2 rows)
テーブルを直接読むと1行ですが、ビュー経由ではテナント2の行も返りました。 直し方は2つあり、両方入れておくと確実です。
ALTER VIEW notes_v SET (security_invoker = true); -- ビューを呼び出した側の権限で評価する
ALTER TABLE notes FORCE ROW LEVEL SECURITY; -- 所有者にもポリシーを効かせる
tenant_id | id | body
-----------+----+-----------------
1 | 1 | テナント1のメモ
(1 row)
なお、先に FORCE を付けていた orders に対する集計ビューは、所有者が作ったビューでもテナント1の集計だけを返しました。
RLS とインデックス
ポリシーの条件は、問い合わせに自動で付く WHERE 句のように働きます。主キーを (tenant_id, id) にした orders で実行計画を見ます。
BEGIN;
SELECT set_config('app.tenant_id', '1', true);
EXPLAIN (COSTS OFF) SELECT * FROM orders WHERE item = 'ペン';
COMMIT;
QUERY PLAN
-------------------------------------------------------------------------------------------------------------
Bitmap Heap Scan on orders
Recheck Cond: (tenant_id = (NULLIF(current_setting('app.tenant_id'::text, true), ''::text))::integer)
Filter: (item = 'ペン'::text)
-> Bitmap Index Scan on orders_pkey
Index Cond: (tenant_id = (NULLIF(current_setting('app.tenant_id'::text, true), ''::text))::integer)
(5 rows)
SQL には tenant_id の条件を書いていませんが、ポリシーの条件が主キーのインデックス(orders_pkey)の検索条件として使われています。tenant_id を先頭にしたインデックスを用意しておくことが、行分離の性能の土台になります。
スキーマ分離の書き方(search_path で切り替える)
スキーマ分離では、テナントごとに同じ名前のテーブルを別スキーマに作り、search_path(テーブル名を探すスキーマの順番)で切り替えます。
CREATE SCHEMA tenant_1;
CREATE SCHEMA tenant_2;
CREATE TABLE tenant_1.tasks (id integer PRIMARY KEY, title text NOT NULL);
CREATE TABLE tenant_2.tasks (id integer PRIMARY KEY, title text NOT NULL);
INSERT INTO tenant_1.tasks VALUES (1, 'テナント1のタスク');
INSERT INTO tenant_2.tasks VALUES (1, 'テナント2のタスク');
BEGIN;
SELECT set_config('search_path', 'tenant_2', true);
SELECT * FROM tasks;
COMMIT;
SHOW search_path;
id | title
----+-------------------
1 | テナント2のタスク
(1 row)
search_path
-----------------
"$user", public
(1 row)
tenant_id 列が要らず、SQL もスキーマ名なしで書けます。search_path もセッション単位で設定すると接続に残るので、行分離と同じくトランザクション単位(第3引数 true)で切り替えると、COMMIT 後に元へ戻ります。CREATE TABLE が2回必要だった点が、マイグレーション回数の掛け算の出どころです。
DB 分離は CREATE DATABASE をテナントごとに実行し、アプリがテナントに応じて接続先を選ぶ形です。SQL 上の工夫は要らないかわりに、接続先の対応表と、テナントごとの接続プールの管理がアプリ側の仕事になります。
MySQL 8.0 で代替する(ビュー・複合主キー・複合外部キー)
MySQL 8.0.46 で CREATE POLICY を流すと構文エラー(ERROR 1064)になるため、「テーブルへの権限を与えず、テナントで絞ったビューだけを使わせる」形で代替します。
複合主キーと複合外部キー
CREATE TABLE customers (
tenant_id INT NOT NULL,
id BIGINT NOT NULL,
name VARCHAR(100) NOT NULL,
PRIMARY KEY (tenant_id, id)
);
CREATE TABLE orders (
tenant_id INT NOT NULL,
id BIGINT NOT NULL,
customer_id BIGINT NOT NULL,
item VARCHAR(100) NOT NULL,
PRIMARY KEY (tenant_id, id),
CONSTRAINT fk_orders_customer
FOREIGN KEY (tenant_id, customer_id) REFERENCES customers (tenant_id, id)
);
INSERT INTO customers VALUES (1, 1, '青木'), (2, 1, '緑川'), (2, 2, '森');
INSERT INTO orders VALUES (1, 1, 1, 'ノート'), (2, 1, 2, 'ファイル');
-- テナント1の注文が、テナント2にしかいない顧客(id=2)を指そうとする
INSERT INTO orders VALUES (1, 2, 2, 'ペン');
ERROR 1452 (23000) at line 21: Cannot add or update a child row: a foreign key constraint fails (`appdb`.`orders`, CONSTRAINT `fk_orders_customer` FOREIGN KEY (`tenant_id`, `customer_id`) REFERENCES `customers` (`tenant_id`, `id`))
外部キーを (tenant_id, customer_id) の複合にしておくと、他テナントの顧客を指す注文は制約で止まります。この書き方は PostgreSQL でもそのまま有効です。PostgreSQL では外部キーや一意制約の検査は RLS を通らずに行われる(PostgreSQL 18 ドキュメント 5.9)ので、参照の取り違えを止める役目は複合外部キーが担います。
テナントで絞るビュー
ビューの定義にユーザー変数を直接書くとエラーになるので、関数で包みます。
CREATE VIEW orders_ng AS SELECT * FROM orders WHERE tenant_id = @tenant_id;
ERROR 1351 (HY000) at line 23: View's SELECT contains a variable or parameter
CREATE FUNCTION current_tenant() RETURNS INT NO SQL RETURN @tenant_id;
CREATE SQL SECURITY DEFINER VIEW orders_v AS
SELECT tenant_id, id, customer_id, item FROM orders
WHERE tenant_id = current_tenant()
WITH CHECK OPTION;
CREATE USER 'app_user'@'localhost' IDENTIFIED BY 'app_pass';
GRANT SELECT, INSERT, UPDATE, DELETE ON appdb.orders_v TO 'app_user'@'localhost';
GRANT EXECUTE ON FUNCTION appdb.current_tenant TO 'app_user'@'localhost';
app_user で接続して確かめます。
SELECT * FROM orders_v; -- テナント未指定
SET @tenant_id = 1;
SELECT * FROM orders_v;
INSERT INTO orders_v VALUES (2, 9, 1, 'のり'); -- 他テナントの行を入れようとする
SELECT * FROM orders; -- テーブルを直接読もうとする
Empty set
+-----------+----+-------------+-----------+
| tenant_id | id | customer_id | item |
+-----------+----+-------------+-----------+
| 1 | 1 | 1 | ノート |
+-----------+----+-------------+-----------+
1 row in set
ERROR 1369 (HY000) at line 4: CHECK OPTION failed 'appdb.orders_v'
ERROR 1142 (42000) at line 5: SELECT command denied to user 'app_user'@'localhost' for table 'orders'
未指定なら0行、指定すれば自テナントの行だけ、他テナントの行の INSERT は WITH CHECK OPTION で止まり、テーブルの直接参照は権限で止まります。ユーザー変数 @tenant_id はセッション(接続)に残るので、接続プールを使う場合はリクエストの最初に必ず設定し直す処理を共通部分に置きます。
PostgreSQL の RLS と MySQL のビューの対応
| やりたいこと | PostgreSQL 18 | MySQL 8.0 |
|---|---|---|
| 読める行を自テナントに絞る | CREATE POLICY の USING | ビューの WHERE |
| 他テナントの行の書き込みを止める | CREATE POLICY の WITH CHECK | ビューの WITH CHECK OPTION |
| テーブルの直接参照 | 可能(ポリシーが効く) | 権限を与えない |
| テナントの指定 | set_config(トランザクション単位にできる) | ユーザー変数(セッション単位) |
| 設定する対象 | テーブルごとにポリシー1つ | テーブルごとにビュー1つ |
| 他テナントの親行への参照を止める | 複合外部キー | 複合外部キー |
アプリ側の WHERE と RLS の違い
アプリのコードですべての SQL に WHERE tenant_id = ? を付ける方法と、DB 側で絞る方法は、守っている場所が違います。
| 観点 | アプリ側で WHERE を付ける | ORM などの共通処理で自動付与 | DB 側で絞る(RLS・ビュー) |
|---|---|---|---|
| 付け忘れた SQL の結果 | 全テナントの行が返る | 共通処理を通らない SQL は全行が返る | テナント未指定なら0行 |
| 手書き SQL・管理用スクリプト | 各自が付ける | 対象外になりやすい | 同じロールなら同じく絞られる |
| レビューで見る場所 | すべての SQL | 共通処理と、それを通らない箇所 | ポリシー定義とロールの属性 |
| DB 製品への依存 | なし | なし | あり(製品ごとに書き方が違う) |
| テナント指定の渡し忘れ | 条件が付かない | 条件が付かない | 0行またはエラーで気づける |
DB 側で絞る方式の価値は、付け忘れた時の既定の結果が「全行」から「0行」に変わることです。アプリ側の WHERE を無くす必要はなく、インデックスを効かせる意味でも残しておき、RLS を二重目の仕切りとして重ねる構成が扱いやすい形です。
確かめて分かったこと
- ENABLE だけでは所有者ロールに全テナントの行が見えた。pg_class の relrowsecurity と relforcerowsecurity の両方が t になっているかを点検項目にすると見落としにくくなります。
- 他テナントの行への UPDATE・DELETE はエラーにならず 0 件で終わる。アプリ側で「更新件数が0なら対象なし」と扱う処理が必要です。
- トランザクション単位の設定が消えたあとの値は、NULL ではなく空文字だった。新しい接続では0行、使い回した接続では型変換エラーと挙動が分かれるため、ポリシーには NULLIF を入れておくと結果がそろいます。RESET と DISCARD ALL のあとも空文字でした。
- 所有者が作ったビューは、FORCE を付けていないテーブルの全行を返した。security_invoker = true と FORCE の両方で1行に絞られました。
- BYPASSRLS のロールは FORCE を付けたあとも全行が見えた。FORCE が効くのは所有者に対してです。
- MySQL ではビュー定義にユーザー変数を直接書けず(ERROR 1351)、関数で包むと作成できた。
導入前のチェックリスト
| 確認項目 | 確かめ方 |
|---|---|
| テナントに属する全テーブルに tenant_id 列がある | information_schema.columns を列名で検索 |
| 主キー・外部キーが tenant_id を含む複合になっている | テーブル定義を確認 |
| 全テーブルで RLS が ENABLE かつ FORCE | pg_class の relrowsecurity・relforcerowsecurity |
| アプリの接続ロールが所有者でも BYPASSRLS でもない | pg_roles の rolbypassrls・rolsuper と、テーブルの所有者 |
| テナント指定がトランザクション単位 | set_config の第3引数が true |
| ポリシーが空文字を NULL として扱う | NULLIF を入れている |
| ビューが呼び出し側の権限で評価される | security_invoker = true |
| テナント未指定で0行になる自動テストがある | 2テナントぶんのデータで、未指定・テナント1・テナント2を確認 |
よくある質問
Q. マルチテナントとシングルテナントの違いは? A. 1つのシステムを複数の利用組織で共用するのがマルチテナント、利用組織ごとにシステム一式を用意するのがシングルテナントです。DB 分離は、DB の層だけをシングルテナントに近づけた形と言えます。
Q. RLS を設定したのに全件見えるときは? A. 接続しているロールが、テーブルの所有者か、BYPASSRLS 属性を持つか、スーパーユーザーのいずれかです。pg_roles と pg_class を確認し、所有者なら FORCE ROW LEVEL SECURITY を付け、アプリは別のロールで接続します。
Q. tenant_id は SET と set_config のどちらで渡す? A. アプリからは set_config を使うと扱いやすくなります。関数なので、PREPARE した文に値をパラメータ($1)として渡して実行でき、第3引数 true でトランザクション単位にできます。SET 文を PREPARE しようとすると syntax error at or near "SET" になりました。
Q. 行分離からスキーマ分離や DB 分離へ後から移れる? A. 主キーを (tenant_id, id) の複合にしておけば移りやすくなります。tenant_id で抜き出した行を別の場所に入れても主キーが衝突しないためです。
Q. MySQL で RLS の代わりになるものは? A. テーブルへの権限を与えず、テナントで絞ったビュー(WITH CHECK OPTION つき)だけを使わせる方法です。あわせて複合外部キーで他テナントへの参照を止めます。
まとめ
- 最初の選択は tenant_id 列+RLS の行分離。テーブル1組・マイグレーション1回・接続プール1つで始められます。
- スキーマ分離・DB分離へ上げる判断は、テナント数・復元単位・マイグレーション回数・接続数の4軸で行い、必要なテナントだけを切り出します。
- RLS は ENABLE と FORCE の両方を付け、アプリは所有者でも BYPASSRLS でもないロールで接続します。
- テナント指定は set_config の第3引数 true でトランザクション単位にし、接続プールに設定を残しません。
- ポリシーには NULLIF を入れ、設定が消えたあとの空文字でも0行になるようにします。
- 主キーと外部キーは (tenant_id, id) の複合にして、他テナントへの参照を制約で止めます。
- MySQL ではビュー+WITH CHECK OPTION+権限の絞り込みで同じ考え方を表せます。
参考・出典
- PostgreSQL 18 Documentation「5.9. Row Security Policies」https://www.postgresql.org/docs/18/ddl-rowsecurity.html (所有者・スーパーユーザー・BYPASSRLS の扱い、ポリシーが無い場合の既定動作、WITH CHECK の既定、制約の検査が RLS を通らない点を確認。確認日 2026-10-06)
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「マルチテナントDB設計|テナント分離3方式の比較とRLSのSQL例」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。