1. 三类隔离模型
一句话总结: 多租户的核心决策是数据隔离粒度,共享库共享表成本最低但隔离最弱,独立库隔离最强但运维成本最高,多数系统需要混合策略。
SaaS 的租户隔离有三档,选择取决于合规要求、租户规模与运维能力:
| 模型 | 数据存放 | 隔离强度 | 成本 | 适用 |
|---|---|---|---|---|
| 共享库共享表 | 同一库,靠 TenantId 列区分 | 弱(依赖代码正确性) | 最低 | 中小租户、通用 SaaS |
| 共享库独立 Schema | 同库不同 Schema | 中 | 中 | 中等规模、需按租户备份 |
| 独立库 | 每租户一个数据库 | 强 | 高 | 大客户、强合规要求 |
混合策略是生产系统的常态:免费与中小企业租户共享库,付费大客户独立库。这要求数据访问层从一开始就支持「按租户决定连接」的抽象,而不是假设单一数据库。
隔离模型的选择还会反向影响其他设计:
- 独立库下,跨租户统计需要额外的汇总管道;共享库下一条 SQL 即可。
- 共享库下,任何遗漏
TenantId过滤的查询都是数据泄露事故;独立库下物理隔离天然兜底。 - 独立库的迁移需要遍历所有库执行;共享库一次迁移即可。
一条务实的原则:先按共享库设计,把租户上下文做成一等公民,等有客户要求时再迁到独立库。反过来(先独立库再合并)几乎不可行,因为代码里会散布大量「连接字符串从哪来」的假设。
2. 租户解析与上下文
一句话总结: 租户解析从请求中提取租户标识,上下文通过 AsyncLocal 在调用链中传播,后台任务必须显式传递而不能依赖环境上下文。
租户标识的来源有四种,实践中常组合使用:
- 子域名:
acme.app.com→ 租户acme。适合面向企业的产品,租户可感知。 - 路径前缀:
/t/acme/orders。实现简单,但污染路由。 - JWT 声明:令牌中携带
tenant_id。最可靠,因为它经过了认证。 - 请求头:
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。但有几个必须知道的限制:
- 过滤器对原始 SQL 无效。
FromSqlRaw不会自动加条件,必须手工拼接WHERE TenantId = @p。 - 过滤器可被绕过。
IgnoreQueryFilters()会移除它——代码评审时应对这个调用保持警惕,它应当只出现在跨租户的运维查询中。 - 过滤器在模型缓存中只编译一次。由于
_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);
});
三个必须注意的点:
- 连接池按连接字符串分池。租户数量多时会产生大量池,每个池都占用连接,总连接数可能击穿数据库上限。对策是限制每租户池大小并对不活跃租户的连接池做回收。
- 迁移要遍历所有库。用
IMigrator逐个执行并记录每个租户的迁移版本,避免版本漂移;漏掉某个库会导致该租户在下次访问时因表结构缺失而报错。 - 共享库到独立库的迁移是一次数据搬迁,需要停机窗口或双写过渡,应在架构早期就设计好导出路径。
关于 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。 - 监控按租户维度聚合。单个租户的异常流量、错误率、延迟应可见,否则无法做租户级限流与排障。
排错清单:
- 查询返回了其他租户的数据 → 检查是否用了
FromSqlRaw或IgnoreQueryFilters。 - 后台任务抛空引用 → 未重建租户上下文,检查消息是否携带
TenantId。 - 缓存串租户 → 缓存键缺少租户前缀,检查是否绕过了封装抽象。
- 租户级限流不生效 → 分区键取了 IP 或用户而非租户。
- 独立库连接池耗尽 → 租户数量 × 池大小超过数据库连接上限,需限制每池大小。
8. 总结
| 环节 | 要点 |
|---|---|
| 隔离模型 | 共享库起步,混合策略是常态,独立库兜底合规 |
| 租户标识 | 用不可变 Guid,Slug 与显示名只是别名 |
| 上下文 | AsyncLocal 传播,后台任务必须显式重建 |
| 数据隔离 | 全局查询过滤器 + 写入自动填充 + 索引以租户开头 |
| 配置限流 | 分区键必须是租户,昂贵操作用并发限流 |
| 缓存 | 键必须带租户前缀,封装抽象杜绝遗漏 |
| 计费 | 异步有界队列计量,硬限制需并发保护 |
| 防御 | 用架构约束而非代码规范防上下文丢失 |
多租户不是加一个 TenantId 字段那么简单,它是一组贯穿数据访问、缓存、后台任务、配置与计费的横切约束。最有效的做法是把这些约束下沉为框架能力:上下文由中间件注入、过滤由 EF Core 自动附加、缓存前缀由封装保证、配额检查由统一切面执行。凡是依赖「开发者记得写对」的环节,最终都会出事故;凡是能在架构层面强制的环节,才是真正安全的。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。