iT邦幫忙

2025 iThome 鐵人賽

DAY 11
0
Build on AWS

30 天將工作室 SaaS 產品部署起來系列 第 11

Day 11: 30天部署SaaS產品到AWS-Database per Tenant 多租戶資料庫架構

  • 分享至 

  • xImage
  •  

前情提要

經過前面 10 天的基礎建設,我們已經有了一個完整的 OTP 服務架構。今天開始我們要升級為真正的健身房管理 SaaS 平台,之前有提到,成立工作室後大部分的接案都是打造健身房系統,所以這次鐵人賽先打造一個好維護和開發的專案架構和部署模式在逐步打造原有健身房功能的微服務。

第一步就是建立多租戶資料庫架構,讓多個客戶擁有完全獨立的資料庫,實現真正的資料隔離和客製化。

多租戶架構設計決策

為什麼選擇 Database per Tenant 模式?

在多租戶 SaaS 系統中,有三種主要模式:

模式 優勢 劣勢 適用場景
Shared Database 成本低、維護簡單 客製化困難、安全風險 標準化程度高的系統
Schema per Tenant 適中隔離、適中成本 連線管理複雜 中等客製化需求
Database per Tenant 完全隔離、客製化 成本略高、需要自動化 高客製化需求 ✅

我們選擇 Database per Tenant,原因:

  • 🏋️ 健身房特性: 每家健身房的業務流程、資料需求差異很大
  • 🔒 絕對安全: 物理層面的完全資料隔離,避免任何資料洩露風險
  • 🎨 客製化: 每家健身房可以有完全不同的資料結構和業務邏輯
  • 📊 效能獨立: 一家健身房的負載不會影響其他健身房的效能
  • 🛡️ 合規友好: 符合個資保護法的高標準

🏗️ Database per Tenant 架構設計

1. 主控資料庫 (Master Database)

主控資料庫只儲存租戶的基本資訊和資料庫連線資訊:

-- 主控資料庫:kyo_system_master
-- 只儲存租戶管理資料,不含業務資料

-- 租戶基本資訊
CREATE TABLE tenants (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(100) NOT NULL,
    subdomain VARCHAR(50) UNIQUE NOT NULL,
    plan_type VARCHAR(20) NOT NULL DEFAULT 'basic',
    status VARCHAR(20) NOT NULL DEFAULT 'active',

    -- 資料庫連線資訊
    database_name VARCHAR(100) NOT NULL,
    database_host VARCHAR(255) NOT NULL,
    secret_arn VARCHAR(500) NOT NULL,  -- AWS Secrets Manager ARN

    -- 業務設定
    settings JSONB DEFAULT '{}',
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

-- 租戶資料庫版本管理
CREATE TABLE tenant_schema_versions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
    version VARCHAR(20) NOT NULL,
    applied_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    migration_log TEXT
);

-- 建立索引
CREATE INDEX idx_tenants_subdomain ON tenants(subdomain);
CREATE INDEX idx_tenants_status ON tenants(status);
CREATE INDEX idx_schema_versions_tenant ON tenant_schema_versions(tenant_id);

2. 租戶專屬資料庫結構

每個健身房都有自己的資料庫,可以完全客製化:

-- 範例:健身房A的資料庫 tenant_12345678_power_gym
-- 完全獨立的資料結構,不需要 tenant_id

-- 會員表 - 可以完全客製化
CREATE TABLE members (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    member_code VARCHAR(20) UNIQUE NOT NULL,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(255),
    phone VARCHAR(20),
    line_user_id VARCHAR(100),

    -- 基本資訊
    birthday DATE,
    gender VARCHAR(10),
    address TEXT,
    emergency_contact JSONB,

    -- 健身資訊(這家健身房特別重視體能數據)
    height INTEGER,
    weight DECIMAL(5,2),
    body_fat_percentage DECIMAL(4,1),
    muscle_mass DECIMAL(5,2),
    fitness_level VARCHAR(20),
    medical_conditions TEXT[],
    fitness_goals TEXT[],

    -- 完全客製化欄位(每家健身房都不同)
    custom_fields JSONB DEFAULT '{}',

    -- 會員狀態
    membership_status VARCHAR(20) DEFAULT 'active',
    joined_date DATE DEFAULT CURRENT_DATE,
    last_visit_date DATE,

    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

-- 教練表
CREATE TABLE trainers (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    trainer_code VARCHAR(20) UNIQUE NOT NULL,
    name VARCHAR(100) NOT NULL,
    email VARCHAR(255),
    phone VARCHAR(20),

    -- 專業資訊
    certifications JSONB DEFAULT '[]',
    specialties TEXT[],
    experience_years INTEGER,
    bio TEXT,

    -- 收費設定
    personal_training_rate DECIMAL(8,2),
    group_class_rate DECIMAL(8,2),

    -- 工作時間設定(每個教練可以不同)
    working_hours JSONB DEFAULT '{}',
    max_students_per_class INTEGER DEFAULT 15,

    -- 自訂欄位
    custom_fields JSONB DEFAULT '{}',

    status VARCHAR(20) DEFAULT 'active',
    hired_date DATE,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

-- 課程表(每家健身房的課程完全不同)
CREATE TABLE courses (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(200) NOT NULL,
    description TEXT,
    course_type VARCHAR(50), -- 'group', 'personal', 'online', 'outdoor'

    -- 課程設定
    duration_minutes INTEGER NOT NULL,
    max_capacity INTEGER,
    min_capacity INTEGER DEFAULT 1,
    difficulty_level VARCHAR(20),
    equipment_needed TEXT[],

    -- 收費
    price_per_session DECIMAL(8,2),
    price_per_package DECIMAL(8,2),
    package_sessions INTEGER,

    -- 教練
    primary_trainer_id UUID REFERENCES trainers(id),
    substitute_trainers UUID[] DEFAULT '{}',

    -- 時間安排
    scheduled_times JSONB DEFAULT '[]', -- 複雜的排課規則
    booking_window_hours INTEGER DEFAULT 168, -- 提前多久開放預約
    cancellation_policy JSONB DEFAULT '{}',

    -- 自訂設定
    custom_fields JSONB DEFAULT '{}',

    is_active BOOLEAN DEFAULT true,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

-- 會籍方案(每家健身房的定價策略完全不同)
CREATE TABLE membership_plans (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    plan_name VARCHAR(100) NOT NULL,
    description TEXT,

    -- 方案類型與期限
    plan_type VARCHAR(50), -- 'monthly', 'quarterly', 'yearly', 'unlimited', 'pay_per_visit'
    duration_months INTEGER,
    total_visits INTEGER, -- -1 表示無限制

    -- 定價
    price DECIMAL(10,2) NOT NULL,
    setup_fee DECIMAL(8,2) DEFAULT 0,

    -- 包含的服務
    included_services JSONB DEFAULT '{}',
    -- 例如:{"group_classes": -1, "personal_training": 2, "guest_passes": 4}

    -- 使用限制
    restrictions JSONB DEFAULT '{}',
    -- 例如:{"peak_hours": false, "weekend_access": true}

    -- 特殊福利
    perks JSONB DEFAULT '{}',
    -- 例如:{"towel_service": true, "locker": true, "parking": false}

    -- 自動續費設定
    auto_renewal BOOLEAN DEFAULT false,

    is_active BOOLEAN DEFAULT true,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

-- 建立專屬索引(針對該健身房的查詢模式優化)
CREATE INDEX idx_members_phone ON members(phone);
CREATE INDEX idx_members_email ON members(email);
CREATE INDEX idx_members_status ON members(membership_status);
CREATE INDEX idx_members_last_visit ON members(last_visit_date);
CREATE INDEX idx_courses_trainer ON courses(primary_trainer_id);
CREATE INDEX idx_courses_active ON courses(is_active);

🛠️ AWS 租戶資料庫管理系統

1. Aurora Serverless v2 架構

// infra/cdk/src/database-per-tenant.ts
import * as rds from 'aws-cdk-lib/aws-rds';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';

export class DatabasePerTenantStack extends Stack {
  public readonly cluster: rds.DatabaseCluster;
  public readonly tenantManager: lambda.Function;

  constructor(scope: Construct, id: string, props: DatabaseStackProps) {
    super(scope, id, props);

    // Aurora Serverless v2 - 支援多資料庫動態擴展
    this.cluster = new rds.DatabaseCluster(this, 'KyoSystemCluster', {
      engine: rds.DatabaseClusterEngine.auroraPostgres({
        version: rds.AuroraPostgresEngineVersion.VER_14_9,
      }),

      // Serverless v2 配置 - 成本優化
      serverlessV2MinCapacity: 0.5,  // 最小 0.5 ACU
      serverlessV2MaxCapuracy: 32,   // 最大 32 ACU,支援多租戶擴展

      defaultDatabaseName: 'kyo_system_master', // 主控資料庫

      instanceProps: {
        vpc: props.vpc,
        vpcSubnets: {
          subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
        },
      },

      // 多租戶優化參數
      parameterGroup: new rds.ParameterGroup(this, 'MultiTenantParams', {
        engine: rds.DatabaseClusterEngine.auroraPostgres({
          version: rds.AuroraPostgresEngineVersion.VER_14_9,
        }),
        parameters: {
          'shared_preload_libraries': 'pg_stat_statements,pg_cron',
          'max_connections': '500', // 支援多租戶連線
          'shared_buffers': '256MB',
          'effective_cache_size': '1GB',
          'work_mem': '4MB',
          'maintenance_work_mem': '64MB',
        },
      }),

      backup: {
        retention: Duration.days(30),
        preferredWindow: '03:00-04:00',
      },

      monitoring: {
        interval: Duration.minutes(1),
      },

      storageEncrypted: true,
      deletionProtection: true,
    });

    // 租戶資料庫管理 Lambda
    this.tenantManager = new lambda.Function(this, 'TenantDatabaseManager', {
      runtime: lambda.Runtime.NODEJS_18_X,
      handler: 'tenantDbManager.handler',
      code: lambda.Code.fromAsset('lambda/tenant-db'),
      timeout: Duration.minutes(10), // 建立資料庫需要時間

      environment: {
        CLUSTER_ENDPOINT: this.cluster.clusterEndpoint.socketAddress,
        MASTER_SECRET_ARN: this.cluster.secret!.secretArn,
        REGION: this.region,
      },

      vpc: props.vpc,
      vpcSubnets: {
        subnetType: ec2.SubnetType.PRIVATE_WITH_EGRESS,
      },
    });

    // 給 Lambda 訪問 RDS 和 Secrets Manager 的權限
    this.cluster.secret!.grantRead(this.tenantManager);
    this.tenantManager.addToRolePolicy(new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      actions: [
        'rds:CreateDatabase',
        'rds:DescribeDBClusters',
        'secretsmanager:CreateSecret',
        'secretsmanager:GetSecretValue',
        'secretsmanager:UpdateSecret'
      ],
      resources: ['*']
    }));
  }
}

2. 租戶資料庫管理 Lambda

// lambda/tenant-db/tenantDbManager.ts
import { RDSDataService } from 'aws-sdk';
import { SecretsManager } from 'aws-sdk';
import { Client } from 'pg';

interface CreateTenantDatabaseEvent {
  tenantId: string;
  tenantName: string;
  subdomain: string;
  planType: 'basic' | 'pro' | 'enterprise';
}

export const handler = async (event: CreateTenantDatabaseEvent) => {
  const tenantDbService = new TenantDatabaseService();

  try {
    const result = await tenantDbService.createTenantDatabase(
      event.tenantId,
      event.tenantName,
      event.subdomain,
      event.planType
    );

    return {
      statusCode: 200,
      body: JSON.stringify({
        success: true,
        tenantId: event.tenantId,
        databaseInfo: result
      })
    };
  } catch (error) {
    console.error('Failed to create tenant database:', error);
    return {
      statusCode: 500,
      body: JSON.stringify({
        success: false,
        error: error.message
      })
    };
  }
};

class TenantDatabaseService {
  private secretsManager: SecretsManager;
  private masterConnection: Client | null = null;

  constructor() {
    this.secretsManager = new SecretsManager();
  }

  async createTenantDatabase(
    tenantId: string,
    tenantName: string,
    subdomain: string,
    planType: string
  ) {
    // 1. 取得主資料庫連線
    const masterCreds = await this.getMasterCredentials();
    this.masterConnection = new Client({
      host: process.env.CLUSTER_ENDPOINT!.split(':')[0],
      port: 5432,
      database: 'kyo_system_master',
      user: masterCreds.username,
      password: masterCreds.password,
      ssl: { rejectUnauthorized: false }
    });

    await this.masterConnection.connect();

    try {
      // 2. 建立租戶專屬資料庫
      const dbName = this.generateDatabaseName(tenantId, subdomain);
      await this.masterConnection.query(`CREATE DATABASE "${dbName}"`);

      // 3. 建立租戶專屬使用者
      const username = `tenant_${tenantId.replace(/-/g, '_').slice(0, 12)}`;
      const password = this.generateSecurePassword();

      await this.masterConnection.query(`
        CREATE USER "${username}" WITH PASSWORD '${password}';
        GRANT ALL PRIVILEGES ON DATABASE "${dbName}" TO "${username}";
      `);

      // 4. 連線到新資料庫並建立 Schema
      const tenantConnection = new Client({
        host: process.env.CLUSTER_ENDPOINT!.split(':')[0],
        port: 5432,
        database: dbName,
        user: username,
        password: password,
        ssl: { rejectUnauthorized: false }
      });

      await tenantConnection.connect();
      await this.createTenantSchema(tenantConnection, planType);
      await tenantConnection.end();

      // 5. 將連線資訊存到 Secrets Manager
      const secretArn = await this.storeConnectionInfo(tenantId, {
        host: process.env.CLUSTER_ENDPOINT!.split(':')[0],
        port: 5432,
        database: dbName,
        username,
        password
      });

      // 6. 在主控資料庫記錄租戶資訊
      await this.masterConnection.query(`
        INSERT INTO tenants (
          id, name, subdomain, plan_type, database_name,
          database_host, secret_arn, status
        ) VALUES ($1, $2, $3, $4, $5, $6, $7, 'active')
      `, [
        tenantId,
        tenantName,
        subdomain,
        planType,
        dbName,
        process.env.CLUSTER_ENDPOINT!.split(':')[0],
        secretArn
      ]);

      return {
        tenantId,
        databaseName: dbName,
        username,
        secretArn,
        status: 'ready'
      };

    } finally {
      await this.masterConnection.end();
    }
  }

  private async createTenantSchema(connection: Client, planType: string) {
    // 根據方案類型建立不同的 Schema
    const baseSchema = `
      -- 會員表
      CREATE TABLE members (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        member_code VARCHAR(20) UNIQUE NOT NULL,
        name VARCHAR(100) NOT NULL,
        email VARCHAR(255),
        phone VARCHAR(20),
        line_user_id VARCHAR(100),

        -- 基本資訊
        birthday DATE,
        gender VARCHAR(10),
        address TEXT,
        emergency_contact JSONB,

        -- 健身資訊
        height INTEGER,
        weight DECIMAL(5,2),
        fitness_goals TEXT[],
        medical_conditions TEXT[],

        -- 客製化欄位
        custom_fields JSONB DEFAULT '{}',

        -- 會員狀態
        membership_status VARCHAR(20) DEFAULT 'active',
        joined_date DATE DEFAULT CURRENT_DATE,
        last_visit_date DATE,

        created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
        updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
      );

      -- 教練表
      CREATE TABLE trainers (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        trainer_code VARCHAR(20) UNIQUE NOT NULL,
        name VARCHAR(100) NOT NULL,
        email VARCHAR(255),
        phone VARCHAR(20),

        certifications JSONB DEFAULT '[]',
        specialties TEXT[],
        experience_years INTEGER,
        personal_training_rate DECIMAL(8,2),

        working_hours JSONB DEFAULT '{}',
        max_students_per_class INTEGER DEFAULT 15,

        status VARCHAR(20) DEFAULT 'active',
        hired_date DATE,
        created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
      );

      -- 課程表
      CREATE TABLE courses (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        name VARCHAR(200) NOT NULL,
        description TEXT,
        course_type VARCHAR(50),

        duration_minutes INTEGER NOT NULL,
        max_capacity INTEGER,
        price_per_session DECIMAL(8,2),

        primary_trainer_id UUID REFERENCES trainers(id),
        scheduled_times JSONB DEFAULT '[]',
        booking_window_hours INTEGER DEFAULT 168,

        custom_fields JSONB DEFAULT '{}',
        is_active BOOLEAN DEFAULT true,
        created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
      );

      -- 會籍方案
      CREATE TABLE membership_plans (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        plan_name VARCHAR(100) NOT NULL,
        description TEXT,
        plan_type VARCHAR(50),
        duration_months INTEGER,
        price DECIMAL(10,2) NOT NULL,

        included_services JSONB DEFAULT '{}',
        restrictions JSONB DEFAULT '{}',
        perks JSONB DEFAULT '{}',

        is_active BOOLEAN DEFAULT true,
        created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
      );

      -- 基本索引
      CREATE INDEX idx_members_phone ON members(phone);
      CREATE INDEX idx_members_email ON members(email);
      CREATE INDEX idx_members_status ON members(membership_status);
      CREATE INDEX idx_courses_trainer ON courses(primary_trainer_id);
      CREATE INDEX idx_courses_active ON courses(is_active);
    `;

    // 進階功能(Pro/Enterprise 版本才有)
    const advancedSchema = planType !== 'basic' ? `
      -- 進階分析表 (Pro+ 功能)
      CREATE TABLE member_analytics (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        member_id UUID REFERENCES members(id) ON DELETE CASCADE,
        metric_date DATE NOT NULL,
        visits_count INTEGER DEFAULT 0,
        workout_duration_minutes INTEGER DEFAULT 0,
        calories_burned DECIMAL(8,2),
        custom_metrics JSONB DEFAULT '{}',
        created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
        UNIQUE(member_id, metric_date)
      );

      -- 營收分析表 (Enterprise 功能)
      ${planType === 'enterprise' ? `
        CREATE TABLE revenue_analytics (
          id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
          date DATE NOT NULL,
          membership_revenue DECIMAL(10,2) DEFAULT 0,
          personal_training_revenue DECIMAL(10,2) DEFAULT 0,
          other_revenue DECIMAL(10,2) DEFAULT 0,
          total_revenue DECIMAL(10,2) GENERATED ALWAYS AS (
            membership_revenue + personal_training_revenue + other_revenue
          ) STORED,
          created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
          UNIQUE(date)
        );
      ` : ''}

      CREATE INDEX idx_member_analytics_member_date ON member_analytics(member_id, metric_date);
      ${planType === 'enterprise' ? 'CREATE INDEX idx_revenue_analytics_date ON revenue_analytics(date);' : ''}
    ` : '';

    // 執行 Schema 建立
    await connection.query(baseSchema + advancedSchema);
  }

  private generateDatabaseName(tenantId: string, subdomain: string): string {
    // 生成安全的資料庫名稱
    const cleanSubdomain = subdomain.replace(/[^a-zA-Z0-9]/g, '');
    const shortId = tenantId.replace(/-/g, '').slice(0, 8);
    return `tenant_${shortId}_${cleanSubdomain}`;
  }

  private generateSecurePassword(): string {
    return require('crypto').randomBytes(16).toString('base64');
  }

  private async getMasterCredentials() {
    const secret = await this.secretsManager.getSecretValue({
      SecretId: process.env.MASTER_SECRET_ARN!
    }).promise();

    return JSON.parse(secret.SecretString!);
  }

  private async storeConnectionInfo(tenantId: string, connectionInfo: any): Promise<string> {
    const secretName = `kyo-system/tenant/${tenantId}/database`;

    const result = await this.secretsManager.createSecret({
      Name: secretName,
      Description: `Database connection for tenant ${tenantId}`,
      SecretString: JSON.stringify(connectionInfo)
    }).promise();

    return result.ARN!;
  }
}

3. 租戶資料庫連線服務

// apps/kyo-tenant-manager/src/services/tenantConnectionService.ts
import { Pool } from 'pg';
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
import { LambdaClient, InvokeCommand } from '@aws-sdk/client-lambda';

export class TenantConnectionService {
  private connections: Map<string, Pool> = new Map();
  private secretsManager: SecretsManagerClient;
  private lambdaClient: LambdaClient;

  constructor() {
    this.secretsManager = new SecretsManagerClient({});
    this.lambdaClient = new LambdaClient({});
  }

  // 建立新租戶資料庫
  async createTenantDatabase(tenantData: {
    tenantId: string;
    tenantName: string;
    subdomain: string;
    planType: 'basic' | 'pro' | 'enterprise';
  }) {
    try {
      // 呼叫 Lambda 函數建立租戶資料庫
      const result = await this.lambdaClient.send(new InvokeCommand({
        FunctionName: 'kyo-system-tenant-db-manager',
        Payload: JSON.stringify(tenantData)
      }));

      const response = JSON.parse(new TextDecoder().decode(result.Payload));

      if (response.statusCode !== 200) {
        throw new Error(`Failed to create tenant database: ${response.body}`);
      }

      const databaseInfo = JSON.parse(response.body);
      return databaseInfo;

    } catch (error) {
      console.error('Error creating tenant database:', error);
      throw error;
    }
  }

  // 取得租戶資料庫連線
  async getTenantConnection(tenantId: string): Promise<Pool> {
    if (!this.connections.has(tenantId)) {
      const connectionInfo = await this.getTenantConnectionInfo(tenantId);

      const pool = new Pool({
        host: connectionInfo.host,
        port: connectionInfo.port,
        database: connectionInfo.database,
        user: connectionInfo.username,
        password: connectionInfo.password,
        ssl: { rejectUnauthorized: false },
        max: 10, // 最大連線數
        idleTimeoutMillis: 30000,
        connectionTimeoutMillis: 2000,
      });

      this.connections.set(tenantId, pool);
    }

    return this.connections.get(tenantId)!;
  }

  // 從 Secrets Manager 取得連線訊息
  private async getTenantConnectionInfo(tenantId: string) {
    try {
      const command = new GetSecretValueCommand({
        SecretId: `kyo-system/tenant/${tenantId}/database`
      });

      const response = await this.secretsManager.send(command);
      return JSON.parse(response.SecretString!);

    } catch (error) {
      console.error(`Failed to get connection info for tenant ${tenantId}:`, error);
      throw new Error('Tenant database connection not available');
    }
  }

  // 關閉租戶連線
  async closeTenantConnection(tenantId: string) {
    if (this.connections.has(tenantId)) {
      const pool = this.connections.get(tenantId)!;
      await pool.end();
      this.connections.delete(tenantId);
    }
  }

  // 關閉所有連線
  async closeAllConnections() {
    for (const [tenantId, pool] of this.connections.entries()) {
      await pool.end();
    }
    this.connections.clear();
  }

  // 執行租戶資料庫查詢
  async executeQuery(tenantId: string, query: string, params?: any[]) {
    const connection = await this.getTenantConnection(tenantId);
    return await connection.query(query, params);
  }

  // 執行交易
  async executeTransaction(tenantId: string, queries: Array<{query: string, params?: any[]}>) {
    const connection = await this.getTenantConnection(tenantId);
    const client = await connection.connect();

    try {
      await client.query('BEGIN');

      const results = [];
      for (const {query, params} of queries) {
        const result = await client.query(query, params);
        results.push(result);
      }

      await client.query('COMMIT');
      return results;

    } catch (error) {
      await client.query('ROLLBACK');
      throw error;
    } finally {
      client.release();
    }
  }
}

📊 成本分析與效能監控

1. 成本效益分析

// apps/kyo-tenant-manager/src/services/costAnalysisService.ts
export class TenantCostAnalysisService {
  calculateMonthlyCost(tenantCount: number, avgDataSizeGB: number = 1) {
    const costs = {
      // Aurora Serverless v2 基礎成本
      auroraBaseCost: 60, // USD/月 for cluster

      // 每個租戶資料庫成本(共享同一個 cluster)
      perTenantComputeCost: 1.5, // USD/月 per database

      // 儲存成本
      storagePerGB: 0.10, // USD/GB/月
      totalStorageCost: tenantCount * avgDataSizeGB * 0.10,

      // Secrets Manager
      secretsCost: tenantCount * 0.40, // USD/月 per secret

      // Lambda 執行成本(租戶建立)
      lambdaCost: 5, // USD/月 估算

      // CloudWatch 監控
      monitoringCost: 10 // USD/月
    };

    const totalMonthlyCost =
      costs.auroraBaseCost +
      (costs.perTenantComputeCost * tenantCount) +
      costs.totalStorageCost +
      costs.secretsCost +
      costs.lambdaCost +
      costs.monitoringCost;

    return {
      breakdown: costs,
      totalMonthlyCost,
      costPerTenant: totalMonthlyCost / tenantCount,

      // 成本對比
      comparison: {
        sharedDatabase: tenantCount * 2, // Shared DB 每租戶成本
        separateRDS: tenantCount * 25,   // 各自獨立 RDS 成本
        currentApproach: totalMonthlyCost
      }
    };
  }

  // 成本範例
  generateCostExamples() {
    return {
      small: this.calculateMonthlyCost(10),    // 10 家健身房
      medium: this.calculateMonthlyCost(50),   // 50 家健身房
      large: this.calculateMonthlyCost(200),   // 200 家健身房
    };
  }
}

// 成本範例結果:
// 10 家健身房: ~$92/月 = $9.2 per gym
// 50 家健身房: ~$170/月 = $3.4 per gym
// 200 家健身房: ~$410/月 = $2.05 per gym

// 比較:
// - Shared Database: $2/gym 但客製化受限
// - 各自 RDS: $25/gym 成本太高
// - Database per Tenant: $2-9/gym 平衡最佳 ✅

2. 租戶資料庫監控

// apps/kyo-tenant-manager/src/services/tenantMonitoringService.ts
export class TenantDatabaseMonitoringService {
  constructor(private cloudWatch: CloudWatchClient) {}

  async trackTenantDatabaseMetrics(tenantId: string, metrics: {
    connectionCount: number;
    queryTime: number;
    databaseSize: number;
    activeQueries: number;
  }) {
    const params = {
      Namespace: 'KyoSystem/TenantDatabase',
      MetricData: [
        {
          MetricName: 'ConnectionCount',
          Dimensions: [
            { Name: 'TenantId', Value: tenantId }
          ],
          Value: metrics.connectionCount,
          Unit: 'Count',
          Timestamp: new Date(),
        },
        {
          MetricName: 'AverageQueryTime',
          Dimensions: [
            { Name: 'TenantId', Value: tenantId }
          ],
          Value: metrics.queryTime,
          Unit: 'Milliseconds',
          Timestamp: new Date(),
        },
        {
          MetricName: 'DatabaseSize',
          Dimensions: [
            { Name: 'TenantId', Value: tenantId }
          ],
          Value: metrics.databaseSize,
          Unit: 'Bytes',
          Timestamp: new Date(),
        },
      ],
    };

    await this.cloudWatch.send(new PutMetricDataCommand(params));
  }

  // 取得租戶資料庫使用狀況
  async getTenantDatabaseStats(tenantId: string, days: number = 7) {
    const tenantConnection = await this.getTenantConnection(tenantId);

    const stats = await tenantConnection.query(`
      SELECT
        pg_database_size(current_database()) as database_size,
        (
          SELECT count(*)
          FROM pg_stat_activity
          WHERE datname = current_database()
        ) as active_connections,
        (
          SELECT avg(mean_exec_time)
          FROM pg_stat_statements
          WHERE dbid = (
            SELECT oid FROM pg_database WHERE datname = current_database()
          )
        ) as avg_query_time
    `);

    return {
      tenantId,
      databaseSize: stats.rows[0].database_size,
      activeConnections: stats.rows[0].active_connections,
      averageQueryTime: stats.rows[0].avg_query_time || 0,
      measuredAt: new Date()
    };
  }

  // 檢查租戶資料庫健康狀況
  async checkTenantDatabaseHealth(tenantId: string) {
    try {
      const connection = await this.getTenantConnection(tenantId);

      // 簡單健康檢查
      const healthCheck = await connection.query('SELECT 1 as healthy');

      const stats = await this.getTenantDatabaseStats(tenantId);

      return {
        tenantId,
        status: 'healthy',
        lastChecked: new Date(),
        metrics: stats
      };

    } catch (error) {
      console.error(`Health check failed for tenant ${tenantId}:`, error);

      return {
        tenantId,
        status: 'unhealthy',
        lastChecked: new Date(),
        error: error.message
      };
    }
  }
}

📈 效能監控與最佳化

1. 租戶使用量監控

// apps/kyo-auth-service/src/services/monitoringService.ts
export class TenantMonitoringService {
  constructor(private cloudWatch: CloudWatchClient) {}

  async trackTenantMetrics(tenantId: string, metrics: {
    activeUsers: number;
    apiCalls: number;
    storageUsed: number;
    queryTime: number;
  }) {
    const params = {
      Namespace: 'KyoSystem/Tenant',
      MetricData: [
        {
          MetricName: 'ActiveUsers',
          Dimensions: [
            { Name: 'TenantId', Value: tenantId }
          ],
          Value: metrics.activeUsers,
          Unit: 'Count',
          Timestamp: new Date(),
        },
        {
          MetricName: 'APICallsPerMinute',
          Dimensions: [
            { Name: 'TenantId', Value: tenantId }
          ],
          Value: metrics.apiCalls,
          Unit: 'Count/Second',
          Timestamp: new Date(),
        },
        {
          MetricName: 'StorageUsage',
          Dimensions: [
            { Name: 'TenantId', Value: tenantId }
          ],
          Value: metrics.storageUsed,
          Unit: 'Bytes',
          Timestamp: new Date(),
        },
      ],
    };

    await this.cloudWatch.send(new PutMetricDataCommand(params));
  }

  async getTenantUsageReport(tenantId: string, days: number = 30) {
    const endTime = new Date();
    const startTime = new Date(endTime.getTime() - days * 24 * 60 * 60 * 1000);

    const params = {
      MetricName: 'ActiveUsers',
      Namespace: 'KyoSystem/Tenant',
      StartTime: startTime,
      EndTime: endTime,
      Period: 86400, // 1 day
      Statistics: ['Average', 'Maximum'],
      Dimensions: [
        { Name: 'TenantId', Value: tenantId }
      ],
    };

    const result = await this.cloudWatch.send(
      new GetMetricStatisticsCommand(params)
    );

    return result.Datapoints?.map(point => ({
      date: point.Timestamp,
      averageUsers: point.Average,
      peakUsers: point.Maximum,
    })) || [];
  }
}

今日成果

今天我們建立了 Kyo-System 的多租戶資料庫架構:

多租戶 PostgreSQL 設計: Shared Database + Row-Level Security
彈性認證系統: 支援 Email/Password、Phone OTP、LINE Login
租戶隔離機制: 應用層 + 資料庫層雙重保護
效能監控: 租戶使用量追蹤與分析
安全性測試: 租戶資料隔離驗證


上一篇
Day 10: 30天部署SaaS產品到AWS-架構回顧與雲端基礎設施總結
系列文
30 天將工作室 SaaS 產品部署起來11
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言