ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Cache多租户隔离:Caffeine与Redis一键切换方案

Spring Cache多租户隔离:Caffeine与Redis一键切换方案 自从把公司某个多租户 SaaS 服务的缓存架构重构成一行配置可变身之后我再也不需要在 Caffeine 和 Redis 之间反复横跳改代码了。事情的起因有点尴尬一次活动流量高峰过后运营反馈有用户看到了别的租户的报价数据排查到最后根因居然出在 Spring Boot 缓存架构里——Cacheable生成的 key 完全没有租户维度Redis 里面一个 cacheName 就混着所有租户的数据。这篇文章我就把完整方案摊开讲如何基于 Spring Cache 抽象用配置项一键切换本地缓存 Caffeine 和分布式缓存 Redis同时保证多租户隔离对业务代码完全透明。适合正在做 SaaS、多租户系统或者想把缓存管理收敛统一的团队参考。方案的落点很朴素业务代码里继续写Cacheable、CacheEvict一行不用改配置文件里改一个值缓存实现就从本地换到分布式而租户 ID 会在缓存 key 层自动拼接你甚至感觉不到它的存在。1. 一次缓存串租户事故暴露出的三个设计缺口1.1 事故现场还原当时的情况是这样一套面向企业的报价查询服务租户 A 和租户 B 通过不同的子域名访问同一套 Spring Boot 应用。某天渠道投放效果不错瞬时 QPS 拉到平时的几十倍数据库连接池很快就打满了。团队同学为了扛住压力快速给报价接口加上了Cacheable(cacheNames quote, key #quoteId)。本地压测没问题上线当天的命中率也很漂亮从 5% 涨到了 90% 以上。问题出在第二天。租户 A 的销售在系统里看到一条报价下单之后发现价格明细和合同完全对不上更严重的是里面还有另一家公司的客户名称。查日志、查权限、查数据权限拦截器全部没问题——因为请求确实只查询了本租户的数据库。最后是 Redis 里quote::9527这个 key 出卖了真相租户 A 和租户 B 的同一个quoteId在数据库里是不同的记录但在缓存里是同一个 key。先到先得后到直接命中别人的数据。这个事故本质上是三个设计缺口叠加的结果缓存 key 没有加入租户维度等于所有租户共用一张缓存表团队没有统一缓存 KeyGenerator每个开发按自己想法拼 key拼不齐很正常当时直接用了 Spring Boot 默认的 Redis 序列化key 带了一串二进制前缀排查 key 归属非常困难。1.2 三个设计缺口背后的问题先说 key 缺租户的问题。很多人直觉会觉得我在每个方法的 key 里拼上 tenantId 不就行了。理论上没错但实操中一定会漏。项目一旦超过十个人维护key #quoteId和key #tenantId : #quoteId会和稀泥一样混在一起。谁新增接口时忘了拼谁就埋了一个事故炸弹。然后是 KeyGenerator 不统一的痛苦。Spring Cache 允许你全局自定义 KeyGenerator但如果没人定义每个Cacheable的 key 规则就是参数全拼。参数多一个少一个key 就变换参数顺序key 也变缓存命中率忽高忽低。多租户系统里这个隐患被放大成串数据因为参数里一旦有 tenantId又因为参数顺序不同同一份数据可能存成两个 key也可能两个租户共用一个 key。最后是 Redis 序列化的问题。Spring Boot 自动装配的RedisCacheManager默认使用 JDK 序列化key 会变成类似\xac\xed\x00\x05t\x00...的二进制value 也存成 Java 对象字节流。平时功能没问题一旦要排查线上 key、要从 Redis 里肉眼比对数据、或者要让缓存数据被非 Java 服务消费直接抓瞎。对我的场景来说多租户隔离意味着运维需要能快速按租户维度扫描和清理缓存这种乱码 key 就是雪上加霜。1.3 方案选型本地缓存和分布式缓存都要能切换事故之后我重新梳理需求列出了几条硬性指标缓存客户端必须同时覆盖本地缓存和分布式缓存两种形态切换成本必须低到接近零最好一个配置项搞定租户隔离必须做在缓存 key 层且对业务代码透明序列化必须可读至少运维能直接看懂 key 的组成。Caffeine 和 Redis 本身没有选择困难Caffeine 是 JVM 进程内缓存性能极高、无网络开销适合单机场景和高频热点数据Redis 是分布式缓存多实例共享、容量可扩展适合集群部署和需要跨实例一致的场景。真正难的是把两者统一到一套代码里——这才是 Spring 框架的 Spring Cache 抽象层最值钱的地方。2. Spring Cache 抽象层为什么缓存切换能一行搞定2.1 CacheManager 接口的定位Spring Cache 不是一个具体缓存产品而是一套缓存门面。它定义了两个核心概念CacheManager和Cache。CacheManager负责创建和管理各种CacheCache则封装了实际的 get/put/evict/clear 操作。业务代码里的Cacheable(cacheNames quote, key #quoteId)在执行时Spring 的拦截器会找到CacheManager根据cacheNames取出名为quote的Cache然后按 key 规则计算缓存 key再去查Cache。这套流程里业务代码完全不感知底层实现——你用的是 Caffeine 还是 Redis对它来说只是这个 Cache 对象是谁提供的的差别。所以一行配置切换的本质就是让CacheManager这个 Bean 的实现可以根据配置动态变化。配置切到 CaffeineSpring 容器里注入的就是CaffeineCacheManager配置切到 Redis注入的就是RedisCacheManager。对上层注解来说只是换了底层的 get/put 实现。2.2 CaffeineCacheManager 与 RedisCacheManager 的差异两者虽然都实现了CacheManager接口但行为上有几个关键差异直接决定了切换方案里需要做哪些额外工作。CaffeineCacheManager是进程内缓存管理器。它内部维护一个 cacheName 到 Caffeine 缓存的映射。如果没显式声明缓存名称它会在第一次请求某个缓存名时动态创建所以用起来非常自由。它的特点是快、简单、零网络开销缺点是每个 JVM 实例各存一份实例之间不共享更新缓存后其他实例无法感知只能等 TTL 过期。RedisCacheManager是分布式缓存管理器。它基于RedisCacheWriter读写 Redis所有实例共享同一份数据。它需要你给出一个RedisCacheConfiguration作为默认配置包括 TTL、序列化方式、key 前缀规则等。它的特点是共享、持久、容量可控缺点是每次缓存读写都涉及网络 IO性能比本地缓存低一个数量级而且序列化配置一旦不对就会出现乱码或类型转换问题。在切换方案里我要做的是把选择哪个 CacheManager 生效这件事交给配置系统把两个管理器各自的坑提前填平。2.3 自定义 CacheManager 的优先级问题很多人以为切换缓存只需要改spring.cache.typecaffeine或spring.cache.typeredis。事实上 Spring Boot 的CacheAutoConfiguration确实支持这个开关但它配套的默认配置比较简陋——Redis 默认 JDK 序列化和默认 TTL 往往撑不起生产需求。所以更可靠的做法是自己注册CacheManagerBean。这里有个 Spring Boot 的重要机制一旦你在容器中显式声明了CacheManager类型的 BeanCacheAutoConfiguration的自动装配就会被ConditionalOnMissingBean条件拦截直接失效。用户定义的 Bean 拥有绝对优先权。这等于把缓存实现的控制权完全交到自己手里。于是我可以用两个配置类分别注册 Caffeine 和 Redis 的CacheManager再用条件注解控制哪一个生效。实现上非常干净不需要if/else不需要运行时判断类型。3. 配置驱动的双实现注册caffeine 与 redis 的无缝切换3.1 运行时配置项设计我在application.yml里新增了一个自定义配置项app: cache: type: redis # 可选值caffeine / redis这个type就是那一行配置。切换时只需要把值改掉然后重启应用缓存实现就变了。为了让切换更安全我还在配置里预留了 Caffeine 和 Redis 各自的参数项比如缓存容量、TTL、缓存名称列表等app: cache: type: redis caffeine: maximum-size: 10000 expire-after-write: 30m cache-names: quote,user,product redis: ttl: 30m cache-names: quote,user,productcache-names我会显式列出来。原因有两个其一提前创建缓存可以减少运行期动态创建带来的锁竞争其二Caffeine 的动态创建虽然方便但在多租户场景下不让它无限动态创建反而更安全——一旦 key 拼错导致缓存名失控至少能从一个有限集合里排查。3.2 Caffeine 缓存管理器注册细节Caffeine 侧配置类如下Configuration(proxyBeanMethods false) ConditionalOnProperty(prefix app.cache, name type, havingValue caffeine, matchIfMissing true) public class CaffeineCacheConfig { Bean public CacheManager cacheManager() { CaffeineCacheManager manager new CaffeineCacheManager(); manager.setCaffeine(Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(30)) .recordStats()); manager.setAllowNullValues(false); return manager; } }注意几个容易踩的点recordStats()对 Caffeine 很重要。开了之后CaffeineCacheManager底层的缓存会记录命中率、加载成功率、驱逐数量等统计信息之后无论做监控还是验证缓存效果都靠它。setAllowNullValues(false)是 Caffeine 的保守配置。Cacheable方法返回 null 时Spring 默认支持缓存 null但这样做既浪费内存还会掩盖数据本来就没查到和缓存里存了 null的区别。我一般直接禁掉让 null 结果照常打到数据库避免 null 值污染缓存。matchIfMissing true是关键——如果配置里没写app.cache.type默认走 Caffeine。这样最安全因为本地缓存不需要依赖外部 Redis 环境本地开发时只要一个配置缺失就自动落到最轻量的实现。3.3 Redis 缓存管理器注册细节Redis 侧配置类如下Configuration(proxyBeanMethods false) ConditionalOnProperty(prefix app.cache, name type, havingValue redis) public class RedisCacheConfig { Bean public CacheManager cacheManager(RedisConnectionFactory factory) { RedisCacheConfiguration config RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(30)) .serializeKeysWith( RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) .serializeValuesWith( RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())) .disableCachingNullValues() .computePrefixWith(cacheName - cacheName ::); return RedisCacheManager.builder(factory) .cacheDefaults(config) .transactionAware() .build(); } }这里有一堆细节值得展开。serializeKeysWith配StringRedisSerializer就是为了解决我开头说的 key 乱码问题。Redis 缓存 key 本质是字符串用 String 序列化后key 长什么样你能肉眼看见比如quote::tenantA:9527。如果你留着 JDK 序列化Redis 里的 key 就是一团乱码多租户排查直接地狱难度。serializeValuesWith配GenericJackson2JsonRedisSerializer。这个序列化器会把 Java 对象序列化成 JSON并保留类型信息反序列化时能还原成正确的目标类型。它有个特点要求缓存对象有默认构造器字段要有 getter/setter。如果你缓存的实体类构造器不标准会有坑我会在后面的常见问题里再提。disableCachingNullValues()和 Caffeine 里的setAllowNullValues(false)意图一致——缓存 null 会让 Redis 里出现大量空壳 key不但浪费内存还让清理工作无从下手。不过在防穿透场景下有人会刻意允许 null 缓存这个我用单独一节讲先按下不表。transactionAware()值得加。它让缓存读写与 Spring 事务保持关联事务回滚时缓存操作也会回滚。多租户系统里数据一致性非常重要这个设置能避免事务失败但缓存已更新的脏状态。computePrefixWith设置 key 前缀。默认情况下 Redis 缓存 key 的格式就是cacheName::实际key。我显式写出来是为了让人一眼看懂 key 的组成。组装的完整 key 会变成quote::tenantA:9527这个格式对后续按租户扫描和清理非常友好。3.4 切换的正确姿势与注意点两个配置类注册好后切换就是改配置重启的事。但这里有个非常容易忽略的问题切换缓存实现之前一定要先想清楚两种缓存的语义差异。Redis 是共享存储切到 Redis 后所有实例看到同一份缓存这是分布式缓存的意义。而 Caffeine 是进程内缓存每个实例各一份一旦从 Redis 切到 Caffeine重新启动后每个实例都会先冷缓存然后各自回源期间数据库压力会突然大起来。反过来从 Caffeine 切到 Redis则要确认 Redis 里的旧数据格式和当前序列化方案兼容否则反序列化就会报错。所以我在切换清单里固定写三条先确认目标缓存里没有历史脏数据有则清空或等 TTL 过期在低峰期操作避免冷缓存冲击数据库切换后观察缓存命中率、数据库负载、Redis 内存三个指标确认达标再放量。4. 透明多租户隔离KeyGenerator 与租户上下文的配合4.1 缓存隔离为什么必须做在 Key 层面多租户隔离有两种常见方案一种是拆分成不同的缓存区域另一种是在 key 里面带上租户标识。拆分区域的做法是给每个租户分配独立的 cacheName比如quote_tenantA、quote_tenantB。这个方案看着干净但有一个致命问题租户数量是动态的。SaaS 平台可能几百个租户每个租户四个缓存域就要几百上千个 cacheName。Redis 里每个 cacheName 都是一套独立 TTL 和序列化设置管理复杂度爆炸Caffeine 里每个 cacheName 对应一个 Caffeine 实例内存开销直接翻倍。所以在 key 上做隔离是最务实的方案。同一个 cacheName 下不同的租户 key 自然拆开。这样租户再多缓存域的总数是固定的。而要做 key 级隔离最优雅的位置就是自定义KeyGenerator。4.2 租户上下文传递Filter ThreadLocal多租户系统的租户标识一般来自请求头比如X-Tenant-Id。我做的第一件事是把租户 ID 从请求头提取出来放到一个ThreadLocal里让同一次请求的任何后续代码都能拿到它。public final class TenantContextHolder { private static final ThreadLocalString TENANT_ID new ThreadLocal(); public static void setTenantId(String tenantId) { TENANT_ID.set(tenantId); } public static String getTenantId() { return TENANT_ID.get(); } public static void clear() { TENANT_ID.remove(); } }用 Filter 拦截所有请求Component Order(Ordered.HIGHEST_PRECEDENCE) public class TenantContextFilter extends OncePerRequestFilter { Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String tenantId request.getHeader(X-Tenant-Id); if (StringUtils.hasText(tenantId)) { TenantContextHolder.setTenantId(tenantId); } try { filterChain.doFilter(request, response); } finally { TenantContextHolder.clear(); } } }这个 Filter 要用Order(Ordered.HIGHEST_PRECEDENCE)保证尽量靠前执行确保后面所有拦截器、切面、业务代码都能读到租户 ID。finally里一定要clear()否则线程池里的线程复用会带着上一个请求的租户 ID 处理下一个请求造成缓存串租户事故。这看起来简单但依赖一个底线所有请求都必须携带X-Tenant-Id。对于外部请求我会在网关层强制校验对于内部调用比如定时任务和 MQ 消费则要在进入业务代码前手动设置租户上下文这个我在 4.4 小节展开。4.3 自定义 KeyGenerator 自动合并租户 ID有了租户上下文下一步就是让缓存 key 自动带上它。Spring 的KeyGenerator接口有个generate方法默认实现SimpleKeyGenerator会把所有方法参数拼成一个 key。我的做法是写一个TenantKeyGenerator把所有参数前面强行加上租户 ID。public class TenantKeyGenerator implements KeyGenerator { Override public Object generate(Object target, Method method, Object... params) { String tenantId TenantContextHolder.getTenantId(); Object[] combined new Object[params.length 1]; combined[0] tenantId; System.arraycopy(params, 0, combined, 1, params.length); return SimpleKeyGenerator.generateKey(combined); } }这个生成器的作用无论参数怎么变key 的第一部分永远是租户 ID。比如租户 A 查quoteId9527实际缓存 key 就是quote::tenantA:9527租户 B 查同一个quoteIdkey 是quote::tenantB:9527。两者井水不犯河水。然后把TenantKeyGenerator注册为全局默认生成器Configuration EnableCaching public class CacheBootstrapConfig implements CachingConfigurer { Override public KeyGenerator keyGenerator() { return new TenantKeyGenerator(); } }这里有一个极其重要的坑CachingConfigurer.keyGenerator()只对没有显式指定key属性的Cacheable生效。如果业务代码里写了Cacheable(key #quoteId)Spring 会优先使用注解上的 SpEL 表达式全局 KeyGenerator 被直接跳过。也就是说你要想让租户隔离透明有两个选择全团队统一规范注解上不写key让全局 KeyGenerator 兜底或保留key表达式但每个方法显式写上keyGenerator tenantKeyGenerator。我推荐前者。一旦key表达式散落在各个方法里你无法保证每个人都记得带租户维度而全局 KeyGenerator 是收口管理安全边际高得多。如果实在需要指定 key那就用cacheNames区分业务域key 交给生成器。4.4 异步线程与定时任务里的租户传递ThreadLocal的天然缺陷是它跟线程绑定。一旦代码跑到异步线程、线程池、MQ 消费线程里TenantContextHolder.getTenantId()就会变成 null缓存 key 退化成不带租户的裸 key串租户事故会以另一种方式回来。解决异步线程的租户传递标准做法是给线程池设置TaskDecoratorBean public Executor asyncExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); executor.setCorePoolSize(8); executor.setMaxPoolSize(16); executor.setQueueCapacity(100); executor.setTaskDecorator(runnable - { String tenantId TenantContextHolder.getTenantId(); return () - { TenantContextHolder.setTenantId(tenantId); try { runnable.run(); } finally { TenantContextHolder.clear(); } }; }); executor.initialize(); return executor; }TaskDecorator的作用是把任务提交到线程池时先捕获提交线程的租户 ID任务真正执行时再把租户 ID 放回执行线程的ThreadLocal。定时任务更直接。XXL-Job、SpringScheduled这类场景天然没有请求头我会在任务入口手动指定租户。比如一个按租户批量清理缓存的定时任务entry 处就该写TenantContextHolder.setTenantId(tenantA); try { doBusiness(); } finally { TenantContextHolder.clear(); }如果你用了 MQ消费者监听消息时也要从消息头取租户 ID 并设置上下文。这个动作必须在业务开头的第一句做完否则后续所有缓存 key 都可能裸奔。4.5 验证隔离效果的方法写完这套后建议用两步验证租户隔离是否真的生效。第一步本地启动应用分别用两个租户请求同一个接口curl -H X-Tenant-Id: tenantA http://localhost:8080/quote/9527 curl -H X-Tenant-Id: tenantB http://localhost:8080/quote/9527第二步看 Redis 里的 key。Redis 版可以用redis-cli --scan --pattern quote::*查看所有 quote 缓存 key。正常情况会看到类似quote::tenantA:9527 quote::tenantB:9527如果只出现一个 key说明缓存 key 生成器没有生效回去检查Cacheable上是否写了自定义key表达式。Caffeine 版验证稍微麻烦一点本地缓存没有外部工具可查。我一般用 actuator 暴露缓存统计接口或者在测试里直接断言TenantKeyGenerator生成的 key 结构。5. 切换之后真正的坑一致性、击穿、穿透与容量5.1 本地缓存与 Redis 的一致性差异很多团队从 Redis 切到 Caffeine 后遇到的第一个诡异问题是数据怎么不变了。原因很简单Caffeine 是进程内缓存更新操作只影响当前实例。比如订单详情在实例 A 被CacheEvict清掉了但实例 B 的 Caffeine 里还留着一份旧数据B 上来的请求依然命中旧值。这不是 bug是分布式架构下本地缓存的天然限制。所以切换到 Caffeine 前先问自己三个问题应用是否只有一个实例数据更新频率是否很低缓存允许短时间不一致吗如果三个回答都是是Caffeine 完全够用性能优势巨大。如果任何一个回答是否就得切回 Redis或者考虑多级缓存方案。多级缓存是切换之外的进化方向Caffeine 做一级本地缓存Redis 做二级共享缓存。思路是自定义一个 Composite CacheManager先查本地、再查 Redis、最后查数据库。但这个方案会带来一致性问题——本地缓存更新后要广播给其他实例。真正落地还要引入 Redis Pub/Sub 或 Redisson 的现成方案复杂度上升一个档次。我通常建议先保证单缓存切换稳定再考虑多级缓存。这个演进我在文末补充两句我的实操结论。5.2 缓存击穿与穿透的多租户应对多租户系统有个特殊现象所有租户共享几个热点 key 的访问模式。比如某个热门产品一到月底就有一大波租户同时访问如果这个 key 恰好过期大量请求同时回源数据库击穿在所难免。Redis 版应对击穿业界最常见的是互斥锁。在缓存回源的地方加一把锁key 同样要带租户维度否则租户 A 的锁会挡住租户 B 的回源反而不合理。比如用 Redis 的setIfAbsent做锁锁 key 就是lock:quote:{tenantId}:{quoteId}。Caffeine 版要简单一些。本地缓存击穿的本质是进程内同一时刻大量线程同时 miss最简单的办法是在业务方法上加 JVM 级互斥比如用synchronized或者本地锁包裹回源逻辑。Caffeine 官方也支持CacheLoader和get(key, loader)的原子加载但Cacheable注解模式用不上这层能力。如果你对击穿特别敏感建议对热点方法用编程式缓存而不是注解式。穿透问题则要在是否缓存 null上做取舍。我前面说disableCachingNullValues()是保守做法但它防不住恶意请求拿一堆不存在的 ID 来打库。真要防穿透布隆过滤器是最干净的方案但多租户下布隆过滤器也要按租户维度分桶否则租户 ID 和业务 ID 混合命中率失真。实际操作中中小团队我更推荐缓存 null 短 TTL允许缓存 null但 TTL 设到 1~2 分钟既能挡住瞬时穿透又不会把空结果存太久。5.3 TTL 与内存容量规划多租户系统里缓存容量不是简单地按业务数据量估算而是大约等于租户数 × 单租户业务数据量。同样一万条报价数据一个租户和一千个租户的缓存压力完全不同。Caffeine 的内存规划要算两类开销maximumSize限制的是元素数量但每个元素对象本身的大小差异很大。一个 10KB 的 JSON 对象和一个 100KB 的报表对象同样一万个元素内存差一个量级。建议用maximumWeight配合 Weigher 做权重控制而不仅是maximumSize。不过Cacheable走CaffeineCacheManager时配置 Weigher 的姿势比较受限通常还是用maximumSize加经验值然后通过recordStats()观察驱逐数量压测时用 JVM 内存曲线反推合理值。Redis 版的内存规划依赖 TTL。我一般这样设计 TTL数据类型推荐 TTL理由报价、订单详情15~30 分钟业务敏感尽量短用户基础信息1 小时变化不频繁产品目录5~10 分钟活动期可能频繁调整统计报表5 分钟数据时效性要求较高还有个运维细节Redis 的 key 数量会直接影响KEYS命令的性能多租户下 key 数量天然翻倍。排查问题时别在 Redis 里跑KEYS *用SCAN按匹配模式游标遍历否则大 key 环境下会直接把 Redis 打挂。5.4 监控与排查建议切到 Redis 后我强烈建议把三块监控补上缓存命中率、Redis 内存水位、慢查询日志。命中率是判断缓存架构是否正常的第一指标。Redis 版可以看 Redis 的keyspace_hits和keyspace_missesCaffeine 版可以直接从recordStats()里取 hitRate。如果命中率骤降大概率是 key 结构变了或者 TTL 太短这时候再看代码就很明确。排查缓存问题还有一个土办法配置变更后先用redis-cli手动查几个典型 key 的 TTL 和值肉眼确认存储格式正确后再放流量。不要完全依赖监控平台很多时候最朴素的手工验证最高效。至于本地缓存我遇到过最迷惑的问题是明明刚写进去为什么查不到。原因十有八九是 JVM 没重启、缓存过期时间设太短、或者实例不对。这种情况我用一个非常土但有效的方法在接口返回里临时带上缓存命中来源标记比如HIT_LOCAL、HIT_REDIS、DB灰度一阵子就能定位问题范围。6. 一点经验总结与演进方向这套方案上线后给我最大的安全感在于团队不再为选 Caffeine 还是 Redis 头疼也不会因为换缓存实现去改业务代码。每次新项目落地配置文件里写一个值缓存实现就定下来了租户隔离也不再依赖每个开发自觉拼 key而是框架层收口漏掉的概率大幅降低。如果你要从这里继续往前走我建议按这个顺序演进第一把缓存 key 的生成规则再往自定义前缀推进一步。比如同 cacheName 下再拆业务子域key 结构设计成cacheName::tenantId:domain:业务key这样同一个缓存域里还能再做业务粒度管理。第二把多级缓存纳入考虑。Caffeine 做一级、Redis 做二级查询链路变成本地缓存 → Redis → 数据库写入链路用CacheEvict 消息广播让其他实例的本地缓存同步失效。这个方案能把 Caffeine 的性能和 Redis 的一致性都拿到但代价是复杂度明显上升建议在业务真正需要时再上。第三如果租户数量特别大Redis key 数量会跟着租户数线性增长。到这一步就要考虑 Lua 脚本批量清理、key 压缩、甚至按租户分片到多个 Redis 实例。不过这些都是后话绝大多数团队先把基础架构做稳就已经能解决绝大部分生产问题了。我在实际维护中的体会是缓存架构最怕的不是性能差而是不可控。不可控体现在你无法预判某个 key 会不会串租户、不知道缓存里存的是什么格式、不清楚切换一种缓存要动多少代码。把这三个问题收口换来的不只是性能更是团队长期维护这套系统的底气。
返回列表