拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Spring Boot 3.x多级缓存数据同步延迟问题解析与实战

先抛一个我压箱底的场景。某个电商项目中运营在后台把商品价格从99改成89数据库里已经更新Redis也删了key但用户端硬是过了一分多钟还能看到99。当时整个团队都盯着Redis排查谁也没想到坑在应用实例自己的本地缓存里——Spring Boot 3.x Caffeine本地缓存 Redis多级缓存架构数据库和Redis都没问题但每台应用节点的Caffeine还在固执地吐旧数据。这个现象就是典型的多级缓存数据同步延迟。做后端几年多级缓存被反复使用也反复踩坑。尤其进入Spring Boot 3.x时代之后Caffeine Redis几乎成了多级缓存的标准答案但“标准答案”不代表没有坑。今天就把我在项目里解决数据同步延迟问题的完整过程、代码方案、踩坑细节都放出来给正在被本地缓存和Redis一致性折磨的同行一个参考。1. 项目背景一次改价引发的“缓存延迟”事故1.1 多级缓存架构的常见形态先说清楚什么叫多级缓存。以Spring Boot 3.x项目为例典型的请求读链路是这样的请求进来先查JVM内的Caffeine本地缓存未命中再查Redis分布式缓存还查不到才落数据库查完之后逐级回填。这套流程跑起来的性能差异非常直观JVM本地缓存读取大约0.1ms到0.2msRedis缓存读取大约1ms到5ms数据库查询大约10ms到50ms在硬件和网络正常的情况下多级缓存可以把热点数据的读取耗时压缩到“微秒到亚毫秒”这个量级。这也是为什么一旦并发量上来几乎所有团队都会不约而同地选择本地缓存 Redis的组合而不是只用Redis单独扛。这里必须强调一个容易忽略的事实本地缓存虽然快但它天然是不共享的。每个应用节点都有一份独立的JVM缓存节点之间没有任何同步机制。你更新了商品价格改的是数据库和Redis但用户请求被负载均衡打到另外一台节点上那台节点的本地缓存还存着旧价格。这也就是多级缓存架构里数据同步延迟问题的根源所在。1.2 数据同步延迟到底是怎么产生的很多人对“缓存延迟”的理解停留在“Redis慢了”或者“网络波动了”。但实际操作中我把这类问题分为两个层面一是不一致窗口期太长。数据库更新成功后等到所有应用节点的本地缓存统一失效中间隔了多少时间这个时间就是数据同步延迟。这个窗口期可能是几十毫秒也可能是一分钟以上取决于失效策略是否完善。二是缓存清理动作根本没执行到所有节点。比如某台实例刚好在广播失效消息的时候重启或者Redis Pub/Sub消息没能到达订阅端那台实例的本地缓存会一直停留在旧数据直到TTL过期。TTL如果设置得很长用户就会长时间看到旧数据。回到我开头说的那个事故。当时项目缓存设计是这样的本地Caffeine的过期策略是expireAfterWrite2分钟Redis里key的TTL是10分钟日常通过写操作触发主动失效。听起来没毛病但事故当天线上服务扩容到了6个节点其中1个节点在改价操作前刚刚重启过那个节点的Caffeine里还留着旧价格并且干净利落地错过了主动失效消息。Redis没问题、数据库也没问题就是这1个节点的本地缓存硬扛了2分钟的旧数据。这个案例让我彻底明白一件事多级缓存的数据同步延迟绝大多数情况下不是单个组件的问题而是架构层面的一致性方案没做严谨。2. 延迟问题四大根因分析2.1 事务提交与缓存失效的时序陷阱先看一个看起来“非常正确”的伪代码Transactional public void updatePrice(Long productId, BigDecimal newPrice) { // 1. 更新数据库 productMapper.updatePrice(productId, newPrice); // 2. 删除本地缓存 cacheService.evict(product: productId); }流程上写数据库、清缓存看起来是标准Cache Aside模式。但这里的坑在于Transactional的代码块里数据库更新操作提交事务是有延迟的而cacheService.evict可能发生在事务提交之前。如果事务还没提交而缓存已经删除此时另一个线程读到了空缓存直接查数据库查到的是旧数据因为事务还没提交于是把旧数据回填进缓存。等事务提交后数据库里的数据变成新值但缓存里回填的旧数据还在而且缓存TTL通常有几分钟用户就一直看到旧数据。这就是典型的“早清理”导致的数据不一致。你明明写了清缓存反而引发更长的脏数据窗口。解决思路有两个方向。第一个方向是把缓存清理动作放到事务真正提交之后比如Spring提供的TransactionalEventListener(phase TransactionPhase.AFTER_COMMIT)第二个方向是引入版本号在回填缓存时做版本比对版本旧的数据直接丢弃。具体怎么落地后面实战部分我会给出代码。2.2 Redis Pub/Sub的可靠性与时效性边界在Spring Boot 3.x项目里跨节点广播缓存失效消息最简单的方式就是用Redis自带的Pub/Sub。RedisTemplate.convertAndSend(channel, message)一行代码就能把消息推出去另一个节点通过MessageListener接收后失效本地缓存。但Pub/Sub有个致命特性不持久化。消息发布时如果某个节点没有在线或者订阅状态异常这条消息就直接丢了没有任何重试机制。举个例子。线上4个节点节点A执行了改价发布了一条cache:evict消息。节点B、C、D都正常收到消息清了本地缓存。但节点B收到消息后在处理消息的前100ms内触发了Full GC消息消费者的线程卡了一下Caffeine的清理动作延迟了几百毫秒这时候恰好有用户请求打过来命中的还是旧缓存。这段延迟虽然短但对于价格、库存这类敏感数据用户可是实打实能看到异常。更麻烦的是如果用默认的RedisConnectionFactory去创建RedisMessageListenerContainer要注意订阅是永久占用一个连接的。如果连接池配置不够或者网络有抖动监听线程会阻塞重连期间所有消息都会丢失。2.3 本地缓存自身刷新策略带来的延迟很多开发者为了省事把本地缓存的TTL设置得很长比如10分钟或者30分钟。这样做带来的直接后果是即使广播失效消息正常到达只要有一台节点漏了消息那台节点的旧数据能存活很久。但反过来如果把TTL设置得很短比如10秒缓存命中率又会明显下降数据库压力上来整体性能又变差了。所以必须想清楚本地缓存的TTL是兜底策略不是主同步方案。真正管用的是主动失效广播TTL只是最后一道保险。另外如果使用refreshAfterWrite这种“写后自动刷新”策略Caffeine会在指定时间之后在下一次读取时触发异步重新加载但加载完成之前旧值仍然会返回。这个设计对性能是友好的不会阻塞请求但对数据实时性其实不友好。如果你选了这个策略就必须接受至少一个refresh周期的延迟。2.4 多节点广播与刷新放大当服务实例数量变多比如从2个节点扩到10个节点每次缓存失效广播都要发给所有节点所有节点都会清掉自己的本地缓存。清完缓存之后必然带来一个连锁反应接下来的用户请求在同一时段内集体回源Redis甚至数据库这就是“刷新放大”效应。10个节点同时从数据库加载同一个热点key数据库瞬时压力翻好几倍。这在流量平稳时还好一旦遇到大促或者热点事件直接可能把DB打挂。所以不能光想着“清缓存清得越快越好”还得考虑清完之后谁来回填回填的并发怎么控制。3. Spring Boot 3.x 低延迟多级缓存同步方案3.1 技术选型与依赖配置我当前项目用到的核心依赖是这些dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdcom.github.ben-manes.caffeine/groupId artifactIdcaffeine/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-actuator/artifactId /dependencySpring Boot 3.x对应的是Spring Framework 6和Java 17Redis连接默认走Lettuce。这里我要特别提醒一下如果你在Spring Boot 2.x时代用的jedis迁移到3.x后默认会改用Lettuce配置方式和连接池参数都不一样别直接照抄旧配置。application.yml中的关键配置spring: application: name: multi-cache-demo data: redis: host: 127.0.0.1 port: 6379 timeout: 1s lettuce: pool: max-active: 32 max-idle: 16 min-idle: 4 management: endpoints: web: exposure: include: health,info,cache,metricsLettuce连接池的配置一定不能省。默认情况下的max-active可能不够用尤其当你既要在主线程读写Redis又要起一个监听线程订阅Pub/Sub消息时连接不足会导致监听阻塞或重连。3.2 手写两级缓存核心组件虽然Spring Cache的Cacheable注解用起来很方便但在多级缓存主动失效广播这个场景下我更推荐手写一个MultiLevelCacheService。原因很直接注解抽象太高层了失效顺序、回填时机、版本比对、日志埋点这些关键细节你控制不到。手写虽然代码多一点但出了延迟问题你看日志就能定位到具体环节。下面是我在实际项目中采用的精简版方案Slf4j Service public class MultiLevelCacheService { private static final String EVICT_CHANNEL cache:evict; private final CacheString, Object localCache; private final StringRedisTemplate redisTemplate; private final ObjectMapper objectMapper; public MultiLevelCacheService(RedisConnectionFactory connectionFactory) { this.localCache Caffeine.newBuilder() .maximumSize(50_000) .expireAfterWrite(2, TimeUnit.MINUTES) .recordStats() .build(); this.redisTemplate new StringRedisTemplate(connectionFactory); this.objectMapper new ObjectMapper(); } public T T get(String key, ClassT clazz, SupplierT dbLoader) { // 第一级本地缓存 Object local localCache.getIfPresent(key); if (local ! null) { return clazz.cast(local); } // 第二级Redis缓存 String json redisTemplate.opsForValue().get(key); if (json ! null) { T value deserialize(json, clazz); localCache.put(key, value); return value; } // 第三级数据库加载 T value dbLoader.get(); if (value ! null) { redisTemplate.opsForValue().set(key, serialize(value), 10, TimeUnit.MINUTES); localCache.put(key, value); } return value; } public void evict(String key) { localCache.invalidate(key); redisTemplate.delete(key); // 广播给其他节点 sendEvictMessage(key); } private void sendEvictMessage(String key) { try { CacheEvictMessage msg new CacheEvictMessage(key, System.currentTimeMillis()); redisTemplate.convertAndSend(EVICT_CHANNEL, objectMapper.writeValueAsString(msg)); } catch (Exception e) { log.error(发送缓存失效消息失败, key{}, key, e); } } // 序列化与反序列化方法省略 }读路径的设计逻辑本地未命中查RedisRedis未命中查DB查完一级一级回填。写路径就是evict先清本地再删Redis最后发广播。这个顺序也是有讲究的——先清本地能立刻防止本节点继续吐旧数据再删Redis保证其他节点回源时能查到最新值广播则是通知其他节点把它们的本地缓存也清掉。这里有个细节evict方法里我自己发了广播消息当前节点已经清过本地缓存了所以即使当前节点也收到广播重复清理的代价很小不会引发问题。但为了日志干净也可以在消息里带一个sourceNode字段接收端判断是本节点就直接忽略。3.3 基于Redis Pub/Sub的失效广播实现先发消息的代码在上面已经有了关键是接收端。Spring Boot 3.x里实现Redis消息监听需要配置RedisMessageListenerContainerConfiguration public class RedisPubSubConfig { Bean public RedisMessageListenerContainer redisMessageListenerContainer( RedisConnectionFactory connectionFactory, CacheEvictMessageListener listener) { RedisMessageListenerContainer container new RedisMessageListenerContainer(); container.setConnectionFactory(connectionFactory); container.addMessageListener(listener, new ChannelTopic(cache:evict)); return container; } }监听器里做的核心事情就是解析消息清理本地缓存并记录延迟指标Slf4j Component public class CacheEvictMessageListener implements MessageListener { private final CacheString, Object localCache; private final ObjectMapper objectMapper; public CacheEvictMessageListener(CacheString, Object localCache, ObjectMapper objectMapper) { this.localCache localCache; this.objectMapper objectMapper; } Override public void onMessage(Message message, byte[] pattern) { try { CacheEvictMessage evictMsg objectMapper.readValue(message.getBody(), CacheEvictMessage.class); long delay System.currentTimeMillis() - evictMsg.getTimestamp(); localCache.invalidate(evictMsg.getKey()); log.info(收到缓存失效消息 key{}, 广播延迟{}ms, evictMsg.getKey(), delay); } catch (Exception e) { log.error(处理缓存失效消息异常, body{}, new String(message.getBody()), e); } } }这里我把ts时间戳放进了消息体接收端用当前时间减去发送时间就能算出消息从发送到处理的真实延迟。这个值相当关键一旦超过500ms说明Redis网络或者监听线程出问题了要马上排查。注意一个坑RedisMessageListenerContainer默认的订阅连接是独立的如果你在application.yml里把连接池的max-active设置得很小比如2个并且主业务读写Redis的并发很高监听连接可能拿不到空闲连接导致订阅断开重连。我建议这里至少留出4个以上的空闲连接。3.4 事务提交后触发缓存清理前面讲到的“事务未提交就清理缓存”的问题在Spring Boot 3.x中最优雅的解法是用TransactionalEventListener。先定义一个领域事件比如商品价格已更新public record ProductUpdatedEvent(Long productId) implements ApplicationEvent {}然后在业务方法里发布事件Service public class ProductService { private final ProductMapper productMapper; private final ApplicationEventPublisher eventPublisher; private final MultiLevelCacheService cacheService; public ProductService(ProductMapper productMapper, ApplicationEventPublisher eventPublisher, MultiLevelCacheService cacheService) { this.productMapper productMapper; this.eventPublisher eventPublisher; this.cacheService cacheService; } Transactional public void updatePrice(Long productId, BigDecimal newPrice) { productMapper.updatePrice(productId, newPrice); eventPublisher.publishEvent(new ProductUpdatedEvent(productId)); } TransactionalEventListener(phase TransactionPhase.AFTER_COMMIT) public void onProductUpdated(ProductUpdatedEvent event) { cacheService.evict(product: event.productId()); } }当ProductService.updatePrice方法所在的数据库事务提交成功后Spring会触发onProductUpdated方法此时再去清缓存就不会出现“早清缓存读到旧数据”的问题。这里有个前置条件事件监听器和事务必须在同一个线程中。如果updatePrice方法里通过Async异步发布事件TransactionalEventListener可能收不到。这也是一个挺隐蔽的坑。另外如果事务回滚了AFTER_COMMIT监听器不会执行这恰好是我们要的行为——数据库没改成缓存也不该动。3.5 延迟观测方法与调优目标延迟不是靠感觉估的要真的量化。我在项目里是这么做的第一在MultiLevelCacheService.get方法里埋了两个时间点统计本地缓存命中和Redis命中的耗时占比。第二在广播消息里放时间戳监听端算出端到端延迟并打日志。第三定期打印Caffeine的recordStats()统计。Scheduled(fixedDelay 60_000) public void printMetrics() { CacheStats stats localCache.stats(); log.info(本地缓存命中率{}, 加载次数{}, 总加载耗时{}ms, stats.hitRate(), stats.loadCount(), stats.totalLoadTime() / 1_000_000); }在正常网络环境下我建议把延迟目标定在本地缓存命中耗时小于0.5msRedis缓存命中耗时小于10ms缓存失效广播端到端延迟小于100ms。如果广播延迟长期超过300ms优先查Redis网络、监听线程阻塞、连接池不足这三个点。调优的时候还有一个经验不要把本地缓存和Redis的TTL设置成一样。本地缓存TTL短一点比如2分钟Redis的TTL长一点比如10分钟。这样即使广播失效消息丢失本地缓存最多污染2分钟Redis里的数据相对稳定能挡住大部分回源请求。3.6 兜底策略与补偿机制广播失效方案再好也无法100%保证消息不丢。Redis Pub/Sub不持久化节点重启期间的消息必然丢失。所以必须做兜底。最基础的兜底就是TTL。本地缓存设一个合理的过期时间过期后自动从Redis重新加载Redis里的数据是TTL设得更久的主缓存。这里有个细节Redis里缓存的值我不能只存业务数据还要存一个合理的过期时间用随机化避免所有key同时过期造成雪崩。实际项目里我通常给Redis key设置一个10分钟 ~ 15分钟之间的随机TTL避免大面积同时过期。针对强一致要求非常高的数据可以引入数据库binlog监听方案比如Canal监听MySQL变更日志解析出主键后广播缓存失效。这个方案的好处是兜底很彻底不依赖业务代码是否显式调用了evict只要数据库变了最终一定会触发缓存清理。我接触过的几个高要求项目中都会把Canal作为同步兜底。补偿机制方面我用过一个比较轻量的延迟队列发送广播失败时把key丢进一个本地延迟队列1分钟后再重试一次如果还是失败就继续抛给监控告警。这个并不复杂但能在关键时刻救急。4. 常见问题与排查技巧实录4.1 从现象到根因一次延迟排查手记有个真实案例。某天业务方反馈订单状态更新后前端要等40秒左右才能看到新状态。订单服务用的是Spring Boot 3.x 多级缓存架构。我当时的排查顺序是这样的第一步先看订单状态改的是哪个缓存通道。发现订单服务调了cacheService.evict(order: orderId)本地缓存清了Redis也删了但生产环境有4个节点怀疑广播延迟。于是翻日志发现有的节点上一分钟就收到了消息延迟只有5ms但有一个节点一直没打收到消息的日志路径到这里就断了。第二步看这个节点是不是订阅丢了。登录那台机器执行redis-cli -p 6379 pubsub numsub order:evict发现channel没有订阅者。查看日志发现这台节点在40秒前发生过一次Redis连接重连原因是Lettuce连接池空闲连接被回收后订阅线程拿不到新连接容器一直在沉默等待。第三步检查连接池配置发现max-idle1业务高并发时把唯一空闲连接占满了订阅线程阻塞。把min-idle调到4并给监听容器单独分配了一个专用连接工厂问题彻底解决。这整个排查经历给我一个很深的印象大多数多级缓存延迟问题根源不是Caffeine本身而是Redis连接生命周期和Pub/Sub订阅机制在边缘条件下出了幺蛾子。4.2 缓存击穿如何放大同步延迟本地缓存失效广播的特性是一次失效所有节点同时清空。一旦热点数据在某个时刻集中回源底层就会引发缓存击穿。这时候数据库打满查询变慢进一步拉长缓存回填时间表现就是数据同步延迟从几百毫秒涨到几秒甚至导致服务雪崩。解决单机内的击穿可以用Caffeine自带的Cache.get(key, mappingFunction)它内部做了并发合并多个线程同时请求同一个key时只会有一个线程执行加载逻辑其他线程等待结果public T T getWithSingleFlight(String key, ClassT clazz, SupplierT dbLoader) { return clazz.cast(localCache.get(key, k - { String json redisTemplate.opsForValue().get(k); if (json ! null) { return deserialize(json, clazz); } T value dbLoader.get(); if (value ! null) { redisTemplate.opsForValue().set(k, serialize(value), 10, TimeUnit.MINUTES); } return value; })); }用这个方式10个节点同时缓存失效每个节点内部只会有1个请求真正穿透到Redis或DB能大大降低刷新放大效应。跨节点的全局防击穿可以考虑加一把分布式锁但复杂度高一般场景下单机single-flight已经能扛住绝大多数压力。4.3 消息丢失后的数据不一致修复消息丢失之后怎么发现问题我的经验是靠三层防线第一层每消费一条广播消息就记录日志第二层本地缓存TTL兜底时间到了自动从Redis加载第三层对关键业务数据做定时一致性巡检比如每天跑一次脚本抽样对比数据库和缓存里的价格、库存等敏感字段。如果确认某个key的数据不一致不要手动去冲Redis直接调业务的evict方法重新走一遍失效流程。手动写Redis容易把版本搞乱而且根本解决不了其他节点的本地缓存问题。4.4 常用诊断命令与监控指标遇到多级缓存延迟问题我常用的排查手段整理成了一张表排查场景命令/工具关注指标Redis订阅状态redis-cli pubsub numsub cache:evict订阅者数量是否为0Redis慢命令redis-cli slowlog get 20是否有写入/删除操作耗时过长JVM本地缓存命中率Actuator Cache端点或Caffeine Stats日志hitRate是否偏高后突然跌到0广播端到端延迟消息时间戳日志单条消息延迟是否超过300ms连接池状态Actuator Metrics连接池活跃数是否打满线程阻塞jstack 进程号RedisMessageListener线程状态这些手段配合使用基本能在几分钟内定位到“是网络延迟、是消息丢失、还是本地缓存策略问题”。4.5 避坑清单最后把我在多级缓存数据同步这个主题下踩过的最深的坑整理出来坑一事务没提交就清缓存。解决方案是TransactionalEventListener(phase TransactionPhase.AFTER_COMMIT)或者显式在TransactionSynchronization的afterCommit里做缓存清理。坑二Pub/Sub订阅连接和业务连接共用一个连接池高峰期取不到连接导致订阅断开。建议给监听容器单独配一个连接工厂并至少留4个空闲连接。坑三只删Redis不广播本地缓存或者广播了但接收端没有做延迟打点出了事故无从下手。务必在消息里加时间戳。坑四本地缓存TTL设置过长把TTL当成主同步方案。TTL必须只是兜底主同步靠主动失效。坑五清缓存时直接更新Redis而不是删除Redis。更新Redis会引入并发写时序问题删除才安全。如果有人问“为什么不更新”解释就是删除简单且能避免两个线程同时写Redis导致旧值覆盖新值。坑六没有做single-flight防护一次热点缓存失效把数据库打爆。用Caffeine的get(key, fn)合并同key并发请求。在我实际项目里用上这些手段之后缓存广播的端到端延迟稳定在10ms到50ms之间最难处理的节点重启漏消息问题也靠着本地缓存短TTL Canal兜底给堵上了。我觉得多级缓存这个事没有一劳永逸的方案核心还是想清楚每一条链路的时序、每一条消息的可靠性、每一个兜底策略的成本边界。数据同步延迟这个问题的答案最后往往不是某个炫技的算法而是把最基础的事务边界、连接生命周期、消息时序都照顾到位做到一旦出问题日志一眼能揪出元凶。这个项目跑下来我的体会是只要敢于拆掉Spring Cache注解那层黑盒自己掌控缓存的每一步读和每一次失效延迟和一致性的主动权才能真正回到自己手里。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门