SaaSBackendEngineering

SaaS متعدد المستأجرين مع PostgreSQL وPrisma: أنماط تصمد في الإنتاج

كيف تصمم تطبيقات SaaS متعددة المستأجرين باستخدام PostgreSQL وPrisma بطريقة آمنة وقابلة للصيانة مع نمو العملاء.

BahrTech Team
٢٢ أبريل ٢٠٢٦ · 4 دقيقة قراءة

بعد ستة أشهر من إطلاق منتج SaaS، وصلت تذكرة دعم: "أرى فواتير لا تخص حسابي." المطور يفحص الاستعلام. لا يوجد فلتر tenantId. المسار كُتب في Sprint تحت ضغط الموعد النهائي ولم ينتبه أحد لأنه كان يعمل بشكل صحيح في بيئة الاختبار مع عيادة تجريبية واحدة.

تسرب بيانات المستأجرين ليس ثغرة نادرة. إنه النتيجة الطبيعية لإضافة سياق المستأجر كفكرة لاحقة لا كجزء من تصميم البنية. هذا المقال يتناول الأنماط التي نستخدمها لجعل العزل الصحيح للمستأجرين هو مسار المقاومة الأدنى.

اختر نموذج العزل مبكراً

ثلاثة نماذج موجودة على الطيف في PostgreSQL، والتبديل بينها بعد نضج التطبيق مكلف:

Schema لكل مستأجر يمنح كل عميل schema منفصل في PostgreSQL. الهجرات تعمل per-schema مما يجعلها أبطأ وأعقد. يناسب عدة مئات من المستأجرين قبل أن تصبح الإدارة عبئاً.

قاعدة بيانات لكل مستأجر يعظم العزل لكن بتكلفة بنية تحتية أعلى بكثير وإدارة اتصالات أصعب. يُحجز للمؤسسات ذات متطلبات إقامة البيانات الصارمة.

قاعدة مشتركة مع عزل على مستوى الصف هو الخيار الافتراضي الصحيح لمعظم SaaS في مراحله المبكرة والمتوسطة. schema واحد، وهجرة واحدة، وtenantId على كل جدول مملوك لمستأجر. هذا ما نغطيه هنا.

اجعل tenantId جزءاً من كل جدول أساسي — وافهرسه بشكل صحيح

يجب أن يظهر tenantId كمفتاح خارجي أساسي على كل جدول ينتمي لمستأجر. لا تعتمد على الجوينات أو ذاكرة التطبيق لاسترداد ملكية المستأجر.

model Appointment {
  id        String   @id @default(cuid())
  tenantId  String
  patientId String
  startsAt  DateTime
  status    String

  tenant  Tenant  @relation(fields: [tenantId], references: [id])
  patient Patient @relation(fields: [patientId], references: [id])

  @@index([tenantId, startsAt])
  @@index([tenantId, patientId])
  @@index([tenantId, status])
}

الفهارس المركبة مهمة. استعلام يبحث عن tenantId = 'abc' AND startsAt BETWEEN ... بدون فهرس مركب سيفحص كل المواعيد عبر جميع المستأجرين قبل التصفية. مع آلاف السجلات هذا غير مرئي؛ مع الملايين يصبح أبطأ استعلام في تطبيقك.

ضع نطاق الاستعلام عند الحدود، لا اختيارياً في الخدمات

أكثر نمط موثوق هو تمرير tenantId كمعامل صريح مطلوب عند كل نقطة وصول للبيانات. ليس متغيراً عالمياً، وليس مستخرجاً من singleton سياق داخل الدالة — يُمرر من المستدعي.

export async function listAppointments({
  prisma,
  tenantId,
  from,
  to,
}: {
  prisma: PrismaClient;
  tenantId: string;
  from: Date;
  to: Date;
}) {
  return prisma.appointment.findMany({
    where: { tenantId, startsAt: { gte: from, lt: to } },
    orderBy: { startsAt: "asc" },
  });
}

الهدف ليس أن يتذكر المطور فلتراً في كل مرة. الهدف أن تُصمم واجهات الخدمة بحيث يبدو حذف tenantId خاطئاً بوضوح لمن يقرأ الكود لاحقاً.

امتدادات Prisma Client كشبكة أمان إضافية

تتيح امتدادات Prisma Client بناء عميل بنطاق مستأجر يحقن tenantId تلقائياً في كل استعلام:

export function tenantClient(prisma: PrismaClient, tenantId: string) {
  return prisma.$extends({
    query: {
      $allModels: {
        async findMany({ args, query }) {
          args.where = { ...args.where, tenantId };
          return query(args);
        },
      },
    },
  });
}

هذا ليس بديلاً عن الفلترة الصريحة — بل طبقة حماية إضافية. استخدمه في معالجات الطلبات؛ واحتفظ بالمعاملات الصريحة في المهام الخلفية والهجرات حيث تحتاج أحياناً وصولاً عابراً للمستأجرين بشكل مقصود.

المهام الخلفية هي المصدر الأكثر شيوعاً لأخطاء خلط البيانات

المهام المجدولة لا تمر عبر middleware الطلبات. غالباً تقرأ من قاعدة البيانات دون سياق مستأجر وتعيد أو تعدل بيانات العميل الخطأ عن طريق الخطأ.

async function sendDailyReminders() {
  const tenants = await prisma.tenant.findMany({ where: { active: true } });
  for (const tenant of tenants) {
    await processRemindersForTenant(tenant.id);
  }
}

async function processRemindersForTenant(tenantId: string) {
  const appointments = await listAppointments({
    prisma,
    tenantId,
    from: startOfDay(new Date()),
    to: endOfDay(new Date()),
  });
  // ...
}

سجّل tenantId في بداية كل تشغيل مهمة بنطاق مستأجر. عند الفشل يجب أن يعرف المشغلون فوراً أي مستأجر تأثر وهل إعادة المحاولة آمنة.

الهجرات عبر جميع المستأجرين تحتاج استراتيجية

في نموذج الـ schema المشترك، هجرة Prisma واحدة تطبق على جميع المستأجرين في نفس الوقت. هذا جيد عادة، لكن بعض السيناريوهات تحتاج عناية:

إضافة عمود NOT NULL لجدول كبير تتطلب قيمة افتراضية أو هجرة backfill قبل تطبيق القيد. افعل ذلك في نشرين منفصلين: أضف العمود nullable أولاً وقم بـ backfill، ثم اجعل القيد أكثر صرامة.

إعادة تسمية عمود أثناء تشغيل الكود القديم يسبب توقفاً إذا تم في خطوة واحدة. استخدم نهج expand-contract: أضف العمود الجديد، اكتب في الاثنين، اقرأ من الجديد، احذف القديم.

Connection Pooling مع PgBouncer وPrisma

اتصالات PostgreSQL المباشرة مكلفة. عند عشرين أو ثلاثين مستخدماً متزامناً، تطبيق Node.js SaaS مع PrismaClient واحد لكل طلب سيستنزف حد اتصالات قاعدة البيانات.

أضف PgBouncer في وضع transaction-mode وحدّث سلسلة اتصال Prisma:

DATABASE_URL="postgres://user:pass@pgbouncer-host:6432/db?pgbouncer=true"

المعامل ?pgbouncer=true يخبر Prisma بتجاوز الـ prepared statements غير المتوافقة مع transaction-mode pooling. بدونه تظهر أخطاء غريبة تحت الضغط فقط.

الوصول العابر للمستأجرين للإدارة والتحليلات

العزل على مستوى الصف لا يعني عدم إمكانية الاستعلام عبر المستأجرين أبداً. لوحات الإدارة وتقارير الفوترة والتحليلات الإجمالية تحتاج وصولاً مشروعاً عبر المستأجرين. اجعل ذلك صريحاً:

// خدمة للإدارة فقط — لا تُستدعى من معالجات طلبات المستأجر
export async function getSystemStats(prisma: PrismaClient) {
  return prisma.appointment.groupBy({
    by: ["tenantId"],
    _count: { id: true },
    where: { startsAt: { gte: startOfMonth(new Date()) } },
  });
}

القاعدة ليست "لا تتجاوز حدود المستأجر أبداً." القاعدة هي "اجعله مقصوداً وقابلاً للتدقيق." الاستعلامات العابرة للمستأجرين تنتمي لـ namespace إدارة مسماً بوضوح، يفضل وضعه خلف بوابة مصادقة منفصلة.

التعددية المتينة بحاجة في الأساس إلى انضباط: نمذج المستأجر، وافهرس بناء عليه، واستعلم من خلاله، واجعل كل مهمة وهجرة واعية بسياقه. الأنماط أعلاه صغيرة بما يكفي لإعدادها في اليوم الأول — وباهظة التكلفة في الإصلاح في الشهر السادس.

للطبقة التي تجلس فوق هذا النموذج، راجع توسيع واجهات GraphQL مع NestJS.

الوسوم
SaaSBackendEngineering