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(模型二) │
└────────────────────────────────────────────────────────────┘
为什么岸端选模型三?
- 跨船查询是核心需求:岸端天天要"全船队备件库存"“全船队到期维保一览”,独立库联邦查询不可接受;
- 租户规模:30~300 艘船,共享库 + 行级隔离完全扛得住,且运维成本最低;
- 数据安全靠纵深防御:EF Core 全局过滤器 + 数据库行级安全策略(RLS)+ 网关层租户校验,三重隔离,后面逐一展开;
- 单租户恢复需求:靠船岸同步天然解决——岸端某船数据坏了,从船端全量重传即可,这是船舶行业独有的"备份副本"。
💡 经验:多租户模型选型不是纯技术题,先问业务三个问题——租户量级多大?跨租户报表是不是刚需?单租户数据导出/恢复频率多高?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



