Modelli di Architettura Multi-Tenancy: Oltre le Basi
Modelli avanzati di architettura multi-tenant per piattaforme SaaS — dalle strategie di isolamento dei dati alla personalizzazione per tenant e allo scaling.

La maggior parte delle guide sulla multi-tenancy si ferma a "aggiungi una colonna tenant_id e filtra le tue query". Questo ti permette di superare i primi mesi. Poi un tenant con 10 volte i dati inizia a degradare le prestazioni per tutti gli altri, un potenziale cliente enterprise chiede informazioni sul white-labeling e un partner vuole integrare il proprio processore di pagamento. Improvvisamente, le basi non sono più sufficienti.
Questo post copre i modelli che ho implementato in sistemi multi-tenant in produzione che vanno oltre lo scoping delle query. Queste sono le decisioni architetturali che separano un prototipo da una piattaforma.
Modelli di Isolamento Avanzati
Il modello a tre livelli di database condiviso, schema-per-tenant e database-per-tenant è ben compreso. Ciò che è meno discusso è l'approccio ibrido che la maggior parte dei sistemi di produzione finisce per utilizzare.
Isolamento a Livelli per Segmento di Cliente
In pratica, raramente si sceglie un livello di isolamento e lo si applica uniformemente. Invece, si stratifica l'isolamento in base a ciò di cui ogni segmento di cliente ha bisogno e per cui è disposto a pagare.
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!);
}
}
Il livello condiviso serve i tuoi clienti self-service. L'isolamento dello schema serve i clienti del mercato intermedio che necessitano di documentazione di conformità. I database dedicati servono gli account enterprise che richiedono la separazione fisica dei dati.
Il vincolo di progettazione chiave: il codice della tua applicazione non dovrebbe sapere o preoccuparsi di quale livello si trovi un tenant. La risoluzione della connessione avviene nel middleware, e tutto ciò che è a valle utilizza la stessa interfaccia di query. Se un gestore di route deve controllare il livello di isolamento per decidere come interrogare i dati, la tua astrazione sta perdendo.
Sicurezza a Livello di Riga come Rete di Sicurezza
La Row-Level Security di PostgreSQL è la singola funzionalità più sottoutilizzata nelle architetture multi-tenant. Anche con uno scoping disciplinato delle query nel tuo livello applicativo, RLS fornisce una garanzia a livello di database che un tenant non può accedere ai dati di un altro tenant.
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;
A livello di connessione, imposti il contesto del tenant prima di eseguire qualsiasi query:
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();
}
}
Con questo in atto, anche se il codice della tua applicazione ha un bug che omette la clausola WHERE tenant_id = ?, il database stesso filtrerà i risultati. Questo ha rilevato veri bug in produzione per me. Uno sviluppatore ha scritto una query di reporting che univa tabelle e ha dimenticato il filtro del tenant su una di esse — RLS ha silenziosamente restituito solo le righe corrette invece di far trapelare dati.
La clausola FORCE ROW LEVEL SECURITY è importante. Senza di essa, i proprietari delle tabelle (tipicamente il ruolo utilizzato dalla tua applicazione) bypassano le policy RLS. Con essa, le policy si applicano a tutti.
Pipeline Middleware Consapevole del Tenant
Il middleware di risoluzione del tenant che ho trattato nel mio precedente post sui backend multi-tenant gestisce le basi: estrae il tenant dal sottodominio o dal JWT, lo allega alla richiesta. Ma un sistema di produzione necessita di una pipeline middleware più ricca che costruisca un contesto completo del tenant.
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;
}
Il middleware quindi allega questo contesto completo alla richiesta:
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);
}
}
Ogni gestore a valle ha ora accesso al contesto completo del tenant senza effettuare ulteriori chiamate al database. La cache di cinque minuti significa che una modifica del piano si propaga rapidamente, ma non stai interrogando il database ad ogni richiesta per verificare se una funzionalità è abilitata.
Configurazione Dinamica per Tenant
Oltre ai feature flag, i tenant spesso necessitano di un comportamento configurabile. Una piattaforma per ristoranti potrebbe consentire a ciascun ristorante di impostare i propri orari limite per gli ordini, aliquote fiscali, zone di consegna e preferenze di notifica. Uno strumento di gestione progetti potrebbe consentire a ciascun workspace di configurare campi personalizzati, fasi del flusso di lavoro e regole di notifica.
Il modello che utilizzo separa la configurazione in una colonna JSON con schema validato:
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>;
Le impostazioni sono memorizzate come JSONB in PostgreSQL, il che ti offre capacità di indicizzazione e interrogazione mantenendo lo schema flessibile:
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;
}
Lo schema Zod ha una doppia funzione: convalida le impostazioni in scrittura e fornisce valori predefiniti in lettura. Se aggiungi una nuova impostazione, i tenant esistenti ottengono automaticamente il valore predefinito senza una migrazione dei dati.
Feature Flag per Tenant
Semplici flag booleani non sono sufficienti per una piattaforma matura. Hai bisogno di supporto per rollout percentuali, gating basato su piani e override per tenant.
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);
}
}
L'hash deterministico è importante per i rollout. Un tenant vede la funzionalità in modo coerente o non la vede — non vuoi che la veda in una richiesta e non nella successiva. L'hash viene calcolato dall'ID del tenant e dalla chiave della funzionalità, quindi diverse funzionalità possono essere distribuite a diversi sottoinsiemi di tenant.
Nei gestori di route, il controllo è pulito:
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));
});
Integrazioni Specifiche per Tenant
I tenant enterprise vorranno connettere i propri strumenti: il proprio account Stripe per l'elaborazione dei pagamenti, il proprio account SendGrid per le email, il proprio bucket S3 per l'archiviazione dei file. La tua piattaforma deve supportare questo senza trasformarsi in un incubo di configurazione.
Il modello è un registro di integrazioni che risolve le credenziali corrette per tenant:
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);
}
}
Il livello di servizio utilizza quindi il registro per ottenere il client corretto:
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}`);
}
}
}
Ciò significa che un tenant può utilizzare l'account SendGrid condiviso della piattaforma mentre un altro utilizza la propria istanza Amazon SES con il proprio dominio e reputazione. Il resto del codice della tua applicazione non lo sa o non se ne preoccupa — chiama emailService.sendEmail() e il livello di integrazione gestisce il routing.
La sicurezza delle credenziali non è negoziabile qui. Le chiavi API fornite dal tenant devono essere crittografate a riposo, preferibilmente con chiavi di crittografia per tenant. Utilizza qualcosa come AWS KMS o HashiCorp Vault per gestire questo — non implementare la tua gestione delle chiavi di crittografia.
Migrazione dei Dati tra Livelli di Isolamento
Una delle sfide operative più complesse nei sistemi multi-tenant è la migrazione di un tenant da un livello di isolamento a un altro. Un tenant in crescita potrebbe passare da tabelle condivise al proprio schema. Un accordo enterprise potrebbe richiedere la migrazione di un tenant a un database dedicato.
La migrazione deve essere a zero downtime, il che significa che non puoi semplicemente fare un dump e ripristinare. Ecco il modello che utilizzo:
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;
}
}
}
Il passaggio di Change Data Capture è critico. Tra la copia dei dati iniziali e l'esecuzione del cutover, nuove scritture avvengono sulle tabelle condivise. CDC cattura queste scritture e le riproduce nel nuovo schema in modo che nulla venga perso.
In pratica, utilizzo la replica logica di PostgreSQL per questo. Si crea una pubblicazione sulle tabelle sorgente filtrate per tenant_id e una sottoscrizione sullo schema di destinazione. Una volta che il ritardo di replica è prossimo allo zero, si esegue il cutover.
Il percorso di rollback è altrettanto importante. Se qualcosa fallisce durante la migrazione, devi essere in grado di annullare tutto in modo pulito. Ciò significa mantenere intatti i dati sorgente fino a quando la migrazione non è verificata e il tenant ha operato sul nuovo schema per un periodo di fiducia (tipicamente aspetto 48 ore prima di pulire i dati sorgente).
Monitoraggio e Osservabilità per Tenant
In un sistema multi-tenant, "l'API è lenta" non è un'informazione utilizzabile. Devi sapere quale tenant è interessato, quali query sono lente e se il problema è specifico del tenant (noisy neighbor, dataset grande) o a livello di piattaforma.
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();
}
Oltre alle metriche a livello di richiesta, traccia il consumo di risorse per tenant:
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"),
};
}
}
Questi dati servono a tre scopi: fatturazione (prezzi basati sull'utilizzo), pianificazione della capacità (quali tenant stanno crescendo più velocemente) e debugging (l'esperienza lenta di questo tenant è dovuta al volume dei suoi dati o a un problema della piattaforma).
Configura dashboard con suddivisioni a livello di tenant. Quando scatta un avviso, dovresti essere in grado di vedere in pochi secondi se riguarda un solo tenant o tutti. Questo cambia la tua risposta agli incidenti da 'qualcosa è lento' a 'Le query di analisi del Tenant X sono lente perché il loro dataset è cresciuto oltre la soglia in cui i nostri indici attuali sono efficaci'.
Modelli di Integrazione per la Fatturazione
La fatturazione in un sistema multi-tenant va oltre il 'addebitare ogni tenant mensilmente'. Devi gestire livelli di piano, componenti basati sull'utilizzo, prezzi basati sui posti e upgrade e downgrade a metà ciclo.
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();
}
}
}
Per la fatturazione basata sull'utilizzo, riporti l'utilizzo a Stripe alla fine di ogni periodo di fatturazione:
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",
}
);
}
Esegui questo tramite un cron job alla fine di ogni periodo di fatturazione e implementa l'idempotenza in modo che l'esecuzione due volte non comporti un doppio addebito.
Architettura White-Labeling
Il white-labeling è la capacità per i tenant di presentare la tua piattaforma come il proprio prodotto. Ciò significa domini personalizzati, branding personalizzato, template email personalizzati e talvolta temi UI personalizzati.
L'architettura ha due preoccupazioni principali: routing e theming.
Routing di Dominio Personalizzato
// 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;
}
Nel tuo middleware di risoluzione del tenant, controlla i domini personalizzati prima di ricorrere alla risoluzione del sottodominio:
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)
}
Per SSL sui domini personalizzati, utilizza un certificato wildcard per i tuoi sottodomini e Let's Encrypt con sfida DNS o HTTP per i domini personalizzati. Servizi come Caddy o Cloudflare for SaaS possono automatizzare completamente questo processo.
Theming e Branding
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;
}
Sul frontend, inietta il branding come proprietà CSS personalizzate:
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);
}
}
Se la piattaforma è renderizzata lato server, inietta il branding nella risposta HTML iniziale in modo che non ci sia un flash di contenuto non stilizzato. Con Next.js, questo significa leggere il branding nel tuo layout radice e impostare le variabili CSS inline nel tag <html> o <body>.
Per i template email, utilizza il contesto di branding del tenant durante il rendering:
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,
});
}
L'utente finale non vede mai il brand della tua piattaforma. Vede il logo, i colori, il dominio e l'indirizzo del mittente del tenant. Per loro, è il prodotto del tenant.
Considerazioni sullo Scaling
Man mano che il numero dei tuoi tenant cresce, alcuni modelli smettono di funzionare e devono essere sostituiti.
Il connection pooling diventa critico. Con i database condivisi, potresti iniziare con un pool di connessioni per istanza dell'applicazione. Ma quando hai schema-per-tenant o database-per-tenant, una gestione ingenua delle connessioni esaurirà i tuoi limiti di connessione. Utilizza PgBouncer in modalità transazione per i database condivisi e implementa il partizionamento del pool di connessioni per l'isolamento dello schema, dove le connessioni sono allocate proporzionalmente all'attività del tenant.
L'invalidazione della cache diventa più difficile. Con una singola istanza Redis, eliminare la cache di un tenant è semplice. Con una cache distribuita, devi trasmettere eventi di invalidazione. Utilizza Redis Pub/Sub o un bus di eventi dedicato per propagare le invalidazioni della cache su tutte le istanze dell'applicazione.
I job in background necessitano del contesto del tenant. Ogni job che metti in coda deve portare l'ID del tenant. Il processore del job deve impostare lo stesso contesto del tenant (connessione al database, feature flag, limiti) che fa il middleware HTTP. Creo un wrapper withTenantScope per i gestori di job:
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);
}));
Il rilevamento del 'vicino rumoroso' è essenziale per l'infrastruttura condivisa. Traccia il tempo di esecuzione delle query, l'utilizzo della CPU e la memoria per tenant. Quando il carico di lavoro di un tenant inizia a