Multi-Tenancy trên .NET 10 — Chiến lược Cô lập Dữ liệu, Row-Level Security và Caching cho SaaS Production 2026

Posted on: 4/17/2026 12:11:52 PM

Khi xây dựng một sản phẩm SaaS (Software as a Service), một trong những quyết định kiến trúc quan trọng nhất là multi-tenancy — cách bạn cho phép nhiều tổ chức (tenant) chia sẻ cùng một ứng dụng mà vẫn đảm bảo cô lập dữ liệu, bảo mậthiệu năng cho từng tenant. Quyết định này ảnh hưởng sâu rộng đến database schema, caching, security, deployment và cả chi phí vận hành. Bài viết này phân tích chi tiết các chiến lược multi-tenancy trên .NET 10 năm 2026, từ mô hình dữ liệu đến Row-Level Security, caching đa tầng và giám sát per-tenant.

1. Multi-Tenancy là gì và tại sao quan trọng?

Trong kiến trúc multi-tenant, một instance duy nhất của ứng dụng phục vụ nhiều khách hàng (tenant). Mỗi tenant có dữ liệu riêng biệt nhưng chạy trên cùng codebase, cùng infrastructure. Đây là nền tảng của mọi sản phẩm SaaS — từ Slack, Notion đến Shopify.

78% SaaS products dùng multi-tenancy (Gartner 2026)
3-5x Giảm chi phí infrastructure so với single-tenant
60% SaaS failures liên quan đến tenant isolation sai
99.95% SLA yêu cầu tối thiểu cho enterprise tenant
graph LR
    T1["Tenant A
(Startup)"] --> APP["SaaS Application
.NET 10"] T2["Tenant B
(Enterprise)"] --> APP T3["Tenant C
(SMB)"] --> APP APP --> CACHE["Redis Cache
(Per-Tenant Keys)"] APP --> DB["Database Layer"] DB --> DB1["Strategy 1:
Shared DB"] DB --> DB2["Strategy 2:
Schema-per-Tenant"] DB --> DB3["Strategy 3:
DB-per-Tenant"] style APP fill:#e94560,stroke:#fff,color:#fff style CACHE fill:#2c3e50,stroke:#e94560,color:#fff style DB fill:#16213e,stroke:#e94560,color:#fff style T1 fill:#f8f9fa,stroke:#e94560,color:#2c3e50 style T2 fill:#f8f9fa,stroke:#e94560,color:#2c3e50 style T3 fill:#f8f9fa,stroke:#e94560,color:#2c3e50

Hình 1: Tổng quan kiến trúc Multi-Tenancy — một ứng dụng phục vụ nhiều tenant với các chiến lược data isolation khác nhau

2. Ba Chiến lược Data Isolation

Mỗi chiến lược có trade-off riêng giữa isolation, chi phíđộ phức tạp vận hành. Không có "best practice" chung — chọn dựa trên yêu cầu compliance, số lượng tenant và ngân sách.

2.1. Shared Database, Shared Schema (Discriminator Column)

Tất cả tenant chia sẻ cùng database, cùng bảng. Mỗi bảng có cột TenantId để phân biệt dữ liệu. Đây là chiến lược đơn giản nhất và tiết kiệm chi phí nhất.

// Entity base class với TenantId
public abstract class TenantEntity
{
    public int Id { get; set; }
    public string TenantId { get; set; } = null!;
}

public class Product : TenantEntity
{
    public string Name { get; set; } = null!;
    public decimal Price { get; set; }
    public string Sku { get; set; } = null!;
}

public class Order : TenantEntity
{
    public DateTime CreatedAt { get; set; }
    public decimal TotalAmount { get; set; }
    public List<OrderItem> Items { get; set; } = [];
}
// EF Core 10 — Global Query Filter cho multi-tenancy
public class AppDbContext : DbContext
{
    private readonly ITenantService _tenantService;

    public AppDbContext(DbContextOptions options, ITenantService tenantService)
        : base(options)
    {
        _tenantService = tenantService;
    }

    public DbSet<Product> Products => Set<Product>();
    public DbSet<Order> Orders => Set<Order>();

    protected override void OnModelCreating(ModelBuilder builder)
    {
        // Global filter — TẤT CẢ query tự động thêm WHERE TenantId = @tenantId
        builder.Entity<Product>()
            .HasQueryFilter(p => p.TenantId == _tenantService.CurrentTenantId);

        builder.Entity<Order>()
            .HasQueryFilter(o => o.TenantId == _tenantService.CurrentTenantId);

        // Composite index để tối ưu query
        builder.Entity<Product>()
            .HasIndex(p => new { p.TenantId, p.Sku })
            .IsUnique();

        builder.Entity<Order>()
            .HasIndex(o => new { o.TenantId, o.CreatedAt });
    }

    public override int SaveChanges()
    {
        SetTenantIdOnNewEntities();
        return base.SaveChanges();
    }

    public override Task<int> SaveChangesAsync(CancellationToken ct = default)
    {
        SetTenantIdOnNewEntities();
        return base.SaveChangesAsync(ct);
    }

    private void SetTenantIdOnNewEntities()
    {
        foreach (var entry in ChangeTracker.Entries<TenantEntity>()
            .Where(e => e.State == EntityState.Added))
        {
            entry.Entity.TenantId = _tenantService.CurrentTenantId;
        }
    }
}

Ưu điểm Shared Database

Chi phí thấp nhất (một database duy nhất), migration đơn giản (chạy một lần cho tất cả tenant), dễ quản lý backup/restore. Phù hợp khi số lượng tenant lớn (hàng nghìn) nhưng mỗi tenant có lượng dữ liệu nhỏ — ví dụ: SaaS quản lý task, CRM cho SMB, nền tảng e-learning.

Rủi ro cần lưu ý

Nếu quên áp dụng Global Query Filter cho một entity, dữ liệu tenant khác sẽ bị lộ — đây là data leak nghiêm trọng. Luôn viết integration test kiểm tra isolation. Ngoài ra, một tenant có lượng dữ liệu lớn bất thường (noisy neighbor) có thể ảnh hưởng hiệu năng query của các tenant khác.

2.2. Shared Database, Schema-per-Tenant

Mỗi tenant có schema riêng trong cùng database. Ví dụ: tenant_abc.Products, tenant_xyz.Products. Isolation tốt hơn shared schema nhưng không tốn chi phí nhiều database.

// Dynamic schema selection dựa trên tenant
public class SchemaPerTenantDbContext : DbContext
{
    private readonly string _schema;

    public SchemaPerTenantDbContext(
        DbContextOptions options,
        ITenantService tenantService) : base(options)
    {
        _schema = $"tenant_{tenantService.CurrentTenantId}";
    }

    protected override void OnModelCreating(ModelBuilder builder)
    {
        builder.HasDefaultSchema(_schema);
        builder.Entity<Product>().ToTable("Products", _schema);
        builder.Entity<Order>().ToTable("Orders", _schema);
    }
}

2.3. Database-per-Tenant

Mỗi tenant có database riêng. Isolation mạnh nhất, phù hợp cho enterprise tenant có yêu cầu compliance cao (HIPAA, PCI-DSS) hoặc cần geo-residency.

// Dynamic connection string resolution
public class TenantConnectionResolver
{
    private readonly Dictionary<string, string> _connections;

    public string GetConnectionString(string tenantId)
    {
        if (_connections.TryGetValue(tenantId, out var conn))
            return conn;

        // Fallback: load từ tenant registry database
        return LoadFromRegistry(tenantId);
    }
}

// DbContext factory cho database-per-tenant
public class TenantDbContextFactory : IDbContextFactory<AppDbContext>
{
    private readonly ITenantService _tenantService;
    private readonly TenantConnectionResolver _resolver;

    public AppDbContext CreateDbContext()
    {
        var connStr = _resolver.GetConnectionString(
            _tenantService.CurrentTenantId);

        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseSqlServer(connStr)
            .Options;

        return new AppDbContext(options, _tenantService);
    }
}
graph TB
    subgraph "Strategy 1: Shared DB"
        SDB["Database"]
        SDB --> T1D["Products
TenantId=A"] SDB --> T2D["Products
TenantId=B"] end subgraph "Strategy 2: Schema-per-Tenant" SDB2["Database"] SDB2 --> S1["Schema: tenant_a
Products, Orders"] SDB2 --> S2["Schema: tenant_b
Products, Orders"] end subgraph "Strategy 3: DB-per-Tenant" DB1["DB: tenant_a"] DB2["DB: tenant_b"] DB3["DB: tenant_c"] end style SDB fill:#e94560,stroke:#fff,color:#fff style SDB2 fill:#2c3e50,stroke:#e94560,color:#fff style DB1 fill:#16213e,stroke:#e94560,color:#fff style DB2 fill:#16213e,stroke:#e94560,color:#fff style DB3 fill:#16213e,stroke:#e94560,color:#fff

Hình 2: Ba chiến lược data isolation — từ shared (trái) đến fully isolated (phải)

3. So sánh Chi tiết Ba Chiến lược

Tiêu chíShared DB + DiscriminatorSchema-per-TenantDatabase-per-Tenant
Mức độ isolationThấp (application-level)Trung bình (DB schema-level)Cao nhất (physical)
Chi phí infrastructureThấp nhấtThấpCao (N databases)
Độ phức tạp migrationĐơn giản (1 lần)Trung bình (N schemas)Cao (N databases)
Noisy neighbor riskCaoTrung bìnhKhông có
Compliance (HIPAA, PCI)Khó đáp ứngTùy thuộc providerDễ đáp ứng nhất
Backup/Restore per tenantKhông hỗ trợ nativeCó thể (schema export)Native support
Số tenant phù hợp1,000 - 100,000+100 - 5,00010 - 500
Phù hợp choB2C SaaS, freemiumB2B SaaS trung bìnhEnterprise, regulated

4. Tenant Resolution — Xác định Tenant từ Request

Trước khi truy vấn dữ liệu, ứng dụng cần biết request thuộc về tenant nào. Có nhiều chiến lược resolution, mỗi cái phù hợp với một kiểu deployment.

4.1. Các chiến lược phổ biến

Chiến lượcVí dụƯu điểmNhược điểm
Subdomainacme.app.comTrực quan, dễ brandingCần wildcard SSL, DNS config
Path prefixapp.com/acme/...Đơn giản, 1 domainRouting phức tạp hơn
Header/ClaimX-Tenant-Id: acmeLinh hoạt cho APIKhông thân thiện browser
JWT Claimtenant_id trong tokenStateless, bảo mậtToken phải refresh khi switch tenant
Custom domainapp.acme.comProfessional nhấtSSL per domain, complex routing
// Tenant Resolution Middleware cho .NET 10
public interface ITenantService
{
    string CurrentTenantId { get; }
    TenantInfo? CurrentTenant { get; }
}

public class TenantMiddleware
{
    private readonly RequestDelegate _next;

    public async Task InvokeAsync(HttpContext context, ITenantService tenantService)
    {
        var tenantId = ResolveTenantId(context);

        if (string.IsNullOrEmpty(tenantId))
        {
            context.Response.StatusCode = 400;
            await context.Response.WriteAsync("Tenant not identified");
            return;
        }

        ((TenantService)tenantService).SetTenant(tenantId);
        await _next(context);
    }

    private static string? ResolveTenantId(HttpContext context)
    {
        // Strategy 1: Subdomain (acme.app.com)
        var host = context.Request.Host.Host;
        var parts = host.Split('.');
        if (parts.Length >= 3)
            return parts[0];

        // Strategy 2: Header
        if (context.Request.Headers.TryGetValue("X-Tenant-Id", out var header))
            return header.ToString();

        // Strategy 3: JWT claim
        var claim = context.User.FindFirst("tenant_id");
        if (claim is not null)
            return claim.Value;

        return null;
    }
}

// Registration trong Program.cs (.NET 10)
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<ITenantService, TenantService>();

var app = builder.Build();
app.UseMiddleware<TenantMiddleware>();

5. Row-Level Security (RLS) — Lớp Bảo vệ ở Database Level

Global Query Filter trong EF Core hoạt động ở application level — nếu có bug hoặc ai đó chạy query trực tiếp vào database, filter sẽ bị bypass. Row-Level Security (RLS) trên SQL Server đảm bảo isolation ở tầng database engine, không phụ thuộc application.

graph TB
    APP["Application
(.NET 10)"] -->|"SET SESSION_CONTEXT
@TenantId = 'acme'"| DB["SQL Server"] DB --> RLS["Row-Level Security
Policy Engine"] RLS -->|"Filter: TenantId = 'acme'"| DATA["Trả về chỉ dữ liệu
của tenant 'acme'"] ADMIN["DBA / Direct Query"] -->|"Cũng bị filter
nếu không có bypass role"| RLS style RLS fill:#e94560,stroke:#fff,color:#fff style DB fill:#2c3e50,stroke:#e94560,color:#fff style APP fill:#f8f9fa,stroke:#e94560,color:#2c3e50

Hình 3: Row-Level Security — database engine tự filter dữ liệu theo tenant, bất kể query đến từ đâu

-- SQL Server — Thiết lập Row-Level Security cho Multi-Tenancy

-- 1. Tạo function kiểm tra tenant
CREATE FUNCTION dbo.fn_TenantFilter(@TenantId NVARCHAR(128))
RETURNS TABLE
WITH SCHEMABINDING
AS
RETURN SELECT 1 AS Result
WHERE @TenantId = CAST(SESSION_CONTEXT(N'TenantId') AS NVARCHAR(128))
   OR IS_MEMBER('db_owner') = 1;  -- DBA bypass

-- 2. Tạo security policy trên bảng Products
CREATE SECURITY POLICY dbo.TenantPolicy_Products
ADD FILTER PREDICATE dbo.fn_TenantFilter(TenantId) ON dbo.Products,
ADD BLOCK PREDICATE dbo.fn_TenantFilter(TenantId) ON dbo.Products
WITH (STATE = ON);

-- 3. Tương tự cho bảng Orders
CREATE SECURITY POLICY dbo.TenantPolicy_Orders
ADD FILTER PREDICATE dbo.fn_TenantFilter(TenantId) ON dbo.Orders,
ADD BLOCK PREDICATE dbo.fn_TenantFilter(TenantId) ON dbo.Orders
WITH (STATE = ON);
// .NET 10 — Set SESSION_CONTEXT trước mỗi query
public class TenantDbConnectionInterceptor : DbConnectionInterceptor
{
    private readonly ITenantService _tenantService;

    public TenantDbConnectionInterceptor(ITenantService tenantService)
    {
        _tenantService = tenantService;
    }

    public override async ValueTask ConnectionOpenedAsync(
        DbConnection connection,
        ConnectionEndEventData eventData,
        CancellationToken ct = default)
    {
        await using var cmd = connection.CreateCommand();
        cmd.CommandText = "EXEC sp_set_session_context @key=N'TenantId', @value=@tenantId";

        var param = cmd.CreateParameter();
        param.ParameterName = "@tenantId";
        param.Value = _tenantService.CurrentTenantId;
        cmd.Parameters.Add(param);

        await cmd.ExecuteNonQueryAsync(ct);
    }
}

// Registration
builder.Services.AddDbContext<AppDbContext>((sp, options) =>
{
    options.UseSqlServer(connectionString)
           .AddInterceptors(sp.GetRequiredService<TenantDbConnectionInterceptor>());
});

Defense in Depth — Kết hợp cả hai lớp

Best practice là dùng cả EF Core Global Query Filter (lớp 1) VÀ SQL Server RLS (lớp 2). Global Query Filter bắt lỗi sớm ở application, RLS đảm bảo an toàn tuyệt đối ở database. Nếu một lớp bị bypass (bug code, raw SQL query), lớp kia vẫn bảo vệ. Đây là nguyên tắc defense in depth quan trọng cho SaaS production.

6. Caching Strategy cho Multi-Tenant System

Caching trong multi-tenant phức tạp hơn single-tenant vì cần đảm bảo: (1) dữ liệu cache không bị lẫn giữa các tenant, (2) một tenant không chiếm hết cache quota, (3) invalidation chính xác per-tenant.

6.1. Tenant-Prefixed Cache Keys

// Cache key strategy — LUÔN prefix TenantId
public class TenantCacheService
{
    private readonly IDatabase _redis;
    private readonly ITenantService _tenantService;

    private string Key(string key) => $"t:{_tenantService.CurrentTenantId}:{key}";

    public async Task<T?> GetAsync<T>(string key)
    {
        var cached = await _redis.StringGetAsync(Key(key));
        return cached.HasValue
            ? JsonSerializer.Deserialize<T>(cached!)
            : default;
    }

    public async Task SetAsync<T>(string key, T value, TimeSpan? expiry = null)
    {
        await _redis.StringSetAsync(
            Key(key),
            JsonSerializer.Serialize(value),
            expiry ?? TimeSpan.FromMinutes(15));
    }

    public async Task InvalidatePatternAsync(string pattern)
    {
        var server = _redis.Multiplexer.GetServer(
            _redis.Multiplexer.GetEndPoints()[0]);

        await foreach (var key in server.KeysAsync(
            pattern: $"t:{_tenantService.CurrentTenantId}:{pattern}*"))
        {
            await _redis.KeyDeleteAsync(key);
        }
    }
}

6.2. Cache Quota per Tenant — Chống Noisy Neighbor

graph LR
    APP["Application"] --> GUARD["Cache Quota Guard"]
    GUARD -->|"Tenant A: 45/100 MB"| REDIS["Redis"]
    GUARD -->|"Tenant B: 98/100 MB ⚠️"| REDIS
    GUARD -->|"Tenant C: 12/100 MB"| REDIS
    REDIS --> METRIC["Prometheus Metrics
cache_usage_per_tenant"] METRIC --> ALERT["Alert: Tenant B
approaching quota"] style GUARD fill:#e94560,stroke:#fff,color:#fff style REDIS fill:#2c3e50,stroke:#e94560,color:#fff style ALERT fill:#f8f9fa,stroke:#ff9800,color:#2c3e50

Hình 4: Cache Quota per Tenant — giới hạn memory sử dụng để tránh noisy neighbor

// Cache Quota Enforcement
public class QuotaAwareCacheService
{
    private readonly IDatabase _redis;
    private readonly ITenantService _tenantService;
    private const long DefaultQuotaBytes = 100 * 1024 * 1024; // 100MB per tenant

    public async Task<bool> SetWithQuotaAsync<T>(
        string key, T value, TimeSpan? expiry = null)
    {
        var tenantId = _tenantService.CurrentTenantId;
        var json = JsonSerializer.Serialize(value);
        var sizeBytes = Encoding.UTF8.GetByteCount(json);

        // Kiểm tra quota
        var currentUsage = await _redis.StringGetAsync($"quota:{tenantId}:bytes");
        var used = currentUsage.HasValue ? (long)currentUsage : 0;

        if (used + sizeBytes > DefaultQuotaBytes)
        {
            // Ghi metric, có thể evict LRU keys của tenant này
            await EvictLruKeysAsync(tenantId, sizeBytes);
        }

        var cacheKey = $"t:{tenantId}:{key}";
        await _redis.StringSetAsync(cacheKey, json, expiry);
        await _redis.StringIncrementAsync(
            $"quota:{tenantId}:bytes", sizeBytes);

        return true;
    }

    private async Task EvictLruKeysAsync(string tenantId, long neededBytes)
    {
        // Scan và xóa keys cũ nhất của tenant cho đến khi đủ space
        var server = _redis.Multiplexer.GetServer(
            _redis.Multiplexer.GetEndPoints()[0]);
        long freed = 0;

        await foreach (var key in server.KeysAsync(pattern: $"t:{tenantId}:*"))
        {
            if (freed >= neededBytes) break;

            var ttl = await _redis.KeyTimeToLiveAsync(key);
            if (ttl.HasValue && ttl.Value < TimeSpan.FromMinutes(2))
            {
                var size = (await _redis.StringGetAsync(key)).Length();
                await _redis.KeyDeleteAsync(key);
                freed += size;
            }
        }
    }
}

7. Hybrid Strategy — Kết hợp theo Tier khách hàng

Trong thực tế, không phải tất cả tenant đều cùng yêu cầu. Hầu hết sản phẩm SaaS phân tier: Free/Starter (shared DB), Pro/Business (schema isolation), Enterprise (dedicated DB). Đây là chiến lược hybrid phổ biến nhất năm 2026.

graph TB
    REQ["Incoming Request"] --> MW["Tenant Middleware"]
    MW --> RESOLVE["Resolve Tenant + Tier"]
    RESOLVE --> ROUTE{"Tenant Tier?"}
    ROUTE -->|"Free / Starter"| SHARED["Shared DB
Discriminator Column
(1 database, N tenants)"] ROUTE -->|"Pro / Business"| SCHEMA["Schema-per-Tenant
(1 database, N schemas)"] ROUTE -->|"Enterprise"| DEDICATED["Dedicated DB
(1 tenant = 1 database)"] SHARED --> CACHE["Shared Redis
Prefix keys"] SCHEMA --> CACHE DEDICATED --> DCACHE["Dedicated Redis
Instance"] style ROUTE fill:#e94560,stroke:#fff,color:#fff style SHARED fill:#f8f9fa,stroke:#e94560,color:#2c3e50 style SCHEMA fill:#f8f9fa,stroke:#2c3e50,color:#2c3e50 style DEDICATED fill:#2c3e50,stroke:#e94560,color:#fff

Hình 5: Hybrid Multi-Tenancy — routing tenant đến isolation level phù hợp với tier

// Hybrid Tenant Router
public class HybridTenantRouter
{
    private readonly TenantRegistry _registry;
    private readonly IServiceProvider _sp;

    public DbContext CreateDbContext(string tenantId)
    {
        var tenant = _registry.Get(tenantId);

        return tenant.Tier switch
        {
            TenantTier.Free or TenantTier.Starter =>
                CreateSharedDbContext(tenantId),

            TenantTier.Pro or TenantTier.Business =>
                CreateSchemaDbContext(tenantId, tenant.SchemaName),

            TenantTier.Enterprise =>
                CreateDedicatedDbContext(tenant.ConnectionString),

            _ => throw new InvalidOperationException(
                $"Unknown tier for tenant {tenantId}")
        };
    }

    private DbContext CreateSharedDbContext(string tenantId)
    {
        var options = new DbContextOptionsBuilder<SharedDbContext>()
            .UseSqlServer(_registry.SharedConnectionString)
            .Options;
        return new SharedDbContext(options, tenantId);
    }

    private DbContext CreateSchemaDbContext(string tenantId, string schema)
    {
        var options = new DbContextOptionsBuilder<SchemaDbContext>()
            .UseSqlServer(_registry.SharedConnectionString)
            .Options;
        return new SchemaDbContext(options, tenantId, schema);
    }

    private DbContext CreateDedicatedDbContext(string connectionString)
    {
        var options = new DbContextOptionsBuilder<DedicatedDbContext>()
            .UseSqlServer(connectionString)
            .Options;
        return new DedicatedDbContext(options);
    }
}

public enum TenantTier
{
    Free,
    Starter,
    Pro,
    Business,
    Enterprise
}

8. Database Migration trong Multi-Tenant

Migration là thách thức lớn nhất của multi-tenancy. Với shared DB, chạy migration một lần. Với schema-per-tenant hoặc DB-per-tenant, phải chạy migration cho từng tenant — và phải đảm bảo tất cả tenant lên cùng version schema.

// Migration Runner cho multi-tenant
public class TenantMigrationRunner
{
    private readonly TenantRegistry _registry;
    private readonly ILogger<TenantMigrationRunner> _logger;

    public async Task MigrateAllTenantsAsync()
    {
        var tenants = await _registry.GetAllAsync();
        var semaphore = new SemaphoreSlim(5); // Max 5 concurrent migrations

        var tasks = tenants.Select(async tenant =>
        {
            await semaphore.WaitAsync();
            try
            {
                _logger.LogInformation(
                    "Migrating tenant {TenantId} (Tier: {Tier})",
                    tenant.Id, tenant.Tier);

                await using var context = CreateContextForTenant(tenant);
                await context.Database.MigrateAsync();

                _logger.LogInformation(
                    "Tenant {TenantId} migrated successfully", tenant.Id);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex,
                    "Migration failed for tenant {TenantId}", tenant.Id);
            }
            finally
            {
                semaphore.Release();
            }
        });

        await Task.WhenAll(tasks);
    }
}

Zero-Downtime Migration

Với hàng trăm tenant, migration có thể mất hàng giờ. Sử dụng expand-contract pattern: (1) Expand — thêm column/table mới nhưng không xóa cũ, (2) Migrate data — copy dữ liệu sang schema mới, (3) Contract — xóa column/table cũ sau khi verify. Điều này cho phép application chạy trên cả schema cũ và mới trong quá trình migration, đảm bảo zero-downtime.

9. Monitoring & Observability Per-Tenant

Trong hệ thống multi-tenant, bạn cần giám sát không chỉ tổng thể mà còn per-tenant: tenant nào đang dùng nhiều resource nhất, tenant nào gặp lỗi nhiều, response time per tenant ra sao.

// Custom metric per-tenant với OpenTelemetry
public class TenantMetrics
{
    private static readonly Meter Meter = new("SaaS.Tenants");

    private static readonly Counter<long> RequestCounter =
        Meter.CreateCounter<long>("saas.tenant.requests",
            description: "Request count per tenant");

    private static readonly Histogram<double> ResponseTime =
        Meter.CreateHistogram<double>("saas.tenant.response_time_ms",
            description: "Response time per tenant");

    private static readonly UpDownCounter<long> ActiveConnections =
        Meter.CreateUpDownCounter<long>("saas.tenant.active_connections",
            description: "Active connections per tenant");

    public static void RecordRequest(string tenantId, double durationMs)
    {
        var tags = new TagList { { "tenant_id", tenantId } };
        RequestCounter.Add(1, tags);
        ResponseTime.Record(durationMs, tags);
    }
}

// Middleware tự động đo per-tenant metrics
public class TenantMetricsMiddleware
{
    private readonly RequestDelegate _next;

    public async Task InvokeAsync(HttpContext context, ITenantService tenant)
    {
        var sw = Stopwatch.StartNew();

        try
        {
            await _next(context);
        }
        finally
        {
            sw.Stop();
            if (tenant.CurrentTenantId is not null)
            {
                TenantMetrics.RecordRequest(
                    tenant.CurrentTenantId, sw.Elapsed.TotalMilliseconds);
            }
        }
    }
}
graph LR
    APP["Application"] -->|"OTel Metrics"| COLLECTOR["OpenTelemetry
Collector"] COLLECTOR --> PROM["Prometheus"] COLLECTOR --> LOKI["Loki (Logs)"] COLLECTOR --> TEMPO["Tempo (Traces)"] PROM --> GRAFANA["Grafana Dashboard"] LOKI --> GRAFANA TEMPO --> GRAFANA GRAFANA --> DASH["Per-Tenant Dashboard
• Request rate
• Error rate
• P99 latency
• Cache hit ratio
• DB query count"] style COLLECTOR fill:#e94560,stroke:#fff,color:#fff style GRAFANA fill:#2c3e50,stroke:#e94560,color:#fff style DASH fill:#f8f9fa,stroke:#e94560,color:#2c3e50

Hình 6: Observability stack per-tenant — từ OTel Collector đến Grafana dashboard

10. Testing Tenant Isolation

Tenant isolation là yêu cầu bảo mật tối quan trọng. Một bug nhỏ có thể khiến dữ liệu của tenant A hiển thị cho tenant B — đây là loại incident nghiêm trọng nhất của SaaS. Integration test cho isolation phải là bắt buộc trong CI/CD pipeline.

// Integration test kiểm tra tenant isolation
[Fact]
public async Task Tenant_Cannot_Access_Other_Tenant_Data()
{
    // Arrange: tạo data cho tenant A
    await using var contextA = CreateContext("tenant-a");
    contextA.Products.Add(new Product
    {
        Name = "Product A", Price = 100, Sku = "SKU-A"
    });
    await contextA.SaveChangesAsync();

    // Act: query từ tenant B
    await using var contextB = CreateContext("tenant-b");
    var productsB = await contextB.Products.ToListAsync();

    // Assert: tenant B KHÔNG thấy dữ liệu của tenant A
    Assert.Empty(productsB);
    Assert.DoesNotContain(productsB, p => p.Name == "Product A");
}

[Fact]
public async Task Tenant_Cannot_Modify_Other_Tenant_Data()
{
    // Arrange
    await using var contextA = CreateContext("tenant-a");
    var product = new Product
    {
        Name = "Secret Product", Price = 999, Sku = "SECRET"
    };
    contextA.Products.Add(product);
    await contextA.SaveChangesAsync();

    // Act: tenant B cố gắng update
    await using var contextB = CreateContext("tenant-b");
    var found = await contextB.Products
        .IgnoreQueryFilters()  // Bypass filter trong test để verify RLS
        .FirstOrDefaultAsync(p => p.Sku == "SECRET");

    // Nếu RLS hoạt động đúng, query trực tiếp cũng không thấy
    // Nếu chỉ dùng EF filter, found sẽ != null khi bypass
    // => Test này verify RLS ở database level
}

11. Production Checklist cho Multi-Tenant SaaS

Hạng mụcYêu cầuTool/Pattern
Data IsolationTenant không thể truy cập dữ liệu tenant khácEF Global Filter + SQL Server RLS
Tenant ResolutionXác định tenant chính xác từ mọi requestMiddleware + Subdomain/JWT/Header
Cache IsolationCache key phải có tenant prefixRedis + Tenant-prefixed keys
Cache QuotaGiới hạn cache usage per tenantRedis memory tracking + eviction
MigrationSchema changes đồng bộ tất cả tenantParallel migration runner + expand-contract
MonitoringMetrics, logs, traces per tenantOpenTelemetry + Grafana per-tenant dashboard
Rate LimitingGiới hạn API calls per tenant/tierPolly v8 + Redis sliding window per tenant
Backup/RestoreKhả năng restore data cho từng tenantDB-per-tenant native; Shared DB cần custom logic
OnboardingProvision tenant mới tự độngTenant provisioning service + migration
TestingIntegration test cho isolationxUnit + Testcontainers per-tenant scenarios

12. Kết luận

Multi-tenancy không phải một pattern đơn lẻ mà là một tập hợp quyết định kiến trúc ảnh hưởng đến mọi layer của hệ thống: database, caching, security, monitoring, deployment và billing. Không có chiến lược "one-size-fits-all" — shared DB phù hợp cho SaaS freemium với hàng nghìn tenant nhỏ, trong khi DB-per-tenant là lựa chọn bắt buộc cho enterprise có yêu cầu compliance cao.

Trên .NET 10 năm 2026, EF Core Global Query Filter kết hợp SQL Server Row-Level Security tạo nên lớp bảo vệ defense in depth mạnh mẽ. Redis với tenant-prefixed keys và cache quota per-tenant giải quyết bài toán caching. Hybrid strategy — routing tenant đến isolation level phù hợp với tier — là kiến trúc được nhiều SaaS production áp dụng nhất hiện nay.

Điểm quan trọng nhất: luôn test isolation. Một data leak giữa các tenant có thể phá huỷ uy tín sản phẩm SaaS trong tích tắc. Hãy đưa integration test cho tenant isolation vào CI/CD pipeline như một requirement không thể bỏ qua.

Nguồn tham khảo