Appearance
多租户支持
概述
多租户(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));
}
}最佳实践
✅ 推荐做法
- 始终使用全局过滤器: 避免忘记添加
Where条件 - 复合索引: 为
(TenantId, Id)创建索引 - 自动化 TenantId 设置: 在
SaveChanges中自动填充 - 租户验证中间件: 在请求入口处验证租户身份
- 监控与日志: 记录每个租户的查询性能和资源使用
❌ 避免的陷阱
- 不要手动添加 Where 条件: 容易遗漏导致数据泄漏
- 不要忘记索引: 多租户查询性能会显著下降
- 不要硬编码租户 ID: 使用依赖注入和上下文
- 不要忽略安全性: 确保租户无法通过 API 猜测其他租户 ID
总结
选择建议
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 初创公司 (< 100 租户) | 列级别隔离 | 简单、成本低 |
| 中型 SaaS (100-1000 租户) | Schema 级别 | 平衡隔离和成本 |
| 大型企业 (> 1000 租户) | 数据库级别 | 最强隔离,易于扩展 |
| 合规要求严格 | 数据库级别 | 满足数据驻留要求 |
多租户是 SaaS 应用的核心架构决策,选择合适的策略至关重要!