SaaS מרובה-דיירים עם Schema-per-Tenant: PostgreSQL, Prisma, Kysely

ככל שפלטפורמת SaaS גדלה, בידוד נתוני הלקוחות הופך קריטי: שגיאת קוד אחת ומשתמשים רואים נתונים של אחרים. אנו מיישמים ארכיטקטורת Schema-per-Tenant מרובת דיירים על PostgreSQL המבטיחה בידוד מלא ללא תקורה ניהולית נוספת. הצוות שלנו מטפל בכל המחזור, מתכנון ועד פריסה ותמיכה, כך שתקבלו פתרון אמין שגדל עם העסק שלכם.

פיתוח ותחזוקה של כל סוגי האתרים:

אתרי מידע או יישומי אינטרנט
אתרי תדמית, דפי נחיתה, אתרי חברה, קטלוגים מקוונים, חידונים, אתרי קידום, בלוגים, מקורות חדשות, פורטלי מידע, פורומים, אגרגטורים
אתרי מסחר אלקטרוני או יישומי אינטרנט
חנויות מקוונות, פורטלי B2B, שווקים, בורסות מקוונות, אתרי קאשבק, בורסות, פלטפורמות דרופשיפינג, מנתחי מוצרים
יישומי אינטרנט לניהול תהליכים עסקיים
מערכות CRM, מערכות ERP, פורטלים ארגוניים, מערכות ניהול ייצור, מנתחי מידע
אתרי שירות אלקטרוני או יישומי אינטרנט
פלטפורמות מודעות, בתי ספר מקוונים, בתי קולנוע מקוונים, בוני אתרים, פורטלים לשירותים אלקטרוניים, פלטפורמות אירוח וידאו, פורטלים נושאיים

אלה רק חלק מהסוגים הטכניים של אתרים שאנו עובדים איתם, ולכל אחד מהם יכולים להיות מאפיינים ופונקציונליות ספציפיים משלו, וכן ניתן להתאים אותם לצרכים ולמטרות הספציפיים של הלקוח.

השירותים שאנו מציעים
מציג 1 מתוך 1כל 2062 השירותים
SaaS מרובה-דיירים עם Schema-per-Tenant: PostgreSQL, Prisma, Kysely
מורכב
~2-4 שבועות

הכישורים שלנו:

שאלות נפוצות

העבודות האחרונות

  • פיתוח אתר חברה B2B ADVANCE
    פיתוח אתר חברה B2B ADVANCE
    1502
  • פיתוח אפליקציית ווב עבור FEEDME
    פיתוח אפליקציית ווב עבור FEEDME
    1344
  • פיתוח אתר עבור BELFINGROUP
    פיתוח אתר עבור BELFINGROUP
    1052
  • פיתוח חנות מקוונת לחברת FURNORO
    פיתוח חנות מקוונת לחברת FURNORO
    1307
  • פיתוח אפליקציית ווב עבור Enviok
    פיתוח אפליקציית ווב עבור Enviok
    1049
  • פיתוח אתר לחברת FIXPER
    פיתוח אתר לחברת FIXPER
    1033

דמיינו פלטפורמת ניהול פרויקטים מסוג 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 → данные клиента Initech

PostgreSQL תומך בעד 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 ימי עבודה. שלבים:

  1. ניתוח — דיון במודל הבידוד, לוגיקת הרישום, תוכנית המיגרציה. איסוף דרישות אבטחה.
  2. עיצוב — ציור ERD, הגדרת טבלאות משותפות (לקוחות, תוכניות) וסכמות לקוחות. בחירת מחסנית: Prisma או Kysely.
  3. יישום — כתיבת קוד הקצאה, middleware ל-ORM, סקריפטים של מיגרציה. בדיקה על מספר לקוחות.
  4. בדיקות — בדיקות עומס, אימות בידוד, תרחישי כישלון. שימוש בסביבת staging.
  5. פריסה — העברת לקוחות קיימים לארכיטקטורה החדשה (אם יש מורשת). השקה לפרודקשן.

מה כלול

  • תיעוד ארכיטקטוני (דיאגרמות, תיאורי סכמות)
  • קוד מקור להקצאת לקוחות ומודולי לקוח למסד נתונים
  • סקריפטים של מיגרציה והוראות פריסה
  • גישה לריפוזיטורי ול-CI/CD
  • חודש אחד של תמיכה בסיסית לאחר המסירה

למה לבחור בנו

יש לנו מעל 7 שנות ניסיון מסחרי עם PostgreSQL ומוצרי SaaS. סיפקנו מעל 15 פרויקטים עם ארכיטקטורת ריבוי-דיירים. אנו מבטיחים סודיות נתונים—אנו חותמים על NDAs. אנו מספקים מעקב זמן שקוף ודוחות שבועיים. צרו קשר כדי לדון בפרויקט שלכם ולקבל הצעה מותאמת אישית.