
做多租户系统后你会发现缓存最怕的不是慢而是串号。我之前维护的那套基于 Spring Boot 的服务Caffeine 和 Redis 都上过线上问题恰恰出在缓存 key 没有带租户维度上租户A的ID1001的数据被缓存后租户B访问同样的ID时直接命中了A的数据。表面看是缓存键设计问题实际上是对缓存架构缺乏顶层抽象。后来我把缓存层整体重构了一遍目标是两件事第一Caffeine 和 Redis 之间只用一行配置切换开发环境不依赖 Redis生产环境切到 Redis 共享缓存第二多租户隔离对业务代码完全透明业务层继续写Cacheable(cacheNames userInfo, key #userId)不需要关心当前是哪个租户缓存层自动把租户维度拼进去。本文把完整设计思路、核心代码和踩坑过程都写出来适合正在做 SaaS 多租户后台、或者想把本地缓存和分布式缓存统一管理的团队参考。1. 先还原那次缓存串号多租户系统对缓存层的真实要求1.1 数据串号是怎么发生的很多团队一开始缓存 key 很简单业务表主键是数字 ID就直接用主键当缓存 key。单租户系统没问题多租户系统会立刻出事。假设租户 A 和租户 B 都有一张user_info表主键都从 1 开始自增。在没有租户隔离的 Redis 里cacheNames userInfo, key #userId生成的 key 就是userInfo::1。租户 A 先查 ID1 的数据Redis 里写入 A 的数据租户 B 再查 ID1命中同一个 key返回的就是 A 的数据。一旦出现这种串号业务上根本不是“数据小瑕疵”而是严重的越权访问。用户看到的不是自己的订单、自己的资料而是别人的隐私数据。所以在多租户系统里缓存 key 的规则不是“怎么简单怎么来”而是“任何一个遗漏租户维度的 key 都是一个事故隐患”。1.2 对缓存层的三个硬性要求第一租户维度必须进入缓存寻址。无论用本地 Caffeine 还是分布式 Redis相同业务 ID 在不同租户下必须落到完全不同的缓存位置。第二缓存提供方可以快速切换。开发环境为了省事可能只想用本地 Caffeine测试环境和生产环境又必须用 Redis 做多实例共享。不能因为换缓存组件就改一坨业务代码。第三租户隔离规则必须收敛到一处。最怕的是每个开发在各自业务代码里拼tenantId前缀拼法还不一样有人用tenant:123有人用123_有人干脆忘了拼。隔离逻辑必须集中在缓存管理层业务层没有感知。2. 一行配置切换的真相条件装配与 CacheManager 抽象2.1 先从 Spring Cache 抽象说起Spring 的缓存抽象做得很好业务代码只依赖CacheManager和Cache两个接口不关心底层是 Caffeine、Redis 还是其他实现。Cacheable、CachePut、CacheEvict最终都会通过CacheAspectSupport调用CacheManager.getCache(cacheName)再对Cache做读写。所以实现切换的关键不是发明新框架而是向 Spring 容器注册不同的CacheManagerBean。只要我们在不同条件下创建不同的CacheManager业务代码完全不用动。用一个自定义配置项app.cache.provider控制值只有两个caffeine或redis。默认是 caffeine生产环境改成 redis。这就是标题里说的“一行配置切换”app: cache: provider: caffeine # 改成 redis 即可切到 Redis2.2 两个配置类与一条配置项每个配置类上加ConditionalOnProperty让 Spring Boot 只装配其中一个Configuration ConditionalOnProperty(prefix app.cache, name provider, havingValue caffeine) public class CaffeineCacheConfig { Bean Primary public CacheManager cacheManager() { CaffeineCacheManager cacheManager new CaffeineCacheManager(); cacheManager.setCaffeine(Caffeine.newBuilder() .maximumSize(20_000) .expireAfterWrite(Duration.ofMinutes(15)) .recordStats()); return cacheManager; } }Configuration ConditionalOnProperty(prefix app.cache, name provider, havingValue redis) public class RedisCacheConfig { Bean Primary public CacheManager cacheManager(RedisConnectionFactory connectionFactory) { RedisCacheConfiguration configuration RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(30)) .disableCachingNullValues() .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())); return RedisCacheManager.builder(connectionFactory) .cacheDefaults(configuration) .transactionAware() .build(); } }提示Spring Boot 本身也有spring.cache.type这个配置但那是给自动配置用的。我们这里自己定义CacheManagerBean 之后自动配置会退让所以需要用自定义的app.cache.provider来控制避免两边语义冲突。这两个配置类共同存在没问题因为条件注解只有一个生效。想让“一行切换”真正成立还需要一个前提项目里同时保留 Caffeine 和 Redis 依赖。如果只保留 Redis 依赖切到 caffeine 时启动会报缺少类如果只保留 Caffeine 依赖切到 redis 时同样起不来。实际中implementation org.springframework.boot:spring-boot-starter-cache implementation com.github.ben-manes.caffeine:caffeine implementation org.springframework.boot:spring-boot-starter-data-redis多引入一个用不到的依赖对运行期影响很小换来的是部署包不变、只改配置项就能切换。这是性价比很高的做法。3. 租户隔离放在哪里才透明TenantCacheManager 包装法3.1 为什么不在业务代码里拼租户 ID最差的实现是让业务代码自己拼 key比如Cacheable(cacheNames userInfo, key T(TenantContext).getTenantId() : #userId)这种写法有几个问题一是容易漏。业务方法几十个上百个总有开发忘记拼或者拼错。二是不可维护。租户标识格式想换一下所有注解都要改。三是测试困难。单元测试里还得维护一个静态 TenentContext分心。更隐蔽的问题是Spring Cache 的key属性优先级高于自定义KeyGenerator如果业务代码显式写了key #userId你再怎么自定义KeyGenerator都不会被调用。所以“靠 KeyGenerator 拼租户”这条路也存在盲区。3.2 在缓存名前缀上做隔离最靠谱的做法是让CacheManager.getCache(name)返回带租户前缀的缓存名。业务代码写的还是cacheNames userInfo但实际向底层缓存寻址的是tenantA::userInfo。实现一个TenantCacheManager包装任意一个CacheManagerpublic class TenantCacheManager implements CacheManager { private final CacheManager delegate; public TenantCacheManager(CacheManager delegate) { this.delegate delegate; } Override public Cache getCache(String name) { String tenantId TenantContext.getTenantId(); if (!StringUtils.hasText(tenantId)) { throw new IllegalStateException(TenantContext为空不能访问租户缓存); } return delegate.getCache(tenantId :: name); } Override public CollectionString getCacheNames() { return delegate.getCacheNames(); } }这个包装类的核心逻辑只有一行delegate.getCache(tenantId :: name)。它有两个好处不关心底层到底是 Caffeine 还是 Redis只要是实现了CacheManager的组件都能包。租户级clear()是安全的。因为cacheManager.getCache(userInfo)拿到的是tenantA::userInfo这个缓存调用cache.clear()只会清当前租户的缓存不会误伤其他租户。如果底层是 Redis 的RedisCacheManager它默认会把cacheName拼到 Redis key 前缀里。于是实际 key 类似tenantA::userInfo::1001 tenantB::userInfo::1001两个租户的数据彻底分开。如果底层是 CaffeineCaffeineCacheManager会为tenantA::userInfo和tenantB::userInfo分别维护独立的内存 Map也不会串数据。业务代码这边的写法可以保持最朴素的样子Cacheable(cacheNames userInfo, key #userId) public UserInfo getUserInfo(Long userId) { // 业务逻辑 }调用链路是Cacheable-CacheManager.getCache(userInfo)-TenantCacheManager返回tenantA::userInfo- 读写底层缓存。业务方法完全不需要感知租户 ID隔离逻辑被收敛到了缓存管理层。4. Caffeine 与 Redis 两套 CacheManager 的落地细节4.1 Caffeine 配置与适用场景Caffeine 作为本地进程内缓存最大的优点是快没有网络开销命中后是纯内存访问P99 通常能到 1ms 以内。最适合两类数据单实例内高频读取的配置项以及非核心但被频繁查询的字典数据。配置时建议根据内存预算设置maximumSize不要依赖默认值CaffeineCacheManager cacheManager new CaffeineCacheManager(); cacheManager.setCaffeine(Caffeine.newBuilder() .maximumSize(50_000) .expireAfterWrite(Duration.ofMinutes(10)) .recordStats());expireAfterWrite比expireAfterAccess更适合多租户场景。expireAfterAccess的意思是“多久没被访问就过期”如果一个 key 一直被某个租户高频访问理论上可以一直存活过期时间形同虚设。expireAfterWrite保证了不管访问多频繁写进去超过 10 分钟就必须重新加载避免本地缓存长期保留脏数据。recordStats()也建议打开后面看命中率、淘汰数都用得上。多租户场景下每个租户的每个缓存名都会生成一个独立 Caffeine 对象如果租户数量特别大注意观察 GC 和内存占用。为了降低单缓存对象开销initialCapacity不要拍脑袋给一个很大的值很多小租户的缓存对象会各自申请数组叠加起来很惊人。4.2 Redis 配置与序列化选择Redis 从功能上是 Caffeine 的“分布式版本”多实例共享同一份缓存数据一致性更好但代价是每次读写都要走网络P99 通常比本地缓存高一个量级。Redis 配置里最关键的三个点Key 序列化用 StringRedisSerializer。租户前缀和缓存名拼出来是字符串key 不搞复杂对象既方便运维排查也避免对象 hashCode 不稳定导致 key 漂移。Value 序列化选型要提前定。GenericJackson2JsonRedisSerializer使用方便但会把类型信息写进 JSON比如class字段。如果缓存对象结构变更频繁反序列化时会报类型不兼容如果更注重性能和存储体积可以考虑 Kryo 或 Protobuf。缓存序列化方案是整个 Redis 缓存最容易返工的地方上线前一定要压测和兼容性测试。TTL 要按业务实际数据新鲜度定。配置项可以 10 分钟报表聚合可以 5 分钟用户基础资料可能 30 分钟。不要所有缓存都用同一个 1 小时 TTL又省事又容易出问题。Redis 模式的完整配置类Configuration ConditionalOnProperty(prefix app.cache, name provider, havingValue redis) public class RedisCacheConfig { Bean Primary public CacheManager cacheManager(RedisConnectionFactory connectionFactory) { RedisCacheConfiguration configuration RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(30)) .prefixCacheNameWith(biz:) .disableCachingNullValues() .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())); RedisCacheManager manager RedisCacheManager.builder(connectionFactory) .cacheDefaults(configuration) .transactionAware() .build(); return new TenantCacheManager(manager); } }transactionAware()的作用是让缓存操作参与当前事务事务回滚时缓存写入也回滚。多租户后台经常有“更新数据 刷新缓存”的组合操作开启事务感知能减少脏缓存出现的概率。5. 从开发环境切到生产 Redis你会撞见的坑5.1 TenantContext 没传到异步线程最隐蔽的串号来源切到 Redis 后第一次压测没发现问题结果高峰流量一来某些请求返回了别人的数据。排查链路是这样的先看 Redis key线上的 key 有两种正常的是tenantA::userInfo::1001另一些是userInfo::1001少了租户前缀。少了前缀的业务 key 访问者包括不同租户数据必然串。接着看日志发现这些请求都走了异步线程池。原来是TenantContext基于ThreadLocal存储租户 ID主线程设置好租户后提交到线程池时子线程拿不到于是TenantContext.getTenantId()返回 null。我把TenantCacheManager里“租户为空就 fallback 到无租户缓存”的逻辑改成了直接抛异常if (!StringUtils.hasText(tenantId)) { throw new IllegalStateException(TenantContext为空不能访问租户缓存); }这样只要租户上下文丢失请求立刻报错暴露问题而不是默默写一个无租户 key 导致串号。异步场景的解法是给线程池加一个TaskDecorator在线程提交时把租户 ID 带过去Component public class TenantContextTaskDecorator implements TaskDecorator { Override public Runnable decorate(Runnable runnable) { String tenantId TenantContext.getTenantId(); return () - { TenantContext.setTenantId(tenantId); try { runnable.run(); } finally { TenantContext.clear(); } }; } }在使用ThreadPoolTaskExecutor时设置setTaskDecorator(tenantContextTaskDecorator)。这样异步子线程能正常拿到租户上下文缓存 key 也就能带上租户前缀。5.2 Redis 序列化模型变更导致反序列化异常上线后第一次发布新版本改动了一个缓存对象的字段类型结果 Redis 里旧数据反序列化失败大量请求直接打到数据库。这不是 Redis 本身的问题而是序列化策略没有考虑演进。GenericJackson2JsonRedisSerializer会把类信息写进 value对象属性多了、少了、类型变了都可能触发异常。规避方案有几种缓存对象里不要直接放 JPA 实体或 DO单独建一个轻量缓存 VO字段稳定、可兼容。上线前评估是否需要清理 Redis。最简单的方式是给prefixCacheNameWith加版本号比如biz:v2:新老 key 自然隔离旧 key 等 TTL 过期即可。如果改动很大直接灰度时通过运维手动清理相关缓存 namespace。多租户系统里还要注意老 key 是无租户前缀的旧格式即使已经切换了缓存配置这些旧 key 依然存在。给 Redis 缓存配置显式加上版本前缀是新旧切换最干净的手段。5.3 一行切换背后的隐形成本“一行切换”指的是运行期切换不是彻底消灭差异。Caffeine 和 Redis 之间有几个重要差异不会因为抽象消失Caffeine 只在本实例有效。如果业务是多实例部署一个实例缓存更新了另一个实例还是旧值直到 TTL 过期。对一致性要求高的接口别在 Caffeine 上放太长的 TTL。Redis 有网络故障风险。切到 Redis 后Redis 抖动会导致缓存请求变慢严重的会拖垮数据库。建议对非核心缓存设置短的 TTL同时开启 Redis 连接池和超时配置别让缓存成为单点。本地缓存的“实时清理”能力比 Redis 更强。Caffeine 可以监听淘汰事件Redis 则依赖 TTL 和主动删除。设计时必须想清楚数据源更新后如何让缓存失效。提示如果生产环境要求缓存强一致Caffeine 只能做一级缓存或者干脆不用核心数据走 Redis。本方案里的“切换”是架构保留能力不是鼓励生产环境无脑用本地缓存。6. 验证隔离性、压测与多租户容量评估6.1 集成测试确认不串号切换做完后第一件事不是上线而是写一个集成测试证明隔离性。测试用同一个业务 ID在两个租户下分别写入数据再互相读取SpringBootTest class TenantCacheIsolationTest { Autowired private CacheManager cacheManager; Test void sameKeyDifferentTenantShouldNotCollide() { try { TenantContext.setTenantId(tenantA); cacheManager.getCache(userInfo).put(1001, dataOfA); TenantContext.setTenantId(tenantB); String valueB cacheManager.getCache(userInfo).get(1001, String.class); assertNull(valueB); } finally { TenantContext.clear(); } } }如果缓存 key 没有租户隔离valueB会返回dataOfA测试直接失败。这套测试在 Caffeine 模式下和 Redis 模式下都应该跑一遍保证切换前后隔离行为一致。6.2 压测结果和参数调整我在一个模拟项目里做过对比。接口是读取用户基础资料单次查询数据库大约 8ms命中缓存后走 Caffeine 大约 0.5ms走局域网 Redis 大约 3ms。场景P99命中率说明无缓存180ms0%数据库扛不住Caffeine 本地缓存0.9ms94%单实例有效Redis 分布式缓存4.2ms94%多实例共享注意P99 只是我这边环境的实测参考不同网络和 Redis 性能差异会很大。关键不是数字而是压测后要观察两个指标命中率如果命中率低于 80%说明 TTL 设置太短或者 key 设计太碎缓存价值不大。Redis slowlog如果大量慢查询出现在缓存 key 读取上优先怀疑 value 序列化体积太大或者 Redis 实例 CPU 已经打满。6.3 租户规模变大时隔离方案的取舍本文方案用“租户前缀作为缓存名的一部分”实现隔离逻辑简单、租户级 clear 好做。但它有一个代价每个租户的每个缓存名都会向底层CacheManager注册一个独立的 Cache 对象。如果租户数量上千、缓存名数量十几个Caffeine 会维护大量内存对象内存开销和 GC 压力都要重新评估。到那个规模可以考虑改成“共享 Cache key 前缀”的隔离方式同一个userInfo缓存内key 统一拼tenantId :: 业务key。这种方式生成的 Cache 对象少内存占用更可控但租户级批量清理会变麻烦因为clear()会清整个缓存必须额外实现按前缀扫描删除。隔离方式内存/对象开销租户数据清理适合场景缓存名带租户前缀每个租户每个缓存一个 Cache 对象调当前租户 Cache 的 clear 即可租户数量中等、经常需要清理租户数据缓存 key 带租户前缀共享 Cachekey 更长需要扫描 key 或显式删除租户数量大、缓存项分散独立 Redis 实例/库资源完全隔离物理隔离最干净合规敏感、租户数据隔离要求极高回到最开始的那个线上事故根本原因不是 Caffeine 或 Redis 选型错误而是对多租户的缓存 key 没有统一治理。后来我用TenantCacheManager把租户隔离下沉到CacheManager层业务代码保持原来的写法切换 provider 只需要改一行配置。如果有人问我做多租户缓存最值得记住的一条经验我会说先定租户维度在缓存寻址中的位置再谈选 Caffeine 还是 Redis顺序不能反。