
对于不熟悉的朋友,我们先来看看什么是多租户?
用一句话来说就是:一套软件系统,同时安全地服务于多个不同客户(租户),且各个客户的数据绝对隔离、互不可见。
.NET 实际开发中,很多团队会遇到以下两个问题:
- 单库字段隔离:在每一条 LINQ / SQL 里手写
WHERE TenantId = xxx,一旦少写了一句,就会导致致命的数据跨租户越权生产事故。 - 多库物理隔离:觉得动态切库太复杂,每次都要手动判断连接字符串,代码里充斥着大量的条件分支。
如何做到业务代码零侵入、自动注入 TenantId、自动防越权?下面结合我的实战经验,聊聊在 .NET 中优雅落地多租户数据隔离的方案(以下代码使用SqlSugar示例)。
快速落地
主要4个步骤,分别是:主库管理配置与数据库路由 -> 域名绑租户 -> 中间件解析租户 -> ORM 全局过滤。
1. 主库管配置,业务数据支持单库或多库
主库是核心,一定要安全可靠
- 主库(也叫平台库,Platform DB):管租户配置(绑定域名、套餐、功能开关、数据库连接信息)、租户菜单、公共字典等。这些数据量通常不大,但属于核心资产。
- 租户业务库(Tenant DB):不管什么业务系统,业务数据存在这,如订单、客户、库存、业务日志。
这里说的“主库”不是数据库读写分离里的主节点。它是整个 SaaS 的租户配置中心,业务库才是真正存订单、客户这些数据的地方。
多租户常见两种隔离模式:
| 模式 | 实现方式 | 适合谁 |
|---|---|---|
| 单库字段隔离 | 多个租户共享一个业务库,每张业务表带 TenantId |
中小 SaaS、租户多但单租户数据量不大 |
| 多库物理隔离 | 一个租户一个业务库,通过租户配置动态路由连接串 | 大客户、强合规、单租户数据量较大 |
单库模式:靠 TenantId 隔开
单库模式是多个租户的数据混在同一批表里,每条记录都必须有 TenantId:
Orders
┌──────────┬──────────┬─────────────┐
│ Id │ TenantId │ OrderNo │
├──────────┼──────────┼─────────────┤
│ 101 │ tenant-a │ SO20260001 │
│ 102 │ tenant-b │ SO20260002 │
└──────────┴──────────┴─────────────┘
它的优点是便宜、部署简单、升级一次就行;缺点也很直接:隔离全靠 TenantId,漏一个过滤条件就可能串数据;所以后面讲的全局过滤必不可少。
多库模式:根据 TenantId 动态路由
多库模式里,每个租户有自己的业务库。主库的租户配置大致会记录这些信息:
TenantId
Domain
ConnectionKey // 连接串密钥标识,不建议直接存明文密码
DatabaseName
DatabaseVersion
DbCreatedStatus // 0:创建中,1:已创建
Status // 0:禁用,1:正常
多库模式下,请求来了以后先通过域名找到 TenantId,再用 TenantId 查主库中的连接配置:
Host → TenantId → 主库租户配置 → ConnectionKey → 连接字符串 → 创建当前请求的 SqlSugarClient
相比之下单库模式就简单些:业务库连接串是固定的,不需要通过租户获取连接,请求解析出 TenantId 后直接使用公共业务库,再由全局过滤器完成隔离。
连接字符串最好别存明文,做个加密,更稳妥的做法是主库只保存密钥标识,真正的密码放到 Key Vault、KMS 一类的密钥服务中。
多库模式下,SqlSugar 怎么动态选连接串?
租户中间件只负责把 TenantId 写入 ICurrentTenant。创建 ISqlSugarClient 时,再拿这个 TenantId 通过 TenantStore 查询对应连接字符串:
services.AddScoped<ICurrentTenant, CurrentTenant>();
services.AddScoped<ISqlSugarClient>(serviceProvider =>
{
var currentTenant = serviceProvider.GetRequiredService<ICurrentTenant>();
var tenantStore = serviceProvider.GetRequiredService<TenantStore>();
if (!currentTenant.TenantId.HasValue)
throw new InvalidOperationException("尚未解析当前租户,不能创建业务数据库客户端");
// 用租户ID换连接字符串
var connectionString = tenantStore.GetConnectionString(currentTenant.TenantId);
return new SqlSugarClient(new ConnectionConfig
{
ConnectionString = connectionString,
DbType = DbType.PostgreSQL,
IsAutoCloseConnection = true
});
});
顺序不能错:必须先解析租户,再创建业务 ISqlSugarClient;另外,主库客户端和业务库客户端要分开,正确做法是拆分成两个项目。
新租户开户必须实现自动化
软件核心就是稳定。总不能每来一个客户,就让运维连数据库,手动建库、跑脚本。客户一多,这种方式不仅慢,而且容易漏步骤。
比较稳妥的做法是把“租户开通”做成一套可重试的自动化流程:
主库创建租户记录
↓
生成数据库名和连接信息
↓
使用专门的建库账号创建数据库
↓
执行 SqlSugar CodeFirst 或版本化升级脚本
↓
初始化角色、菜单、字典等默认数据
↓
做一次连接和基础查询检查
↓
保存连接串密钥标识,DbCreatedStatus = 1(已创建)
代码层可以抽一个 ITenantProvisioner,把建库和迁移集中起来:
public enum TenantStatus
{
Disabled = 0, // 禁用
Active = 1 // 正常
}
public enum DbCreatedStatus
{
Creating = 0, // 创建中
Created = 1 // 已创建
}
public async Task ProvisionAsync(Guid tenantId, CancellationToken cancellationToken)
{
var tenant = await _platformClient.Queryable<Tenant>().SingleAsync(x => x.Id == tenantId);
if (tenant.DbCreatedStatus == DbCreatedStatus.Created)
return; // 重复执行时直接结束
tenant.DbCreatedStatus = DbCreatedStatus.Creating;
await _platformClient.Updateable(tenant).UpdateColumns(x => x.DbCreatedStatus).ExecuteCommandAsync();
try
{
var databaseName = $"tenant_{tenant.Id:N}";
// 建库账号和应用运行账号分开,数据库名必须由系统生成
await _databaseCreator.CreateIfNotExistsAsync(databaseName, cancellationToken);
var connectionString = await _secretStore
.CreateTenantConnectionAsync(tenant.Id, databaseName, cancellationToken);
using var tenantClient = _tenantClientFactory.Create(connectionString);
// 也可以在这里执行自己维护的版本化 SQL 升级脚本
tenantClient.CodeFirst.InitTables(typeof(Order), typeof(Customer), typeof(Product));
await _tenantSeeder.SeedAsync(tenantClient, tenant.Id, cancellationToken);
tenant.ConnectionKey = await _secretStore.SaveAsync(tenant.Id, connectionString, cancellationToken);
tenant.DatabaseName = databaseName;
tenant.DbCreatedStatus = DbCreatedStatus.Created;
}
catch (Exception ex)
{
tenant.ProvisioningError = ex.Message;
throw;
}
finally
{
await _platformClient.Updateable(tenant).ExecuteCommandAsync();
}
}
上面是流程示意,生产环境还要注意几件事:
- 异步建库迁移。 这个过程很慢,扔到消息队列或后台任务里执行,前端轮询或SignalR实时接收查进度;
- 建库账号和应用账号分权。 日常跑业务的数据库账号不该拥有
CREATE DATABASE权限; - 流程必须幂等。 消息可能重复消费,建库、迁移和初始化数据都要能安全重试,不能第二次执行就插出一堆重复菜单;
- 数据库名不能直接用客户输入。 用系统生成的租户 ID 拼数据库名,避免非法字符和 SQL 注入。
后续发布新版本时,多库模式要批量升级所有租户库;建议自建一套批量执行脚本程序,在平台管理调用,确保所有租户库执行成功,再发布后端新版本。
2. 泛域名解析,开通租户就绑子域名
多租户入口别做成“登录后再选公司”(内部系统做法)——对 SaaS 来说,域名就是租户身份证。
实现很简单:
- 运维侧提前把
*.yoursaas.com泛解析到你的应用服务器(或负载均衡)。 - 开通新租户时,给它分配一个子域名,比如
business.yoursaas.com,并写进主库的租户域名表。 - 客户也可以绑定自己的自定义域名,验证域名所有权、配好证书后,同样落到主库,一对一映射到
TenantId。
主库写入租户(DbCreatedStatus = 0)→ 绑定域名 → 初始化租户数据库 → 完成后更新 DbCreatedStatus = 1
注意:应用挂在 Nginx、Ingress 或负载均衡后面时,要正确配置 ForwardedHeaders 和受信任代理。域名入库前也要统一转成小写、去掉末尾的点,并建立唯一索引。
3. 中间件里用域名找租户ID
请求一进管道,就该把租户身份固定。越早解析,后面的鉴权、切库、过滤直接用。
大致流程:
- 从
Host拿当前访问域名(注意去掉端口); - 查主库 / 缓存知道这个域名绑的是哪个租户;
- 把
TenantId和租户状态塞进一个作用域服务(比如ICurrentTenant); - 创建业务数据库客户端时,多库模式通过
TenantId查询连接字符串,单库模式直接使用固定连接串; - 租户不存在、未开通完成或已禁用直接抛出 404 / 403。
示例代码:
public class TenantResolveMiddleware
{
private readonly RequestDelegate _next;
public TenantResolveMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(
HttpContext context,
ITenantStore store,
ICurrentTenant currentTenant)
{
var host = context.Request.Host.Host; // business.yoursaas.com
var tenantId = await store.FindTenantIdByHostAsync(host);
var tenant = tenantId.HasValue
? await store.FindByIdAsync(tenantId.Value)
: null;
if (tenant is null || !tenant.IsActive || !tenant.IsDatabaseCreated)
{
context.Response.StatusCode = StatusCodes.Status404NotFound;
await context.Response.WriteAsync("租户不存在或已停用");
return;
}
currentTenant.Set(tenant.Id);
await _next(context);
}
}
需要注意的几个地方:
- 一定要缓存域名到租户的映射和数据库路由;
- 缓存不光要有过期时间,租户停用、换域名、切库时还得主动失效;
- 开发环境可以在Swagger上增加
X-Tenant-Host,方便调试; - 中间件顺序要放对:默认转发头处理之后、认证授权和业务中间件之前;
- 域名只负责确定“哪个租户”,不代表当前用户有权进入这个租户;用户登录后仍需校验Token中的租户声明或用户租户关系与当前
TenantId一致。
4. 全局过滤,避免手写TenantId。
业务代码里尽量看不到TenantId,但每条 SQL 都自带租户过滤。
SqlSugar 做法
实体统一实现租户接口,当前租户仍然从中间件已经赋值的 ICurrentTenant 中获取。QueryFilter 负责给查询追加租户条件,AOP 则在插入时自动写入 TenantId:
public interface ITenantEntity
{
Guid TenantId { get; set; }
}
public class Order : ITenantEntity
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public string OrderNo { get; set; } = string.Empty;
}
services.AddScoped<ISqlSugarClient>(serviceProvider =>
{
var currentTenant = serviceProvider.GetRequiredService<ICurrentTenant>();
var configuration = serviceProvider.GetRequiredService<IConfiguration>();
if (!currentTenant.TenantId.HasValue)
throw new InvalidOperationException("当前请求未解析到租户,禁止访问租户数据");
var db = new SqlSugarClient(new ConnectionConfig
{
ConnectionString = configuration.GetConnectionString("Default")!,
DbType = DbType.PostgreSQL,
IsAutoCloseConnection = true
});
// 自动追加当前租户
db.QueryFilter.AddTableFilter<ITenantEntity>(entity => entity.TenantId == currentTenant.TenantId);
// 插入时自动覆盖TenantId
db.Aop.DataExecuting = (_, entityInfo) =>
{
if (entityInfo.OperationType == DataFilterType.InsertByObject &&
entityInfo.PropertyName == nameof(ITenantEntity.TenantId))
{
entityInfo.SetValue(currentTenant.TenantId);
}
};
return db;
});
ISqlSugarClient 这里注册成 Scoped,必须在租户中间件设置好 ICurrentTenant 之后再创建。
业务代码只管正常查询和插入:
var list = await _db.Queryable<Order>()
.Where(x => x.OrderNo.StartsWith("SO"))
.ToListAsync();
await _db.Insertable(new Order
{
Id = Guid.NewGuid(),
OrderNo = "SO20260001"
}).ExecuteCommandAsync();
代码里没有手写 TenantId,但查询 SQL 会自动带上租户条件,插入时 AOP 也会自动赋值。
共享库 vs 独立库,怎么配合?
- 共享库:必须上全局过滤,这是底线。
- 一租户一库:连接串已经物理隔离了,过滤可以简化。
记得给共享库常用查询建立以 TenantId 开头的联合索引,比如 (TenantId, CreatedTime)、(TenantId, OrderNo),这样能加快租户ID的查询。
避坑/生产事故预防
以上方案能挡住大部分的漏写过滤导致的数据越权,但不等于可以完全躺平,还需特别注意意思4点:
-
原生SQL要手动过滤。 它们不走 ORM 过滤器,需要把
TenantId条件手动补上; -
后台任务必须显式携带租户身份。 Job、消息队列和 Hangfire 没有 HTTP Host,消息体或任务参数里要带
TenantId;消费时先建立租户作用域,再解析数据库路由,处理完马上释放; -
共享资源要考虑租户命名空间。 最典型的是缓存Key,至少应该长成
tenant:{tenantId}:order:{orderId};还有对象存储目录、搜索索引、分布式锁、限流计数器也一样; -
跨租户管理能力值得思考。 平台管理员某些场景可能需要跨租户查数据,我见过大多数系统,惯例是在平台管理增加租户免密登录(直接进到租户业务系统,以租户视角排查问题最好)。
最后,建议所有租户功能开发完,至少要新建两个租户来回切换测试,务必覆盖全场景。