ASP.NET Core 多租户架构深度实战:一套系统,管理一支船队

0. 开篇:从"每船一套库"到"一套系统管船队"

PMS 系统最早的部署形态很朴素:给每艘船单独装一套,船端一份数据库,岸端一份数据库,船岸之间靠报文同步。后来客户船队扩张到 30 多艘船,运维瞬间崩盘:

  • 升级一次程序,要远程到 30 多艘船逐船更新(卫星远程桌面,一升就是一整夜);
  • 岸端想做"全船队备件库存汇总",要连 30 多个库做联邦查询;
  • 某船数据库结构升级失败,版本碎片化,排查问题先问"你是哪个版本";
  • 新船上线,DBA 要手工建库、建账号、初始化基础数据,耗时一天。

架构演进的方向很明确:岸端 SaaS 化,一套系统服务所有船舶,船端轻量部署、岸端统一管理。这就是典型的多租户(Multi-tenancy)架构------每艘船是一个租户(Tenant),共享应用实例,但数据必须严格隔离。

多租户架构的本质矛盾就一句话:共享要彻底(降成本、易运维),隔离要严密(防串数据、防越权)。

这篇博客把我们在 PMS 中落地多租户的完整方案讲透:租户模型选型、租户识别、EF Core 自动隔离、租户感知缓存、跨租户管理、定时任务多租户化、新船上线流程,以及那些只有上线后才会遇到的坑。

💬 互动一下:你们的系统是"一个客户一套部署",还是"一套系统服务所有客户"?有没有经历过从前者往后者迁移的阵痛?评论区聊聊。


1. 三种多租户数据模型:没有银弹

1.1 模型对比

维度 模型一:独立数据库(Database per Tenant) 模型二:独立 Schema(Schema per Tenant) 模型三:共享库 + TenantId(Shared Database, Shared Schema)
隔离级别 最高(物理隔离) 高(逻辑隔离) 中(行级隔离)
单租户成本 高(每租户一个库的连接、备份、运维) 最低
租户数量上限 几十~几百 几百~几千 几万~几十万
跨租户查询 难(要跨库聚合) 中(跨 Schema UNION) 极易(一条 SQL)
新租户开通 慢(建库+初始化) 中(建 Schema) 毫秒级(插一条租户记录)
备份恢复粒度 单租户独立恢复 ✅ 单租户可恢复 ✅ 单租户恢复困难(要按行导)⚠️
版本升级 每库单独迁移,最慢 每 Schema 迁移 一次迁移全部生效
故障爆炸半径 单库故障只影响一船 ✅ 单 Schema 一库故障影响全部租户 ⚠️
适用规模 大客户、强合规要求 中等规模 SaaS 海量小租户

1.2 PMS 的选择:混合模型

PMS 的业务有个天然特点:船端和岸端是两种完全不同的部署形态。

复制代码
┌────────────────────────────────────────────────────────────┐
│ 船端(每艘船一套,本地局域网/单机部署)                        │
│  - 单租户数据库:船上只有自己的数据,物理隔离,断网可用        │
│  - 模型一:Database per Ship(天然如此,无需多租户框架)      │
└────────────────────────────────────────────────────────────┘
                          │ 卫星报文同步
                          ▼
┌────────────────────────────────────────────────────────────┐
│ 岸端(云端集中部署,SaaS 化,服务全船队)                     │
│  - 共享数据库 + TenantId(ship_id)行级隔离                  │
│  - 模型三为主:跨船汇总、统一升级、新船秒级开通               │
│  - 大客户/特殊船型:可选择性迁到独立 Schema(模型二)         │
└────────────────────────────────────────────────────────────┘

为什么岸端选模型三?

  1. 跨船查询是核心需求:岸端天天要"全船队备件库存""全船队到期维保一览",独立库联邦查询不可接受;
  2. 租户规模:30~300 艘船,共享库 + 行级隔离完全扛得住,且运维成本最低;
  3. 数据安全靠纵深防御:EF Core 全局过滤器 + 数据库行级安全策略(RLS)+ 网关层租户校验,三重隔离,后面逐一展开;
  4. 单租户恢复需求:靠船岸同步天然解决------岸端某船数据坏了,从船端全量重传即可,这是船舶行业独有的"备份副本"。

💡 经验:多租户模型选型不是纯技术题,先问业务三个问题------租户量级多大?跨租户报表是不是刚需?单租户数据导出/恢复频率多高?PMS 正是因为"跨船汇总刚需 + 船端天然持有全量副本",才敢在岸端用共享库模型。


2. 租户识别:请求怎么知道"你是哪艘船"

2.1 四种识别策略

策略 示例 优点 缺点
子域名 ship001.pms.company.com 直观、可做 CDN 隔离 证书泛域名、DNS 管理成本
路由段 /api/ships/{shipId}/applies 显式、RESTful 每个 URL 都要带、易遗漏
请求头 X-Ship-Id: SHIP_001 简单、不污染路由 头可被伪造(必须配合鉴权)
JWT Claim token 内嵌 ship_id / ship_ids[] 不可伪造(有签名)、与授权统一 换船要换 token

2.2 PMS 的方案:JWT Claim 为准,请求头为辅,岸端人员多船授权

PMS 有两类用户,识别逻辑不同:

  • 船端用户 :属于一艘船,JWT 里带单个 ship_id,所有操作自动归属该船;
  • 岸端用户 (机务主管、采购、审批人):可能管理多艘船,JWT 里带 ship_ids[] 数组,请求时通过 X-Ship-Id切换当前工作船;岸端总部角色(如系统管理员)可不带船上下文,做跨船查询。
csharp 复制代码
// 租户上下文:整个请求生命周期内的"当前租户"
public interface ITenantContext
{
    string? ShipId { get; }              // 当前工作船(null 表示岸端跨船视角)
    IReadOnlyList<string> AuthorizedShipIds { get; }  // 用户有权访问的所有船
    string UserId { get; }
    bool IsShoreUser { get; }
    bool IsHeadquarters { get; }         // 总部角色,可跨租户
}

public sealed class TenantContext : ITenantContext
{
    public string? ShipId { get; set; }
    public IReadOnlyList<string> AuthorizedShipIds { get; set; } = Array.Empty<string>();
    public string UserId { get; set; } = "";
    public bool IsShoreUser { get; set; }
    public bool IsHeadquarters { get; set; }
}

租户解析中间件(认证之后执行):

csharp 复制代码
public class TenantResolutionMiddleware
{
    private readonly RequestDelegate _next;

    public TenantResolutionMiddleware(RequestDelegate next) => _next = next;

    public async Task InvokeAsync(HttpContext context, TenantContext tenant)
    {
        // 1. 从 JWT Claims 解析身份
        var shipIdClaim = context.User.FindFirst("ship_id")?.Value;
        var shipIdsClaim = context.User.FindFirst("ship_ids")?.Value; // 逗号分隔
        var role = context.User.FindFirst(ClaimTypes.Role)?.Value;

        var authorized = !string.IsNullOrEmpty(shipIdsClaim)
            ? shipIdsClaim.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList()
            : (shipIdClaim is not null ? new List<string> { shipIdClaim } : new List<string>());

        tenant.UserId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? "";
        tenant.AuthorizedShipIds = authorized;
        tenant.IsShoreUser = context.User.IsInRole("shore");
        tenant.IsHeadquarters = context.User.IsInRole("hq_admin");

        // 2. 当前工作船:优先取请求头,岸端用户切换工作船用
        if (context.Request.Headers.TryGetValue("X-Ship-Id", out var headerShipId)
            && !string.IsNullOrWhiteSpace(headerShipId))
        {
            var requested = headerShipId.ToString();

            // 3. 鉴权:请求的船必须在授权列表内(总部角色除外)
            if (!tenant.IsHeadquarters && !authorized.Contains(requested))
            {
                context.Response.StatusCode = StatusCodes.Status403Forbidden;
                await context.Response.WriteAsJsonAsync(new
                {
                    error = $"未授权访问船舶 {requested} 的数据"
                });
                return;
            }
            tenant.ShipId = requested;
        }
        else
        {
            // 没显式指定:船端用户默认自己的船;岸端用户默认 null(跨船视角)
            tenant.ShipId = shipIdClaim;
        }

        using (LogContext.PushProperty("ShipId", tenant.ShipId ?? "shore"))
        using (LogContext.PushProperty("UserId", tenant.UserId))
        {
            await _next(context);
        }
    }
}

注册顺序很关键:认证 → 租户解析 → 业务中间件

csharp 复制代码
app.UseAuthentication();
app.UseMiddleware<TenantResolutionMiddleware>();  // 依赖 HttpContext.User
app.UseAuthorization();
app.UseMiddleware<IdempotencyMiddleware>();       // 幂等中间件需要租户拼键,见幂等篇

⚠️ 安全红线X-Ship-Id 头绝不能被信任为"身份证明"。它只是岸端用户在已授权范围内 的切换开关。每次都必须校验 requested ∈ AuthorizedShipIds。我们见过的最严重多租户事故,就是某个内部接口只信请求头、不校验授权,船端用户改个 Header 就看到了别的船的数据。


3. 数据隔离核心:EF Core 全局查询过滤器

3.1 HasQueryFilter:自动隔离的魔法

EF Core 的全局查询过滤器(Global Query Filter)是多租户数据隔离的主力武器:给实体配置一个过滤条件后,所有查询自动带上 WHERE 租户条件 ,开发者在业务代码里完全不用手写 Where(x => x.ShipId == ...),从根上杜绝"忘了加租户条件导致串数据"。

先定义租户实体基类:

csharp 复制代码
public interface ITenantEntity
{
    string ShipId { get; set; }
}

public abstract class TenantEntityBase : ITenantEntity
{
    public string ShipId { get; set; } = "";   // 租户键:船舶 ID
    public int IsDelete { get; set; }          // 软删除(沿用 PMS 既有约定)
    public string? Opuser { get; set; }
    public DateTime? Opdate { get; set; }
}

在 DbContext 里统一配置:

csharp 复制代码
public class PmsDbContext : DbContext
{
    private readonly ITenantContext _tenant;

    public PmsDbContext(DbContextOptions<PmsDbContext> options, ITenantContext tenant)
        : base(options) => _tenant = tenant;

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        base.OnModelCreating(modelBuilder);

        // 对所有租户实体统一配置过滤器:租户隔离 + 软删除
        modelBuilder.Entity<SparePartApply>();
        modelBuilder.Entity<Equipment>();
        modelBuilder.Entity<Material>();
        // ... 其余实体

        foreach (var entityType in modelBuilder.Model.GetEntityTypes())
        {
            if (typeof(ITenantEntity).IsAssignableFrom(entityType.ClrType))
            {
                // 动态构建 lambda:e => e.ShipId == _tenant.ShipId
                var parameter = Expression.Parameter(entityType.ClrType, "e");
                var shipIdProp = Expression.Property(parameter, nameof(ITenantEntity.ShipId));
                var tenantValue = Expression.Property(
                    Expression.Constant(this), nameof(CurrentShipId));
                var equals = Expression.Equal(shipIdProp, tenantValue);
                var lambda = Expression.Lambda(equals, parameter);
                entityType.SetQueryFilter(lambda);

                // 软删除过滤器合并(也可用两个过滤器,EF Core 会自动 AND)
                var isDeleteProp = Expression.Property(parameter, "IsDelete");
                var notDeleted = Expression.Equal(isDeleteProp, Expression.Constant(0));
                var combined = Expression.AndAlso(lambda.Body, notDeleted);
                entityType.SetQueryFilter(Expression.Lambda(combined, parameter));
            }
        }
    }

    // 过滤器引用当前租户值;null 时用不可能匹配的值兜底(fail-closed)
    private string CurrentShipId => _tenant.ShipId ?? "__NO_TENANT__";

    // 写入时自动填充租户键
    public override int SaveChanges()
    {
        FillTenantId();
        return base.SaveChanges();
    }

    public override Task<int> SaveChangesAsync(CancellationToken ct = default)
    {
        FillTenantId();
        return base.SaveChangesAsync(ct);
    }

    private void FillTenantId()
    {
        foreach (var entry in ChangeTracker.Entries<ITenantEntity>())
        {
            switch (entry.State)
            {
                case EntityState.Added:
                    if (string.IsNullOrEmpty(entry.Entity.ShipId))
                    {
                        if (string.IsNullOrEmpty(_tenant.ShipId) && !_tenant.IsHeadquarters)
                            throw new InvalidOperationException(
                                "新增租户实体时租户上下文为空,拒绝写入(fail-closed)。");
                        entry.Entity.ShipId = _tenant.ShipId!;
                    }
                    break;
                case EntityState.Modified:
                    // 防止越权改租户键:ShipId 一旦写入不可变更
                    entry.Property(nameof(ITenantEntity.ShipId)).IsModified = false;
                    break;
            }
        }
    }
}

几个生死攸关的细节:

① fail-closed(失败即关闭)原则 :租户上下文为空时,CurrentShipId 返回 "__NO_TENANT__" 这种不可能存在的值,让查询查不到任何数据;新增时直接抛异常。绝不允许"租户为空就不过滤"------那等于把全船队数据暴露出去。

csharp 复制代码
// ❌ 致命写法:租户为空时过滤器失效,全表泄露
e => _tenant.ShipId == null || e.ShipId == _tenant.ShipId

// ✅ 正确:租户为空匹配不到任何行
e => e.ShipId == (_tenant.ShipId ?? "__NO_TENANT__")

② ShipId 不可修改Modified 状态下把 ShipIdIsModified 置 false,防止有人通过更新接口把 A 船的单据"搬"到 B 船(这也是一种越权)。

③ 过滤器是"按需编译"进 SQL 的_tenant.ShipId 在查询求值时读取当前值,因为 ITenantContext 是 Scoped,每个请求一个实例,过滤器自然按请求租户生效。验证方法------打开 EF Core 日志,每条 SQL 都应该带 WHERE ship_id = @__xxx

sql 复制代码
SELECT s.id, s.ship_id, s.apply_number, ...
FROM spare_part_apply s
WHERE s.ship_id = @__CurrentShipId_0 AND s.is_delete = 0

3.2 跨租户查询:IgnoreQueryFilters

岸端总部需要跨船查询(全船队报表、全局搜索),用 IgnoreQueryFilters() 显式绕过:

csharp 复制代码
public class FleetInventoryQueryService
{
    private readonly PmsDbContext _db;
    private readonly ITenantContext _tenant;

    public async Task<FleetInventorySummaryDto> GetFleetSummaryAsync(CancellationToken ct)
    {
        // 跨租户操作必须显式声明权限
        if (!_tenant.IsHeadquarters && _tenant.AuthorizedShipIds.Count == 0)
            throw new ForbiddenException("无跨船数据访问权限");

        var scope = _db.SpareParts.IgnoreQueryFilters();

        // 非总部的岸端用户:只能看授权范围内的船(白名单收缩)
        if (!_tenant.IsHeadquarters)
        {
            var allowed = _tenant.AuthorizedShipIds;
            scope = scope.Where(x => allowed.Contains(x.ShipId));
        }

        var summary = await scope
            .GroupBy(x => x.ShipId)
            .Select(g => new ShipInventoryDto(
                g.Key,
                g.Count(),
                g.Sum(x => x.StockQty)))
            .ToListAsync(ct);

        return new FleetInventorySummaryDto(summary);
    }
}

规则:IgnoreQueryFilters 是危险操作,全项目只允许出现在显式命名的"跨租户查询服务"里(如 Fleet*QueryService),并通过 Code Review 或架构测试强制约束。 用 NetArchTest 写一条架构测试守门:

csharp 复制代码
[Fact]
public void IgnoreQueryFilters_OnlyUsedInFleetQueryServices()
{
    var result = Types.InAssembly(typeof(PmsDbContext).Assembly)
        .That()
        .ResideInNamespaceContaining("Infrastructure")
        .Should()
        .MeetCustomRule(new NoCrossTenantFilterRule())  // 自定义规则:非 Fleet* 类禁止调用
        .GetResult();
    result.IsSuccessful.Should().BeTrue();
}

3.3 导航属性与 Include 的陷阱

全局过滤器会自动作用于导航属性的关联查询(Include/集合导航),这是好事------查申领单时带出的明细也自动隔离。但有两个坑:

坑 1:可选关系 + 过滤器导致内层数据"意外消失"。 比如申领单关联设备,设备表有租户过滤器而某历史数据 ShipId 为空,Include 时设备被过滤掉,申领单还在但设备字段为 null。对策:数据迁移阶段保证所有历史行 ShipId 不为空(迁移脚本回填 + NOT NULL 约束)。

坑 2:自有查询(Defining Query/Keyless Entity)忘了过滤器。 报表用的无键实体([Keyless] 映射视图)也要配过滤器,或者视图本身在数据库层带上租户条件。

3.4 数据库行级安全(RLS):最后一道防线

即使应用层过滤器有疏漏(比如某个原生 SQL 忘了条件、DBA 直连查库),数据库层还能兜底。PostgreSQL 的 Row Level Security:

sql 复制代码
-- 1. 所有租户表启用 RLS
ALTER TABLE spare_part_apply ENABLE ROW LEVEL SECURITY;

-- 2. 策略:应用连接通过会话变量 current_ship_id 传递租户
CREATE POLICY tenant_isolation ON spare_part_apply
    USING (ship_id = current_setting('app.current_ship_id', true))
    WITH CHECK (ship_id = current_setting('app.current_ship_id', true));

-- 3. 总部角色绕过(岸端跨船报表走独立连接串/角色)
--    BYPASSRLS 权限只给报表只读账号

应用侧在每次打开连接后设置会话变量(EF Core 拦截器实现):

csharp 复制代码
public class TenantSessionInterceptor : DbConnectionInterceptor
{
    private readonly ITenantContext _tenant;
    public TenantSessionInterceptor(ITenantContext tenant) => _tenant = tenant;

    public override async Task ConnectionOpenedAsync(
        DbConnection connection, ConnectionOpenedEventData eventData,
        CancellationToken ct = default)
    {
        if (_tenant.ShipId is { } shipId)
        {
            await using var cmd = connection.CreateCommand();
            cmd.CommandText = "SELECT set_config('app.current_ship_id', @p, false)";
            var p = cmd.CreateParameter();
            p.ParameterName = "@p";
            p.Value = shipId;
            cmd.Parameters.Add(p);
            await cmd.ExecuteNonQueryAsync(ct);
        }
        await base.ConnectionOpenedAsync(connection, eventData, ct);
    }
}

这样即使应用层 SQL 完全没有 WHERE ship_id,数据库也只返回当前租户的行------纵深防御的最后一环。注意 RLS 策略要对每张租户表逐一创建,建议写个迁移生成脚本扫描实体自动生成。

💬 互动一下:数据隔离你倾向于"全靠应用层过滤器"还是"数据库 RLS 强兜底"?我们的教训是------只要团队里会有人写原生 SQL 或 Dapper,RLS 就值得上,它是唯一不依赖开发者自觉的隔离层。


4. 租户感知的缓存:Key 不带租户等于缓存投毒

多租户下缓存最容易出两类事故:缓存串租户 (A 船读到 B 船的缓存)和缓存雪崩按租户放大

4.1 缓存键强制租户前缀

封装一个租户感知的缓存服务,所有 Key 自动拼租户:

csharp 复制代码
public interface ITenantCache
{
    Task<T?> GetOrCreateAsync<T>(string key, Func<Task<T>> factory, TimeSpan ttl, CancellationToken ct = default);
    Task RemoveAsync(string key, CancellationToken ct = default);
}

public class TenantHybridCache : ITenantCache
{
    private readonly HybridCache _cache;   // .NET 9 HybridCache,见缓存实战篇
    private readonly ITenantContext _tenant;

    public TenantHybridCache(HybridCache cache, ITenantContext tenant)
    {
        _cache = cache;
        _tenant = tenant;
    }

    // 键结构:tenant:{shipId}:{业务键}
    private string Scoped(string key)
        => $"tenant:{_tenant.ShipId ?? "shore"}:{key}";

    public ValueTask<T?> GetOrCreateAsync<T>(
        string key, Func<CancellationToken, ValueTask<T>> factory,
        HybridCacheEntryOptions? options = null, CancellationToken ct = default)
        => _cache.GetOrCreateAsync(Scoped(key), factory, options, cancellationToken: ct);

    public ValueTask RemoveAsync(string key, CancellationToken ct = default)
        => _cache.RemoveTagAsync(Scoped(key), ct); // 按 tag 失效
}

使用时业务代码只写业务键,租户前缀自动注入:

csharp 复制代码
// 业务代码:看起来是同一个键
var stock = await _cache.GetOrCreateAsync(
    $"equipment:{eqId}", async ct => await _db.Equipment.FindAsync([eqId], ct), ...);
// 实际缓存键:tenant:SHIP_001:equipment:eq-123
// SHIP_002 请求同样代码 → tenant:SHIP_002:equipment:eq-123,物理隔离

4.2 按租户打 Tag,支持整租户失效

HybridCache(或 Redis 手动维护 tag 集合)支持 Tag 失效。新船数据初始化、租户配置变更时,一键清掉该船所有缓存:

csharp 复制代码
// 写入时打租户 tag
var tags = new[] { $"ship:{shipId}" };
await _cache.GetOrCreateAsync(key, factory,
    new HybridCacheEntryOptions { Expiration = ttl },
    tags: tags, cancellationToken: ct);

// 整租户失效(如该船基础数据全量同步后)
await _cache.RemoveTagAsync($"ship:{shipId}");

4.3 跨租户缓存:共享数据要走"无租户"通道

不是所有数据都该按租户隔离。备件目录(标准件库)、系统字典、币种汇率这类全局主数据是所有船共享的,缓存时应走无租户前缀的共享键,否则每船缓存一份,浪费内存且数据更新要失效 N 份:

csharp 复制代码
// 全局共享缓存:不带租户
public interface IGlobalCache { /* 同结构,Scoped() 返回 key 原样 */ }

// 判断原则:
//  - 数据行带 ship_id 且内容因船而异 → 租户缓存
//  - 数据行无 ship_id、全船队一致   → 全局缓存
//  - 数据行带 ship_id 但岸端要跨船聚合 → 不缓存或缓存聚合结果到 "shore" 租户空间

4.4 TTL 抖动按租户错开

缓存雪崩防护(见缓存实战篇)的随机 TTL 抖动在多租户下有个细节:如果 30 艘船的缓存都在同一时刻写入(比如凌晨全船队同步后统一预热),抖动窗口要足够大,或者干脆按 shipId 哈希分散预热时间

csharp 复制代码
// 同步后预热缓存:按船哈希错峰,避免 30 艘船同时回源打爆数据库
var delaySeconds = (shipId.GetHashCode() & 0x7fffffff) % 600;  // 0~10 分钟错峰
await Task.Delay(TimeSpan.FromSeconds(delaySeconds), ct);
await WarmupTenantCacheAsync(shipId, ct);

5. 租户级配置与功能开关

不同船的业务差异不小:有的船用三级审批、有的四级;有的船启用燃油管理模块、有的没有;报表模板、编号规则、设备分类编码都可能不同。这些差异不能写死在代码里,要做成租户级配置

5.1 租户表与租户配置

sql 复制代码
CREATE TABLE tenant (
    ship_id        VARCHAR(32) PRIMARY KEY,      -- 租户键
    ship_name      VARCHAR(128) NOT NULL,
    ship_type      VARCHAR(32),                  -- 船型:散货/油轮/集装箱...
    status         VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',  -- ACTIVE/SUSPENDED/PROVISIONING
    approval_levels INT NOT NULL DEFAULT 3,      -- 审批级数:3 或 4
    features       JSONB NOT NULL DEFAULT '{}',  -- 功能开关 {"fuel_module":true,...}
    number_rule    JSONB NOT NULL DEFAULT '{}',  -- 单号规则
    report_template VARCHAR(32) DEFAULT 'default',
    db_schema      VARCHAR(32),                  -- 模型二预留:独立 Schema 名
    created_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
    timezone       VARCHAR(64) DEFAULT 'Asia/Shanghai'
);

5.2 强类型 Options 绑定

csharp 复制代码
public class TenantSettings
{
    public int ApprovalLevels { get; set; } = 3;
    public Dictionary<string, bool> Features { get; set; } = new();
    public NumberingRule NumberRule { get; set; } = new();
    public string ReportTemplate { get; set; } = "default";
    public string Timezone { get; set; } = "Asia/Shanghai";

    public bool IsFeatureEnabled(string feature)
        => Features.TryGetValue(feature, out var v) && v;
}

// 租户配置提供者:带缓存的强类型访问
public class TenantSettingsProvider
{
    private readonly PmsDbContext _db;
    private readonly IGlobalCache _cache;  // 租户配置本身按租户缓存

    public async Task<TenantSettings> GetAsync(string shipId, CancellationToken ct = default)
    {
        return await _cache.GetOrCreateAsync(
            $"tenant-settings:{shipId}",
            async token =>
            {
                var t = await _db.Tenants.IgnoreQueryFilters()  // tenant 表自身不隔离
                    .FirstOrDefaultAsync(x => x.ShipId == shipId, token)
                    ?? throw new TenantNotFoundException(shipId);

                if (t.Status != TenantStatus.Active)
                    throw new TenantSuspendedException(shipId);

                return new TenantSettings
                {
                    ApprovalLevels = t.ApprovalLevels,
                    Features = t.Features,
                    NumberRule = t.NumberRule,
                    ReportTemplate = t.ReportTemplate,
                    Timezone = t.Timezone
                };
            },
            TimeSpan.FromMinutes(10), ct);
    }
}

业务里按租户配置走分支:

csharp 复制代码
var settings = await _tenantSettings.GetAsync(tenant.ShipId!, ct);
var maxLevel = settings.ApprovalLevels;   // 这艘船 3 级还是 4 级审批
if (settings.IsFeatureEnabled("fuel_module"))
{
    // 燃油模块逻辑
}

⚠️ 反模式警告 :不要在代码里写 if (shipId == "SHIP_001") 这种硬编码租户特例。租户差异一律进配置表。硬编码特例超过 3 个,系统就会变成"改一处怕碰另一船"的玄学代码。

5.3 功能开关与模块裁剪

租户配置里的 features 配合菜单/接口权限做模块级开关:

csharp 复制代码
// 接口级:功能开关过滤器
[AttributeUsage(AttributeTargets.Method)]
public class RequireFeatureAttribute : Attribute, IAuthorizationFilter
{
    private readonly string _feature;
    public RequireFeatureAttribute(string feature) => _feature = feature;

    public void OnAuthorization(AuthorizationFilterContext context)
    {
        var tenant = context.HttpContext.RequestServices.GetRequiredService<ITenantContext>();
        var settingsProvider = context.HttpContext.RequestServices
            .GetRequiredService<TenantSettingsProvider>();

        if (tenant.ShipId is null) return; // 岸端跨船视角不裁剪
        var settings = settingsProvider.GetAsync(tenant.ShipId).GetAwaiter().GetResult();
        if (!settings.IsFeatureEnabled(_feature))
            context.Result = new NotFoundResult();  // 未开通的模块直接 404,不暴露存在性
    }
}

[HttpPost("fuel/reports")]
[RequireFeature("fuel_module")]
public async Task<IActionResult> CreateFuelReport(...) { ... }

6. 定时任务与后台处理的多租户化

这是多租户改造中最容易漏掉、也最容易出大事故 的部分。HTTP 请求有天然的租户上下文(中间件解析),但 Hangfire 定时任务、消息消费者、后台 Worker 没有请求上下文,ITenantContext 是空的------按 fail-closed 原则它们什么数据都查不到,或者更糟:有人为了让任务跑起来把过滤器一绕,任务跑成了全租户批量操作。

6.1 租户上下文工厂:显式开租户作用域

csharp 复制代码
public interface ITenantScopeFactory
{
    Task<IDisposable> BeginScopeAsync(string shipId, string? systemUserId = null);
}

public class TenantScopeFactory : ITenantScopeFactory
{
    private readonly IServiceScopeFactory _scopeFactory;

    public TenantScopeFactory(IServiceScopeFactory scopeFactory) => _scopeFactory = scopeFactory;

    public async Task<IDisposable> BeginScopeAsync(string shipId, string? systemUserId = null)
    {
        var scope = _scopeFactory.CreateScope();
        var tenant = scope.ServiceProvider.GetRequiredService<TenantContext>();

        // 显式建立租户上下文:后台任务代表"系统"操作该租户
        tenant.ShipId = shipId;
        tenant.UserId = systemUserId ?? "system";
        tenant.AuthorizedShipIds = new[] { shipId };
        tenant.IsShoreUser = true;

        // 校验租户存在且有效
        var settings = scope.ServiceProvider.GetRequiredService<TenantSettingsProvider>();
        await settings.GetAsync(shipId);  // 无效租户会抛异常

        return new Scope(scope);
    }

    private sealed class Scope(IServiceScope scope) : IDisposable
    {
        public void Dispose() => scope.Dispose();
    }
}

6.2 Hangfire:两种多租户任务模式

模式 A:单租户任务(任务参数带 shipId)------如某船的维保提醒:

csharp 复制代码
public class MaintenanceReminderJob
{
    public async Task Execute(string shipId)   // shipId 作为 Job 参数持久化
    {
        await using var scope = await _tenantScopeFactory.BeginScopeAsync(shipId);
        // scope 内所有服务解析出来的 ITenantContext 都是该船
        var dueItems = await _maintenanceService.GetDueMaintenanceAsync();
        await _notificationService.NotifyDueMaintenanceAsync(dueItems);
    }
}

// 注册循环任务:为每艘船各注册一条
foreach (var ship in await GetActiveShipsAsync())
{
    RecurringJob.AddOrUpdate<MaintenanceReminderJob>(
        $"maintenance-reminder:{ship.ShipId}",   // Job ID 带租户前缀
        job => job.Execute(ship.ShipId),
        ship.Settings.ReminderCron);             // 每船可独立 Cron(时区也按船)
}

模式 B:全租户遍历任务------如每日全船队数据快照:

csharp 复制代码
public class DailyFleetSnapshotJob
{
    public async Task Execute()
    {
        var ships = await _tenantService.GetActiveShipIdsAsync();
        foreach (var shipId in ships)
        {
            try
            {
                // 每艘船独立租户作用域、独立 try-catch:
                // 一条船失败不影响其他船,失败可单独重试
                await using var scope = await _tenantScopeFactory.BeginScopeAsync(shipId);
                await _snapshotService.CreateSnapshotAsync();
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "船队快照失败:{ShipId}", shipId);
            }
        }
    }
}

⚠️ 关键纪律 :后台任务里永远不要IgnoreQueryFilters 批量处理全租户数据再内存分组。正确做法是"遍历租户 + 每租户开作用域"------隔离逻辑复用、日志带 ShipId、失败隔离、单船可重跑。租户量极大时用批处理调度(Hangfire Batch 把每船任务作为独立 Job 入队,并行度可控)。

6.3 消息消费者同理

csharp 复制代码
public class SyncMessageConsumer : IConsumer<ShipSyncMessage>
{
    public async Task Consume(ConsumeContext<ShipSyncMessage> context)
    {
        var shipId = context.Message.ShipId;   // 消息必须携带租户键
        await using var scope = await _tenantScopeFactory.BeginScopeAsync(shipId);
        // 消费者管道内的 DbContext/缓存自动落入该租户
        await _syncService.ProcessAsync(context.Message);
    }
}

消息契约强制带 ShipId:所有跨租户流转的消息/DTO,基类就带租户键,从生产端杜绝"无主消息":

csharp 复制代码
public abstract record TenantMessageBase(string ShipId);
public record SparePartApplySubmitted(string ShipId, Guid ApplyId, string ApplyNumber)
    : TenantMessageBase(ShipId);

7. 网关层:YARP 动态路由与多租户

岸端 SaaS 化后,YARP 网关也参与租户治理(网关细节见《YARP 反向代理与 API 网关实战》):

  1. 路由级租户校验 :船端上报接口 /api/sync/** 必须携带有效 X-Ship-Id 且证书/Token 匹配,网关层先挡一道;
  2. 租户级限流:限流策略按 ShipId 分区,避免某船异常重发把全船队 API 打挂:
csharp 复制代码
builder.Services.AddRateLimiter(options =>
{
    options.GlobalLimiter = PartitionedRateLimiter.Create<HttpContext, string>(ctx =>
    {
        var shipId = ctx.Request.Headers["X-Ship-Id"].FirstOrDefault()
                     ?? ctx.User.FindFirst("ship_id")?.Value ?? "anonymous";
        return RateLimitPartition.GetSlidingWindowLimiter(
            partitionKey: $"ship:{shipId}",   // 按租户分区限流
            _ => new SlidingWindowRateLimiterOptions
            {
                PermitLimit = 200,
                Window = TimeSpan.FromMinutes(1),
                SegmentsPerWindow = 4
            });
    });
});
  1. 灰度发布按租户:新版本先放给 2~3 艘"种子船",网关按 ShipId 路由到灰度集群(权重路由 + 租户白名单),验证一周再全量。这比按流量比例灰度更适合船舶行业------船端版本和岸端协议有兼容性要求,按船灰度可精确控制。

8. 新船上线:租户开通全流程

共享库模型最大的红利就是新租户开通快。PMS 新船上线标准流程:

复制代码
1. 商务签约,录入船舶基础信息
        │
        ▼
2. 调用租户开通 API(或运维后台一键开通)
   ├─ INSERT tenant 记录(PROVISIONING 状态)
   ├─ 初始化该船基础数据:设备台账模板、物料目录映射、
   │   船员账号、审批流配置、编号规则
   ├─ 生成船端安装包与注册凭证(船端激活码)
   └─ 预热该船缓存
        │
        ▼
3. 船端安装软件,输入激活码 → 注册到岸端,拉取基础数据
        │
        ▼
4. 试跑一周(灰度租户),数据验证
        │
        ▼
5. tenant.status → ACTIVE;注册该船 Hangfire 定时任务
   (维保提醒、报表生成);加入监控大盘

开通操作做成幂等的(呼应《API 幂等性设计实战》):以 shipId 为幂等键,重复点击/接口重试不会初始化两份基础数据。

数据初始化用"种子数据 + 覆盖策略"而非裸 INSERT:

csharp 复制代码
public class TenantProvisioningService
{
    public async Task ProvisionAsync(string shipId, TenantProvisionRequest req, CancellationToken ct)
    {
        await using var scope = await _tenantScopeFactory.BeginScopeAsync(shipId, "system-provision");

        // 基础数据 UPSERT:可重复执行(开通接口本身幂等)
        await SeedEquipmentCatalogAsync(shipId, req.ShipType, ct);   // 按船型套台账模板
        await SeedApprovalFlowAsync(shipId, req.ApprovalLevels, ct);
        await SeedNumberingRuleAsync(shipId, ct);
        await SeedAdminAccountsAsync(shipId, req.AdminUsers, ct);

        await _db.Tenants.IgnoreQueryFilters()
            .Where(t => t.ShipId == shipId)
            .ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TenantStatus.Active), ct);
    }
}

租户下线/暂停status = SUSPENDED,租户解析中间件直接拒绝该租户所有请求(友好提示"服务已暂停"),数据保留;彻底下线则归档该船数据到独立归档库后软删。


9. 多租户测试策略

9.1 租户隔离测试(安全测试的重中之重)

csharp 复制代码
public class TenantIsolationTests : IClassFixture<WebAppFactory>
{
    [Fact]
    public async Task ShipA_CannotRead_ShipB_Data()
    {
        // Arrange:船 A 和船 B 各有一张申领单
        await SeedAsync("SHIP_A", applyId: Guid.NewGuid());
        await SeedAsync("SHIP_B", applyId: Guid.NewGuid());

        // Act:船 A 的 Token 请求列表
        var clientA = _factory.CreateClientWithToken(shipId: "SHIP_A");
        var resp = await clientA.GetFromJsonAsync<List<ApplyDto>>("/api/spare-part-applies");

        // Assert:只能看到自己的
        resp.Should().OnlyContain(x => x.ShipId == "SHIP_A");
    }

    [Fact]
    public async Task ForgedShipHeader_WithoutAuthorization_Returns403()
    {
        var clientA = _factory.CreateClientWithToken(shipId: "SHIP_A");
        clientA.DefaultRequestHeaders.Add("X-Ship-Id", "SHIP_B");  // 伪造头
        var resp = await clientA.GetAsync("/api/spare-part-applies");
        resp.StatusCode.Should().Be(HttpStatusCode.Forbidden);
    }

    [Fact]
    public async Task ShoreUser_SeesOnly_AuthorizedShips()
    {
        // 岸端主管只管 A/B 两船,请求 fleet 接口不能看到 C 船
        var client = _factory.CreateClientWithToken(
            role: "shore", shipIds: new[] { "SHIP_A", "SHIP_B" });
        var resp = await client.GetFromJsonAsync<FleetInventorySummaryDto>("/api/fleet/inventory");
        resp.Ships.Select(s => s.ShipId).Should().BeEquivalentTo(new[] { "SHIP_A", "SHIP_B" });
    }

    [Fact]
    public async Task BackgroundJob_WithoutTenantScope_FailsClosed()
    {
        // 不开租户作用域直接跑任务:应当查不到数据/抛异常,而不是操作全租户
        var act = async () => await _maintenanceService.GetDueMaintenanceAsync();
        await act.Should().ThrowAsync<InvalidOperationException>();
    }
}

9.2 必测清单

  • A 船 Token 访问 B 船数据 → 403/空结果(CRUD 每个接口都验)
  • 伪造 X-Ship-Id → 403
  • 租户上下文为空请求写接口 → 拒绝(fail-closed)
  • 修改实体 ShipId → 被忽略/拒绝
  • 岸端用户跨船查询 → 只见授权范围
  • 缓存 A 船数据后 B 船请求 → 不命中 A 的缓存
  • 定时任务单船失败 → 不影响其他船
  • 租户暂停 → 该船请求被拒,其他船正常
  • 新船开通接口重复调用 → 基础数据不重复
  • RLS 策略验证:用绕过应用的直连 SQL(带错误会话变量)查不到他船数据

10. 上线后踩过的十个坑

  1. 迁移历史数据时 ShipId 回填遗漏 :几张老表漏了回填,过滤器上线后老数据"消失"。对策:迁移脚本先 SELECT COUNT(*) WHERE ship_id IS NULL 验零,再加 NOT NULL 约束。
  2. Dapper 手写 SQL 没带租户条件:EF 过滤器管不到 Dapper。全项目规定 Dapper SQL 必须经过租户 SQL 拼接帮助类,或干脆靠 RLS 兜底。
  3. 后台任务忘记开租户作用域:任务"能跑"但处理了零数据(fail-closed 生效但无人察觉),报表连续几天为空。加监控:任务处理条数为 0 且租户非空时告警。
  4. 缓存键漏拼租户:某接口缓存了"设备详情"不带租户前缀,两艘船同 ID 设备串数据。封装 ITenantCache 后业务层拿不到原生缓存客户端,从结构上杜绝。
  5. 日志不带 ShipId:多租户后排查问题必须先按船过滤。Serilog 的 LogContext 在租户中间件里 PushProperty,所有日志强制带 ShipId 列。
  6. 时区坑:船在不同时区,Cron 任务按服务器时区跑导致提醒时间错乱。租户表存 timezone,Hangfire 调度按船时区换算(或 Cron 表达式里带时区)。
  7. 序列/单号跨租户共享:单号生成器早期用全局序列,船端互相对单号能推断彼此业务量。改为每租户独立序列段(编号规则按租户配置,见编号生成器)。
  8. 超管接口成了泄露口 :某个"内部调试"接口加了 [Authorize(Roles="admin")] 就忘了租户过滤,admin 账号一查全船队。管理员接口同样要过授权船校验,HQ 管理员也要审计日志。
  9. 新开通租户的缓存预热雪崩:批量开通 5 艘船同时预热,数据库瞬时打满。按 ShipId 哈希错峰(见 4.4)。
  10. 软删除 + 租户过滤器双重条件漏建索引WHERE ship_id=? AND is_delete=0 的组合查询没建联合索引,租户多了之后全表扫描。所有租户表建 (ship_id, is_delete)(ship_id, 业务查询列) 联合索引。

11. 决策速查表

问题 建议
选哪种租户模型? 先问租户量级、跨租户报表刚需、单租户恢复频率;PMS 岸端选共享库+TenantId,船端天然独立库
租户识别用什么? JWT Claim 为身份凭证,请求头仅作授权范围内切换,每次校验授权
隔离靠哪层? EF 全局过滤器(日常)+ RLS(兜底)+ 网关校验(边界),纵深防御
租户为空怎么办? fail-closed:查询匹配不到行,写入直接抛异常
跨租户查询怎么写? 仅 Fleet* 服务可用 IgnoreQueryFilters,且强制授权白名单收缩 + 架构测试守门
缓存 Key? 强制 tenant:{shipId}: 前缀,全局主数据走无租户通道
后台任务? 遍历租户 + ITenantScopeFactory 显式开作用域,单船失败隔离
租户差异配置? tenant 表 JSON 配置 + 强类型 Options,禁止 if(shipId=="...") 硬编码
新船上线? 幂等开通接口 + UPSERT 种子数据 + 灰度试跑 + ACTIVE
灰度发布? 按 ShipId 路由灰度集群,比按流量比例更适合船岸协议兼容场景

12. 小结

多租户架构的核心不是某个技术点,而是一套**"租户上下文"在全链路无死角传递和强制约束**的工程体系:

  • 模型层:共享库 + TenantId 行级隔离(岸端)/ 独立库(船端),按业务形态混合选型;
  • 识别层:JWT Claim 定身份、请求头切工作船、每次校验授权、fail-closed 兜底;
  • 数据层:EF Core 全局查询过滤器自动隔离 + 写入自动填充 + ShipId 不可变 + PostgreSQL RLS 数据库兜底;
  • 缓存层:租户前缀强制注入、租户 Tag 整租失效、全局数据走共享通道、错峰预热;
  • 配置层:租户表驱动审批级数/功能开关/编号规则/时区,消灭硬编码特例;
  • 任务层:ITenantScopeFactory 显式租户作用域,Hangfire/MQ 遍历租户、失败隔离;
  • 网关层:租户级限流、按船灰度、边界鉴权;
  • 运维层:日志强制 ShipId、联合索引、新船幂等开通、隔离测试守门。

最后一句话送给所有做多租户的团队:多租户系统里,"隔离"是默认值,"共享"是需要申请权限的例外。 把每一次数据访问都当成"可能越权"来审查,系统才敢说自己管得住一支船队。

💬 互动一下

  1. 你们的系统多租户隔离做到哪一层了------应用过滤、数据库 RLS,还是还在用"一个客户一套库"?
  2. 后台任务/消息消费者的租户上下文,你们是怎么传递的?有没有踩过"任务跑了但没带租户"的坑?
  3. 如果让你给一个存量单租户系统做多租户改造,你会从哪一步开始?欢迎说说你的迁移路径。
相关推荐
深念Y23 分钟前
视频平台架构重构:从微服务到可插拔基础设施
后端·微服务·云原生·架构·rabbitmq·音视频·rocketmq
Thneonl23 分钟前
同一资源、不同 ID:多源拓扑的 Identity Resolution
架构·rust
汤姆yu24 分钟前
基于Django的学习资料共享与智能推荐系统
后端·python·django·毕业设计·学习资料共享
Freak嵌入式25 分钟前
树莓派 Pico GPIO 深度解析:从 MCU 架构到寄存器控制,底层原理与实践指南
java·开发语言·科技·单片机·嵌入式硬件·架构
Jesse_EC27 分钟前
从 getStore() 到 execute() —— 单连接 IMAP 管理器的五次迭代记录
后端
摇滚侠28 分钟前
《SpringBoot 3:入门与应用实战》第 9 章 使用 WebMvc 开发应用 阅读笔记 20
spring boot·笔记·后端
AI多Agent协作实战派38 分钟前
AI多Agent协作系统实战(五十三):同一套系统,Linux沉默,Windows刷屏
后端
那咋乎吧43 分钟前
TCP状态机11个状态全梳理(以java netty 结合bio,nio, io多路复用模型为线索在linux上梳理)
后端
用户7813667114451 小时前
RGW 对象多版本功能系统架构与代码解析
后端