Паттерны многопользовательской архитектуры: За пределами основ
Продвинутые паттерны многопользовательской архитектуры для SaaS-платформ — от стратегий изоляции данных до настройки и масштабирования для каждого клиента.

Большинство руководств по многопользовательской архитектуре останавливаются на фразе «добавьте столбец tenant_id и фильтруйте свои запросы». Этого хватит на первые несколько месяцев. Затем клиент с в 10 раз большим объемом данных начинает ухудшать производительность для всех остальных, потенциальный корпоративный клиент спрашивает о white-label, а партнер хочет интегрировать свой собственный платежный процессор. Внезапно базовых знаний становится недостаточно.
В этом посте рассматриваются паттерны, которые я реализовал в производственных многопользовательских системах, выходящие за рамки простого ограничения запросов. Это архитектурные решения, которые отличают прототип от полноценной платформы.
Продвинутые паттерны изоляции
Трехуровневая модель с общей базой данных, схемой для каждого клиента и базой данных для каждого клиента хорошо известна. Менее обсуждаемым является гибридный подход, который в конечном итоге используют большинство производственных систем.
Многоуровневая изоляция по сегментам клиентов
На практике вы редко выбираете один уровень изоляции и применяете его повсеместно. Вместо этого вы выстраиваете свою изоляцию в зависимости от того, что нужно каждому сегменту клиентов и за что он готов платить.
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!);
}
}
Общий уровень обслуживает ваших клиентов, работающих по принципу самообслуживания. Изоляция по схеме обслуживает клиентов среднего бизнеса, которым требуется документация по соответствию требованиям. Выделенные базы данных обслуживают корпоративных клиентов, которым требуется физическое разделение данных.
Ключевое ограничение дизайна: ваш код приложения не должен знать или заботиться о том, на каком уровне находится клиент. Разрешение соединения происходит в промежуточном ПО, и все последующие компоненты используют один и тот же интерфейс запросов. Если обработчик маршрута должен проверять уровень изоляции, чтобы решить, как запрашивать данные, ваша абстракция протекает.
Безопасность на уровне строк как страховочная сетка
Безопасность на уровне строк (Row-Level Security) в PostgreSQL — это самая недооцененная функция в многопользовательских архитектурах. Даже при дисциплинированном ограничении запросов на уровне вашего приложения 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 = ?, сама база данных отфильтрует результаты. Это помогло мне обнаружить реальные ошибки в продакшене. Разработчик написал отчетный запрос, который объединял таблицы, и забыл фильтр клиента в одной из них — 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);
}
}
Каждый последующий обработчик теперь имеет доступ к полному контексту клиента без выполнения дополнительных вызовов базы данных. Пятиминутный кэш означает, что изменение плана быстро распространяется, но вы не обращаетесь к базе данных при каждом запросе, чтобы проверить, включена ли функция.
Динамическая конфигурация для каждого клиента
Помимо флагов функций, клиентам часто требуется настраиваемое поведение. Платформа для ресторанов может позволить каждому ресторану устанавливать свои собственные сроки приема заказов, налоговые ставки, зоны доставки и настройки уведомлений. Инструмент управления проектами может позволить каждому рабочему пространству настраивать пользовательские поля, этапы рабочего процесса и правила уведомлений.
Паттерн, который я использую, разделяет конфигурацию на столбец 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>;
Настройки хранятся как JSONB в PostgreSQL, что обеспечивает возможности индексирования и запросов, сохраняя при этом гибкость схемы:
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);
}
}
Детерминированный хеш важен для развертывания. Клиент либо постоянно видит функцию, либо нет — вы не хотите, чтобы он видел ее в одном запросе и не видел в следующем. Хеш вычисляется из идентификатора клиента и ключа функции, поэтому разные функции могут развертываться для разных подмножеств клиентов.
В обработчиках маршрутов проверка выглядит чисто:
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 для управления этим — не создавайте собственное управление ключами шифрования.
Миграция данных между уровнями изоляции
Одной из самых сложных операционных задач в многопользовательских системах является миграция клиента с одного уровня изоляции на другой. Растущий клиент может перейти от общих таблиц к своей собственной схеме. Корпоративная сделка может потребовать миграции клиента в выделенную базу данных.
Миграция должна быть без простоев, что означает, что вы не можете просто сделать дамп и восстановить. Вот паттерн, который я использую:
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;
}
}
}
Шаг Change Data Capture (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"),
};
}
}
Эти данные служат трем целям: выставление счетов (ценообразование на основе использования), планирование мощностей (какие клиенты растут быстрее всего) и отладка (связана ли медленная работа этого клиента с объемом его данных или с проблемой платформы).
Настройте дашборды с детализацией по клиентам. Когда срабатывает оповещение, вы должны иметь возможность за считанные секунды увидеть, затрагивает ли оно одного клиента или всех. Это меняет вашу реакцию на инцидент с «что-то работает медленно» на «аналитические запросы клиента 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-задания в конце каждого расчетного периода и реализуйте идемпотентность, чтобы повторный запуск не приводил к двойному списанию средств.
Архитектура White-Label
White-label — это возможность для клиентов представлять вашу платформу как свой собственный продукт. Это означает пользовательские домены, пользовательский брендинг, пользовательские шаблоны электронной почты, а иногда и пользовательские темы пользовательского интерфейса.
Архитектура имеет две основные задачи: маршрутизация и тематизация.
Маршрутизация пользовательских доменов
// 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 на пользовательских доменах используйте wildcard-сертификат для ваших поддоменов и Let's Encrypt с DNS или HTTP-проверкой для пользовательских доменов. Такие сервисы, как 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 это означает чтение брендинга в вашем корневом макете и установку переменных CSS непосредственно в теге <html> или <body>.
Для шаблонов электронной почты используйте контекст брендинга клиента при рендеринге:
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 в режиме транзакций для об