.NET 多租户 SaaS 架构

讲解 .NET 多租户 SaaS 架构的完整落地路径,覆盖租户解析与 AsyncLocal 上下文传播、共享库与独立库的隔离策略、EF Core 全局查询过滤器与写入填充、按租户配置与限流分区,以及用量计量、配额执行与租户级监控告警的设计要点。

1. 三类隔离模型

一句话总结: 多租户的核心决策是数据隔离粒度,共享库共享表成本最低但隔离最弱,独立库隔离最强但运维成本最高,多数系统需要混合策略。

SaaS 的租户隔离有三档,选择取决于合规要求、租户规模与运维能力:

模型数据存放隔离强度成本适用
共享库共享表同一库,靠 TenantId 列区分弱(依赖代码正确性)最低中小租户、通用 SaaS
共享库独立 Schema同库不同 Schema中中中等规模、需按租户备份
独立库每租户一个数据库强高大客户、强合规要求

混合策略是生产系统的常态:免费与中小企业租户共享库,付费大客户独立库。这要求数据访问层从一开始就支持「按租户决定连接」的抽象,而不是假设单一数据库。

隔离模型的选择还会反向影响其他设计:

  • 独立库下,跨租户统计需要额外的汇总管道;共享库下一条 SQL 即可。
  • 共享库下,任何遗漏 TenantId 过滤的查询都是数据泄露事故;独立库下物理隔离天然兜底。
  • 独立库的迁移需要遍历所有库执行;共享库一次迁移即可。

一条务实的原则:先按共享库设计,把租户上下文做成一等公民,等有客户要求时再迁到独立库。反过来(先独立库再合并)几乎不可行,因为代码里会散布大量「连接字符串从哪来」的假设。

2. 租户解析与上下文

一句话总结: 租户解析从请求中提取租户标识,上下文通过 AsyncLocal 在调用链中传播,后台任务必须显式传递而不能依赖环境上下文。

租户标识的来源有四种,实践中常组合使用:

  1. 子域名:acme.app.com → 租户 acme。适合面向企业的产品,租户可感知。
  2. 路径前缀:/t/acme/orders。实现简单,但污染路由。
  3. JWT 声明:令牌中携带 tenant_id。最可靠,因为它经过了认证。
  4. 请求头:X-Tenant-Id。仅适合内部服务间调用,不能作为对外入口的唯一依据。

解析顺序应当是「认证声明优先,其次子域名」。仅靠子域名是不安全的——攻击者可以伪造 Host 头。正确的做法是用子域名定位租户,再用 JWT 声明校验用户是否属于该租户。

public sealed class TenantContext : ITenantContext
{
    private static readonly AsyncLocal<TenantInfo?> Current = new();

    public TenantInfo? Tenant => Current.Value;

    public IDisposable BeginScope(TenantInfo tenant)
    {
        var previous = Current.Value;
        Current.Value = tenant;
        return new Scope(() => Current.Value = previous);
    }

    private sealed class Scope(Action restore) : IDisposable
    {
        public void Dispose() => restore();
    }
}

AsyncLocal 保证上下文在同一异步流的各层可见,但它不会自动流向新起的线程或后台任务。这是多租户系统中最常见的 bug 来源:请求里写入的租户上下文,在 Task.Run 或消息消费时消失,导致查询拿不到租户而返回全量数据或抛异常。

中间件负责解析并开启作用域:

app.Use(async (ctx, next) =>
{
    var tenant = await ResolveTenantAsync(ctx);
    if (tenant is null)
    {
        ctx.Response.StatusCode = StatusCodes.Status400BadRequest;
        await ctx.Response.WriteAsync("无法识别租户");
        return;
    }

    using var scope = ctx.RequestServices
        .GetRequiredService<ITenantContext>().BeginScope(tenant);

    await next();
});

2.1 租户标识的稳定性

一句话总结: 租户标识必须用不可变的内部 ID 而非可编辑的名称,子域名与显示名都只是它的可变更别名。

一个容易被低估的设计点是租户标识的选择。常见错误是用租户名称(acme)作为数据库里的 TenantId,结果客户改名后需要全库更新外键,风险极高。

正确做法是引入不可变的内部标识:

public sealed class Tenant
{
    public Guid Id { get; init; }          // 不可变内部标识,用于所有关联
    public required string Slug { get; set; }   // 子域名,可变更
    public required string DisplayName { get; set; } // 展示名,可变更
    public TenantStatus Status { get; set; }
}

数据库中的所有业务表用 Guid Id 关联,子域名通过一张映射表或缓存解析到 Id。改名只需更新 Slug 字段与 DNS,业务数据完全不动。

另外要考虑租户生命周期状态:Active、Suspended(欠费暂停)、Deleted(软删除待清理)。中间件在解析后必须校验状态,否则已停用租户仍能访问数据。软删除时不要物理删除数据——合规审计通常要求保留一段时间。

3. 数据隔离策略

一句话总结: EF Core 的全局查询过滤器是共享库隔离的最后一道防线,配合写入时自动填充 TenantId 与数据库级行级安全可以做到多层防护。

EF Core 的全局查询过滤器(Global Query Filter)是共享库方案的基石:

public sealed class AppDbContext : DbContext
{
    private readonly ITenantContext _tenant;

    public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenant)
        : base(options) => _tenant = tenant;

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

    protected override void OnModelCreating(ModelBuilder builder)
    {
        builder.Entity<Order>().HasQueryFilter(o =>
            o.TenantId == _tenant.Tenant!.Id);

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

过滤器自动附加到所有查询上,包括 Include 导航与 FirstOrDefault。但有几个必须知道的限制:

  1. 过滤器对原始 SQL 无效。FromSqlRaw 不会自动加条件,必须手工拼接 WHERE TenantId = @p。
  2. 过滤器可被绕过。IgnoreQueryFilters() 会移除它——代码评审时应对这个调用保持警惕,它应当只出现在跨租户的运维查询中。
  3. 过滤器在模型缓存中只编译一次。由于 _tenant 是实例字段,EF Core 会在每次查询时求值,这是可行的,但要注意不能把租户值捕获进模型缓存。

写入侧必须自动填充 TenantId,绝不能依赖调用方:

public override Task<int> SaveChangesAsync(CancellationToken ct = default)
{
    var tenantId = _tenant.Tenant!.Id;

    foreach (var entry in ChangeTracker.Entries<ITenantScoped>())
    {
        if (entry.State == EntityState.Added)
        {
            entry.Entity.TenantId = tenantId;
        }
        else if (entry.State == EntityState.Modified
                 && entry.Entity.TenantId != tenantId)
        {
            throw new InvalidOperationException("禁止跨租户修改");
        }
    }

    return base.SaveChangesAsync(ct);
}

3.1 独立库与 Schema 的切换

一句话总结: 独立库通过按租户决定连接字符串实现,需要在 DbContext 注册时用工厂模式而非单例,并处理好迁移与连接池。

独立库方案的核心是连接字符串按租户解析:

public sealed class TenantConnectionResolver
{
    private readonly IConfiguration _config;

    public TenantConnectionResolver(IConfiguration config) => _config = config;

    public string Resolve(TenantInfo tenant) => tenant.Isolation switch
    {
        Isolation.Shared => _config.GetConnectionString("Shared")!,
        Isolation.Dedicated => _config.GetConnectionString($"Tenant_{tenant.Id}")
            ?? throw new InvalidOperationException($"缺少租户 {tenant.Id} 的连接串"),
        _ => throw new NotSupportedException(),
    };
}

注册时用 AddDbContext 的工厂重载,而不是让容器缓存单一实例:

builder.Services.AddDbContext<AppDbContext>((sp, options) =>
{
    var tenant = sp.GetRequiredService<ITenantContext>().Tenant!;
    var conn = sp.GetRequiredService<TenantConnectionResolver>().Resolve(tenant);
    options.UseNpgsql(conn);
});

三个必须注意的点:

  1. 连接池按连接字符串分池。租户数量多时会产生大量池,每个池都占用连接,总连接数可能击穿数据库上限。对策是限制每租户池大小并对不活跃租户的连接池做回收。
  2. 迁移要遍历所有库。用 IMigrator 逐个执行并记录每个租户的迁移版本,避免版本漂移;漏掉某个库会导致该租户在下次访问时因表结构缺失而报错。
  3. 共享库到独立库的迁移是一次数据搬迁,需要停机窗口或双写过渡,应在架构早期就设计好导出路径。

关于 DbContext 生命周期、变更跟踪与 N+1 的完整讨论,可参考 EF Core 数据访问 。

4. 按租户配置与限流

一句话总结: 租户级配置用 IOptions 的动态取值或配置存储实现,限流必须按租户分区,否则单个租户可以耗尽全部配额。

租户级配置有两种形态:静态配置(写在 appsettings 里,按租户段读取)与动态配置(存在数据库或配置中心,运行时可改)。前者适合功能开关,后者适合配额与限流阈值。

静态形态用命名选项:

public sealed class TenantLimits
{
    public int RequestsPerMinute { get; set; } = 60;
    public int MaxProjects { get; set; } = 5;
    public bool EnableExports { get; set; }
}

builder.Services.AddOptions<TenantLimits>()
    .Configure<IConfiguration>((limits, config) =>
    {
        var id = /* 当前租户 */;
        config.GetSection($"Tenants:{id}:Limits").Bind(limits);
    });

限流必须按租户分区。ASP.NET Core 的内建限流中间件支持分区:

builder.Services.AddRateLimiter(options =>
{
    options.AddPolicy("per-tenant", httpContext =>
    {
        var tenant = httpContext.RequestServices
            .GetRequiredService<ITenantContext>().Tenant!;
        var limits = httpContext.RequestServices
            .GetRequiredService<IOptions<TenantLimits>>().Value;

        return RateLimitPartition.GetFixedWindowLimiter(tenant.Id.ToString(),
            _ => new FixedWindowRateLimiterOptions
            {
                PermitLimit = limits.RequestsPerMinute,
                Window = TimeSpan.FromMinutes(1),
                QueueLimit = 0,
            });
    });

    options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
});

分区键的选择决定了限流的正确性。按租户分区是基本要求,但要注意两点:

  • 不要在分区键里混入用户 ID。否则租户总配额会被稀释,单个恶意用户可以在多个用户键之间分散流量。
  • 未认证请求要有独立的兜底分区。按 IP 分区,且阈值应显著低于认证租户,避免匿名流量挤占。

对昂贵的操作(导出、报表、批量导入)应使用独立的并发限流而非请求数限流,因为一次导出消耗的资源相当于上千次普通请求:

options.AddConcurrencyLimiter("heavy-ops", httpContext =>
    RateLimitPartition.GetConcurrencyLimiter(tenantId,
        _ => new ConcurrencyLimiterOptions
        {
            PermitLimit = 2,
            QueueLimit = 10,
        }));

配置系统的整体组织方式(含热更新与校验)见 配置与选项模式 。

5. 缓存与后台任务中的租户上下文

一句话总结: 所有缓存键必须带租户前缀,后台任务必须显式接收租户标识并重建上下文,绝不能让上下文随请求生命周期一起消失。

缓存是多租户系统最容易出事故的地方,因为缓存穿透租户边界导致的泄露极其隐蔽。

规则一:缓存键必须包含租户标识。

public sealed class TenantCache
{
    private readonly IDistributedCache _cache;
    private readonly ITenantContext _tenant;

    private string Key(string name) => $"t:{_tenant.Tenant!.Id}:{name}";

    public async Task<T?> GetAsync<T>(string name, CancellationToken ct)
    {
        var bytes = await _cache.GetAsync(Key(name), ct);
        return bytes is null ? default : JsonSerializer.Deserialize<T>(bytes);
    }

    public Task SetAsync<T>(string name, T value, CancellationToken ct)
        => _cache.SetAsync(Key(name),
            JsonSerializer.SerializeToUtf8Bytes(value), ct);
}

更稳妥的做法是把租户前缀封装进一个专用的缓存抽象(如上),禁止业务代码直接使用 IDistributedCache,从结构上杜绝遗漏。缓存穿透、击穿与雪崩的通用对策在单租户与多租户下是一致的,可参考 缓存与分布式并发 。

规则二:后台任务必须显式携带租户标识。

消息里带上租户 ID,消费时重建上下文:

public sealed record OrderSyncMessage(Guid TenantId, Guid OrderId);

public async Task HandleAsync(OrderSyncMessage msg, CancellationToken ct)
{
    var tenant = await _directory.GetAsync(msg.TenantId, ct);
    using var scope = _tenant.BeginScope(tenant);
    // 此后的 DbContext 查询会自动带上正确的租户过滤
}

如果消息里不带租户标识,消费端要么无法确定租户,要么会用「当前上下文」——而后台任务里根本没有上下文,结果是空引用异常或(更糟)拿到上一次遗留的值。

规则三:定时任务要遍历租户。全局定时任务(如每日结算)必须显式枚举活跃租户并逐个建立上下文,不能假设存在「全局租户」:

foreach (var tenant in await _directory.GetActiveAsync(ct))
{
    using var scope = _tenant.BeginScope(tenant);
    await _billing.RunDailyAsync(ct);
}

租户数量多时,这种串行遍历会很慢,需要分批并行并限制并发度,避免同时打开过多数据库连接。

6. 计费与配额

一句话总结: 计量事件应异步落库并保证幂等,配额检查要区分软限制与硬限制,超额行为必须有明确的产品定义。

计费建立在用量计量之上。计量的第一步是定义可计量事件:API 调用次数、存储占用、活跃用户数、导出次数。定义原则是「可稳定复现、可归因到租户、不依赖客户端上报」。

计量写入不应阻塞业务请求:

public sealed class UsageRecorder
{
    private readonly Channel<UsageEvent> _channel =
        Channel.CreateBounded<UsageEvent>(new BoundedChannelOptions(10_000)
        {
            FullMode = BoundedChannelFullMode.DropWrite,
        });

    public ValueTask RecordAsync(UsageEvent evt) => _channel.Writer.WriteAsync(evt);

    // 后台循环按批消费,批量写入降低存储压力
}

这里有两个刻意的取舍:有界队列 + 丢弃策略保证计量写入永远不会拖垮业务请求;批量写入降低存储压力。代价是极端情况下会丢失少量计量数据——对账时应有容忍机制,或者用「业务表反算」做校验。

配额执行要区分两类:

类型行为示例
软限制警告但放行接近配额时提示升级
硬限制拒绝请求超出项目数上限时禁止创建

硬限制的检查必须在数据写入前完成,且要有并发保护,否则并发请求会同时通过检查导致超额:

var current = await _db.Projects.CountAsync(ct);
if (current >= _options.Value.MaxProjects)
    return Result.Fail("已达到项目数量上限,请升级套餐");

_db.Projects.Add(new Project { Name = name });
await _db.SaveChangesAsync(ct);

这段代码在并发下有竞态:两个请求同时读到 current = 4(上限 5),都通过检查,最终创建 6 个。修复方式是用数据库唯一约束或行级锁,或者接受轻微超额并在后台对账时纠正。产品上必须明确超额的处理策略——是回滚、按量补收,还是容忍,这个决定应当写进需求而不是留给实现。

配额与租户状态(Suspended)需要联动:欠费暂停后,配额检查应直接拒绝而非仅提示。鉴权与授权模型在多租户下的组织方式见 安全、认证与身份 。

7. 工程实践与常见坑

一句话总结: 多租户系统的事故几乎都源于上下文丢失或过滤遗漏,用架构约束而非代码规范来防御是最有效的策略。

实践建议:

  • 禁止业务代码直接使用 DbContext 的 IgnoreQueryFilters,用分析器或代码评审强制。
  • 封装缓存与后台任务抽象,让租户前缀与上下文重建成为框架行为而非开发者责任。
  • 为租户隔离写集成测试。至少覆盖「租户 A 无法读取租户 B 的数据」这条断言,且要在所有新增实体上扩展。
  • 索引以 TenantId 开头。共享库下几乎所有查询都带租户条件,复合索引的首列应是 TenantId。
  • 监控按租户维度聚合。单个租户的异常流量、错误率、延迟应可见,否则无法做租户级限流与排障。

排错清单:

  1. 查询返回了其他租户的数据 → 检查是否用了 FromSqlRaw 或 IgnoreQueryFilters。
  2. 后台任务抛空引用 → 未重建租户上下文,检查消息是否携带 TenantId。
  3. 缓存串租户 → 缓存键缺少租户前缀,检查是否绕过了封装抽象。
  4. 租户级限流不生效 → 分区键取了 IP 或用户而非租户。
  5. 独立库连接池耗尽 → 租户数量 × 池大小超过数据库连接上限,需限制每池大小。

8. 总结

环节要点
隔离模型共享库起步,混合策略是常态,独立库兜底合规
租户标识用不可变 Guid,Slug 与显示名只是别名
上下文AsyncLocal 传播,后台任务必须显式重建
数据隔离全局查询过滤器 + 写入自动填充 + 索引以租户开头
配置限流分区键必须是租户,昂贵操作用并发限流
缓存键必须带租户前缀,封装抽象杜绝遗漏
计费异步有界队列计量,硬限制需并发保护
防御用架构约束而非代码规范防上下文丢失

多租户不是加一个 TenantId 字段那么简单,它是一组贯穿数据访问、缓存、后台任务、配置与计费的横切约束。最有效的做法是把这些约束下沉为框架能力:上下文由中间件注入、过滤由 EF Core 自动附加、缓存前缀由封装保证、配额检查由统一切面执行。凡是依赖「开发者记得写对」的环节,最终都会出事故;凡是能在架构层面强制的环节,才是真正安全的。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「csharp」更多文章

  1. 从 WCF 迁移到 gRPC 与 REST
  2. Avalonia 跨平台桌面 UI
  3. Dapr 集成微服务