news 2026/10/1 14:18:19

EasyAdminBlazor 多租户 Redis 隔离:数据库隔离了,缓存还可能串租户?

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
EasyAdminBlazor 多租户 Redis 隔离:数据库隔离了,缓存还可能串租户?

上一篇讲了数据库是怎么隔离的。这一篇讲一个更容易被忽略的事实:数据库隔离 ≠ 整个系统天然隔离。


一、问题出在哪

EasyAdminBlazor 有两条缓存通道:

通道实现作用域
ICacheService默认MemoryCacheService(进程内IMemoryCache)单进程,所有租户共用同一个实例
IRedisServiceRedisService(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_MatchesTenantCodetenant: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 里写东西,先问自己四个问题:

  1. 这个数据属于某个租户吗?属于 → 键必须带TenantCachePrefix。
  2. 这个键会被不同租户读到吗?会 → 必须带前缀。
  3. 这是锁吗?是 → 锁键也必须带前缀,否则会跨租户互斥。
  4. 这是"进程级/连接级"状态吗?比如在线连接数——这类数据可以全局,但要在文档里写明,避免后来的人以为它是隔离的。

还有一条实践建议:不要用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
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 14:17:37

审查器不生效时,dsh-auto-review 先查这五个原因

dsh-auto-review 在审批链上放一个只读的第二模型&#xff1a;它读取证据并返回 { decision, reason, riskLevel } 裁决&#xff0c;默认失败关闭&#xff0c;全过程可从会话日志审计&#xff08;approval/asked → autoReview/verdict → approval/decided&#xff09;。它的行…

作者头像 李华
网站建设 2026/10/1 14:14:51

Madeira 项目拆解:Wine 生态下 Windows 应用兼容层的架构设计与实操

1. 项目缘起&#xff1a;为什么要在 Linux 上折腾 Windows 应用兼容层1.1 一个真实的需求场景我在日常工作中主力机是 Linux 桌面环境&#xff0c;但总有一些绕不开的 Windows 软件——比如某些行业工具、老版本的办公套件、特定的调试工具。双系统切换太麻烦&#xff0c;虚拟机…

作者头像 李华
网站建设 2026/10/1 14:14:49

一站式在线PDF工具箱pdfClaw:编辑转换OCR全解析与避坑指南

1. 定位与整体印象解析1.1 pdfClaw是什么&#xff1a;一站式PDF在线工具箱先聊点实在的。做项目、跑业务的人手里基本都会攒几个PDF工具——有的处理编辑&#xff0c;有的管格式转换&#xff0c;有的偶尔派上用场做文字识别。但说实话&#xff0c;大部分工具都让人不太放心&…

作者头像 李华
网站建设 2026/10/1 14:14:49

INT8量化陷阱:GEMM溢出、零点偏移与校准失效深度解析

1. 为什么INT8量化不是“简单除以127”——从矩阵乘法底层看精度坍塌的根源很多人第一次接触模型量化时&#xff0c;脑子里浮现的画面是&#xff1a;把FP32权重和激活值“缩放”成INT8&#xff0c;存起来&#xff0c;计算时再“反缩放”回来。听起来像给数字拍张压缩照片&#…

作者头像 李华
网站建设 2026/10/1 14:14:47

马德拉岛旅行攻略:徒步levada、丰沙尔老城与观鲸体验

第一次听说马德拉&#xff08;Madeira&#xff09;这个名字&#xff0c;是收到一张葡萄牙朋友寄来的明信片。当时没太当回事&#xff0c;只记得邮票上是一大片蓝绿色的海水&#xff0c;远处是陡到近乎垂直的悬崖。后来真正踏上这座岛&#xff0c;我才明白为什么欧洲人叫它“大西…

作者头像 李华
网站建设 2026/10/1 14:13:46

Qwen3+LoRA+LlamaFactory:72小时打造可控轻量AI智能体

1. 项目概述&#xff1a;为什么“自己训一个 Jev”不是口号&#xff0c;而是可落地的工程实践最近在多个技术社区和模型分享平台看到“Jev”这个词高频出现&#xff0c;有人把它当作新发布的开源模型&#xff0c;有人当成某个神秘AI助手的代号&#xff0c;还有人直接在GitHub上…

作者头像 李华