マルチテナンシーアーキテクチャパターン:基本を超えて
SaaSプラットフォーム向けの高度なマルチテナントアーキテクチャパターン — データ分離戦略からテナントごとのカスタマイズ、スケーリングまで。

ほとんどのマルチテナンシーガイドは、「tenant_idカラムを追加してクエリをフィルタリングする」というところで止まっています。それは最初の数ヶ月は乗り切れるでしょう。しかし、他のテナントの10倍のデータを持つテナントがパフォーマンスを低下させ始めたり、エンタープライズの顧客候補がホワイトラベリングについて尋ねてきたり、パートナーが独自の決済プロセッサを統合したいと言ってきたりすると、突然、基本的なことだけでは不十分になります。
この記事では、クエリスコープを超えて、私が本番のマルチテナントシステムで実装してきたパターンについて説明します。これらは、プロトタイプとプラットフォームを分けるアーキテクチャ上の決定です。
高度な分離パターン
共有データベース、テナントごとのスキーマ、テナントごとのデータベースという3層モデルはよく理解されています。しかし、ほとんどの本番システムが実際に採用しているハイブリッドアプローチについてはあまり議論されていません。
顧客セグメントによる階層化された分離
実際には、1つの分離レベルを選んで一律に適用することは稀です。代わりに、各顧客セグメントが必要とし、支払う意思のあるものに基づいて分離を階層化します。
interface TenantConfig {
id: string;
slug: string;
isolationLevel: "shared" | "schema" | "dedicated";
databaseUrl?: string; // only for dedicated tenants
schemaName?: string; // only for schema-isolated tenants
}
function getConnectionForTenant(tenant: TenantConfig) {
switch (tenant.isolationLevel) {
case "shared":
return getSharedPool();
case "schema":
return getSchemaConnection(tenant.schemaName!);
case "dedicated":
return getDedicatedConnection(tenant.databaseUrl!);
}
}
共有ティアはセルフサービスのお客様に提供されます。スキーマ分離は、コンプライアンス文書を必要とするミッドマーケットのクライアントに提供されます。専用データベースは、物理的なデータ分離を必要とするエンタープライズアカウントに提供されます。
重要な設計上の制約は、アプリケーションコードがテナントがどのティアにあるかを知る必要も、気にする必要もないということです。接続の解決はミドルウェアで行われ、ダウンストリームのすべてが同じクエリインターフェースを使用します。ルートハンドラがデータのクエリ方法を決定するために分離レベルをチェックしなければならない場合、抽象化が漏洩しています。
セーフティネットとしての行レベルセキュリティ
PostgreSQLの行レベルセキュリティ(RLS)は、マルチテナントアーキテクチャにおいて最も活用されていない機能の1つです。アプリケーション層で厳密なクエリスコープを設定していても、RLSは、あるテナントが別のテナントのデータにアクセスできないというデータベースレベルの保証を提供します。
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.current_tenant')::uuid);
ALTER TABLE orders FORCE ROW LEVEL SECURITY;
接続レベルでは、クエリを実行する前にテナントコンテキストを設定します。
async function withTenantContext<T>(
tenantId: string,
callback: (client: PoolClient) => Promise<T>
): Promise<T> {
const client = await pool.connect();
try {
await client.query("SET app.current_tenant = $1", [tenantId]);
return await callback(client);
} finally {
await client.query("RESET app.current_tenant");
client.release();
}
}
これが導入されていれば、アプリケーションコードにWHERE tenant_id = ?句を省略するバグがあったとしても、データベース自体が結果をフィルタリングします。これは、本番環境で実際にバグを検出した経験があります。ある開発者がテーブルを結合するレポートクエリを作成し、そのうちの1つでテナントフィルタを忘れていましたが、RLSはデータを漏洩させることなく、正しい行のみをサイレントに返しました。
FORCE ROW LEVEL SECURITYは重要です。これがないと、テーブル所有者(通常、アプリケーションが使用するロール)はRLSポリシーをバイパスします。これがあると、ポリシーはすべての人に適用されます。
テナント対応ミドルウェアパイプライン
以前のマルチテナントバックエンドに関する投稿で説明したテナント解決ミドルウェアは、サブドメインまたはJWTからテナントを抽出し、リクエストにアタッチするという基本的な処理を行います。しかし、本番システムでは、完全なテナントコンテキストを構築する、よりリッチなミドルウェアパイプラインが必要です。
interface TenantContext {
id: string;
slug: string;
config: TenantConfig;
plan: PlanTier;
features: Set<string>;
limits: TenantLimits;
branding?: TenantBranding;
}
async function buildTenantContext(tenantId: string): Promise<TenantContext> {
// Cache this aggressively — it's read on every request
const cacheKey = `tenant:${tenantId}:context`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const [tenant, plan, features, limits, branding] = await Promise.all([
db("tenants").where({ id: tenantId }).first(),
db("tenant_plans").where({ tenant_id: tenantId, active: true }).first(),
db("tenant_features").where({ tenant_id: tenantId, enabled: true }).select("feature_key"),
db("tenant_limits").where({ tenant_id: tenantId }).first(),
db("tenant_branding").where({ tenant_id: tenantId }).first(),
]);
const context: TenantContext = {
id: tenant.id,
slug: tenant.slug,
config: tenant.config,
plan: plan.tier,
features: new Set(features.map((f) => f.feature_key)),
limits: limits ?? DEFAULT_LIMITS,
branding: branding ?? undefined,
};
await redis.set(cacheKey, JSON.stringify(context), "EX", 300);
return context;
}
ミドルウェアは、この完全なコンテキストをリクエストにアタッチします。
export async function tenantContextMiddleware(
req: Request,
res: Response,
next: NextFunction
) {
if (!req.tenantId) return next();
try {
req.tenantContext = await buildTenantContext(req.tenantId);
// Check if tenant is active
if (req.tenantContext.config.status === "suspended") {
return res.status(403).json({
error: "Account suspended",
reason: req.tenantContext.config.suspensionReason,
});
}
next();
} catch (error) {
next(error);
}
}
これにより、すべてのダウンストリームハンドラは、追加のデータベース呼び出しを行うことなく、完全なテナントコンテキストにアクセスできます。5分間のキャッシュにより、プランの変更は迅速に伝播しますが、機能が有効になっているかどうかをチェックするために、すべてのリクエストでデータベースにアクセスすることはありません。
テナントごとの動的設定
機能フラグを超えて、テナントはしばしば設定可能な動作を必要とします。レストランプラットフォームでは、各レストランが独自の注文締め切り時間、税率、配送ゾーン、通知設定を設定できるようにするかもしれません。プロジェクト管理ツールでは、各ワークスペースがカスタムフィールド、ワークフローステージ、通知ルールを設定できるようにするかもしれません。
私が使用するパターンは、設定をスキーマ検証済みのJSONカラムに分離します。
import { z } from "zod";
const TenantSettingsSchema = z.object({
timezone: z.string().default("UTC"),
locale: z.string().default("en"),
currency: z.string().default("USD"),
notifications: z.object({
emailDigest: z.enum(["daily", "weekly", "never"]).default("daily"),
slackWebhook: z.string().url().optional(),
webhookUrl: z.string().url().optional(),
}).default({}),
limits: z.object({
maxUsersOverride: z.number().optional(),
maxStorageMbOverride: z.number().optional(),
apiRateLimitOverride: z.number().optional(),
}).default({}),
customFields: z.array(z.object({
key: z.string(),
label: z.string(),
type: z.enum(["text", "number", "date", "select"]),
options: z.array(z.string()).optional(),
required: z.boolean().default(false),
})).default([]),
});
type TenantSettings = z.infer<typeof TenantSettingsSchema>;
設定はPostgreSQLのJSONBとして保存され、スキーマを柔軟に保ちながら、インデックス作成とクエリ機能を提供します。
async function getTenantSettings(tenantId: string): Promise<TenantSettings> {
const row = await db("tenant_settings")
.where({ tenant_id: tenantId })
.first();
return TenantSettingsSchema.parse(row?.settings ?? {});
}
async function updateTenantSettings(
tenantId: string,
updates: Partial<TenantSettings>
): Promise<TenantSettings> {
const current = await getTenantSettings(tenantId);
const merged = { ...current, ...updates };
const validated = TenantSettingsSchema.parse(merged);
await db("tenant_settings")
.insert({
tenant_id: tenantId,
settings: validated,
updated_at: new Date(),
})
.onConflict("tenant_id")
.merge();
// Invalidate cache
await redis.del(`tenant:${tenantId}:context`);
return validated;
}
Zodスキーマは二重の役割を果たします。書き込み時に設定を検証し、読み込み時にデフォルト値を提供します。新しい設定を追加した場合、既存のテナントはデータ移行なしで自動的にデフォルト値を取得します。
テナントごとの機能フラグ
単純なブール値のフラグだけでは、成熟したプラットフォームには不十分です。パーセンテージロールアウト、プランベースのゲーティング、テナントごとのオーバーライドのサポートが必要です。
interface FeatureFlag {
key: string;
defaultEnabled: boolean;
rolloutPercentage: number; // 0-100
planMinimum?: PlanTier;
tenantOverrides: Map<string, boolean>; // explicit per-tenant overrides
}
class FeatureFlagService {
private flags: Map<string, FeatureFlag>;
constructor(private cache: CacheStore) {
this.flags = new Map();
}
async isEnabled(tenantId: string, featureKey: string): Promise<boolean> {
const flag = await this.getFlag(featureKey);
if (!flag) return false;
// Explicit per-tenant override takes precedence
if (flag.tenantOverrides.has(tenantId)) {
return flag.tenantOverrides.get(tenantId)!;
}
// Plan-based gating
if (flag.planMinimum) {
const tenantPlan = await this.getTenantPlan(tenantId);
if (planRank(tenantPlan) < planRank(flag.planMinimum)) {
return false;
}
}
// Percentage rollout — deterministic based on tenant ID
if (flag.rolloutPercentage < 100) {
const hash = this.hashTenantFeature(tenantId, featureKey);
return hash % 100 < flag.rolloutPercentage;
}
return flag.defaultEnabled;
}
private hashTenantFeature(tenantId: string, featureKey: string): number {
const str = `${tenantId}:${featureKey}`;
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0;
}
return Math.abs(hash);
}
}
決定論的なハッシュはロールアウトにとって重要です。テナントは一貫して機能を見るか、見ないかのどちらかです。あるリクエストでは機能が見えて、次のリクエストでは見えないという状況は望ましくありません。ハッシュはテナントIDと機能キーから計算されるため、異なる機能が異なるテナントのサブセットにロールアウトできます。
ルートハンドラでは、チェックはクリーンです。
app.get("/api/v2/analytics", async (req, res) => {
const useNewAnalytics = await featureFlags.isEnabled(
req.tenantId,
"analytics_v2"
);
if (useNewAnalytics) {
return res.json(await analyticsV2.getDashboard(req.tenantId));
}
return res.json(await analyticsV1.getDashboard(req.tenantId));
});
テナント固有の統合
エンタープライズテナントは、独自のツールを接続したいと考えるでしょう。決済処理のための独自のStripeアカウント、メールのための独自のSendGridアカウント、ファイルストレージのための独自のS3バケットなどです。あなたのプラットフォームは、設定の悪夢に陥ることなく、これをサポートする必要があります。
このパターンは、テナントごとに正しい認証情報を解決する統合レジストリです。
interface IntegrationConfig {
provider: string;
credentials: Record<string, string>; // encrypted at rest
settings: Record<string, unknown>;
isCustom: boolean; // true = tenant's own account, false = platform default
}
class IntegrationRegistry {
async getIntegration(
tenantId: string,
integrationType: "email" | "payment" | "storage" | "sms"
): Promise<IntegrationConfig> {
// Check for tenant-specific integration first
const custom = await db("tenant_integrations")
.where({ tenant_id: tenantId, type: integrationType, active: true })
.first();
if (custom) {
return {
provider: custom.provider,
credentials: await decrypt(custom.encrypted_credentials),
settings: custom.settings,
isCustom: true,
};
}
// Fall back to platform defaults
return this.getPlatformDefault(integrationType);
}
}
サービス層はレジストリを使用して正しいクライアントを取得します。
class EmailService {
constructor(private integrations: IntegrationRegistry) {}
async sendEmail(tenantId: string, email: EmailPayload): Promise<void> {
const config = await this.integrations.getIntegration(tenantId, "email");
const client = this.createClient(config);
await client.send({
from: config.settings.fromAddress as string,
...email,
});
}
private createClient(config: IntegrationConfig): EmailClient {
switch (config.provider) {
case "sendgrid":
return new SendGridClient(config.credentials.apiKey);
case "ses":
return new SESClient(config.credentials);
case "resend":
return new ResendClient(config.credentials.apiKey);
default:
throw new Error(`Unknown email provider: ${config.provider}`);
}
}
}
これは、あるテナントがプラットフォームの共有SendGridアカウントを使用する一方で、別のテナントが独自のドメインとレピュテーションを持つ独自のAmazon SESインスタンスを使用できることを意味します。アプリケーションコードの残りの部分は、それを知る必要も気にする必要もありません。emailService.sendEmail()を呼び出すだけで、統合レイヤーがルーティングを処理します。
ここでは認証情報のセキュリティは譲れません。 テナントが提供するAPIキーは、保存時に暗号化される必要があり、できればテナントごとの暗号化キーを使用すべきです。AWS KMSやHashiCorp Vaultのようなものを使用してこれを管理し、独自の暗号化キー管理を実装しないでください。
分離レベル間のデータ移行
マルチテナントシステムにおける最も厄介な運用上の課題の1つは、テナントをある分離レベルから別の分離レベルへ移行することです。成長中のテナントは共有テーブルから独自のスキーマへ移行するかもしれません。エンタープライズ契約では、テナントを専用データベースへ移行する必要があるかもしれません。
移行はゼロダウンタイムである必要があり、これは単にダンプしてリストアするだけでは済まないことを意味します。私が使用するパターンは次のとおりです。
interface TenantMigration {
tenantId: string;
fromLevel: IsolationLevel;
toLevel: IsolationLevel;
status: "pending" | "syncing" | "verifying" | "cutover" | "complete" | "failed";
startedAt: Date;
completedAt?: Date;
}
class TenantMigrator {
async migrateToSchema(tenantId: string): Promise<void> {
const migration = await this.createMigration(tenantId, "shared", "schema");
try {
// 1. Create the target schema with all tables
await this.createSchema(tenantId);
await this.updateStatus(migration, "syncing");
// 2. Copy existing data to the new schema
await this.copyData(tenantId, "public", `tenant_${tenantId}`);
// 3. Set up Change Data Capture to sync ongoing writes
const cdcStream = await this.startCDC(tenantId, "public", `tenant_${tenantId}`);
// 4. Verify data consistency
await this.updateStatus(migration, "verifying");
const isConsistent = await this.verifyConsistency(tenantId);
if (!isConsistent) throw new Error("Data consistency check failed");
// 5. Cutover: update tenant config to point to new schema
await this.updateStatus(migration, "cutover");
await db("tenants").where({ id: tenantId }).update({
isolation_level: "schema",
schema_name: `tenant_${tenantId}`,
});
// 6. Invalidate caches
await redis.del(`tenant:${tenantId}:context`);
// 7. Stop CDC and clean up source data
await cdcStream.stop();
await this.cleanupSourceData(tenantId, "public");
await this.updateStatus(migration, "complete");
} catch (error) {
await this.updateStatus(migration, "failed");
await this.rollback(migration);
throw error;
}
}
}
変更データキャプチャ(CDC)のステップは非常に重要です。初期データのコピーと切り替えの間に、共有テーブルに対して新しい書き込みが発生しています。CDCはこれらの書き込みをキャプチャし、新しいスキーマにリプレイすることで、何も失われないようにします。
実際には、これにはPostgreSQLの論理レプリケーションを使用します。tenant_idでフィルタリングされたソーステーブルにパブリケーションを作成し、ターゲットスキーマにサブスクリプションを作成します。レプリケーションラグがほぼゼロになったら、切り替えを実行します。
ロールバックパスも同様に重要です。移行中に何らかの問題が発生した場合、すべてをきれいに元に戻せる必要があります。これは、移行が検証され、テナントが新しいスキーマで一定の信頼期間(通常、ソースデータのクリーンアップ前に48時間待機します)運用されるまで、ソースデータをそのまま保持することを意味します。
テナントごとのモニタリングと可観測性
マルチテナントシステムでは、「APIが遅い」というだけでは対処できません。どのテナントが影響を受けているのか、どのクエリが遅いのか、そして問題がテナント固有のもの(うるさい隣人、大規模なデータセット)なのか、プラットフォーム全体のものなのかを知る必要があります。
import { metrics } from "./lib/metrics"; // Prometheus, Datadog, etc.
function tenantMetricsMiddleware(req: Request, res: Response, next: NextFunction) {
const start = Date.now();
res.on("finish", () => {
const duration = Date.now() - start;
const labels = {
tenant_id: req.tenantId,
method: req.method,
route: req.route?.path ?? "unknown",
status: String(res.statusCode),
};
metrics.histogram("http_request_duration_ms", duration, labels);
metrics.counter("http_requests_total", 1, labels);
// Alert on tenant-specific degradation
if (duration > 2000) {
metrics.counter("http_slow_requests_total", 1, labels);
}
});
next();
}
リクエストレベルのメトリクスに加えて、テナントごとのリソース消費を追跡します。
interface TenantUsageMetrics {
tenantId: string;
period: string; // "2026-03"
apiCalls: number;
storageBytes: number;
bandwidthBytes: number;
computeMs: number;
activeUsers: number;
}
class UsageTracker {
async recordAPICall(tenantId: string, durationMs: number): Promise<void> {
const period = this.getCurrentPeriod();
await redis.hincrby(`usage:${tenantId}:${period}`, "apiCalls", 1);
await redis.hincrby(`usage:${tenantId}:${period}`, "computeMs", durationMs);
}
async getUsage(tenantId: string, period: string): Promise<TenantUsageMetrics> {
const data = await redis.hgetall(`usage:${tenantId}:${period}`);
return {
tenantId,
period,
apiCalls: parseInt(data.apiCalls ?? "0"),
storageBytes: parseInt(data.storageBytes ?? "0"),
bandwidthBytes: parseInt(data.bandwidthBytes ?? "0"),
computeMs: parseInt(data.computeMs ?? "0"),
activeUsers: parseInt(data.activeUsers ?? "0"),
};
}
}
このデータは、課金(使用量ベースの料金)、キャパシティプランニング(どのテナントが最も速く成長しているか)、デバッグ(このテナントの遅い体験はデータ量によるものか、プラットフォームの問題によるものか)という3つの目的に役立ちます。
テナントレベルの内訳を含むダッシュボードを設定します。アラートが発生した場合、それが1つのテナントに影響しているのか、それともすべてのテナントに影響しているのかを数秒以内に確認できるはずです。これにより、インシデント対応は「何かが遅い」から「テナントXの分析クエリが遅いのは、データセットが現在のインデックスが有効な閾値を超えて成長したためである」へと変わります。
課金統合パターン
マルチテナントシステムにおける課金は、「各テナントに毎月請求する」という単純なものではありません。プランティア、使用量ベースのコンポーネント、シートベースの料金設定、サイクル途中のアップグレードとダウングレードを処理する必要があります。
class BillingService {
private stripe: Stripe;
async syncPlanChange(tenantId: string, newPlan: PlanTier): Promise<void> {
const tenant = await db("tenants").where({ id: tenantId }).first();
// Update Stripe subscription
const subscription = await this.stripe.subscriptions.retrieve(
tenant.stripe_subscription_id
);
await this.stripe.subscriptions.update(subscription.id, {
items: [{
id: subscription.items.data[0].id,
price: PLAN_PRICE_IDS[newPlan],
}],
proration_behavior: "create_prorations",
});
// Sync feature flags based on new plan
await this.syncFeatureFlags(tenantId, newPlan);
// Update limits
await this.syncLimits(tenantId, newPlan);
// Invalidate tenant context cache
await redis.del(`tenant:${tenantId}:context`);
}
private async syncFeatureFlags(tenantId: string, plan: PlanTier): Promise<void> {
const planFeatures = PLAN_FEATURE_MAP[plan];
// Disable features not included in new plan
await db("tenant_features")
.where({ tenant_id: tenantId })
.whereNotIn("feature_key", planFeatures)
.update({ enabled: false });
// Enable features included in new plan
for (const feature of planFeatures) {
await db("tenant_features")
.insert({ tenant_id: tenantId, feature_key: feature, enabled: true })
.onConflict(["tenant_id", "feature_key"])
.merge();
}
}
}
使用量ベースの課金の場合、各請求期間の終わりにStripeに使用量を報告します。
async function reportUsageToStripe(tenantId: string): Promise<void> {
const tenant = await db("tenants").where({ id: tenantId }).first();
const usage = await usageTracker.getUsage(tenantId, getCurrentPeriod());
// Report metered usage for API calls
await stripe.subscriptionItems.createUsageRecord(
tenant.stripe_metered_item_id,
{
quantity: usage.apiCalls,
timestamp: Math.floor(Date.now() / 1000),
action: "set",
}
);
}
これを各請求期間の終わりにcronジョブで実行し、2回実行しても二重課金されないように冪等性を実装します。
ホワイトラベリングアーキテクチャ
ホワイトラベリングとは、テナントがあなたのプラットフォームを自社製品として提示できる機能です。これには、カスタムドメイン、カスタムブランディング、カスタムメールテンプレート、そして時にはカスタムUIテーマが含まれます。
このアーキテクチャには、ルーティングとテーマ設定という2つの主要な懸念事項があります。
カスタムドメインルーティング
// Tenant domain mapping
interface TenantDomain {
tenantId: string;
domain: string; // "orders.acme-restaurant.com"
sslStatus: "pending" | "active" | "expired";
verifiedAt?: Date;
}
async function resolveCustomDomain(hostname: string): Promise<string | null> {
const mapping = await redis.get(`domain:${hostname}`);
if (mapping) return mapping;
const row = await db("tenant_domains")
.where({ domain: hostname, ssl_status: "active" })
.first();
if (row) {
await redis.set(`domain:${hostname}`, row.tenant_id, "EX", 3600);
return row.tenant_id;
}
return null;
}
テナント解決ミドルウェアでは、サブドメイン解決にフォールバックする前にカスタムドメインをチェックします。
export async function tenantMiddleware(req: Request, res: Response, next: NextFunction) {
// 1. Check JWT claim
// ... (existing logic)
// 2. Check custom domain
const customTenantId = await resolveCustomDomain(req.hostname);
if (customTenantId) {
req.tenantId = customTenantId;
return next();
}
// 3. Fall back to subdomain
// ... (existing logic)
}
カスタムドメインのSSLについては、サブドメインにはワイルドカード証明書を、カスタムドメインにはDNSまたはHTTPチャレンジ付きのLet's Encryptを使用します。CaddyやCloudflare for SaaSのようなサービスは、これを完全に自動化できます。
テーマ設定とブランディング
interface TenantBranding {
tenantId: string;
logoUrl: string;
faviconUrl: string;
primaryColor: string;
secondaryColor: string;
fontFamily?: string;
customCSS?: string;
emailFromName: string;
emailFromAddress: string;
supportUrl?: string;
termsUrl?: string;
privacyUrl?: string;
}
フロントエンドでは、ブランディングをCSSカスタムプロパティとして注入します。
function applyBranding(branding: TenantBranding) {
const root = document.documentElement;
root.style.setProperty("--color-primary", branding.primaryColor);
root.style.setProperty("--color-secondary", branding.secondaryColor);
if (branding.fontFamily) {
root.style.setProperty("--font-family", branding.fontFamily);
}
}
プラットフォームがサーバーレンダリングされている場合、スタイルが適用されていないコンテンツのちらつきがないように、ブランディングを初期HTMLレスポンスに注入します。Next.jsでは、これはルートレイアウトでブランディングを読み込み、<html>または<body>タグ内にCSS変数をインラインで設定することを意味します。
メールテンプレートの場合、レンダリング時にテナントのブランディングコンテキストを使用します。
async function sendTenantEmail(tenantId: string, template: string, data: unknown) {
const branding = await getBranding(tenantId);
const integration = await integrations.getIntegration(tenantId, "email");
const html = renderEmailTemplate(template, {
...data,
logo: branding.logoUrl,
primaryColor: branding.primaryColor,
companyName: branding.emailFromName,
});
await emailClient(integration).send({
from: `${branding.emailFromName} <${branding.emailFromAddress}>`,
html,
});
}
エンドユーザーはあなたのプラットフォームのブランドを見ることはありません。彼らはテナントのロゴ、色、ドメイン、差出人アドレスを見ます。彼らにとっては、それはテナントの製品なのです。
スケーリングに関する考慮事項
テナント数が増えるにつれて、特定のパターンは機能しなくなり、置き換える必要があります。
コネクションプーリングが重要になります。 共有データベースでは、アプリケーションインスタンスごとにコネクションプールを開始するかもしれません。しかし、テナントごとのスキーマやテナントごとのデータベースがある場合、素朴なコネクション管理では接続制限を使い果たしてしまいます。共有データベースにはトランザクションモードでPgBouncerを使用し、テナントのアクティビティに比例して接続が割り当てられるスキーマ分離のためにコネクションプールパーティショニングを実装します。
キャッシュの無効化が難しくなります。 単一のRedisインスタンスでは、テナントのキャッシュを削除するのは簡単です。分散キャッシュでは、無効化イベントをブロードキャストする必要があります。Redis Pub/Subまたは専用のイベントバスを使用して、すべてのアプリケーションインスタンスにキャッシュ無効化を伝播させます。
バックグラウンドジョブにはテナントコンテキストが必要です。 エンキューするすべてのジョブはテナントIDを保持する必要があります。ジョブプロセッサは、HTTPミドルウェアが行うのと同じテナントコンテキスト(データベース接続、機能フラグ、制限)を設定する必要があります。私はジョブハンドラのためにwithTenantScopeラッパーを作成します。
function withTenantScope(handler: (tenantId: string, data: unknown) => Promise<void>) {
return async (job: Job) => {
const { tenantId, ...data } = job.data;
const tenant = await buildTenantContext(tenantId);
// Set up database context
await withTenantContext(tenantId, async () => {
await handler(tenantId, data);
});
};
}
// Usage
queue.process("generate-report", withTenantScope(async (tenantId, data) => {
// This handler runs with full tenant context
const report = await generateReport(tenantId, data);
await storeReport(tenantId, report);
}));
ノイジーネイバー検出は共有インフラストラクチャにとって不可欠です。テナントごとのクエリ実行時間、CPU使用率、メモリを追跡します。テナントのワークロードが共有プールを劣化させ始めた場合、3つの選択肢があります。スロットリングする、より高い分離ティアに移行する、またはそのテナント固有のクエリを最適化する、です。モニタリングは、他のテナントが影響を受ける前にその判断を下すためのデータを提供します。
まとめ
本番のマルチテナントアーキテクチャは単一のパターンではなく、互いに影響し合う一連の決定のスタックです。分離戦略は接続管理に影響を与え、機能フラ