Skip to content

多租户支持 ​

概述 ​

多租户(Multi-tenancy)是一种软件架构模式,允许单个应用实例为多个租户(客户/组织)提供服务,同时确保各租户数据的隔离性。在 SaaS(Software as a Service)应用中,多租户是核心需求之一。EF Core 提供了多种策略来实现多租户架构。

多租户的核心挑战 ​

  • 数据隔离: 确保租户 A 无法访问租户 B 的数据
  • 性能: 多租户不应显著影响查询性能
  • 维护成本: 简化运维和扩展
  • 合规性: 满足数据驻留和隐私法规要求

多租户架构模式对比 ​

模式描述隔离级别成本复杂度适用场景
数据库级别每个租户独立数据库⭐⭐⭐ 最高💰💰💰 高🔧 中金融、医疗等强监管行业
Schema 级别同一数据库,不同 Schema⭐⭐ 高💰💰 中🔧🔧 高中等规模 SaaS
列级别同一表,TenantId 列区分⭐ 中💰 低🔧 低初创公司、小型 SaaS

方案 1: 列级别隔离(Discriminator Column) ​

架构设计 ​

┌─────────────────────────────────────┐
│         Single Database             │
├─────────────────────────────────────┤
│  Products Table                     │
│  ┌────┬──────────┬──────────┬──────┐│
│  │ Id │ Name     │ Price    │TenantId││
│  ├────┼──────────┼──────────┼──────┤│
│  │ 1  │ Laptop   │ 999.99   │ T001 ││
│  │ 2  │ Phone    │ 599.99   │ T001 ││
│  │ 3  │ Tablet   │ 399.99   │ T002 ││ ← 租户 T002 看不到 T001 的数据
│  └────┴──────────┴──────────┴──────┘│
└─────────────────────────────────────┘

实现步骤 ​

1. 定义租户感知实体 ​

csharp
// Domain/Common/ITenantEntity.cs
namespace MyApp.Domain.Common;

public interface ITenantEntity
{
    string TenantId { get; set; }
}

// Domain/Entities/Product.cs
namespace MyApp.Domain.Entities;

public class Product : BaseEntity, ITenantEntity
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public decimal Price { get; set; }
    
    // 租户 ID
    public string TenantId { get; set; } = string.Empty;
}

// Domain/Entities/Order.cs
namespace MyApp.Domain.Entities;

public class Order : BaseEntity, ITenantEntity
{
    public int Id { get; set; }
    public string CustomerEmail { get; set; } = string.Empty;
    public decimal TotalAmount { get; set; }
    
    // 租户 ID
    public string TenantId { get; set; } = string.Empty;
    
    public ICollection<OrderItem> Items { get; set; } = new List<OrderItem>();
}

2. 配置全局查询过滤器 ​

csharp
// Infrastructure/Data/AppDbContext.cs
using Microsoft.EntityFrameworkCore;
using MyApp.Domain.Common;
using MyApp.Domain.Entities;

namespace MyApp.Infrastructure.Data;

public class AppDbContext : DbContext
{
    private readonly string _currentTenantId;
    
    public AppDbContext(
        DbContextOptions<AppDbContext> options,
        ITenantProvider tenantProvider)
        : base(options)
    {
        // 从租户提供者获取当前租户 ID
        _currentTenantId = tenantProvider.GetTenantId();
    }
    
    public DbSet<Product> Products => Set<Product>();
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<Customer> Customers => Set<Customer>();
    
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // 为所有实现 ITenantEntity 的实体添加全局过滤器
        foreach (var entityType in modelBuilder.Model.GetEntityTypes())
        {
            if (typeof(ITenantEntity).IsAssignableFrom(entityType.ClrType))
            {
                // 动态构建过滤器: e => e.TenantId == _currentTenantId
                var parameter = Expression.Parameter(entityType.ClrType, "e");
                var property = Expression.Property(parameter, nameof(ITenantEntity.TenantId));
                var constant = Expression.Constant(_currentTenantId);
                var equality = Expression.Equal(property, constant);
                var lambda = Expression.Lambda(equality, parameter);
                
                modelBuilder.Entity(entityType.ClrType).HasQueryFilter(lambda);
            }
        }
        
        // 其他配置...
        modelBuilder.Entity<Product>(entity =>
        {
            entity.HasKey(p => p.Id);
            entity.Property(p => p.TenantId).IsRequired().HasMaxLength(50);
            
            // 复合索引提升性能
            entity.HasIndex(p => new { p.TenantId, p.Id });
        });
        
        modelBuilder.Entity<Order>(entity =>
        {
            entity.HasKey(o => o.Id);
            entity.Property(o => o.TenantId).IsRequired().HasMaxLength(50);
            
            entity.HasIndex(o => new { o.TenantId, o.Id });
        });
    }
    
    public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
    {
        // 自动设置 TenantId
        var entries = ChangeTracker.Entries<ITenantEntity>()
            .Where(e => e.State == EntityState.Added);
        
        foreach (var entry in entries)
        {
            entry.Entity.TenantId = _currentTenantId;
        }
        
        return base.SaveChangesAsync(cancellationToken);
    }
}

3. 租户提供者实现 ​

csharp
// Infrastructure/Tenancy/ITenantProvider.cs
namespace MyApp.Infrastructure.Tenancy;

public interface ITenantProvider
{
    string GetTenantId();
    void SetTenantId(string tenantId);
}

// Infrastructure/Tenancy/TenantProvider.cs
public class TenantProvider : ITenantProvider, IDisposable
{
    private readonly AsyncLocal<string> _currentTenantId = new();
    
    public string GetTenantId()
    {
        return _currentTenantId.Value ?? throw new InvalidOperationException("Tenant ID not set");
    }
    
    public void SetTenantId(string tenantId)
    {
        _currentTenantId.Value = tenantId;
    }
    
    public void Dispose()
    {
        _currentTenantId.Value = null;
    }
}

// 或者基于 HttpContext 的实现
public class HttpContextTenantProvider : ITenantProvider
{
    private readonly IHttpContextAccessor _httpContextAccessor;
    
    public HttpContextTenantProvider(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }
    
    public string GetTenantId()
    {
        var httpContext = _httpContextAccessor.HttpContext;
        
        if (httpContext == null)
            throw new InvalidOperationException("No HTTP context available");
        
        // 从请求头获取租户 ID
        if (httpContext.Request.Headers.TryGetValue("X-Tenant-ID", out var tenantId))
        {
            return tenantId.ToString();
        }
        
        // 或从子域名提取
        var host = httpContext.Request.Host.Host;
        var subdomain = host.Split('.')[0];
        return subdomain;
    }
    
    public void SetTenantId(string tenantId)
    {
        // HTTP 场景下通常不需要手动设置
        throw new NotImplementedException("Use request headers or subdomain instead");
    }
}

4. 依赖注入配置 ​

csharp
// Program.cs
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITenantProvider, HttpContextTenantProvider>();

builder.Services.AddDbContext<AppDbContext>((sp, options) =>
{
    var tenantProvider = sp.GetRequiredService<ITenantProvider>();
    options.UseSqlServer(connectionString);
    // TenantProvider 会在 DbContext 构造时自动注入
});

5. API 中间件验证租户 ​

csharp
// Middleware/TenantValidationMiddleware.cs
public class TenantValidationMiddleware
{
    private readonly RequestDelegate _next;
    
    public TenantValidationMiddleware(RequestDelegate next)
    {
        _next = next;
    }
    
    public async Task InvokeAsync(HttpContext context, ITenantProvider tenantProvider)
    {
        // 跳过健康检查等特定路径
        if (context.Request.Path.StartsWithSegments("/health"))
        {
            await _next(context);
            return;
        }
        
        try
        {
            // 验证租户 ID 是否存在
            var tenantId = tenantProvider.GetTenantId();
            
            if (string.IsNullOrWhiteSpace(tenantId))
            {
                context.Response.StatusCode = StatusCodes.Status400BadRequest;
                await context.Response.WriteAsJsonAsync(new 
                { 
                    error = "Missing X-Tenant-ID header" 
                });
                return;
            }
            
            // 可选: 验证租户是否在数据库中注册
            // await ValidateTenantExistsAsync(tenantId);
            
            await _next(context);
        }
        catch (InvalidOperationException ex)
        {
            context.Response.StatusCode = StatusCodes.Status400BadRequest;
            await context.Response.WriteAsJsonAsync(new 
            { 
                error = ex.Message 
            });
        }
    }
}

// 注册中间件
app.UseMiddleware<TenantValidationMiddleware>();

方案 2: Schema 级别隔离 ​

架构设计 ​

┌─────────────────────────────────────┐
│         Single Database             │
├─────────────────────────────────────┤
│  Schema: tenant_001                 │
│  ├── Products                       │
│  ├── Orders                         │
│  └── Customers                      │
├─────────────────────────────────────┤
│  Schema: tenant_002                 │
│  ├── Products                       │
│  ├── Orders                         │
│  └── Customers                      │
└─────────────────────────────────────┘

实现步骤 ​

1. 配置默认 Schema ​

csharp
// Infrastructure/Data/AppDbContext.cs
public class AppDbContext : DbContext
{
    private readonly string _tenantSchema;
    
    public AppDbContext(
        DbContextOptions<AppDbContext> options,
        ITenantProvider tenantProvider)
        : base(options)
    {
        _tenantSchema = $"tenant_{tenantProvider.GetTenantId()}";
    }
    
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // 为所有实体设置 Schema
        foreach (var entityType in modelBuilder.Model.GetEntityTypes())
        {
            modelBuilder.Entity(entityType.ClrType)
                .ToTable(entityType.GetTableName()!, _tenantSchema);
        }
        
        // 具体配置
        modelBuilder.Entity<Product>(entity =>
        {
            entity.HasKey(p => p.Id);
            // Schema 已在上面统一设置
        });
    }
}

2. 动态创建 Schema ​

csharp
// Infrastructure/Tenancy/TenantManager.cs
public class TenantManager
{
    private readonly AppDbContext _context;
    
    public TenantManager(AppDbContext context)
    {
        _context = context;
    }
    
    public async Task CreateTenantAsync(string tenantId)
    {
        var schemaName = $"tenant_{tenantId}";
        
        // 创建 Schema
        await _context.Database.ExecuteSqlRawAsync(
            $"CREATE SCHEMA IF NOT EXISTS {schemaName}");
        
        // 在该 Schema 下创建表
        await _context.Database.MigrateAsync(schemaName);
    }
    
    public async Task DeleteTenantAsync(string tenantId)
    {
        var schemaName = $"tenant_{tenantId}";
        
        // 删除 Schema 及其所有对象
        await _context.Database.ExecuteSqlRawAsync(
            $"DROP SCHEMA IF EXISTS {schemaName} CASCADE");
    }
}

方案 3: 数据库级别隔离 ​

架构设计 ​

┌──────────────────┐  ┌──────────────────┐
│  Database: T001  │  │  Database: T002  │
│  ├── Products    │  │  ├── Products    │
│  ├── Orders      │  │  ├── Orders      │
│  └── Customers   │  │  └── Customers   │
└──────────────────┘  └──────────────────┘

实现步骤 ​

1. 租户数据库映射 ​

csharp
// Infrastructure/Tenancy/TenantDatabaseResolver.cs
public class TenantDatabaseResolver
{
    private readonly IConfiguration _configuration;
    private readonly ITenantProvider _tenantProvider;
    
    public TenantDatabaseResolver(
        IConfiguration configuration,
        ITenantProvider tenantProvider)
    {
        _configuration = configuration;
        _tenantProvider = tenantProvider;
    }
    
    public string ResolveConnectionString()
    {
        var tenantId = _tenantProvider.GetTenantId();
        
        // 从配置中获取租户对应的连接字符串
        var connectionStringKey = $"ConnectionStrings:Tenant_{tenantId}";
        var connectionString = _configuration[connectionStringKey];
        
        if (string.IsNullOrEmpty(connectionString))
        {
            throw new InvalidOperationException(
                $"Connection string not found for tenant {tenantId}");
        }
        
        return connectionString;
    }
}

// Infrastructure/Data/AppDbContextFactory.cs
public class AppDbContextFactory : IDbContextFactory<AppDbContext>
{
    private readonly TenantDatabaseResolver _databaseResolver;
    
    public AppDbContextFactory(TenantDatabaseResolver databaseResolver)
    {
        _databaseResolver = databaseResolver;
    }
    
    public AppDbContext CreateDbContext()
    {
        var connectionString = _databaseResolver.ResolveConnectionString();
        
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseSqlServer(connectionString)
            .Options;
        
        return new AppDbContext(options);
    }
}

2. 运行时动态切换数据库 ​

csharp
// Program.cs
builder.Services.AddScoped<AppDbContext>(sp =>
{
    var factory = sp.GetRequiredService<IDbContextFactory<AppDbContext>>();
    return factory.CreateDbContext();
});

高级特性 ​

1. 租户元数据管理 ​

csharp
// Domain/Entities/Tenant.cs
namespace MyApp.Domain.Entities;

public class Tenant
{
    public string Id { get; set; } = string.Empty;
    public string Name { get; set; } = string.Empty;
    public string ConnectionString { get; set; } = string.Empty;
    public bool IsActive { get; set; }
    public DateTime CreatedAt { get; set; }
    public string Plan { get; set; } = "Free"; // Free, Pro, Enterprise
    
    // 租户级别的配置
    public Dictionary<string, string> Settings { get; set; } = new();
}

// Infrastructure/Data/TenantDbContext.cs
public class TenantDbContext : DbContext
{
    public TenantDbContext(DbContextOptions<TenantDbContext> options)
        : base(options) { }
    
    public DbSet<Tenant> Tenants => Set<Tenant>();
    
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Tenant>(entity =>
        {
            entity.HasKey(t => t.Id);
            entity.Property(t => t.Name).IsRequired().HasMaxLength(200);
            entity.HasIndex(t => t.Name).IsUnique();
        });
    }
}

2. 跨租户查询(管理员功能) ​

csharp
// Application/Admin/AdminService.cs
public class AdminService
{
    private readonly AppDbContext _context;
    
    public AdminService(AppDbContext context)
    {
        _context = context;
    }
    
    // 忽略租户过滤器,查询所有数据
    public async Task<List<Product>> GetAllProductsAcrossTenantsAsync()
    {
        return await _context.Products
            .IgnoreQueryFilters() // 忽略全局过滤器
            .ToListAsync();
    }
    
    // 按租户统计
    public async Task<Dictionary<string, int>> GetProductCountByTenantAsync()
    {
        return await _context.Products
            .IgnoreQueryFilters()
            .GroupBy(p => p.TenantId)
            .Select(g => new { g.Key, Count = g.Count() })
            .ToDictionaryAsync(x => x.Key, x => x.Count);
    }
}

3. 租户迁移策略 ​

csharp
// Infrastructure/Migrations/TenantMigrationService.cs
public class TenantMigrationService
{
    private readonly IEnumerable<string> _tenantIds;
    private readonly IServiceProvider _serviceProvider;
    
    public TenantMigrationService(
        IEnumerable<string> tenantIds,
        IServiceProvider serviceProvider)
    {
        _tenantIds = tenantIds;
        _serviceProvider = serviceProvider;
    }
    
    public async Task MigrateAllTenantsAsync()
    {
        foreach (var tenantId in _tenantIds)
        {
            Console.WriteLine($"Migrating tenant: {tenantId}");
            
            using var scope = _serviceProvider.CreateScope();
            var tenantProvider = scope.ServiceProvider.GetRequiredService<ITenantProvider>();
            tenantProvider.SetTenantId(tenantId);
            
            var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
            await context.Database.MigrateAsync();
            
            Console.WriteLine($"Tenant {tenantId} migrated successfully");
        }
    }
}

性能优化 ​

1. 缓存租户配置 ​

csharp
// Infrastructure/Tenancy/CachedTenantProvider.cs
public class CachedTenantProvider : ITenantProvider
{
    private readonly IMemoryCache _cache;
    private readonly ITenantProvider _innerProvider;
    
    public CachedTenantProvider(
        IMemoryCache cache,
        ITenantProvider innerProvider)
    {
        _cache = cache;
        _innerProvider = innerProvider;
    }
    
    public string GetTenantId()
    {
        return _cache.GetOrCreate("CurrentTenantId", entry =>
        {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30);
            return _innerProvider.GetTenantId();
        })!;
    }
    
    public void SetTenantId(string tenantId)
    {
        _innerProvider.SetTenantId(tenantId);
        _cache.Remove("CurrentTenantId");
    }
}

2. 分片策略(Sharding) ​

csharp
// Infrastructure/Sharding/ShardResolver.cs
public class ShardResolver
{
    public string ResolveShard(string tenantId)
    {
        // 基于哈希的分片策略
        var hash = Math.Abs(tenantId.GetHashCode());
        var shardNumber = hash % 10; // 10 个分片
        
        return $"Shard_{shardNumber:D2}";
    }
}

测试策略 ​

单元测试 ​

csharp
public class MultiTenantTests
{
    [Fact]
    public async Task Query_ShouldOnlyReturnTenantData()
    {
        // Arrange
        var tenantProvider = new Mock<ITenantProvider>();
        tenantProvider.Setup(p => p.GetTenantId()).Returns("T001");
        
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase($"TestDb_{Guid.NewGuid()}")
            .Options;
        
        await using var context = new AppDbContext(options, tenantProvider.Object);
        
        // Seed data for multiple tenants
        context.Products.AddRange(
            new Product { Name = "Product A", TenantId = "T001", Price = 100m },
            new Product { Name = "Product B", TenantId = "T001", Price = 200m },
            new Product { Name = "Product C", TenantId = "T002", Price = 300m }
        );
        await context.SaveChangesAsync();
        
        // Act
        var products = await context.Products.ToListAsync();
        
        // Assert: 只返回 T001 的产品
        Assert.Equal(2, products.Count);
        Assert.All(products, p => Assert.Equal("T001", p.TenantId));
    }
}

最佳实践 ​

✅ 推荐做法 ​

  1. 始终使用全局过滤器: 避免忘记添加 Where 条件
  2. 复合索引: 为 (TenantId, Id) 创建索引
  3. 自动化 TenantId 设置: 在 SaveChanges 中自动填充
  4. 租户验证中间件: 在请求入口处验证租户身份
  5. 监控与日志: 记录每个租户的查询性能和资源使用

❌ 避免的陷阱 ​

  1. 不要手动添加 Where 条件: 容易遗漏导致数据泄漏
  2. 不要忘记索引: 多租户查询性能会显著下降
  3. 不要硬编码租户 ID: 使用依赖注入和上下文
  4. 不要忽略安全性: 确保租户无法通过 API 猜测其他租户 ID

总结 ​

选择建议 ​

场景推荐方案原因
初创公司 (< 100 租户)列级别隔离简单、成本低
中型 SaaS (100-1000 租户)Schema 级别平衡隔离和成本
大型企业 (> 1000 租户)数据库级别最强隔离,易于扩展
合规要求严格数据库级别满足数据驻留要求

多租户是 SaaS 应用的核心架构决策,选择合适的策略至关重要!

基于 MIT 许可发布