أنماط بنية تعدد المستأجرين: ما وراء الأساسيات
أنماط بنية متقدمة لتعدد المستأجرين لمنصات SaaS — من استراتيجيات عزل البيانات إلى التخصيص لكل مستأجر والتوسع.

تتوقف معظم أدلة تعدد المستأجرين عند "أضف عمود tenant_id وقم بتصفية استعلاماتك". هذا يكفيك للأشهر القليلة الأولى. ثم يبدأ مستأجر لديه 10 أضعاف البيانات في تدهور الأداء للجميع، ويسأل عميل محتمل من الشركات عن العلامة البيضاء (white-labeling)، ويرغب شريك في دمج معالج الدفع الخاص به. فجأة، لم تعد الأساسيات كافية.
تغطي هذه المقالة الأنماط التي طبقتها في أنظمة تعدد المستأجرين الإنتاجية والتي تتجاوز نطاق الاستعلام. هذه هي القرارات المعمارية التي تفصل النموذج الأولي عن المنصة.
أنماط العزل المتقدمة
نموذج الطبقات الثلاث لقاعدة البيانات المشتركة، والمخطط لكل مستأجر (schema-per-tenant)، وقاعدة البيانات لكل مستأجر (database-per-tenant) مفهوم جيدًا. ما هو أقل نقاشًا هو النهج الهجين الذي ينتهي المطاف بمعظم أنظمة الإنتاج باستخدامه فعليًا.
العزل المتدرج حسب شريحة العملاء
من الناحية العملية، نادرًا ما تختار مستوى عزل واحدًا وتطبقه بشكل موحد. بدلاً من ذلك، تقوم بتصنيف عزلك بناءً على ما تحتاجه كل شريحة عملاء وما هي مستعدة لدفعه.
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!);
}
}
تخدم الطبقة المشتركة عملاء الخدمة الذاتية. يخدم عزل المخطط (schema isolation) عملاء السوق المتوسط الذين يحتاجون إلى وثائق الامتثال. تخدم قواعد البيانات المخصصة (dedicated databases) حسابات الشركات التي تتطلب فصلًا ماديًا للبيانات.
القيد التصميمي الرئيسي: يجب ألا يعرف كود تطبيقك أو يهتم بالطبقة التي يوجد بها المستأجر. يتم حل الاتصال في البرمجيات الوسيطة (middleware)، ويستخدم كل شيء في المراحل اللاحقة نفس واجهة الاستعلام. إذا كان معالج المسار (route handler) مضطرًا للتحقق من مستوى العزل لتحديد كيفية استعلام البيانات، فإن تجريدك يتسرب.
أمان مستوى الصف (Row-Level Security) كشبكة أمان
يُعد أمان مستوى الصف (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. معها، تنطبق السياسات على الجميع.
مسار البرمجيات الوسيطة (Middleware Pipeline) الواعي بالمستأجر
تتعامل البرمجيات الوسيطة لحل المستأجر (tenant resolution middleware) التي غطيتها في مقالتي السابقة حول الواجهات الخلفية متعددة المستأجرين مع الأساسيات: استخراج المستأجر من النطاق الفرعي أو 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);
}
}
يتمتع كل معالج لاحق الآن بالوصول إلى سياق المستأجر الكامل دون إجراء مكالمات إضافية لقاعدة البيانات. يعني التخزين المؤقت لمدة خمس دقائق أن تغيير الخطة ينتشر بسرعة، لكنك لا تضرب قاعدة البيانات في كل طلب للتحقق مما إذا كانت الميزة ممكّنة.
التكوين الديناميكي لكل مستأجر
بالإضافة إلى علامات الميزات (feature flags)، غالبًا ما يحتاج المستأجرون إلى سلوك قابل للتكوين. قد تسمح منصة مطاعم لكل مطعم بتعيين أوقات قطع الطلبات الخاصة به، ومعدلات الضرائب، ومناطق التسليم، وتفضيلات الإشعارات. قد تسمح أداة إدارة المشاريع لكل مساحة عمل بتكوين حقول مخصصة، ومراحل سير العمل، وقواعد الإشعارات.
النمط الذي أستخدمه يفصل التكوين إلى عمود JSON تم التحقق من صحته بواسطة مخطط (schema-validated JSON column):
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([]),
});