[051][缓存模块]基于 StringRedisTemplate 的多租户 Key 隔离设计与实践——以 RedisBitmapUtils 为例

[051][缓存模块]基于 StringRedisTemplate 的多租户 Key 隔离设计与实践——以 RedisBitmapUtils 为例

本文章代码: gitee , gitcode , github

在多租户(SaaS)系统中,不同租户的数据必须严格隔离,而 Redis 作为高性能缓存/存储中间件,其 Key 的命名空间管理是隔离的关键。Spring Data Redis 提供的 StringRedisTemplate 默认不对 Key 做任何修饰,若直接使用,不同租户的相同业务 Key(如 user:online)会互相覆盖。

本文介绍一种透明、低侵入的方案:通过自定义 StringRedisSerializer,在序列化 Key 时自动注入租户前缀,从而实现租户级别的自然隔离。并以项目中的 RedisBitmapUtils 工具类为例,展示如何在实际业务中安全使用这一机制。


一、核心设计思路

我们希望达到的效果:

  • 业务代码中只需写入逻辑 Key(例如 "login:2025-06-09");
  • 实际存入 Redis 的 Key 自动变为 {tenantId}:login:2025-06-09;
  • 读取时同样自动处理前缀,业务层无需感知租户 ID。

实现这一目标不需要修改 StringRedisTemplate 的 API,只需要替换其 Key 的序列化器。StringRedisTemplate 默认使用 StringRedisSerializer(UTF-8 编码),我们扩展该类,在 serialize(String key) 方法中动态拼接租户前缀。


二、关键组件解析

1. 租户前缀策略接口 RedisKeyPrefix

@FunctionalInterface
public interface RedisKeyPrefix {
    String SEPARATOR = ":";
    String compute(String cacheName);

    static RedisKeyPrefix tenant() {
        return name -> TenantContextHolder.get() + SEPARATOR + name;
    }
    // ... 其他方法
}
  • tenant() 工厂方法返回一个函数:租户ID + ":" + 原始Key。
  • TenantContextHolder.get() 是线程本地变量,保存当前请求的租户 ID(通常由拦截器或过滤器注入)。

2. 自定义序列化器 TenantStringRedisSerializer

public class TenantStringRedisSerializer extends StringRedisSerializer {
    @Override
    public byte[] serialize(String value) throws SerializationException {
        // value 是业务传入的原始 Key,先计算带前缀的完整 Key,再交给父类序列化
        return super.serialize(RedisKeyPrefix.tenant().compute(value));
    }
}
  • 继承 StringRedisSerializer,复用其字符串 → byte[] 的能力。
  • 重写 serialize 方法,在序列化前将原始 Key 转换为 租户ID:原始Key。
  • deserialize 方法无需重写(因为 Redis 返回的字节数组已经是带前缀的完整 Key,直接按原样反序列化即可)。但若想从完整 Key 中剥离租户前缀,可单独提供工具方法。

3. 配置注入:替换默认序列化器

在 RedisCacheConfiguration 中:

@Bean
RedisTemplateDecorator redisTemplateDecorator(StringRedisTemplate stringRedisTemplate,
                                               RedisTemplate<Object, Object> redisTemplate) {
    TenantStringRedisSerializer serializer = new TenantStringRedisSerializer();
    stringRedisTemplate.setKeySerializer(serializer);
    stringRedisTemplate.setHashKeySerializer(serializer);
    redisTemplate.setKeySerializer(serializer);
    redisTemplate.setHashKeySerializer(serializer);
    // ... 存入全局装饰器
    return RedisTemplateDecorator.instance;
}
  • 同时配置 StringRedisTemplate 和普通 RedisTemplate 的 Key 序列化器。
  • RedisTemplateDecorator 是一个单例持有者,便于全局获取已配置好的 Template 实例(见下文示例)。

三、实践示例:RedisBitmapUtils 如何利用多租户 Template

RedisBitmapUtils 是一个对 Redis Bitmap 操作的封装,其核心依赖于 RedisTemplateDecorator.stringRedisTemplate() —— 该静态方法返回的正是已经替换了序列化器的 StringRedisTemplate。

public class RedisBitmapUtils {
    public static final RedisBitmapUtils instance = new RedisBitmapUtils();

    public Boolean setBit(String key, String param, boolean value) {
        // 使用的 stringRedisTemplate 已自动添加租户前缀
        return RedisTemplateDecorator.stringRedisTemplate()
                .opsForValue().setBit(key, hash(param), value);
    }

    public boolean getBit(String key, String param) {
        return Boolean.TRUE.equals(RedisTemplateDecorator.stringRedisTemplate()
                .opsForValue().getBit(key, hash(param)));
    }

    public Long bitCount(String key) {
        // 注意:这里需要先序列化 key 以保持前缀一致
        byte[] newKey = RedisTemplateDecorator.instance.serializeKey(key);
        return RedisTemplateDecorator.stringRedisTemplate().execute(
            (RedisCallback<Long>) connection -> connection.stringCommands().bitCount(newKey)
        );
    }
    // ... 其他方法
}

关键观察点

  • setBit / getBit:直接传入逻辑 Key(如 "user:active"),底层自动转换为 {tenantId}:user:active 存储。不同租户的相同逻辑 Key 在 Redis 中完全独立。
  • bitCount 等底层命令:使用了 RedisTemplateDecorator.instance.serializeKey(key),该方法内部调用了 TenantStringRedisSerializer.serialize(key),确保传递给 Redis 原生 connection 的字节数组同样包含租户前缀。
  • Hash 结构:如果工具类涉及 Hash 操作,也需要使用 setHashKeySerializer(serializer),以保证 Hash 中的 field 也带租户前缀(除非明确不需要)。

四、租户上下文的传递

上述方案的前提是 TenantContextHolder.get() 能够正确返回当前租户 ID。通常的实现方式:

  • Web 应用:使用过滤器或拦截器,从请求头(如 X-Tenant-ID)、JWT 或子域名解析出租户 ID,存入 ThreadLocal。
  • 非 Web 环境(如定时任务、MQ 消费):在任务开始处手动设置租户 ID,执行完毕后清理。
  • 注意:使用 ThreadLocal 务必在请求结束或任务完成后调用 clear(),避免内存泄漏或跨租户污染。

五、注意事项与最佳实践

  1. Key 长度控制
    租户前缀会增加 Key 的长度,建议租户 ID 使用数字或短字符串(如 t1001),避免过长影响内存和网络。

  2. 适用于所有 Redis 数据结构
    该方案不仅对 Bitmap(String 结构)有效,对 Hash、List、Set、ZSet 等同样适用,只需设置对应的 Key 序列化器。对于 Value 序列化器,通常保持 JSON/Jackson 不变。

  3. 兼容性考虑
    如果已有旧数据不带租户前缀,需要做数据迁移或在代码中做兼容判断(例如先尝试带租户前缀的 Key,若不存在再查不带前缀的)。建议统一设计。

  4. 缓存管理器(RedisCacheManager)的联动
    在使用 Spring Cache 注解(@Cacheable)时,也需要确保 RedisCacheManager 配置了相同的 Key 前缀策略。通常通过 RedisCacheManagerBuilderCustomizer 设置 RedisCacheConfiguration#prefixCacheNameWith 或自定义 CacheKeyPrefix。

  5. 性能影响
    TenantStringRedisSerializer 仅在序列化时做一次字符串拼接,开销极小。hash 计算等正常业务操作不受影响。


六、总结

通过自定义 StringRedisSerializer,我们以一种对业务代码几乎无感知的方式实现了 Redis Key 的多租户隔离。RedisBitmapUtils 作为示例清晰地展示了:只要全局使用同一个预先配置好的 StringRedisTemplate(或通过 RedisTemplateDecorator 获取),所有 Key 都会被自动装饰租户前缀,开发者只需关注业务逻辑本身。

这种模式不仅适用于 Bitmap,也适用于任何基于 StringRedisTemplate 或 RedisTemplate 的操作,是构建多租户 Redis 层的一个轻量且高效的解决方案。

延伸思考:如果需要支持动态切换租户前缀策略(例如不同租户使用不同的前缀规则),可将 RedisKeyPrefix 设计为可配置的 Bean,并在 TenantStringRedisSerializer 中注入该策略。本文的 tenant() 是硬编码实现,实际生产中可以更灵活。

posted @ 2026-07-22 08:39  杨运交  阅读(8)  评论(0)    收藏  举报