上一篇讲了数据库是怎么隔离的。这一篇讲一个更容易被忽略的事实:数据库隔离 ≠ 整个系统天然隔离。
一、问题出在哪
EasyAdminBlazor 有两条缓存通道:
| 通道 | 实现 | 作用域 |
|---|---|---|
ICacheService |
默认 MemoryCacheService(进程内 IMemoryCache) |
单进程,所有租户共用同一个实例 |
IRedisService |
RedisService(FreeRedis) |
所有应用实例、所有租户共用同一个 Redis |
在单租户模式下,键相同就等于数据相同,没人会多想。但在多租户模式下:
租户 A 读配置 SYSTEM_NAME → 写入 key "SysConfig:SYSTEM_NAME"
租户 B 读配置 SYSTEM_NAME → 命中同一个 key,读到 A 的系统名称
数据库连的是两个库,缓存却是同一份。这不是理论风险:GetConfig 在后台每个页面都会调用(顶部系统名称、图标),一旦串了,表现就是"B 客户的后台显示 A 客户的公司名"。
权限缓存串了更严重:B 租户的用户可能加载到 A 租户的角色,看到不该看到的菜单。
二、方案:统一租户前缀
框架的做法很直接——所有跨租户共享的缓存键,统一加 tenant:{code}: 前缀:
/// <summary>主租户(未启用多租户 / 未解析到租户)使用的缓存命名空间。</summary>
public const string MainTenantCode = "main";
/// <summary>当前租户编码;未解析到租户时返回 main。</summary>
public string TenantCode => Tenant?.Code is { Length: > 0 } code ? code : MainTenantCode;
/// <summary>
/// 统一的多租户缓存/Redis 键前缀,格式 tenant:{code}:。
/// 所有跨租户共享的缓存、Redis Key 都必须带上此前缀,避免租户之间串数据。
/// </summary>
public string TenantCachePrefix => $"tenant:{TenantCode}:";
注意 MainTenantCode = "main" 这个兜底:单租户模式下前缀是 tenant:main:。这样:
- 单租户项目升级到多租户时,主站的数据在缓存里仍然落在同一个命名空间,不会因为"以前没前缀、现在有前缀"而出现两套缓存;
- 测试里专门锁定了这个历史命名空间(
MainTenantKeys_MatchHistoricalNamespace)。
三、哪些键必须带前缀
1. 系统配置
/// <summary>
/// 获取配置
/// 缓存键包含租户编码(tenant:{tenantCode}:SysConfig:{key}),避免多租户之间串配置
/// </summary>
public string GetConfig(string key, string defaultValue = "")
{
return cache.GetOrCreate<string>($"{TenantCachePrefix}SysConfig:{key}", () =>
{
...
});
}
2. 权限与菜单(最不能串的一组)
private string ScopedPermissionVersionKey => $"{TenantCachePrefix}{PermissionVersionKey}";
private string ScopedUserRolesKey(int version) => $"{TenantCachePrefix}UserRoles:{version}:{User!.Id}";
private string ScopedUserRoleMenusKey(int version) => $"{TenantCachePrefix}UserRoleMenus:{version}:{User!.Id}";
三个键各有用意:
PermissionVersion:权限缓存版本号,角色/菜单变更时 +1,让所有用户权限缓存立即失效;UserRoles:{version}:{userId}:当前用户的角色列表(30 分钟 TTL);UserRoleMenus:{version}:{userId}:当前用户可见的菜单(30 分钟 TTL)。
如果这三个键不带租户前缀,两个租户里 Id = 100 的用户会共享同一份角色缓存——而用户 Id 在不同租户库里是各自独立生成的,撞 Id 几乎是必然的。
3. 字典与选择器
// AdminDictSelect.razor
var key = $"{admin.TenantCachePrefix}Dict:{ParentName}";
// AdminDictMultiSelect.razor
var key = $"{admin.TenantCachePrefix}DictMulti:{ParentName}";
// AdminSelectEntity.razor
var key = $"{admin.TenantCachePrefix}Select:{typeof(TItem).FullName}:{whereHash}";
// AdminSelectTreeEntity.razor
var key = $"{admin.TenantCachePrefix}Tree:{typeof(TItem).FullName}:{SortString ?? "default"}:{whereHash}";
这四个键里,Select: / Tree: 的键包含实体类型和 where 的哈希,所以同一个实体在不同过滤条件下不会互相覆盖;再加上租户前缀,就不会跨租户命中。
4. 审批流程配置
// IApprovalFlowProvider
var cacheKey = $"{admin.TenantCachePrefix}ApprovalFlow:{billType}";
流程配置可能存在"参数配置"表里(APPROVAL_FLOW_{单据全名}),而参数配置是租户库的数据。缓存不带前缀,就会出现"A 租户改了审批流程,B 租户跟着变"。
5. 聊天消息与分布式锁
// Chat.razor:消息先写 Redis 列表,再由后台任务落库
_redisService?.LPush($"{admin.TenantCachePrefix}chat_messages:{receiverId}", JsonConvert.SerializeObject(newMessage));
// AdminMessageService:落库任务用分布式锁避免多实例重复消费
private const string LockKey = "save_messages_lock";
private const int LockExpireSeconds = 30;
/// <summary>
/// 将缓存的消息保存到数据库(需要 Redis 支持)
/// Redis Key 统一使用 easyadmin:{tenant}:chat_messages:* 前缀,租户之间互不可见
/// </summary>
public async Task SaveMessagesToDatabase()
{
if (_redis == null) return;
var lockObj = _redis.Lock($"{_admin.TenantCachePrefix}{LockKey}", LockExpireSeconds);
if (lockObj != null)
{
var keys = _redis.Keys($"{_admin.TenantCachePrefix}chat_messages:*");
foreach (var key in keys)
{
var messagesJson = _redis.LRange(key, 0, -1);
...
_redis.Del(key);
}
_redis.ReleaseLock(lockObj);
}
}
锁也要带租户前缀。如果锁键是全局的 save_messages_lock,租户 A 的落库任务持锁时,租户 B 的任务会直接跳过——表现就是"B 的消息攒在 Redis 里迟迟不入库"。
四、一个需要留意的例外
Redis 里有一个键目前没有租户前缀:
public class RedisService : EasyAdminBlazor.IRedisService
{
private const string ONLINE_KEY = "easyadmin_online";
public void JoinOnline(long userId)
{
_redisClient.HSet<int>(ONLINE_KEY, userId.ToString(), 1);
}
...
}
在线状态用的是全局 Hash。原因可以理解——用户 Id 在同一个进程里是全局唯一的,而在线状态本质是"连接级"的状态,不属于业务数据。
但要知道它的含义:在线状态是跨租户共享的。如果你希望"租户 A 看不到租户 B 的在线用户",需要在业务层自己包一层(例如 key 改成 {TenantCachePrefix}online)。这不是框架当前的默认行为,写在这里避免误判。
五、测试怎么锁定隔离行为
TenantCacheIsolationTests.cs 先固定了一份"必须带前缀的键清单":
private static readonly string[] ExpectedScopedKeyPrefixes =
[
"tenant:main:SysConfig:",
"tenant:main:PermissionVersion",
"tenant:main:UserRoles:",
"tenant:main:UserRoleMenus:",
"tenant:main:Select:",
"tenant:main:Dict:",
"tenant:main:DictMulti:",
"tenant:main:Tree:",
"tenant:main:chat_messages:",
"tenant:main:save_messages_lock",
];
然后逐条验证行为:
| 测试 | 验证内容 |
|---|---|
TenantCachePrefix_MatchesTenantCode |
tenant:main: 格式正确 |
TenantCachePrefix_WithoutTenant_FallsBackToMainNamespace |
无租户时回退到 main |
TenantCachePrefix_DifferentTenants_ProduceDifferentNamespaces |
不同租户前缀不同 |
ScopedCacheKeys_AlwaysCarryTenantPrefix |
各类业务键都带前缀 |
MainTenantKeys_MatchHistoricalNamespace |
主租户命名空间与历史一致 |
GetConfig_TwoTenants_DoNotShareCacheEntries |
两个租户写同名配置,读到的各自正确 |
SetTenant_AlwaysInvalidatesResolvedTenant |
切换租户会清掉已解析的租户缓存 |
InvalidateTenantCache_ClearsResolvedTenant |
显式失效后重新解析 |
其中 GetConfig_TwoTenants_DoNotShareCacheEntries 最直观:
cacheA.Set($"{tenantA.TenantCachePrefix}SysConfig:SYSTEM_NAME", "系统A");
cacheB.Set($"{tenantB.TenantCachePrefix}SysConfig:SYSTEM_NAME", "系统B");
tenantA.GetConfig("SYSTEM_NAME").Should().Be("系统A");
tenantB.GetConfig("SYSTEM_NAME").Should().Be("系统B");
六、自己写代码时的检查清单
只要往缓存或 Redis 里写东西,先问自己四个问题:
- 这个数据属于某个租户吗? 属于 → 键必须带
TenantCachePrefix。 - 这个键会被不同租户读到吗? 会 → 必须带前缀。
- 这是锁吗? 是 → 锁键也必须带前缀,否则会跨租户互斥。
- 这是"进程级/连接级"状态吗? 比如在线连接数——这类数据可以全局,但要在文档里写明,避免后来的人以为它是隔离的。
还有一条实践建议:不要用 Keys("chat_messages:*") 这类不带前缀的通配扫描。源码里的写法是 Keys($"{_admin.TenantCachePrefix}chat_messages:*"),前缀既解决隔离,也避免误删别人的数据。
七、小结
多租户的隔离是分层的:
| 层 | 隔离方式 | 由谁保证 |
|---|---|---|
| 数据库 | 独立库 / 连接串替换 | MultiTenantService |
| 文件 | wwwroot/uploads/{tenantCode}/... |
FileService.TenantPrefix |
| 内存缓存 / Redis 键 | tenant:{code}: 前缀 |
AdminContext.TenantCachePrefix |
| 分布式锁 | 键同样带租户前缀 | 调用方按约定拼前缀 |
| 在线状态 | 全局键(当前实现如此) | 需要隔离时自行调整 |
一句话总结:数据库隔离解决的是"数据存哪",缓存前缀解决的是"数据被谁读到"。 两者都做,多租户才算真的隔离。
如果你正在用 .NET 10 + Blazor 做多租户 SaaS,可以看看 EasyAdminBlazor 的隔离实现:数据库、文件、缓存、锁各有对应机制,源码和测试都能直接对照。