广告位 · 页头(后台-模块管理-新建模块后替换此区域)

C# 如何实现多租户架构?从租户解析到数据隔离的实战落地

C# 项目落地多租户,通常不是直接引入某个重型框架,而是把租户标识、隔离策略和上下文传递这三件事理顺。ASP.NET Core 的请求管道天然适合做租户识别,EF Core 的模型配置又能把租户过滤条件收口到一处,避免业务代码里到处写 TenantId。本文用一个精简示例串起中间件、DbContext 和缓存环节,帮助你在现有 Web API 或 MVC 项目中搭起轻量多租户骨架。

C# 如何实现多租户架构?从租户解析到数据隔离的实战落地

一、三种数据隔离模型怎么选

多租户最核心的决策是数据放在哪里。共享数据库共享表是最轻量的方案,所有租户的数据存在同一张业务表中,仅通过 TenantId 列区分。查询时必须对每张表追加租户条件,否则就会串数据。优点是开发和迁移成本最低,适合租户数量多但单租户数据量不大的 SaaS 场景。缺点也很明显,单表数据量会随租户数线性增长,索引维护和备份恢复的粒度都很粗。

共享数据库独立 Schema 的隔离性强一些。每个租户拥有一个独立 Schema,表结构相同但物理上分隔开。切换租户时需要动态修改 SQL 的默认 Schema,或者让 ORM 在生成 SQL 时带上 Schema 前缀。这种方式适合同一产品下租户数据边界要求较高的场景,但数据库迁移脚本必须循环应用到每一个 Schema,租户数量增多后发布成本会上升。

独立数据库是隔离性最高的方式,每个租户连到各自的连接字符串,互不干扰,也便于单独备份和恢复。代价是需要维护租户到连接字符串的映射,跨租户的聚合查询几乎无法直接完成。实际项目里可以采用混合策略,比如基础版租户走共享表,企业版租户切换独立库。下面表格汇总了三种模型的关键差异。

隔离模型隔离性运维成本适用场景
共享库共享表低低租户多、单租户数据量小
共享库独立 Schema中中租户不多但隔离要求提升
独立数据库高高企业租户或合规要求严格

二、在请求管道中解析租户标识

租户标识可以从多个来源读取,常见的有子域名、请求头、路径段和登录令牌里的 Claim。子域名方式对浏览器友好,但本地调试和网关配置要额外处理。请求头方式简单直接,适合前后端分离的 API 项目。JWT Claim 方式最安全,但必须等待认证中间件执行完成后才能拿到值。一个折中方案是先允许请求头传入租户标识,在网关层做校验,服务内部信任网关注入的头部。

下面给出的中间件会在每次请求开始时读取 X-Tenant-Id 请求头,并把租户 Id 写入一个 Scoped 生命周期的 Provider 中。Scoped 生命周期保证同一个请求内所有服务获取到同一个租户上下文,不会因为并发请求互相覆盖。

public class TenantResolutionMiddleware
{
    private readonly RequestDelegate _next;
    public TenantResolutionMiddleware(RequestDelegate next)
    {
        _next = next;
    }
    public async Task InvokeAsync(HttpContext context, ITenantProvider tenantProvider)
    {
        var tenantId = context.Request.Headers["X-Tenant-Id"].FirstOrDefault();
        if (string.IsNullOrEmpty(tenantId))
        {
            context.Response.StatusCode = 400;
            await context.Response.WriteAsync("Missing tenant id");
            return;
        }
        tenantProvider.SetTenantId(tenantId);
        await _next(context);
    }
}

注册中间件时需要把顺序放在 UseRouting 之前,这样后续的路由匹配、权限中间件和控制器都能读取到租户上下文。如果租户标识来自 JWT Claim,则中间件要放在 UseAuthentication 之后,因为在认证完成前无法可靠读取声明信息。

对应 Provider 的实现不需要依赖 HttpContext,这样可以方便地在后台任务或测试环境中手动设置租户值。具体接口和类如下。

public interface ITenantProvider
{
    string GetTenantId();
    void SetTenantId(string tenantId);
}
public class TenantProvider : ITenantProvider
{
    private string _tenantId = string.Empty;
    public string GetTenantId() => _tenantId;
    public void SetTenantId(string tenantId)
    {
        _tenantId = tenantId;
    }
}

在 Startup 或 Program 中使用依赖注入时,将 Provider 注册为 Scoped,同时注册中间件。这样每个请求都会获得独立的 Provider 实例,请求结束后自动释放。不要将 Provider 注册为 Singleton,否则租户值会在并发场景下串号。

三、用 EF Core 全局筛选器实现共享表隔离

共享表方案最关键的是减少业务代码里的重复过滤。EF Core 提供全局查询筛选器,可以在 DbContext 的 OnModelCreating 方法中为实体统一追加租户条件。需要特别注意的是,不要在模型构建时提前取出租户 Id 变量再捕获,因为模型只会构建一次,之后所有请求都会复用一个固定的过滤值,造成严重串数据。

正确做法是让筛选器表达式在每次查询时调用当前 DbContext 实例上的 Provider。下面示例中 Order 实体包含 TenantId 字段,筛选器直接读取 _tenantProvider.GetTenantId()。EF Core 会把这个成员访问保留在表达式树中,查询时再从当前请求的 Provider 取值。

public class AppDbContext : DbContext
{
    private readonly ITenantProvider _tenantProvider;
    public AppDbContext(
        DbContextOptions<AppDbContext> options,
        ITenantProvider tenantProvider)
        : base(options)
    {
        _tenantProvider = tenantProvider;
    }
    public DbSet<Order> Orders { get; set; }
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Order>()
            .HasQueryFilter(o => o.TenantId == _tenantProvider.GetTenantId());
    }
}

全局筛选器生效后,查询 Orders 时 SQL 会自动带上 TenantId 过滤条件,新增实体时如果业务层漏赋值,EF Core 不会自动补齐,因此建议在 SaveChanges 重写中统一设置新增实体的租户值。对于后台跨租户统计任务,可以调用 IgnoreQueryFilters 方法绕过过滤,但这类操作必须放在受控服务中,避免普通业务误用导致数据泄露。

如果不同租户使用独立数据库或独立 Schema,就需要在 DbContext 中动态选择连接字符串。下面示例在 OnConfiguring 里读取 Provider 提供的连接字符串,每次请求构建 DbContext 时都会重新解析。连接字符串可以缓存到内存,但缓存键必须包含租户 Id。

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    var connectionString = _tenantProvider.GetConnectionString();
    optionsBuilder.UseSqlServer(connectionString);
}

独立 Schema 场景可以借助 EF Core 的 HasDefaultSchema 方法或者拦截 SQL 命令来改写 Schema。实现复杂度高于共享表,通常只在租户较少且隔离要求较高时使用。

四、缓存、后台任务与权限校验中的租户穿透

租户上下文最大的挑战发生在脱离 HttpContext 的场景。定时任务、消息队列消费者、Hangfire 后台作业中都没有当前请求,Provider 里自然也不会有租户值。此时必须在任务创建时显式传入租户 Id,并在任务执行最开始调用 SetTenantId 建立上下文。如果任务由多租户队列触发,可以在消息体里携带租户标识,消费端先解析再执行。

缓存是另一个容易出问题的地方。所有缓存键都必须把租户 Id 作为前缀或复合组成部分。比如月度报表缓存键要写成 tenantId:report:monthly,否则不同租户会命中同一份数据。下面是一个简单的缓存键生成方式。

var cacheKey = string.Format("{0}:report:{1}", tenantId, reportType);
var report = await _cache.GetOrCreateAsync(cacheKey, entry =>
{
    entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
    return BuildReportAsync(tenantId, reportType);
});

权限校验同样要避免只按用户 Id 查询角色和资源。一个用户可能隶属于多个租户,只判断 UserId 会导致跨租户越权。服务层的仓储方法应当强制接收租户上下文,或者在内部通过 Provider 自动拼接租户条件。对于需要访问另一租户数据的支持工单场景,最好单独设计受审计的跨租户查询接口,不要复用普通业务方法。

把这些边界守住以后,C# 多租户架构的骨架就基本完成:中间件负责识别租户,Provider 负责传递上下文,EF Core 负责数据隔离,业务层负责缓存键和权限边界。后续再逐步引入租户配额、租户级配置和运维工具,就能支撑更复杂的 SaaS 场景。

广告位 · 文章底部(后台-模块管理替换此区域)