XiHan Framework 处于预览阶段

查看获取方式
跳转到内容

租户分片与分库 ​

这一页讲怎样把每个租户的数据落到租户自己的数据库:分片契约 ITenantShardResolver 与 TenantShard,缺省实现怎样从租户属性推出分片,持久化 Provider 怎样按分片选连接,以及不经管道的代码怎样用 TenantShardScope 显式绑定分片。读完你能给某个租户配一条独立连接串,让它的多租户实体读写都落到独立库,而平台表仍在默认库。阅读前先读租户解析与隔离与持久化与 Provider。

为什么需要 ​

行级隔离(每张表一列 TenantId)对大多数租户够用,但大客户往往要求数据物理分开:独立备份、独立容量、独立合规审计,甚至放在客户自己的数据库实例里。如果让业务代码自己按租户挑连接,每个仓储、每个作业都要重复这段逻辑,漏掉一处就会把租户数据写进平台库。框架把「这个租户用哪条连接」做成能力 multitenancy.sharding@1:Tenant 阶段解析一次分片写进调用上下文,持久化 Provider 创建客户端时读它;解析失败就让调用失败,不降级为共库。框架只做库级隔离,行级隔离就是上一页的租户过滤,Schema 级隔离由应用经透传属性自行处理。

分片契约 ​

类型说明
ITenantShardResolverResolveAsync(tenantId) 返回 Result<Maybe<TenantShard>>:共库租户与未知租户是成功且无值;租户存储不可达返回 MULTITENANCY_UNAVAILABLE_002。Invalidate(tenantId?) 丢弃缓存,参数为 null 时清空全部
TenantShard一个分片:TenantId、ConnectionString、可选的 Provider(提供者名,转成小写,如 postgresql)与透传属性 Properties
TenantStoreShardResolver缺省解析器,从 ITenantStore 里租户的 Properties 推出分片,按租户缓存结果
TenantShardKeys租户属性键的常量
TenantShardScope作用域服务,给不经管道的作用域显式绑定分片
context.GetTenantShard()读调用上下文里的分片,Maybe<TenantShard>

两个多租户 Provider(MultiTenancy.InMemory、MultiTenancy.Database)在分片能力被选中时都注册 TenantStoreShardResolver,它按下面的租户属性推出分片:

租户属性键常量含义
ConnectionStringTenantShardKeys.ConnectionString有值即该租户分库;没有或为空白是共库租户
DatabaseProviderTenantShardKeys.DatabaseProvider数据库提供者名,可选;空表示与应用默认连接同一种
Shard.<key>TenantShardKeys.PropertyPrefix去掉前缀后作为 <key> 透传给持久化 Provider;用点不用冒号,因为属性来自配置时冒号是节分隔符

最小示例 ​

给内存租户目录里的 acme 配一条独立连接,beta 不配就是共库租户:

json
{
  "XiHan": {
    "Bindings": { "db": "Host=localhost;Database=app" },
    "MultiTenancy": {
      "Tenants": {
        "acme": {
          "DisplayName": "Acme Corp",
          "Properties": {
            "ConnectionString": "Host=localhost;Database=acme",
            "DatabaseProvider": "postgresql"
          }
        },
        "beta": { "DisplayName": "Beta" }
      }
    }
  }
}

装配一个关系型持久化 Provider,下面用 Persistence.SqlSugar,默认连接来自绑定 db:

csharp
using SqlSugar;
using XiHan.Framework.Hosting.AspNetCore;
using XiHan.Framework.Hosting.AspNetCore.Endpoints;
using XiHan.Framework.MultiTenancy.Abstractions.Sharding;
using XiHan.Framework.Persistence.SqlSugar;
using XiHan.Framework.Profiles.Standard;

var builder = WebApplication.CreateBuilder(args);

// Standard 组合包已含内存多租户;SqlSugar 顶替其中的内存持久化,并以可选依赖接上分片能力
builder.AddXiHan(xihan => xihan
    .UseStandardProfile()
    .UseFeature(new SqlSugarPersistenceFeature(static (binding, config) =>
    {
        config.DbType = DbType.PostgreSQL;
        config.ConnectionString = binding.ValueOrThrow().Value;
    })));

var app = builder.Build();
app.UseXiHan();

// 分片在 Tenant 阶段解析一次,写进调用上下文;共库租户与无租户的调用没有分片
app.MapGet("/shard", (HttpContext http) =>
    http.GetXiHanContext().ValueOrThrow().GetTenantShard().TryGetValue(out var shard)
        ? $"{shard.TenantId} -> {shard.Provider ?? "default provider"}"
        : "shared");

app.Run();

用租户为 acme 的主体调用 /shard,响应 acme -> postgresql;租户为 beta 的主体响应 shared。此后在 acme 的调用里,实现 IMultiTenant 的实体经仓储读写都落到 Database=acme,不实现它的平台表仍落 Database=app。

解析与缓存 ​

分片在 Tenant 阶段解析一次,写进调用上下文;解析失败即调用失败(MULTITENANCY_UNAVAILABLE_002),不降级为共库,因为降级会把租户数据写进平台库。无租户的调用没有分片。

TenantStoreShardResolver 的结果按租户缓存,「无分片」也缓存;缓存条目数达到 XiHan:MultiTenancy:ShardCacheSize(缺省 10000)时整体清空。租户连接变更后调用 ITenantShardResolver.Invalidate(tenantId);经数据库目录的 TenantRecordStore 写入时会自动失效,见数据库租户目录。

想自己决定分片规则(按租户哈希选库、从独立的分片表读连接)时,实现 ITenantShardResolver 并在特性里登记为单例;中间件与持久化 Provider 只认契约。自带分片 Provider 的应用同样要登记 TenantShardScope。

持久化 Provider 按分片选连接 ​

持久化 Provider(Persistence.SqlSugar、Persistence.EntityFrameworkCore)以可选依赖声明 multitenancy.sharding@1,在作用域内创建客户端时经 TenantShardBinding.Resolve 读分片,不访问存储、不阻塞。分片是实体级的落点,不是整个作用域的切换:带分片的调用里,实现 IMultiTenant 的实体走分片连接,平台表仍走默认连接。

Provider带分片的调用
Persistence.SqlSugar客户端同时持有默认连接与分片连接,工作单元在两个连接上开事务、依次提交或回滚(不是两阶段提交),见 SqlSugar Provider
Persistence.EntityFrameworkCore一个 DbContext 一个连接,整个上下文切到分片连接;平台表要另起 DbContext,由应用处理,见 EF Core Provider

TenantShard.Provider 为空时沿用默认连接的数据库种类;有值时 Provider 据此选驱动,所以同一应用可以让默认库是 SQL Server、某个租户库是 PostgreSQL。

分片库的表结构由应用管理

框架的建表开关(SqlSugar 的 InitTables、EF Core 的 EnsureCreated)只作用于默认连接。租户库的建表与升级由应用自己执行,下一节的 TenantShardScope 用于在管道外连到租户库。EF Core 在 SQLite 上的时间列核对对分片库在首次绑定时执行:分片库还是旧的文本形式时,这个租户的工作单元开不起来,经管道的调用是 KERNEL_UNEXPECTED_001(HTTP 500,原因 PERSISTENCE_VALIDATION_008),修好库之后下一次绑定即恢复,见建表约定与索引。

管道外绑定分片 ​

后台写入、调度作业、租户库初始化这类代码自己建服务作用域,不经 App.InvokeAsync,作用域里没有调用上下文,也就没有分片。作用域服务 TenantShardScope 给这类作用域显式绑定分片:

成员说明
BindTenantAsync(tenantId, cancellationToken)经 ITenantShardResolver 解析并绑定,返回解析结果;不看租户是否启用,也不受应用是否在服务状态(启动、排空)影响;共库租户、未知租户与没有装配分片解析器时成功且不绑定;解析失败返回错误、不绑定
Bind(shard)直接绑定一个分片
Shard已绑定的分片,没有绑定为无值
IsConnected作用域的持久化客户端是否已经取过连接

绑定必须发生在作用域内首次创建持久化客户端之前。客户端建好之后再绑定,或改绑到另一个分片,抛出 InvalidOperationException;同一个分片重复绑定没有副作用。显式绑定优先于调用上下文里的分片。

csharp
using Microsoft.Extensions.DependencyInjection;
using XiHan.Framework.MultiTenancy.Abstractions.Sharding;
using XiHan.Framework.Persistence.Abstractions.Queries;
using XiHan.Framework.Persistence.Abstractions.Repositories;
using XiHan.Framework.Persistence.Abstractions.UnitOfWork;
using XiHan.Framework.Primitives;

// Invoice 见上一页,仓储已经登记
public sealed class InvoiceCounter(IServiceScopeFactory scopes)
{
    public async Task<Result<long>> CountAsync(string tenantId, CancellationToken cancellationToken)
    {
        await using var scope = scopes.CreateAsyncScope();
        var services = scope.ServiceProvider;

        // 先绑定再解析任何持久化服务:客户端在作用域里只建一次,建好后改不了连接
        var bound = await services.GetRequiredService<TenantShardScope>().BindTenantAsync(tenantId, cancellationToken);
        if (bound.IsFailure)
        {
            return bound.Error!;
        }

        // 没有 Transaction 阶段代劳,工作单元自己开;租户由调用方给出,与绑定的租户一致
        var unitOfWork = services.GetRequiredService<IUnitOfWork>();
        await unitOfWork.BeginAsync(new UnitOfWorkOptions(tenantId, IsReadOnly: true), cancellationToken);
        try
        {
            var invoices = services.GetRequiredService<IRepository<Invoice, Guid>>();
            var count = await invoices.CountAsync(EntityQuery.All<Invoice>(), cancellationToken);
            var committed = await unitOfWork.CommitAsync(cancellationToken);
            return committed.IsFailure ? committed.Error! : count;
        }
        catch
        {
            if (unitOfWork.State == UnitOfWorkState.Active)
            {
                await unitOfWork.RollbackAsync(CancellationToken.None);
            }

            throw;
        }
    }
}

工作单元的租户不会从绑定推导出来,两者都由调用方给出;管道外开工作单元、派发领域事件的完整写法见工作单元与事务。两个多租户 Provider 都登记 TenantShardScope,与是否选中分片能力无关。

常见问题 ​

租户配了连接串但数据还是写进了默认库 ​

先确认装配的持久化 Provider 是 Persistence.SqlSugar 或 Persistence.EntityFrameworkCore:Persistence.InMemory 不接分片能力。再确认实体实现了 IMultiTenant:分片只作用于多租户实体,平台表始终走默认连接。最后看 /shard 这样的端点能不能读到分片;读不到说明调用里没有租户,或者分片解析器的缓存还是旧值,调用 Invalidate(tenantId)。

租户库连不上时调用会降级到共库吗 ​

不会。分片解析失败(租户存储不可达)返回 MULTITENANCY_UNAVAILABLE_002,调用以 503 结束;分片解析成功但租户库连不上,工作单元开不起来,调用同样失败。降级会把租户数据写进平台库,所以框架不做。

怎样给新租户建表 ​

租户库的表结构由应用管理。在建租户的流程里用 TenantShardScope.BindTenantAsync 绑到新库,再用 Provider 自己的建表能力(SqlSugar 的 CodeFirst、EF Core 的迁移)执行;或者在数据库运维流程里完成,再把连接串写进租户属性。不要依赖框架的 InitTables / EnsureCreated,它们只作用于默认连接。

能按租户切换 Schema 而不是整库吗 ​

框架只做库级分片。Schema 级隔离把 Schema 名写进 Shard.<key> 属性透传给持久化 Provider,由应用在 Provider 的配置回调里按 TenantShard.Properties 处理。

同一调用里能同时读平台表和租户表吗 ​

能。SqlSugar Provider 的客户端同时持有默认连接与分片连接,按实体是否实现 IMultiTenant 落点;两个连接上的事务依次提交,不是两阶段提交,跨库一致性由应用的补偿或发件箱保证。EF Core Provider 一个 DbContext 一个连接,平台表要另起 DbContext。

相关包与下一步 ​

Released under The MIT License