对于不熟悉的朋友,我们先来看看什么是多租户?

用一句话来说就是:一套软件系统,同时安全地服务于多个不同客户(租户),且各个客户的数据绝对隔离、互不可见。

.NET 实际开发中,很多团队会遇到以下两个问题:

  1. 单库字段隔离:在每一条 LINQ / SQL 里手写 WHERE TenantId = xxx,一旦少写了一句,就会导致致命的数据跨租户越权生产事故。
  2. 多库物理隔离:觉得动态切库太复杂,每次都要手动判断连接字符串,代码里充斥着大量的条件分支。

如何做到业务代码零侵入、自动注入 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 来说,域名就是租户身份证

实现很简单:

  1. 运维侧提前把 *.yoursaas.com 泛解析到你的应用服务器(或负载均衡)。
  2. 开通新租户时,给它分配一个子域名,比如 business.yoursaas.com,并写进主库的租户域名表。
  3. 客户也可以绑定自己的自定义域名,验证域名所有权、配好证书后,同样落到主库,一对一映射到 TenantId
主库写入租户(DbCreatedStatus = 0)→ 绑定域名 → 初始化租户数据库 → 完成后更新 DbCreatedStatus = 1

注意:应用挂在 Nginx、Ingress 或负载均衡后面时,要正确配置 ForwardedHeaders 和受信任代理。域名入库前也要统一转成小写、去掉末尾的点,并建立唯一索引。

3. 中间件里用域名找租户ID

请求一进管道,就该把租户身份固定。越早解析,后面的鉴权、切库、过滤直接用。

大致流程:

  1. Host 拿当前访问域名(注意去掉端口);
  2. 查主库 / 缓存知道这个域名绑的是哪个租户;
  3. TenantId 和租户状态塞进一个作用域服务(比如 ICurrentTenant);
  4. 创建业务数据库客户端时,多库模式通过 TenantId 查询连接字符串,单库模式直接使用固定连接串;
  5. 租户不存在、未开通完成或已禁用直接抛出 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};还有对象存储目录、搜索索引、分布式锁、限流计数器也一样;

  • 跨租户管理能力值得思考。 平台管理员某些场景可能需要跨租户查数据,我见过大多数系统,惯例是在平台管理增加租户免密登录(直接进到租户业务系统,以租户视角排查问题最好)。

最后,建议所有租户功能开发完,至少要新建两个租户来回切换测试,务必覆盖全场景。