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

Original 2026-09-30 684 views

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


一、问题出在哪

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 里写东西,先问自己四个问题:

  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 的隔离实现:数据库、文件、缓存、锁各有对应机制,源码和测试都能直接对照。