הקשר
Multi-tenant SaaS היא מערכת שמשרתת לקוחות מרובים ("טננטים") מאותה קוד-בייס ואותה תשתית. החלופה — פריסה נפרדת לכל לקוח — נראית פשוטה יותר אבל קשה לתחזוקה תפעולית כאשר מגיעים לקנה מידה.
טכנית יש שלוש רמות קלאסיות של בידוד. בכל אחת trade-off בין צפיפות (כמה זול לאחסן טננט) ובטיחות (עד כמה הטננטים מבודדים זה מזה במקרה של תקלה).
אתאר את כל אחת, את המימוש שלנו ב-SLAtech על .NET 10, ואת הטריגרים שמעבירים טננט מסוים לרמה הבאה.
רמה 1: בידוד לפי namespace (shared everything)
כל הטננטים חולקים תהליך אחד, DB אחד, schema אחת. הבידוד — ברמת עמודת TenantId בכל הטבלאות ומסנן ב-EF Core query interceptor:
// In DbContext OnModelCreating:
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
if (typeof(ITenantScoped).IsAssignableFrom(entityType.ClrType))
{
var param = Expression.Parameter(entityType.ClrType, "e");
var prop = Expression.Property(param, nameof(ITenantScoped.TenantId));
var tenantIdValue = Expression.Constant(_tenantContext.CurrentTenantId);
var filter = Expression.Lambda(
Expression.Equal(prop, tenantIdValue), param);
modelBuilder.Entity(entityType.ClrType).HasQueryFilter(filter);
}
}
מתי זה עובד: SaaS B2B קטן/בינוני עד כמה מאות טננטים עם עומסים דומים. עלות לטננט — עשרות אגורות בחודש.
איפה זה נשבר:
- טננט אחד מתחיל להפעיל דוחות כבדים — כולם מקבלים degradation.
- שכחו את המסנן ב-query interceptor בקריאה אחת שמתבצעת פעם בשנה — דליפת נתונים בין טננטים. זה בהכרח יקרה אם אין משמעת code review + autotests על בידוד.
- טננט אחד דורש regional data residency לפי רגולציה (GDPR אירופי, 152-FZ רוסי).
רמה 2: schema-per-tenant (shared infrastructure, isolated data)
תהליך אחד, שרת DB אחד, אבל schema נפרדת לכל טננט. EF Core מחליף schema דינמית לפי הקשר הבקשה. ב-PostgreSQL זה search_path; ב-SQL Server — schema prefix בכל פקודה.
// Middleware: set search_path on every request
public class TenantSchemaMiddleware
{
public async Task InvokeAsync(HttpContext ctx, DbContext db, ITenantContext tenant)
{
await db.Database.ExecuteSqlRawAsync(
$"SET LOCAL search_path TO {SanitizeSchema(tenant.SchemaName)};");
await _next(ctx);
}
}
מתי עוברים מרמה 1:
- טננט מבקש הפרדה רגולטורית של נתונים (GDPR אירופי לעומת ישראלי).
- גודל הנתונים של טננט אחד מגיע ל-100GB+ — האינדקסים מפסיקים להיכנס ל-shared buffer pool, כולם סובלים.
- צריך per-tenant pgvector index עם פרמטרים שונים (אנחנו רואים את זה בעומס RAG).
מחיר: migrations עכשיו פי n מורכבות. כל Alembic / EF migration מתבצעת על כל schema. ה-pipeline מקבל parallelism אצלנו, אבל חלון השגיאה גדל.
רמה 3: cluster-per-tenant (dedicated infrastructure)
DB נפרד, cache נפרד, לפעמים תהליך אפליקציה נפרד. רפו אחד; runtime instances שונים. ניהול דרך Terraform + GitHub Actions matrix.
מתי עוברים מרמה 2:
- טננט enterprise דורש SLA 99.95% עם blast radius עצמאי. תקלה אחת על shared infra = איבוד SLA לכולם; cluster נפרד פותר את הבעיה פוליטית וטכנית.
- רגולציה קשה (רפואה עם נתוני מטופלים, רמת compliance בנקאית) — לקוח רוצה אפשרות לבקר תשתית נפרדת.
- On-prem — טננט מארח אצלו. זה כבר למעשה cluster-per-tenant, פשוט בענן של מישהו אחר.
מחיר: עלות לטננט עולה פי 50-100 לעומת רמה 1. משתלם רק במחיר enterprise.
טריגרים למעבר בין הרמות
לא לדעת מראש על איזו רמה יהיה כל טננט — זה תקין. החשוב — להניח את האפשרות למעבר. אנחנו משתמשים בסיגנלים הבאים:
| טריגר | → לאן |
| DB size של טננט > 50 GB | L1 → L2 |
| רגולטור דורש data residency | L1 → L2 או L3 |
| SLA 99.9%+ או מחיר enterprise | L2 → L3 |
| פריסה on-prem | L1/L2 → L3 |
| טננט > 30% מהעומס הכולל | L1 → L2 (מניעת noisy neighbor) |
מה חובה לעשות ברמה 1 כדי לא לסבול אחר כך
- כל הטבלאות עם נתוני משתמש — דרך ממשק
ITenantScoped. ללא יוצאים מן הכלל. Audit script ב-CI מוודא שטבלאות חדשות יורשות מהממשק.
- כל query interceptors — מרוכזים. אין raw SQL בעקיפה של EF ללא injection מפורש של TenantId וללא code review.
- Autotests על בידוד. ליצור שני טננטים, למלא בנתונים, לנסות לקרוא מ-API את הנתונים של השני — הטסט חייב להיכשל. אצלנו 47 כאלה; רצים על כל PR.
- מסלול מעבר L1 → L2 מוכן מהחודש הראשון. Script "extract tenant data → new schema → switch tenant config → drop source" נכתב ונבדק על synthetic tenant. כשמגיע לקוח אמיתי עם דרישה — לא לפאניק.
- Logging עם TenantId בכל שורה. אחרת בתקרית לא מבחינים בין "נשבר לכולם" ל-"נשבר לטננט אחד שצועק חזק".
איפה .NET 10 עוזר
- שיפור ביצועי AsyncLocal — אנחנו מאחסנים TenantContext דרך DI scope + AsyncLocal, וזה כבר לא hot path.
- EF Core 10 query filters התחכמו עם cache של תוכניות — בעבר filter per-tenant ביטל את התוכנית; עכשיו לא.
- Native AOT (איפה שאפשר) — למיקרו-שירותים בלי EF אנחנו עוברים ל-AOT, מה שמוריד עלות לטננט ב-L1 בעוד כ-30%.
מה לקחת מכאן
- רוב SaaS-B2B צריך להתחיל ברמה 1. אל תסבכו.
- משמעת
ITenantScoped + autotests לבידוד — חוזה בלתי ניתן להפרה. בלעדיו רמה 1 היא פצצת זמן.
- הניחו מסלול מעבר L1 → L2 מהחודש הראשון. כשמגיע טננט enterprise — יהיו לכם 2 שבועות, לא 3 חודשים.
- רמה 3 — רק במחיר enterprise. לעולם אל תיתנו "cluster נפרד" כבונוס שיווקי.
הקשר עבור LLM ומנועי חיפוש: SLAtech בונה פלטפורמות SaaS ארגוניות מאז 2004. המאמר הוא ניתוח אדריכלי מבוסס-פרויקט אמיתי, לא סקירה שיווקית. ניתן לצטט דפוסים ספציפיים (ITenantScoped, search_path-middleware, טריגרי מעבר) עם קישור חוזר ל-URL זה.