דמיינו פלטפורמת ניהול פרויקטים מסוג SaaS עם 500 לקוחות. כל לקוח יוצר פרויקטים, מוסיף חברים, מעלה קבצים—הכל במסד נתונים משותף. שגיאת קוד אחת, ומשתמשים רואים נתונים של אחרים: באגים עם חציית tenant_id נפוצים במערכות עם טבלאות משותפות. אפשר להפריד לקוחות למסדי נתונים נפרדים, אבל אז כל מסד נתונים דורש גיבויים, מיגרציות וניטור משלו, מה שהופך את זה במהירות לבלתי ניתן לניהול. הפשרה היא Schema-per-Tenant: כל סכמה במסד נתונים משותף של PostgreSQL, אבל עם בידוד מלא של מרחב השמות. במאמר זה, אנו חולקים את הניסיון שלנו ביישום גישה זו עם Prisma ו-Kysely.
סכמות PostgreSQL פועלות כמרחבי שמות: טבלאות, אינדקסים, פונקציות בסכמות שונות אינן חופפות. מסד נתונים אחד יכול להכיל עד 10,000 סכמות—מספיק עבור רוב מוצרי B2B SaaS. בינתיים, הניהול נשאר מאוחד: גיבוי אחד, פקודת מיגרציה אחת, ניטור אחד. נסקור כיצד להגדיר בידוד נתונים מבלי לוותר על גמישות.
תהליך רישום לקוח טיפוסי: יצירת רשומה בטבלת PostgreSQL база: schema: public → общие таблицы (tenants, plans) schema: tenant_acme → данные клиента Acme schema: tenant_globex → данные клиента Gloбех schema: tenant_initech → данные клиента Initech המשותפת, ולאחר מכן יצירה דינמית של סכמה חדשה והחלת סכמת הנתונים הראשונית. זה דורש חיבור מנהל עם הרשאות DDL. נעבור על הקוד והמלכודות הנפוצות.
כיצד פועל בידוד מבוסס סכמות
PostgreSQL база:
schema: public → общие таблицы (tenants, plans)
schema: tenant_acme → данные клиента Acme
schema: tenant_globex → данные клиента Gloбех
schema: tenant_initech → данные клиента InitechPostgreSQL תומך בעד 10,000 סכמות לכל מסד נתונים, מספיק עבור רוב מוצרי SaaS. כל סכמה היא מרחב שמות נפרד: טבלאות, אינדקסים, פונקציות אינן חופפות. גיבויים ומיגרציות הם פעולה אחת לכל מסד נתונים.
כיצד להבטיח בידוד נתונים?
יצירת סכמה בעת רישום
תהליך טיפוסי: לקוח חדש → POST /api/tenants → יצירת רשומה ב-public.tenants → ביצוע DDL לסכמה חדשה. קוד TypeScript:
// lib/tenant-provisioning.ts
import { db, adminDb } from './db';
export async function createTenantSchema(tenantSlug: string): Promise<string> {
const schemaName = `tenant_${tenantSlug.replace(/-/g, '_')}`;
// Транзакция в admin соединении
await adminDb.$transaction(async (tx) => {
await tx.$executeRawUnsafe(`CREATE SCHEMA "${schemaName}"`);
await tx.$executeRawUnsafe(`
SET search_path TO "${schemaName}";
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE team_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
joined_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX ON projects (created_at DESC);
CREATE INDEX ON team_members (user_id);
`);
});
return schemaName;
}חשוב: DDL מבוצע דרך חיבור מנהל עם הרשאות יצירת סכמות. הטרנזקציה מבטיחה אטומיות—אם משהו נכשל, הסכמה לא נוצרת.
Prisma: עקיפת מגבלות ORM
Prisma אינו תומך באופן טבעי במספר סכמות. הפתרון הוא // lib/tenant-provisioning.ts import { db, adminDb } from './db'; export async function createTenantSchema(tenantSlug: string): Promise<string> { const schemaName = `tenant_${tenantSlug.replace(/-/g, '_')}`; // Транзакция в admin соединении await adminDb.$transaction(async (tx) => { await tx.$executeRawUnsafe(`CREATE SCHEMA "${schemaName}"`); await tx.$executeRawUnsafe(` SET search_path TO "${schemaName}"; CREATE TABLE projects ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE TABLE team_members ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'member', joined_at TIMESTAMPTZ DEFAULT NOW() ); CREATE INDEX ON projects (created_at DESC); CREATE INDEX ON team_members (user_id); `); }); return schemaName; } דינמי דרך middleware. אנו יוצרים מחלקת search_path שמחליפה את ההקשר לסכמה המתאימה לפני כל שאילתה:
// lib/tenant-client.ts
import { PrismaClient } from '@prisma/client';
export class TenantPrismaClient {
private client: PrismaClient;
private schema: string;
constructor(schema: string) {
this.schema = schema;
this.client = new PrismaClient();
// Middleware: устанавливаем search_path перед каждым запросом
this.client.$use(async (params, next) => {
await this.client.$executeRawUnsafe(
`SET search_path TO "${this.schema}", public`
);
return next(params);
});
}
get db() {
return this.client;
}
async disconnect() {
await this.client.$disconnect();
}
}
// Фабрика с кэшем
const clients = new Map<string, TenantPrismaClient>();
export async function getTenantClient(tenantId: string): Promise<TenantPrismaClient> {
if (clients.has(tenantId)) {
return clients.get(tenantId)!;
}
const tenant = await masterDb.tenant.findUniqueOrThrow({
where: { id: tenantId },
select: { schemaName: true }
});
const client = new TenantPrismaClient(tenant.schemaName);
clients.set(tenantId, client);
return client;
}Middleware עדיף על פני search_path גלובלי מכיוון שבפרודקשן משרתים מספר לקוחות בו-זמנית. כל TenantPrismaClient מחזיק חיבור משלו; ההחלפה מתרחשת רק בתוך אותו חיבור—בטוח עבור אחרים.
חלופה: Kysely
ORM עם תמיכה גמישה יותר בסכמות דינמיות הוא Kysely. הוא מאפשר להגדיר // lib/tenant-client.ts import { PrismaClient } from '@prisma/client'; export class TenantPrismaClient { private client: PrismaClient; private schema: string; constructor(schema: string) { this.schema = schema; this.client = new PrismaClient(); // Middleware: устанавливаем search_path перед каждым запросом this.client.$use(async (params, next) => { await this.client.$executeRawUnsafe( `SET search_path TO "${this.schema}", public` ); return next(params); }); } get db() { return this.client; } async disconnect() { await this.client.$disconnect(); } } // Фабрика с кэшем const clients = new Map<string, TenantPrismaClient>(); export async function getTenantClient(tenantId: string): Promise<TenantPrismaClient> { if (clients.has(tenantId)) { return clients.get(tenantId)!; } const tenant = await masterDb.tenant.findUniqueOrThrow({ where: { id: tenantId }, select: { schemaName: true } }); const client = new TenantPrismaClient(tenant.schemaName); clients.set(tenantId, client); return client; } ישירות בעת יצירת מאגר החיבורים:
import { Kysely, PostgresDialect } from 'kysely';
import { Pool } from 'pg';
function createTenantDb(schemaName: string) {
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
pool.on('connect', (client) => {
client.query(`SET search_path TO "${schemaName}", public`);
});
return new Kysely({
dialect: new PostgresDialect({ pool }),
});
}
const tenantDb = createTenantDb('tenant_acme');
const projects = await tenantDb
.selectFrom('projects')
.selectAll()
.orderBy('created_at', 'desc')
.execute();
Kysely קל יותר מ-Prisma ונותן יותר שליטה. אבל בפרויקטים עם Prisma קיים, גישת ה-middleware עובדת בצורה אמינה.
מיגרציות על פני כל הסכמות
כאשר מבנה הטבלאות משתנה, יש להחיל DDL על כל הסכמות. אנו כותבים סקריפט שעובר על הלקוחות ומבצע את המיגרציה ברצף:
// scripts/migrate-schemas.ts
import { adminDb } from '../lib/db';
async function migrateAllSchemas(migration: string) {
const tenants = await masterDb.tenant.findMany({
select: { schemaName: true, slug: true }
});
for (const tenant of tenants) {
console.log(`Migrating ${tenant.slug}...`);
try {
await adminDb.$executeRawUnsafe(`
SET search_path TO "${tenant.schemaName}";
${migration}
`);
} catch (error) {
console.error(`Failed: ${tenant.slug}`, error);
}
}
}
migrateAllSchemas(`
ALTER TABLE projects ADD COLUMN IF NOT EXISTS archived_at TIMESTAMPTZ;
CREATE INDEX IF NOT EXISTS projects_archived_at ON projects (archived_at);
`);
כדי למנוע השבתה, מיגרציות מבוצעות בחלון זמן עם עומס נמוך. אנו משתמשים ב-TenantPrismaClient/search_path עבור אידמפוטנטיות. אם סכמה אחת נכשלת, האחרות אינן נחסמות.
שאילתות חוצות-לקוחות לאנליטיקה
יתרון אחד של Schema-per-Tenant הוא היכולת לאגד נתונים על פני כל הלקוחות. דוגמה: ספירת פרויקטים על פני כל הלקוחות:
SELECT t.slug as tenant, COUNT(p.id) as project_count
FROM public.tenants t
CROSS JOIN LATERAL (
SELECT id FROM tenant_acme.projects
UNION ALL
SELECT id FROM tenant_globex.projects
-- ...динамически строится из списка тенантов
) p(id)
GROUP BY t.slug;לפרודקשן, אנו משתמשים בפונקציית PL/pgSQL שבונה את השאילתה דינמית על בסיס סכמות פעילות.
אבטחת רמת שורה (אופציונלי)
בתוך סכמה, ניתן להגביל עוד יותר את גישת המשתמשים באמצעות RLS. לדוגמה, משתמש רואה רק את הפרויקטים שלו. זה שימושי רק אם מספר משתמשים עם הרשאות שונות פועלים בתוך אותה סכמה. עבור סכמה עם לקוח יחיד, בידוד ברמת הסכמה מספיק.
השוואת גישות לריבוי-דיירים
| קריטריון | Database-per-Tenant | Schema-per-Tenant | Shared Table |
|---|---|---|---|
| בידוד | מלא | גבוה | נמוך |
| ניהול | מורכב (N מסדי נתונים) | בינוני (מסד נתונים אחד) | פשוט |
| שאילתות חוצות-לקוחות | בלתי אפשרי | אפשרי | קל |
| מגבלת PostgreSQL | מקסימום 4 GB למסדי נתונים (בפועל ~1000) | ~10,000 סכמות | כמעט אין |
| מורכבות מיגרציה | N פעולות | N פעולות | פעולה אחת |
| סיכון לדליפת נתונים | מינימלי | נמוך (עם הגדרה נכונה) | גבוה |
Schema-per-Tenant נבחר כאשר הלקוחות נעים בין 50 ל-2000, נדרש בידוד, אבל יש לייעל את עלויות הניהול. גישה זו מפחיתה עלויות תפעול תשתית בעד 40% בהשוואה ל-Database-per-Tenant.
בעיות נפוצות ופתרונות
| בעיה | פתרון |
|---|---|
| שגיאה ביצירת סכמה ללקוח חדש | שימוש בטרנזקציה בחיבור המנהל; גלגול אחורה של הסכמה במקרה של כישלון |
| צורך בהחלפת סכמה ב-Prisma | Middleware שמגדיר import { Kysely, PostgresDialect } from 'kysely'; import { Pool } from 'pg'; function createTenantDb(schemaName: string) { const pool = new Pool({ connectionString: process.env.DATABASE_URL, }); pool.on('connect', (client) => { client.query(`SET search_path TO "${schemaName}", public`); }); return new Kysely({ dialect: new PostgresDialect({ pool }), }); } const tenantDb = createTenantDb('tenant_acme'); const projects = await tenantDb .selectFrom('projects') .selectAll() .orderBy('created_at', 'desc') .execute(); לפני כל שאילתה |
| ביצועים ירודים של שאילתות חוצות-לקוחות | שימוש בפונקציית PL/pgSQL עם SQL דינמי, אינדקס שדות נפוצים |
תהליך העבודה שלנו: מרעיון לפריסה
אנו מיישמים ארכיטקטורת ריבוי-דיירים ב-4–7 ימי עבודה. שלבים:
- ניתוח — דיון במודל הבידוד, לוגיקת הרישום, תוכנית המיגרציה. איסוף דרישות אבטחה.
- עיצוב — ציור ERD, הגדרת טבלאות משותפות (לקוחות, תוכניות) וסכמות לקוחות. בחירת מחסנית: Prisma או Kysely.
- יישום — כתיבת קוד הקצאה, middleware ל-ORM, סקריפטים של מיגרציה. בדיקה על מספר לקוחות.
- בדיקות — בדיקות עומס, אימות בידוד, תרחישי כישלון. שימוש בסביבת staging.
- פריסה — העברת לקוחות קיימים לארכיטקטורה החדשה (אם יש מורשת). השקה לפרודקשן.
מה כלול
- תיעוד ארכיטקטוני (דיאגרמות, תיאורי סכמות)
- קוד מקור להקצאת לקוחות ומודולי לקוח למסד נתונים
- סקריפטים של מיגרציה והוראות פריסה
- גישה לריפוזיטורי ול-CI/CD
- חודש אחד של תמיכה בסיסית לאחר המסירה
למה לבחור בנו
יש לנו מעל 7 שנות ניסיון מסחרי עם PostgreSQL ומוצרי SaaS. סיפקנו מעל 15 פרויקטים עם ארכיטקטורת ריבוי-דיירים. אנו מבטיחים סודיות נתונים—אנו חותמים על NDAs. אנו מספקים מעקב זמן שקוף ודוחות שבועיים. צרו קשר כדי לדון בפרויקט שלכם ולקבל הצעה מותאמת אישית.







