Chuyển đến nội dung chính

第 14 課:多租戶架構 — 租戶隔離、配額和計費

多租戶設計模式、租戶隔離策略、每個租戶的配置、資源配額、計費計量、資料分區、租戶加入自動化。

🏗️ 建築 — 第 14 課 第 14 課:多租戶架構 — 租戶 隔離、配額和計費

企業人工智慧聊天機器人平台架構-從原型到生產

第 4 部分:企業功能與安全

亞洲開發網

1. 多租戶架構概述

企業聊天機器人平台為數百個組織提供服務——每個租戶都有自己的數據、配置、品牌、模型和配額。多租戶是決定因素 單位經濟 的平台。


┌────────── MULTI-TENANT ISOLATION MODEL ──────────────┐
│                                                       │
│  ┌─────────┐ ┌─────────┐ ┌─────────┐                 │
│  │Tenant A │ │Tenant B │ │Tenant C │  (Customers)    │
│  └────┬────┘ └────┬────┘ └────┬────┘                 │
│       │           │           │                       │
│  ═════╪═══════════╪═══════════╪═══════ API Gateway    │
│       │           │           │       (Auth + Route)  │
│       │           │           │                       │
│  ┌────▼───────────▼───────────▼────┐                 │
│  │      SHARED APPLICATION LAYER   │                 │
│  │   (Same code, different config) │                 │
│  └────┬───────────┬───────────┬────┘                 │
│       │           │           │                       │
│  Level 1: Shared DB + Row-Level Security             │
│  Level 2: Separate schemas per tenant                │
│  Level 3: Separate databases (enterprise tier)       │
│                                                       │
│  ┌────▼───────────▼───────────▼────┐                 │
│  │  tenant_a    tenant_b   tenant_c│  Data Layer     │
│  │  ┌──────┐   ┌──────┐  ┌──────┐ │                 │
│  │  │ data │   │ data │  │ data │ │                 │
│  │  └──────┘   └──────┘  └──────┘ │                 │
│  └─────────────────────────────────┘                 │
└───────────────────────────────────────────────────────┘

2. 租戶配置系統


interface TenantConfig {
  id: string;
  name: string;
  slug: string;
  plan: 'starter' | 'professional' | 'enterprise';
  
  // AI Configuration
  ai: {
    defaultModel: string;
    allowedModels: string[];
    temperature: number;
    maxTokensPerRequest: number;
    systemPromptOverride?: string;
  };

  // Feature flags
  features: {
    multiAgent: boolean;
    voiceAgent: boolean;
    textToSQL: boolean;
    customTools: boolean;
    analyticsDashboard: boolean;
    sso: boolean;
    customDomain: boolean;
  };

  // Guardrails
  guardrails: {
    piiMasking: boolean;
    toxicityFilter: boolean;
    jailbreakDetection: boolean;
    allowedTopics: string[];
    forbiddenTopics: string[];
    brandVoice: string;
  };

  // Branding
  branding: {
    botName: string;
    botAvatar: string;
    primaryColor: string;
    welcomeMessage: string;
  };

  // Resource quotas
  quotas: {
    maxConversationsPerMonth: number;
    maxTokensPerMonth: number;
    maxDocuments: number;
    maxStorageMB: number;
    maxConcurrentUsers: number;
    maxToolsCustom: number;
  };
}

class TenantConfigService {
  private cache = new Map<string, { config: TenantConfig; expiresAt: number }>();

  async getConfig(tenantId: string): Promise<TenantConfig> {
    // Check cache (5 min TTL)
    const cached = this.cache.get(tenantId);
    if (cached && cached.expiresAt > Date.now()) {
      return cached.config;
    }

    const config = await this.db.tenantConfig.findUnique({ where: { id: tenantId } });
    if (!config) throw new TenantNotFoundError(tenantId);

    this.cache.set(tenantId, {
      config,
      expiresAt: Date.now() + 5 * 60 * 1000,
    });

    return config;
  }

  async updateConfig(tenantId: string, updates: Partial<TenantConfig>): Promise<void> {
    await this.db.tenantConfig.update({
      where: { id: tenantId },
      data: updates,
    });

    // Invalidate cache
    this.cache.delete(tenantId);

    // Notify all instances
    await this.redis.publish('tenant:config:updated', JSON.stringify({ tenantId }));
  }
}

3. 資料隔離-行級安全


-- PostgreSQL Row-Level Security for multi-tenant

-- Enable RLS on all tables
ALTER TABLE conversations ENABLE ROW LEVEL SECURITY;
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
ALTER TABLE analytics_events ENABLE ROW LEVEL SECURITY;

-- Create policies
CREATE POLICY tenant_isolation_conversations ON conversations
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

CREATE POLICY tenant_isolation_messages ON messages
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

CREATE POLICY tenant_isolation_documents ON documents
  USING (tenant_id = current_setting('app.tenant_id')::uuid);

-- Application sets tenant context per request
-- SET app.tenant_id = 'tenant-uuid-here';

// Middleware to set tenant context
class TenantMiddleware {
  async handle(req: Request, res: Response, next: NextFunction): Promise<void> {
    const tenantId = this.extractTenantId(req);
    if (!tenantId) {
      res.status(401).json({ error: 'Missing tenant identification' });
      return;
    }

    // Validate tenant exists and is active
    const tenant = await this.tenantService.getConfig(tenantId);
    if (!tenant || tenant.status === 'suspended') {
      res.status(403).json({ error: 'Tenant suspended or not found' });
      return;
    }

    // Set context for downstream services
    req.tenantId = tenantId;
    req.tenantConfig = tenant;

    // Set RLS context for database queries
    await this.db.$executeRaw`SET app.tenant_id = ${tenantId}`;

    next();
  }

  private extractTenantId(req: Request): string | null {
    // Strategy 1: From JWT token
    if (req.user?.tenantId) return req.user.tenantId;
    // Strategy 2: From subdomain (tenant-a.chatbot.example.com)
    const subdomain = req.hostname.split('.')[0];
    return this.tenantBySubdomain.get(subdomain) ?? null;
    // Strategy 3: From API key header
  }
}

4. 資源配額與使用計量


class QuotaManager {
  async checkQuota(
    tenantId: string,
    resource: keyof TenantConfig['quotas'],
    amount: number = 1,
  ): Promise<{ allowed: boolean; remaining: number }> {
    const config = await this.tenantService.getConfig(tenantId);
    const limit = config.quotas[resource];

    // Get current usage from Redis (real-time counter)
    const currentMonth = new Date().toISOString().slice(0, 7); // YYYY-MM
    const key = `quota:${tenantId}:${resource}:${currentMonth}`;
    const currentUsage = parseInt(await this.redis.get(key) ?? '0', 10);

    const remaining = limit - currentUsage;
    const allowed = remaining >= amount;

    if (!allowed) {
      await this.notifyQuotaExceeded(tenantId, resource, currentUsage, limit);
    }

    return { allowed, remaining };
  }

  async recordUsage(
    tenantId: string,
    resource: keyof TenantConfig['quotas'],
    amount: number = 1,
  ): Promise<void> {
    const currentMonth = new Date().toISOString().slice(0, 7);
    const key = `quota:${tenantId}:${resource}:${currentMonth}`;

    // Atomic increment in Redis
    await this.redis.incrby(key, amount);

    // Set expiry (end of next month as safety)
    await this.redis.expire(key, 62 * 24 * 3600);

    // Persist to DB for billing (async)
    await this.usageQueue.publish('usage.recorded', {
      tenantId,
      resource,
      amount,
      timestamp: new Date(),
    });
  }
}

class BillingMeter {
  async calculateMonthlyBill(tenantId: string, month: string): Promise<Bill> {
    const usage = await this.db.usageRecord.aggregate({
      where: { tenantId, month },
      groupBy: ['resource'],
      _sum: { amount: true },
    });

    const config = await this.tenantService.getConfig(tenantId);
    const basePlanPrice = this.getPlanPrice(config.plan);

    const overageCharges = usage.map(u => {
      const limit = config.quotas[u.resource];
      const overage = Math.max(0, u._sum.amount - limit);
      return {
        resource: u.resource,
        used: u._sum.amount,
        included: limit,
        overage,
        charge: overage * this.getOverageRate(u.resource),
      };
    });

    return {
      tenantId,
      month,
      basePlanPrice,
      overageCharges,
      total: basePlanPrice + overageCharges.reduce((sum, o) => sum + o.charge, 0),
    };
  }
}

5. 自動化租戶入職


class TenantOnboardingService {
  async onboard(input: OnboardingInput): Promise<TenantConfig> {
    // 1. Create tenant record
    const tenant = await this.db.tenant.create({
      data: {
        id: crypto.randomUUID(),
        name: input.organizationName,
        slug: this.slugify(input.organizationName),
        plan: input.plan,
        status: 'active',
        createdAt: new Date(),
      },
    });

    // 2. Create default config based on plan
    const config = await this.createDefaultConfig(tenant.id, input.plan);

    // 3. Provision resources
    await Promise.all([
      this.provisionVectorNamespace(tenant.id),
      this.provisionRedisNamespace(tenant.id),
      this.createDefaultPersona(tenant.id),
      this.createAPIKeys(tenant.id),
    ]);

    // 4. Import initial knowledge (if provided)
    if (input.initialDocuments?.length) {
      await this.knowledgeService.bulkIngest(tenant.id, input.initialDocuments);
    }

    // 5. Setup default guardrails
    await this.guardrailService.applyDefaults(tenant.id, input.industry);

    return config;
  }
}

第 14 課總結

  • 隔離等級:共享資料庫+RLS(入門版)→單獨模式(專業版)→單獨資料庫(企業版)
  • 租戶配置:AI 模型、功能、護欄、品牌、配額 — 全部針對每位租戶
  • 行級安全性: PostgreSQL RLS + 中介軟體 SET app.tenant_id 確保資料隔離
  • 配額與計費:Redis原子計數器用於即時限制,非同步持久化到資料庫進行計費
  • 入職自動化:一鍵配置 — DB、向量儲存、Redis、角色、護欄

下一篇: 分析和可觀察性——對話分析、法學碩士指標、儀表板、警報、成本追蹤。