Lost of 程序猿头像

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 头切换当前工作船;岸端总部角色(如系统管理员)可不带船上下文,做跨船查询。
// 租户上下文:整个请求生命周期内的"当前租户"
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; }
}

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

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);
        }
    }
}

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

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 == ...),从根上杜绝"忘了加租户条件导致串数据"。

先定义租户实体基类:

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 里统一配置:

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

转载自 CSDN-专业IT技术社区

原文链接:https://blog.csdn.net/a15242316443/article/details/164174226

文章来源转载

评论

评论列表

微信小程序
QQ小程序

关于作者

点赞数:0
关注数:0
粉丝:0
文章:0
关注标签:0
加入于:--