租户识别中间件必须在UseRouting之后、UseAuthentication之前执行,否则会导致多租户失效;应从Host/Path/Header提取TenantId并存入HttpContext.Items或AsyncLocal,DbContextOptions需按租户动态构造且注册为Scoped工厂,HasQueryFilter须闭包捕获Scoped生命周期的租户提供者,所有文件路径、日志、缓存、异常响应均需显式携带TenantId。

租户识别必须在 UseRouting 之后、UseAuthentication 之前做中间件
漏掉这个顺序,整个多租户就失效了。UseRouting 解析完 Host/Path/Header 后,请求上下文才稳定;UseAuthentication 一执行,IDbContextFactory、IAuthorizationService 等服务就开始构建实例——此时再塞 TenantId 已经晚了。
常见错误是把解析逻辑写在 Controller 构造函数或 Action 里,或者用 [FromHeader] 拿 X-Tenant-ID 再传给 DbContext,这会导致所有中间件(日志、限流、授权)都跑在“无租户”或“上一个租户”的上下文中。
- 推荐从
HttpContext.Request.Host提取,比如匹配tenant1.example.com,天然支持子域名部署 - 若用路径前缀(如
/t/abc/api/orders),必须在MapControllers()前调用app.UsePathBase("/t/{tenantId}"),否则路由匹配失败 - Header 方式(如
X-Tenant-ID)适合网关统一注入,但绝不能直接信任客户端传入的值,需配合 JWT 声明或网关白名单校验 - 存值必须用
HttpContext.Items["TenantId"]或AsyncLocal<string></string>,禁用static string TenantId——线程池复用下必然串租户
DbContextOptions 必须按租户动态构造,且注册为 Scoped 工厂
DbContextOptions 是只读的,一旦实例化,Database.GetDbConnection().ConnectionString 就不可变。硬改连接字符串会引发连接池混用、事务跨库、连接泄漏等运行时故障。
正确做法是:在 AddScoped 注册时传入工厂委托,每次请求都新建 DbContextOptionsBuilder,从缓存或密钥服务拉取租户专属连接字符串。
- 连接字符串中必须包含标识:SQL Server 加
Application Name=tenant-{id},PostgreSQL 加ApplicationName=tenant-{id},否则监控和限流无法区分租户 - 租户数 ≤ 500 时,可用
IMemoryCache缓存已构建的DbContextOptions;超千级建议用ConcurrentDictionary<string dbcontextoptions></string>避免锁争用 - 不要在
appsettings.json里硬编码多个连接字符串——既不安全也不可扩展 - 若用 Azure Key Vault 或 HashiCorp Vault,务必确保租户 ID 校验通过后再发起密钥拉取,避免高频 403
HasQueryFilter 必须闭包捕获租户 ID,且依赖服务生命周期为 Scoped
HasQueryFilter 是 EF Core 实现逻辑隔离的核心,但它只对 DbSet<T> 查询生效,对 FromSqlRaw、原生 SQL、ExecuteSql* 完全无效。最常踩的坑是生命周期错配。
示例写法(正确):
modelBuilder.Entity<Order>().HasQueryFilter(x => x.TenantId == _tenantProvider.GetCurrentTenantId());
这里 _tenantProvider.GetCurrentTenantId() 必须是同步方法,不能是 async;_tenantProvider 必须注册为 Scoped,若注册成 Singleton,会跨请求返回上一个租户的 ID。
- 所有含租户边界的实体(如
Order、UserProfile、Setting)都应统一加此过滤,避免遗漏 - 软删除(
IsDeleted)可与租户过滤组合:x => x.TenantId == tid && !x.IsDeleted - 需要绕过过滤的场景(如后台迁移、租户合并)只能用
IgnoreQueryFilters(),切勿暴露给用户请求路径 - 注意:
HasQueryFilter不支持异步委托,所以GetCurrentTenantIdAsync()不能直接用在这里
文件路径、日志、异常上下文必须携带 TenantId
租户隔离不是只管数据库。上传的头像、导出的 Excel、定时任务生成的报告,如果路径没按租户哈希隔离,就会被跨租户访问;日志不带 TenantId,线上排查等于盲人摸象。
关键点在于:所有对外输出、存储、记录的地方,都要显式注入当前租户上下文,而不是靠“业务层传参”这种脆弱方式。
- 文件存储路径建议用
/uploads/{tenantHash}/{guid}.jpg,其中tenantHash是MD5(tenantId).Substring(0,8),避免路径暴露真实租户 ID - 日志结构化字段必须含
tenant_id,Serilog 可用Enrich.WithProperty("tenant_id", HttpContext.Items["TenantId"]) - 全局异常过滤器(
IExceptionFilter)中,需从HttpContext.Items提取TenantId并写入响应头或错误详情,便于前端定位问题租户 - 任何缓存键(Redis/MemoryCache)都应包含租户维度,例如
cache.GetOrCreate("user-list-" + tenantId, ...)
真正难的不是写代码,而是让租户上下文像空气一样贯穿整个请求生命周期——从中间件进来到日志写出、文件落盘、数据库查询,每一步都不能断。一旦某处漏掉,数据就可能越界,而这种 bug 往往只在高并发或跨租户混合流量下才爆发。


















