← ブログ一覧

マルチテナント SaaS の DB 設計パターン実践ガイド

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

#データベース#PostgreSQL#SaaS#アーキテクチャ
マルチテナント SaaS の DB 設計パターン実践ガイド

マルチテナント 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 設計では、ビジネスフェーズに応じた適切なパターン選択が重要です。

選定フローチャート:

  1. テナント数 < 50、全て小規模 → Row 分離(RLS)
  2. テナント数 50〜500、サイズに差 → Schema 分離
  3. エンタープライズ顧客あり、規制対応必須 → DB 分離(一部テナントのみ)

実装時の必須チェック:

  • [ ] データ分離の実装と RLS ポリシーのテスト
  • [ ] インデックス設計(tenant_id を先頭配置)
  • [ ] 監査ログの実装
  • [ ] バックアップ・復元手順の確立
  • [ ] テナント削除フローの実装(GDPR 対応)
  • [ ] パフォーマンス監視の仕組み構築

受託開発では、初期は Row 分離で開発速度を優先し、成長に応じて段階的に移行する戦略が現実的です。Yureate では、こうしたマルチテナント設計の選定から実装・移行支援まで対応しています。技術選定や設計でお困りの際は、お気軽にご相談ください。


Yureate について

スタートアップ・中小企業向けの受託開発を行っています。技術選定の壁打ち、MVP 開発、既存システムのリファクタリングなど、お気軽にご相談ください。

  • 公式サイト: https://yureate.com
  • お問い合わせ: https://yureate.com/contact
この内容について相談する他の記事を見る