マルチテナント SaaS の DB 設計パターン実践ガイド
データ分離・コスト・運用性のトレードオフを整理。Schema 分離・Row 分離・DB 分離の比較、移行戦略、RLS 実装例まで、受託開発で即使える実務ガイド。

マルチテナント SaaS の DB 設計パターン実践ガイド
SaaS アプリケーションを設計する際、最も重要な判断の一つが「マルチテナント構成をどう実現するか」です。データ分離・コスト・運用性・スケーラビリティのトレードオフを理解し、適切なパターンを選ぶことが成功の鍵となります。
この記事では、受託開発・自社開発の現場で即使える 3 つの DB 設計パターンを比較し、具体的な実装例・移行戦略・運用ノウハウまで解説します。
1. マルチテナント設計の 3 つの基本パターン
マルチテナント SaaS の DB 設計には、データ分離の粒度によって 3 つの代表的なパターンがあります。
1.1 パターン比較表
| パターン | データ分離 | コスト効率 | 運用負荷 | スケーラビリティ | 適用ケース | |---------|----------|----------|---------|----------------|----------| | DB 分離 | ★★★ | ★ | ★ | ★★★ | エンタープライズ顧客中心、規制対応が必要 | | Schema 分離 | ★★ | ★★ | ★★ | ★★ | 中規模テナント、データサイズに差がある | | Row 分離 | ★ | ★★★ | ★★★ | ★ | スタートアップ、小規模テナント多数 |
1.2 各パターンの特徴
DB 分離(Database per Tenant)
各テナントごとに物理的に独立した DB インスタンスを用意します。
メリット:
- 完全なデータ分離、セキュリティ要件に最適
- テナントごとの DB バージョン・設定変更が可能
- パフォーマンス影響の完全分離
デメリット:
- インフラコストが高い
- スキーマ変更の適用が複雑
- テナント数に比例して運用負荷が増加
Schema 分離(Schema per Tenant)
1 つの DB インスタンス内で、テナントごとに Schema(PostgreSQL の場合は public 以外の名前空間)を分離します。
メリット:
- 論理的なデータ分離を保ちつつコスト削減
- DB インスタンス数を抑えられる
- バックアップ・復元がテナント単位で可能
デメリット:
- 接続プール管理が複雑
- 大規模テナントの影響が他に波及する可能性
- Schema 数の上限に注意が必要
Row 分離(Row-Level Isolation)
全テナントが同じテーブルを共有し、tenant_id カラムで行レベルで分離します。
メリット:
- 最もコスト効率が良い
- スキーマ変更が一度で完了
- テナント横断の分析が容易
デメリット:
- クエリミスでデータ漏洩のリスク
- インデックス設計が複雑化
- 大規模テナントのパフォーマンス影響
2. Row 分離パターンの実装
スタートアップ・MVP フェーズで最も採用されるパターンです。PostgreSQL の Row Level Security (RLS) を使った実装例を紹介します。
2.1 テーブル設計の基本
全テーブルに tenant_id を含め、複合主キー・インデックスを設計します。
-- テナントテーブル
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
plan TEXT NOT NULL, -- 'free', 'pro', 'enterprise'
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ユーザーテーブル
CREATE TABLE users (
id UUID DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
email TEXT NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (tenant_id, id),
UNIQUE (tenant_id, email)
);
-- プロジェクトテーブル
CREATE TABLE projects (
id UUID DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
owner_id UUID NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (tenant_id, id),
FOREIGN KEY (tenant_id, owner_id) REFERENCES users(tenant_id, id)
);
-- 重要: tenant_id を含む複合インデックス
CREATE INDEX idx_users_tenant_email ON users(tenant_id, email);
CREATE INDEX idx_projects_tenant_owner ON projects(tenant_id, owner_id);
2.2 RLS ポリシーの設定
PostgreSQL の Row Level Security で、アプリケーション層のバグがあってもデータ漏洩を防ぎます。
-- RLS を有効化
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- セッション変数から tenant_id を取得するポリシー
CREATE POLICY tenant_isolation_policy ON users
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
CREATE POLICY tenant_isolation_policy ON projects
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
-- スーパーユーザー以外は必ずポリシーを適用
ALTER TABLE users FORCE ROW LEVEL SECURITY;
ALTER TABLE projects FORCE ROW LEVEL SECURITY;
2.3 アプリケーション側の実装(Node.js + pg)
import { Pool } from 'pg';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
// テナント ID をセッション変数にセット
export async function withTenant<T>(
tenantId: string,
callback: (client: PoolClient) => Promise<T>
): Promise<T> {
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query('SET LOCAL app.current_tenant_id = $1', [tenantId]);
const result = await callback(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
// 使用例
export async function getProjects(tenantId: string) {
return withTenant(tenantId, async (client) => {
const result = await client.query(
'SELECT * FROM projects ORDER BY created_at DESC'
);
return result.rows;
});
}
2.4 必須チェック項目
- [ ] 全テーブルに
tenant_idを含む複合主キーを設定 - [ ] 全外部キー制約に
tenant_idを含める - [ ] RLS ポリシーを全テーブルに適用
- [ ]
FORCE ROW LEVEL SECURITYでスーパーユーザーも制限 - [ ] アプリケーション層で
current_settingを必ずセット - [ ] テスト環境で異なるテナント ID でのアクセス検証
3. Schema 分離パターンの実装
中規模テナントが増えてきたら Schema 分離を検討します。
3.1 Schema 作成と初期化
-- テナント用 Schema 作成
CREATE SCHEMA tenant_abc123;
CREATE SCHEMA tenant_def456;
-- 共通テーブル定義を関数化
CREATE OR REPLACE FUNCTION create_tenant_schema(schema_name TEXT)
RETURNS VOID AS $
BEGIN
EXECUTE format('CREATE SCHEMA IF NOT EXISTS %I', schema_name);
EXECUTE format('
CREATE TABLE %I.users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
)', schema_name);
EXECUTE format('
CREATE TABLE %I.projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
owner_id UUID REFERENCES %I.users(id),
created_at TIMESTAMPTZ DEFAULT NOW()
)', schema_name, schema_name);
END;
$ LANGUAGE plpgsql;
-- 新規テナント作成時に実行
SELECT create_tenant_schema('tenant_abc123');
3.2 アプリケーション側の実装
import { Pool } from 'pg';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
export async function withTenantSchema<T>(
tenantId: string,
callback: (client: PoolClient) => Promise<T>
): Promise<T> {
const schemaName = `tenant_${tenantId}`;
const client = await pool.connect();
try {
// search_path を設定
await client.query(`SET search_path TO ${schemaName}, public`);
const result = await callback(client);
return result;
} finally {
// デフォルトに戻す
await client.query('SET search_path TO public');
client.release();
}
}
// 使用例
export async function getProjects(tenantId: string) {
return withTenantSchema(tenantId, async (client) => {
// Schema 名を指定不要
const result = await client.query(
'SELECT * FROM projects ORDER BY created_at DESC'
);
return result.rows;
});
}
3.3 Schema パターンの運用課題と対策
| 課題 | 対策 | |-----|-----| | スキーマ変更の適用 | マイグレーションツールで全 Schema に適用 | | 接続プールの枯渇 | テナントごとに分離せず、search_path で切り替え | | バックアップ・復元 | pg_dump で Schema 単位で実行 | | Schema 数の上限 | PostgreSQL は数千 Schema まで対応可能 |
4. DB 分離パターンの実装
エンタープライズ顧客向けや規制対応が必要な場合に採用します。
4.1 Infrastructure as Code での管理
// Terraform で DB インスタンスを動的作成
resource "aws_db_instance" "tenant" {
for_each = var.enterprise_tenants
identifier = "tenant-${each.key}"
engine = "postgres"
instance_class = each.value.instance_class
allocated_storage = each.value.storage_gb
db_name = "tenant_db"
username = "admin"
password = random_password.tenant[each.key].result
backup_retention_period = 7
multi_az = true
tags = {
Tenant = each.key
Plan = "enterprise"
}
}
4.2 接続情報の管理
import { SecretsManager } from '@aws-sdk/client-secrets-manager';
import { Pool } from 'pg';
const secretsManager = new SecretsManager({});
const poolCache = new Map<string, Pool>();
export async function getTenantPool(tenantId: string): Promise<Pool> {
if (poolCache.has(tenantId)) {
return poolCache.get(tenantId)!;
}
// Secrets Manager から接続情報を取得
const secret = await secretsManager.getSecretValue({
SecretId: `tenant/${tenantId}/db-credentials`,
});
const credentials = JSON.parse(secret.SecretString!);
const pool = new Pool({
host: credentials.host,
port: credentials.port,
database: credentials.database,
user: credentials.username,
password: credentials.password,
max: 10, // テナントごとにプールサイズを制限
});
poolCache.set(tenantId, pool);
return pool;
}
4.3 スキーマ変更の一括適用
#!/bin/bash
# 全テナント DB にマイグレーションを適用
MIGRATION_FILE=$1
for TENANT_ID in $(aws secretsmanager list-secrets \
--filters Key=tag-key,Values=Tenant \
--query 'SecretList[].Name' --output text); do
echo "Applying migration to ${TENANT_ID}..."
# 接続情報を取得
CREDENTIALS=$(aws secretsmanager get-secret-value \
--secret-id "${TENANT_ID}" --query SecretString --output text)
HOST=$(echo $CREDENTIALS | jq -r '.host')
DB=$(echo $CREDENTIALS | jq -r '.database')
USER=$(echo $CREDENTIALS | jq -r '.username')
# マイグレーション実行
PGPASSWORD=$(echo $CREDENTIALS | jq -r '.password') \
psql -h $HOST -U $USER -d $DB -f $MIGRATION_FILE
if [ $? -eq 0 ]; then
echo "✓ ${TENANT_ID} migration successful"
else
echo "✗ ${TENANT_ID} migration failed"
exit 1
fi
done
5. パターン移行戦略
ビジネス成長に応じて、パターンを段階的に移行する実務手順を解説します。
5.1 Row → Schema 移行
移行タイミング:
- テナント数が 100 を超えた
- 大規模テナントのパフォーマンス影響が顕在化
- データサイズに 10 倍以上の差がある
移行手順:
-- 1. 移行対象テナントの Schema 作成
SELECT create_tenant_schema('tenant_large_customer');
-- 2. データコピー
INSERT INTO tenant_large_customer.users
SELECT id, email, name, created_at
FROM public.users
WHERE tenant_id = 'large_customer_uuid';
INSERT INTO tenant_large_customer.projects
SELECT id, name, owner_id, created_at
FROM public.projects
WHERE tenant_id = 'large_customer_uuid';
-- 3. データ整合性確認
SELECT
(SELECT COUNT(*) FROM public.users WHERE tenant_id = 'large_customer_uuid') AS old_count,
(SELECT COUNT(*) FROM tenant_large_customer.users) AS new_count;
-- 4. アプリケーション側でルーティング変更
-- 5. 元データを削除
DELETE FROM public.projects WHERE tenant_id = 'large_customer_uuid';
DELETE FROM public.users WHERE tenant_id = 'large_customer_uuid';
5.2 Schema → DB 移行
移行タイミング:
- エンタープライズ契約で専用 DB が要件
- 規制対応(GDPR、HIPAA など)
- SLA 99.99% 以上が必要
移行手順:
#!/bin/bash
# Schema から専用 DB への移行
SOURCE_SCHEMA="tenant_enterprise_abc"
TARGET_DB="tenant-enterprise-abc-prod"
# 1. pg_dump で Schema をエクスポート
pg_dump -h source-db.example.com -U admin \
--schema=$SOURCE_SCHEMA \
--no-owner --no-acl \
-f /tmp/tenant_export.sql
# 2. Schema 名を public に変換
sed -i "s/${SOURCE_SCHEMA}/public/g" /tmp/tenant_export.sql
# 3. 新規 DB にインポート
psql -h $TARGET_DB.rds.amazonaws.com -U admin \
-d tenant_db -f /tmp/tenant_export.sql
# 4. データ整合性確認
SOURCE_COUNT=$(psql -h source-db.example.com -U admin -d main_db \
-t -c "SELECT COUNT(*) FROM ${SOURCE_SCHEMA}.users")
TARGET_COUNT=$(psql -h $TARGET_DB.rds.amazonaws.com -U admin -d tenant_db \
-t -c "SELECT COUNT(*) FROM public.users")
if [ "$SOURCE_COUNT" -eq "$TARGET_COUNT" ]; then
echo "✓ Migration successful: $SOURCE_COUNT records"
else
echo "✗ Migration failed: source=$SOURCE_COUNT, target=$TARGET_COUNT"
exit 1
fi
6. パフォーマンス最適化
6.1 Row 分離パターンのインデックス設計
-- 悪い例: tenant_id を含まない
CREATE INDEX idx_projects_created ON projects(created_at);
-- 良い例: tenant_id を先頭に配置
CREATE INDEX idx_projects_tenant_created ON projects(tenant_id, created_at);
-- さらに良い例: INCLUDE でカバリングインデックス化
CREATE INDEX idx_projects_tenant_created_covering
ON projects(tenant_id, created_at)
INCLUDE (name, owner_id);
6.2 パーティショニングの活用
大規模テナントが混在する場合、パーティショニングで分離します。
-- tenant_id でパーティション分割
CREATE TABLE projects_partitioned (
id UUID DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
) PARTITION BY LIST (tenant_id);
-- 大規模テナント用の専用パーティション
CREATE TABLE projects_large_tenant
PARTITION OF projects_partitioned
FOR VALUES IN ('large-tenant-uuid-1', 'large-tenant-uuid-2');
-- その他のテナント用
CREATE TABLE projects_default
PARTITION OF projects_partitioned DEFAULT;
6.3 接続プールの最適化
import { Pool } from 'pg';
// テナント規模に応じた接続プール設定
const poolConfigs = {
small: { max: 5, idleTimeoutMillis: 30000 },
medium: { max: 10, idleTimeoutMillis: 60000 },
large: { max: 20, idleTimeoutMillis: 120000 },
};
export function createTenantPool(tenantPlan: 'small' | 'medium' | 'large') {
const config = poolConfigs[tenantPlan];
return new Pool({
connectionString: process.env.DATABASE_URL,
...config,
// 接続エラー時の再試行
connectionTimeoutMillis: 5000,
});
}
7. セキュリティ・コンプライアンス対策
7.1 データ暗号化チェックリスト
- [ ] 転送中の暗号化: SSL/TLS 接続を強制(
sslmode=require) - [ ] 保存時の暗号化: RDS / Cloud SQL の暗号化オプションを有効化
- [ ] バックアップの暗号化: 自動バックアップも暗号化対象に含める
- [ ] 個人情報の追加暗号化: クレカ情報など機微データは AES-256 で暗号化
7.2 監査ログの実装
-- 監査ログテーブル
CREATE TABLE audit_logs (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL,
user_id UUID,
action TEXT NOT NULL, -- 'CREATE', 'UPDATE', 'DELETE'
table_name TEXT NOT NULL,
record_id UUID,
changes JSONB,
ip_address INET,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- パーティション化(月次)
CREATE TABLE audit_logs_2026_06
PARTITION OF audit_logs
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
-- トリガーで自動記録
CREATE OR REPLACE FUNCTION log_audit()
RETURNS TRIGGER AS $
BEGIN
INSERT INTO audit_logs (tenant_id, action, table_name, record_id, changes)
VALUES (
NEW.tenant_id,
TG_OP,
TG_TABLE_NAME,
NEW.id,
jsonb_build_object('old', to_jsonb(OLD), 'new', to_jsonb(NEW))
);
RETURN NEW;
END;
$ LANGUAGE plpgsql;
CREATE TRIGGER projects_audit
AFTER INSERT OR UPDATE OR DELETE ON projects
FOR EACH ROW EXECUTE FUNCTION log_audit();
7.3 GDPR 対応: データ削除の実装
export async function deleteTenantData(tenantId: string) {
const client = await pool.connect();
try {
await client.query('BEGIN');
// 監査ログに記録
await client.query(
`INSERT INTO audit_logs (tenant_id, action, table_name)
VALUES ($1, 'TENANT_DELETION', 'all_tables')`,
[tenantId]
);
// 外部キー制約の順序で削除
await client.query('DELETE FROM projects WHERE tenant_id = $1', [tenantId]);
await client.query('DELETE FROM users WHERE tenant_id = $1', [tenantId]);
await client.query('DELETE FROM tenants WHERE id = $1', [tenantId]);
await client.query('COMMIT');
// S3 などの外部ストレージも削除
await deleteS3Files(`tenants/${tenantId}/`);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
8. 運用・モニタリング
8.1 テナント別のリソース監視
-- テナント別のディスク使用量
SELECT
t.name AS tenant_name,
pg_size_pretty(pg_total_relation_size('tenant_' || t.id || '.users')) AS users_size,
pg_size_pretty(pg_total_relation_size('tenant_' || t.id || '.projects')) AS projects_size
FROM tenants t
ORDER BY pg_total_relation_size('tenant_' || t.id || '.users') DESC
LIMIT 10;
-- テナント別のクエリパフォーマンス(pg_stat_statements 拡張必須)
SELECT
current_setting('app.current_tenant_id') AS tenant_id,
query,
calls,
mean_exec_time,
max_exec_time
FROM pg_stat_statements
WHERE query LIKE '%tenant_%'
ORDER BY mean_exec_time DESC
LIMIT 20;
8.2 アラート設定例(CloudWatch / Datadog)
| メトリクス | 閾値 | アクション | |----------|-----|----------| | CPU 使用率 | 80% 超過が 5 分継続 | スケールアップ検討 | | ディスク使用率 | 85% 超過 | ストレージ拡張 | | 接続数 | max_connections の 80% | プール設定見直し | | スロークエリ | 1 秒超過が 10 回/分 | インデックス追加検討 | | レプリケーション遅延 | 30 秒超過 | インスタンスサイズ拡大 |
まとめ
マルチテナント SaaS の DB 設計では、ビジネスフェーズに応じた適切なパターン選択が重要です。
選定フローチャート:
- テナント数 < 50、全て小規模 → Row 分離(RLS)
- テナント数 50〜500、サイズに差 → Schema 分離
- エンタープライズ顧客あり、規制対応必須 → DB 分離(一部テナントのみ)
実装時の必須チェック:
- [ ] データ分離の実装と RLS ポリシーのテスト
- [ ] インデックス設計(
tenant_idを先頭配置) - [ ] 監査ログの実装
- [ ] バックアップ・復元手順の確立
- [ ] テナント削除フローの実装(GDPR 対応)
- [ ] パフォーマンス監視の仕組み構築
受託開発では、初期は Row 分離で開発速度を優先し、成長に応じて段階的に移行する戦略が現実的です。Yureate では、こうしたマルチテナント設計の選定から実装・移行支援まで対応しています。技術選定や設計でお困りの際は、お気軽にご相談ください。
Yureate について
スタートアップ・中小企業向けの受託開発を行っています。技術選定の壁打ち、MVP 開発、既存システムのリファクタリングなど、お気軽にご相談ください。
- 公式サイト: https://yureate.com
- お問い合わせ: https://yureate.com/contact
