上一篇讲了数据库是怎么隔离的。这一篇讲一个更容易被忽略的事实:数据库隔离 ≠ 整个系统天然隔离。
一、问题出在哪
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>publicconststringMainTenantCode="main";/// <summary>当前租户编码;未解析到租户时返回 main。</summary>publicstringTenantCode=>Tenant?.Codeis{Length:>0}code?code:MainTenantCode;/// <summary>/// 统一的多租户缓存/Redis 键前缀,格式 tenant:{code}:。/// 所有跨租户共享的缓存、Redis Key 都必须带上此前缀,避免租户之间串数据。/// </summary>publicstringTenantCachePrefix=>$"tenant:{TenantCode}:";注意MainTenantCode = "main"这个兜底:单租户模式下前缀是tenant:main:。这样:
- 单租户项目升级到多租户时,主站的数据在缓存里仍然落在同一个命名空间,不会因为"以前没前缀、现在有前缀"而出现两套缓存;
- 测试里专门锁定了这个历史命名空间(
MainTenantKeys_MatchHistoricalNamespace)。
三、哪些键必须带前缀
1. 系统配置
/// <summary>/// 获取配置/// 缓存键包含租户编码(tenant:{tenantCode}:SysConfig:{key}),避免多租户之间串配置/// </summary>publicstringGetConfig(stringkey,stringdefaultValue=""){returncache.GetOrCreate<string>($"{TenantCachePrefix}SysConfig:{key}",()=>{...});}2. 权限与菜单(最不能串的一组)
privatestringScopedPermissionVersionKey=>$"{TenantCachePrefix}{PermissionVersionKey}";privatestringScopedUserRolesKey(intversion)=>$"{TenantCachePrefix}UserRoles:{version}:{User!.Id}";privatestringScopedUserRoleMenusKey(intversion)=>$"{TenantCachePrefix}UserRoleMenus:{version}:{User!.Id}";三个键各有用意:
PermissionVersion:权限缓存版本号,角色/菜单变更时 +1,让所有用户权限缓存立即失效;UserRoles:{version}:{userId}:当前用户的角色列表(30 分钟 TTL);UserRoleMenus:{version}:{userId}:当前用户可见的菜单(30 分钟 TTL)。
如果这三个键不带租户前缀,两个租户里Id = 100的用户会共享同一份角色缓存——而用户 Id 在不同租户库里是各自独立生成的,撞 Id 几乎是必然的。
3. 字典与选择器
// AdminDictSelect.razorvarkey=$"{admin.TenantCachePrefix}Dict:{ParentName}";// AdminDictMultiSelect.razorvarkey=$"{admin.TenantCachePrefix}DictMulti:{ParentName}";// AdminSelectEntity.razorvarkey=$"{admin.TenantCachePrefix}Select:{typeof(TItem).FullName}:{whereHash}";// AdminSelectTreeEntity.razorvarkey=$"{admin.TenantCachePrefix}Tree:{typeof(TItem).FullName}:{SortString??"default"}:{whereHash}";这四个键里,Select:/Tree:的键包含实体类型和where的哈希,所以同一个实体在不同过滤条件下不会互相覆盖;再加上租户前缀,就不会跨租户命中。
4. 审批流程配置
// IApprovalFlowProvidervarcacheKey=$"{admin.TenantCachePrefix}ApprovalFlow:{billType}";流程配置可能存在"参数配置"表里(APPROVAL_FLOW_{单据全名}),而参数配置是租户库的数据。缓存不带前缀,就会出现"A 租户改了审批流程,B 租户跟着变"。
5. 聊天消息与分布式锁
// Chat.razor:消息先写 Redis 列表,再由后台任务落库_redisService?.LPush($"{admin.TenantCachePrefix}chat_messages:{receiverId}",JsonConvert.SerializeObject(newMessage));// AdminMessageService:落库任务用分布式锁避免多实例重复消费privateconststringLockKey="save_messages_lock";privateconstintLockExpireSeconds=30;/// <summary>/// 将缓存的消息保存到数据库(需要 Redis 支持)/// Redis Key 统一使用 easyadmin:{tenant}:chat_messages:* 前缀,租户之间互不可见/// </summary>publicasyncTaskSaveMessagesToDatabase(){if(_redis==null)return;varlockObj=_redis.Lock($"{_admin.TenantCachePrefix}{LockKey}",LockExpireSeconds);if(lockObj!=null){varkeys=_redis.Keys($"{_admin.TenantCachePrefix}chat_messages:*");foreach(varkeyinkeys){varmessagesJson=_redis.LRange(key,0,-1);..._redis.Del(key);}_redis.ReleaseLock(lockObj);}}锁也要带租户前缀。如果锁键是全局的save_messages_lock,租户 A 的落库任务持锁时,租户 B 的任务会直接跳过——表现就是"B 的消息攒在 Redis 里迟迟不入库"。
四、一个需要留意的例外
Redis 里有一个键目前没有租户前缀:
publicclassRedisService:EasyAdminBlazor.IRedisService{privateconststringONLINE_KEY="easyadmin_online";publicvoidJoinOnline(longuserId){_redisClient.HSet<int>(ONLINE_KEY,userId.ToString(),1);}...}在线状态用的是全局 Hash。原因可以理解——用户 Id 在同一个进程里是全局唯一的,而在线状态本质是"连接级"的状态,不属于业务数据。
但要知道它的含义:在线状态是跨租户共享的。如果你希望"租户 A 看不到租户 B 的在线用户",需要在业务层自己包一层(例如 key 改成{TenantCachePrefix}online)。这不是框架当前的默认行为,写在这里避免误判。
五、测试怎么锁定隔离行为
TenantCacheIsolationTests.cs先固定了一份"必须带前缀的键清单":
privatestaticreadonlystring[]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 的隔离实现:数据库、文件、缓存、锁各有对应机制,源码和测试都能直接对照。
- 文档:https://easyadmin.wang-zhan.com.cn/doc
- 源码:https://gitee.com/gudufy/EasyAdminBlazor