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头切换当前工作船;岸端总部角色(如系统管理员)可不带船上下文,做跨船查询。
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 状态下把 ShipId 的 IsModified 置 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 网关实战》):
- 路由级租户校验 :船端上报接口
/api/sync/**必须携带有效X-Ship-Id且证书/Token 匹配,网关层先挡一道; - 租户级限流:限流策略按 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
});
});
});
- 灰度发布按租户:新版本先放给 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. 上线后踩过的十个坑
- 迁移历史数据时 ShipId 回填遗漏 :几张老表漏了回填,过滤器上线后老数据"消失"。对策:迁移脚本先
SELECT COUNT(*) WHERE ship_id IS NULL验零,再加 NOT NULL 约束。 - Dapper 手写 SQL 没带租户条件:EF 过滤器管不到 Dapper。全项目规定 Dapper SQL 必须经过租户 SQL 拼接帮助类,或干脆靠 RLS 兜底。
- 后台任务忘记开租户作用域:任务"能跑"但处理了零数据(fail-closed 生效但无人察觉),报表连续几天为空。加监控:任务处理条数为 0 且租户非空时告警。
- 缓存键漏拼租户:某接口缓存了"设备详情"不带租户前缀,两艘船同 ID 设备串数据。封装 ITenantCache 后业务层拿不到原生缓存客户端,从结构上杜绝。
- 日志不带 ShipId:多租户后排查问题必须先按船过滤。Serilog 的 LogContext 在租户中间件里 PushProperty,所有日志强制带 ShipId 列。
- 时区坑:船在不同时区,Cron 任务按服务器时区跑导致提醒时间错乱。租户表存 timezone,Hangfire 调度按船时区换算(或 Cron 表达式里带时区)。
- 序列/单号跨租户共享:单号生成器早期用全局序列,船端互相对单号能推断彼此业务量。改为每租户独立序列段(编号规则按租户配置,见编号生成器)。
- 超管接口成了泄露口 :某个"内部调试"接口加了
[Authorize(Roles="admin")]就忘了租户过滤,admin 账号一查全船队。管理员接口同样要过授权船校验,HQ 管理员也要审计日志。 - 新开通租户的缓存预热雪崩:批量开通 5 艘船同时预热,数据库瞬时打满。按 ShipId 哈希错峰(见 4.4)。
- 软删除 + 租户过滤器双重条件漏建索引 :
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、联合索引、新船幂等开通、隔离测试守门。
最后一句话送给所有做多租户的团队:多租户系统里,"隔离"是默认值,"共享"是需要申请权限的例外。 把每一次数据访问都当成"可能越权"来审查,系统才敢说自己管得住一支船队。
💬 互动一下:
- 你们的系统多租户隔离做到哪一层了------应用过滤、数据库 RLS,还是还在用"一个客户一套库"?
- 后台任务/消息消费者的租户上下文,你们是怎么传递的?有没有踩过"任务跑了但没带租户"的坑?
- 如果让你给一个存量单租户系统做多租户改造,你会从哪一步开始?欢迎说说你的迁移路径。