三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Redis缓存进阶:从穿透、雪崩到多级缓存架构的实战解决方案

Redis缓存进阶:从穿透、雪崩到多级缓存架构的实战解决方案

1. 项目概述:从“能用”到“好用”的Redis缓存进阶之路

在分布式系统里,Redis几乎是缓存的代名词。很多朋友上手很快,几条SETGET命令就能让接口响应速度飙升,成就感满满。但用了一段时间后,各种“怪现象”开始出现:数据库明明更新了,前端看到的还是旧数据;大促时缓存突然集体失效,数据库差点被打挂;内存使用率像坐过山车,时不时就来个OOM告警。这些问题,本质上都是因为我们只把Redis当成了一个“更快的字典”来用,而忽略了它作为一个复杂中间件的特性和最佳实践。这次,我们不聊基础命令,直接切入实战中那些最棘手的问题,比如缓存穿透、雪崩、击穿,以及如何设计一个兼顾性能与一致性的高级缓存架构。我会结合一个电商项目的真实场景,拆解从问题现象到根因分析,再到解决方案落地的全过程,目标是让你手里的Redis从“能用”升级为“好用且可靠”。

2. 缓存典型问题深度解析与应对策略

缓存引入的目的很明确:提升性能,降低后端压力。但若使用不当,它本身就会成为系统中最脆弱的环节。下面我们逐一拆解三个经典难题。

2.1 缓存穿透:当查询不存在的数据时

缓存穿透是指查询一个根本不存在的数据,这个请求会穿透缓存,直接打到数据库上。如果这类请求量很大(比如恶意攻击者随机构造不存在的ID进行请求),数据库就会承受巨大压力,甚至崩溃。

问题场景:在电商项目中,用户查询一个不存在的商品IDproduct:9999999。按照常规逻辑,我们会先查Redis,查不到就去查数据库,数据库也查不到,于是返回空。但这个过程没有在Redis中留下任何记录,导致下一次同样的请求,又会重复这个穿透流程。

根因分析

  1. 业务逻辑缺陷:缓存层没有对“不存在”这种状态进行有效缓存。
  2. 恶意攻击:攻击者利用此缺陷,用脚本批量请求大量非法ID。

解决方案对比与实践

方案一:缓存空对象这是最直接有效的方案。当数据库查询结果为空时,我们仍然将这个空结果(比如null或一个特殊标记对象)写入缓存,并设置一个较短的过期时间(如30-60秒)。

public Product getProduct(Long id) { String key = "product:" + id; // 1. 先查缓存 Product product = redisTemplate.opsForValue().get(key); if (product != null) { // 注意:这里需要判断是否是空对象标记 if (isNullObject(product)) { return null; // 明确返回空,避免继续穿透 } return product; } // 2. 查数据库 product = productMapper.selectById(id); if (product == null) { // 3. 数据库为空,缓存空对象 redisTemplate.opsForValue().set(key, NULL_OBJECT, 30, TimeUnit.SECONDS); return null; } // 4. 数据库有数据,写入缓存 redisTemplate.opsForValue().set(key, product, 30, TimeUnit.MINUTES); return product; }

实操心得:空对象的值需要谨慎设计。我通常用一个全局常量对象,比如public static final Product NULL_OBJ = new Product();并为其设置一个特殊ID(如-1),在判断时检查这个特殊标记。这样能清晰区分“缓存了空”和“缓存了正常数据”。过期时间不宜过长,防止存储大量无用键,但也不能太短,否则失去保护意义。

方案二:布隆过滤器这是一个更前置、更节省空间的方案。布隆过滤器(Bloom Filter)是一种概率型数据结构,用于判断一个元素是否一定不存在于集合中。我们可以将所有有效的商品ID预先加载到布隆过滤器中。查询时,先经过布隆过滤器:

  • 如果过滤器说“不存在”,那么该ID一定不存在,直接返回空,无需查询缓存和数据库。
  • 如果过滤器说“可能存在”,则继续走正常的缓存查询流程。
// 使用Guava的布隆过滤器(单机版示例) BloomFilter<Long> bloomFilter = BloomFilter.create( Funnels.longFunnel(), // 指定数据类型 1000000, // 预期插入数量 0.01 // 期望的误判率 ); // 系统启动时,预热布隆过滤器 List<Long> allProductIds = productMapper.getAllIds(); for (Long id : allProductIds) { bloomFilter.put(id); } public Product getProductWithBloomFilter(Long id) { // 1. 布隆过滤器校验 if (!bloomFilter.mightContain(id)) { log.info("ID {} 一定不存在,被布隆过滤器拦截", id); return null; } // 2. 后续流程与普通查询一致... return getProduct(id); }

注意事项:布隆过滤器有误判率(False Positive),即它可能错误地判断一个不存在的元素为“可能存在”。因此它适用于拦截绝对非法请求的场景,对于“可能存在”的请求,放行到后续流程是安全的。在分布式环境下,需要使用Redis自带的布隆过滤器模块(redisbloom)或通过其他分布式方案实现。对于数据频繁更新的场景,布隆过滤器的维护(如数据删除)会比较复杂,通常结合缓存空对象使用。

2.2 缓存雪崩:大量缓存同时失效的灾难

缓存雪崩是指在同一时刻,大量的缓存键(Key)同时过期失效,导致所有请求瞬间涌向数据库,造成数据库压力激增甚至宕机。

问题场景:电商首页有1000个热门商品推荐,这些商品信息都缓存起来了,并且设置了相同的过期时间,比如都是凌晨2点过期。当凌晨2点到来时,这1000个缓存同时失效。此时如果有大量用户请求首页,每个请求都会去查询数据库,数据库瞬间接收到上千个查询,极易被打垮。

根因分析

  1. 过期时间设置过于集中:这是最常见的原因,业务代码中为一批数据设置了相同的TTL
  2. Redis服务宕机:缓存服务本身不可用,所有请求自然 fallback 到数据库。

解决方案与实践

核心思路:差异化过期时间避免大量缓存同时失效的最有效方法,就是让它们的过期时间随机化、分散开。

// 不推荐的写法:所有缓存设置相同时间 // redisTemplate.opsForValue().set(key, value, 30, TimeUnit.MINUTES); // 推荐的写法:基础时间 + 随机偏移量 public void setProductCache(Product product) { String key = "product:" + product.getId(); // 基础缓存时间,例如30分钟 long baseTTL = 30L; // 生成一个随机偏移量,例如 ±5分钟 long randomOffset = ThreadLocalRandom.current().nextLong(-5, 6); // [-5, 5] 分钟 long finalTTL = baseTTL + randomOffset; // 最终TTL在25-35分钟之间 redisTemplate.opsForValue().set(key, product, finalTTL, TimeUnit.MINUTES); }

进阶方案:永不过期 + 异步更新对于极其关键、访问量巨大的热点数据,可以考虑“逻辑过期”策略。即Redis中存储的数据不设置物理过期时间(TTL),而是在数据值内部封装一个逻辑过期时间戳。

@Data public class RedisData { private Object data; // 真正的业务数据,如Product对象 private LocalDateTime expireTime; // 逻辑过期时间 } public Product getProductLogicalExpire(Long id) { String key = "product:" + id; // 1. 从Redis获取封装对象 RedisData redisData = redisTemplate.opsForValue().get(key); if (redisData == null) { // 缓存未构建,主动加载 return loadProductToCache(id); } // 2. 判断逻辑是否过期 if (redisData.getExpireTime().isAfter(LocalDateTime.now())) { // 未过期,直接返回 return (Product) redisData.getData(); } // 3. 已逻辑过期,尝试获取更新锁 String lockKey = "lock:product:" + id; boolean isLock = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS); if (isLock) { // 获取锁成功,开启独立线程异步更新缓存 CompletableFuture.runAsync(() -> { try { // 查询最新数据 Product latestProduct = productMapper.selectById(id); // 重建缓存,设置新的逻辑过期时间 RedisData newData = new RedisData(); newData.setData(latestProduct); newData.setExpireTime(LocalDateTime.now().plusMinutes(30)); redisTemplate.opsForValue().set(key, newData); } finally { // 释放锁 redisTemplate.delete(lockKey); } }); } // 4. 无论是否获取到锁,都返回旧的(已过期的)数据 // 这是一种降级策略,保证用户体验,虽然数据可能不是最新的 return (Product) redisData.getData(); }

踩坑记录:使用“永不过期+异步更新”方案时,一定要做好降级和监控。如果异步更新任务失败,数据会一直处于旧状态。因此,需要确保更新任务有重试机制,并且对逻辑过期时间较久的数据进行监控告警。同时,获取分布式锁的粒度要细(如按商品ID),避免一个缓存更新失败阻塞所有相关请求。

2.3 缓存击穿:热点Key失效的瞬间风暴

缓存击穿是雪崩的一个特例,指的是某一个热点Key(访问量巨大的Key)在过期瞬间,持续的高并发请求穿透缓存,直接请求数据库,仿佛在缓存屏障上击穿了一个洞。

问题场景:秒杀活动中,一款热门秒杀商品的信息缓存在seckill:product:1001这个Key中。晚上8点秒杀开始前,该Key过期。在8点整的瞬间,数万用户同时点击秒杀按钮,第一个请求发现缓存失效,去数据库查询并重建缓存。但这个重建过程(包括数据库查询、数据组装、网络IO、Redis写入)需要几十甚至上百毫秒。在这段时间内,成千上万个后续请求同样发现缓存失效,它们不会等待,而是各自发起对数据库的查询,导致数据库在瞬间承受了本该由缓存承担的巨量请求。

根因分析

  1. 热点Key集中访问:该Key承载了远超普通Key的流量。
  2. 缓存重建非原子性:从缓存失效到新缓存建立完成,存在一个“时间窗口”,在这个窗口内并发请求无法被缓存拦截。

解决方案与实践:互斥锁(Mutex Lock)核心思想是:只允许一个线程去重建缓存,其他线程等待或返回旧数据。

public Product getProductWithMutex(Long id) { String key = "product:" + id; // 1. 尝试从缓存获取 Product product = redisTemplate.opsForValue().get(key); if (product != null && !isNullObject(product)) { return product; } // 2. 缓存未命中,尝试获取重建锁 String lockKey = "lock:product:" + id; Product result = null; try { // 使用SETNX命令实现分布式锁,并设置锁的过期时间,防止死锁 Boolean isLock = redisTemplate.opsForValue().setIfAbsent(lockKey, "locked", 10, TimeUnit.SECONDS); if (Boolean.TRUE.equals(isLock)) { // 2.1 获取锁成功,再次检查缓存(Double Check),防止其他线程已重建 product = redisTemplate.opsForValue().get(key); if (product != null) { return product; } // 2.2 查询数据库 result = productMapper.selectById(id); if (result == null) { // 处理缓存穿透 redisTemplate.opsForValue().set(key, NULL_OBJECT, 1, TimeUnit.MINUTES); } else { // 2.3 写入缓存 redisTemplate.opsForValue().set(key, result, 30 + getRandomOffset(), TimeUnit.MINUTES); } } else { // 3. 未获取到锁,说明有其他线程正在重建缓存 // 方案A:短暂休眠后重试(自旋) Thread.sleep(50); return getProductWithMutex(id); // 递归重试,注意控制深度 // 方案B:直接返回旧数据或默认数据(如果业务允许) // return getOldDataOrDefault(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.error("获取缓存重建锁时被中断", e); // 降级处理:直接查库(需评估性能风险)或抛业务异常 result = productMapper.selectById(id); } finally { // 4. 释放锁(确保是锁的持有者才释放,这里用Lua脚本保证原子性更安全) if (Boolean.TRUE.equals(redisTemplate.hasKey(lockKey)) && "locked".equals(redisTemplate.opsForValue().get(lockKey))) { // 简单释放,生产环境建议使用Lua脚本比对value再删除 redisTemplate.delete(lockKey); } } return result; }

核心要点

  1. 锁的粒度:锁的Key必须与缓存Key关联(如lock:product:1001),粒度要细,避免锁住不相关的资源。
  2. 锁的过期时间:必须设置锁的过期时间!这是防止持有锁的线程崩溃导致死锁的生命线。时间应略大于缓存重建的平均耗时。
  3. Double Check:在获取锁成功后、查询数据库前,必须再次检查缓存。因为在你等待锁的过程中,可能已经有其他线程完成了重建。
  4. 释放锁的安全性:直接调用DELETE命令可能误删其他线程的锁(如果当前线程执行超时,锁自动过期后被其他线程获取)。更安全的做法是使用Lua脚本,在删除前判断锁的值是否仍是自己设置的值。
  5. 降级策略:对于未获取到锁的线程,是选择“重试”、“等待”还是“返回降级数据”,需要根据业务容忍度来决定。对于秒杀场景,等待或返回默认信息可能比击穿数据库更好。

3. 高级缓存实现:多级缓存与一致性保障

解决了单点缓存的问题后,我们来看系统级的缓存架构。在高并发场景下,单一的Redis缓存可能成为瓶颈,并且存在网络延迟。引入多级缓存是进一步提升性能、保障可用性的关键。

3.1 本地缓存 + Redis 的多级缓存架构

架构思路:在应用服务器本地(JVM堆内)维护一份热点数据的缓存(如Caffeine、Guava Cache),作为第一级缓存(L1)。Redis作为第二级缓存(L2)。查询时,先查本地缓存,命中则返回;未命中则查Redis;Redis未命中再查数据库。更新时,需要同时或顺序失效多级缓存。

优势

  • 极致性能:本地缓存没有网络IO,速度极快(纳秒级)。
  • 降低Redis负载:大部分热点请求被本地缓存拦截,Redis QPS下降,更稳定。
  • 高可用兜底:即使Redis短暂不可用,本地缓存仍能支撑部分核心流量。

实现示例(Spring Boot + Caffeine + Redis)

@Configuration @EnableCaching public class CacheConfig { @Bean public CacheManager cacheManager(RedisConnectionFactory factory) { // 配置Caffeine本地缓存 CaffeineCacheManager caffeineCacheManager = new CaffeineCacheManager(); caffeineCacheManager.setCaffeine(Caffeine.newBuilder() .initialCapacity(100) // 初始容量 .maximumSize(1000) // 最大缓存条目数 .expireAfterWrite(10, TimeUnit.SECONDS) // 写入后过期时间(较短,保证最终一致性) .recordStats()); // 记录统计信息 return caffeineCacheManager; } // RedisTemplate配置略... } @Service public class ProductService { @Cacheable(value = "products", key = "#id", unless = "#result == null") public Product getProduct(Long id) { // 这个方法会被Spring Cache拦截 // 1. 先查Caffeine本地缓存(由@Cacheable注解和CacheManager自动完成) // 2. 如果本地缓存未命中,则执行本方法体 Product product = productMapper.selectById(id); if (product == null) { // 可在此处处理缓存穿透,如抛出自定义异常或返回特定对象 throw new ProductNotFoundException("商品不存在"); } // 3. 方法返回后,结果会被自动存入Caffeine缓存 return product; } // 一个更手动的、显式控制多级缓存的方案 @Autowired private CacheManager cacheManager; @Autowired private RedisTemplate<String, Product> redisTemplate; public Product getProductMultiLevel(Long id) { String localKey = "product:" + id; String redisKey = "product:redis:" + id; // L1: 查本地缓存 (Caffeine) Cache localCache = cacheManager.getCache("products"); Product product = localCache.get(localKey, Product.class); if (product != null) { return product; } // L2: 查Redis product = redisTemplate.opsForValue().get(redisKey); if (product != null) { // 回填到本地缓存 localCache.put(localKey, product); return product; } // L3: 查数据库,并解决缓存击穿 String lockKey = "lock:product:" + id; try { // 尝试获取分布式锁(简化示例,生产环境需更严谨) Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { product = productMapper.selectById(id); if (product != null) { // 写入Redis和本地缓存 redisTemplate.opsForValue().set(redisKey, product, 30, TimeUnit.MINUTES); localCache.put(localKey, product); } else { // 缓存空对象防穿透 redisTemplate.opsForValue().set(redisKey, NULL_OBJECT, 1, TimeUnit.MINUTES); } } else { // 未获取到锁,等待片刻后重试或降级 Thread.sleep(100); return getProductMultiLevel(id); // 递归重试 } } catch (InterruptedException e) { Thread.currentThread().interrupt(); // 降级:直接查库或抛异常 product = productMapper.selectById(id); } finally { // 释放锁 redisTemplate.delete(lockKey); } return product; } @CacheEvict(value = "products", key = "#id") @CachePut(value = "products", key = "#id") // 也可以使用@CachePut更新 public Product updateProduct(Product product) { productMapper.updateById(product); // 需要手动清除Redis缓存,因为Spring Cache默认只管理Caffeine redisTemplate.delete("product:redis:" + product.getId()); return productMapper.selectById(product.getId()); } }

注意事项与心得

  1. 本地缓存容量与淘汰策略:本地缓存占用JVM堆内存,必须严格限制其最大容量(maximumSize)和过期时间(expireAfterWriteexpireAfterAccess),避免内存溢出。Caffeine的W-TinyLFU淘汰策略在大多数场景下比Guava Cache的LRU更优。
  2. 数据一致性问题:这是多级缓存最大的挑战。本地缓存分布在多台应用服务器上,更新数据时,很难实时、一致地失效所有服务器上的本地缓存。上述示例中,我们通过设置较短的本地缓存过期时间(如10秒)来接受最终一致性。对于强一致性要求极高的场景(如商品库存),可以考虑:
    • 使用广播机制(如Redis Pub/Sub、MQ)通知所有节点失效本地缓存。
    • 直接禁用本地缓存,或仅将其用于极少变更的数据(如城市列表、配置信息)。
  3. 缓存预热:系统启动或热点活动前,可以主动将热点数据加载到本地缓存和Redis中,避免冷启动时的缓存击穿。

3.2 数据库与缓存的一致性策略

“先更新数据库,还是先删除缓存?”这是一个经典问题。在高并发下,任何顺序都可能产生数据不一致的窗口。

策略对比分析

策略操作顺序潜在问题适用场景
Cache-Aside (旁路缓存)1. 读:先读缓存,未命中读库,再写缓存。
2. 写:先更新数据库,再删除缓存
在“更新数据库”后、“删除缓存”前,如果有读请求,会读到旧缓存并可能被写回(概率较低,因为写操作通常比读慢)。最常用、最推荐的通用策略。
Write-Through (直写)1. 写:同时更新缓存和数据库(通常在一个事务内)。
2. 读:直接读缓存。
实现复杂,性能损耗大(所有写操作都涉及缓存)。缓存故障会影响数据库写入。用于缓存是唯一数据源的场景,或对一致性要求极高的配置类数据。
Write-Behind (后写)1. 写:只更新缓存,异步批量更新数据库。
2. 读:直接读缓存。
数据有丢失风险(缓存宕机)。一致性最弱。用于写入吞吐量极高、可容忍少量数据丢失的场景(如点赞计数)。

针对Cache-Aside策略的深度优化:延迟双删为了解决“先更新数据库,再删除缓存”策略下,在删除缓存失败或延迟时的不一致问题,可以采用“延迟双删”。

public void updateProductWithDelayDoubleDelete(Product product) { String cacheKey = "product:" + product.getId(); // 1. 先删除缓存(第一次删除) redisTemplate.delete(cacheKey); // 2. 更新数据库 productMapper.updateById(product); // 3. 提交数据库事务后,休眠一段时间(如500ms),再删除一次缓存(第二次删除) // 这个延迟是为了确保在步骤1和步骤2之间读到的旧数据,其设置的缓存已经过期 // 可以将第二次删除放入一个异步任务中执行 CompletableFuture.runAsync(() -> { try { Thread.sleep(500); // 延迟时间需大于一次“读请求+写缓存”的耗时 redisTemplate.delete(cacheKey); log.info("延迟双删执行完成,key: {}", cacheKey); } catch (InterruptedException e) { Thread.currentThread().interrupt(); // 记录日志,或放入重试队列 retryDelete(cacheKey); } }); }

实操心得:延迟双删的“延迟时间”很难精确设定,太短可能删不掉脏缓存,太长又影响实时性。在实践中,我更倾向于结合消息队列确保删除操作最终成功。在删除缓存失败时,将删除任务发送到MQ,由消费者重试,直到成功。同时,对核心数据设置一个较短的缓存过期时间作为兜底,即使出现不一致,也能在短时间内自动恢复。

4. 实战:电商商品详情页缓存架构设计

让我们综合运用上述策略,设计一个能应对高并发、保障最终一致性的商品详情页缓存方案。

4.1 架构设计图(文字描述)

  1. 客户端请求:用户访问商品详情页。
  2. Nginx层:部署Nginx,并启用本地缓存(proxy_cache)。对于极端热点商品,可以考虑在此层做缓存,将请求拦截在接入层。
  3. 应用层(多级缓存)
    • 第一级:进程内缓存:使用Caffeine,缓存极热数据(如Top 100商品),过期时间短(10-30秒)。
    • 第二级:Redis集群:缓存全量商品数据,采用主从复制+哨兵或集群模式保证高可用。Key设计为product:{id}:detail,Value使用Hash结构存储商品字段,TTL采用基础时间+随机偏移。
  4. 数据库层:MySQL主从架构,承担数据持久化和最终存储。

4.2 核心代码流程

查询流程

public ProductDetailDTO getProductDetail(Long productId) { // 0. 布隆过滤器拦截(如果已部署) if (!bloomFilter.mightContain(productId)) { return null; } // 1. 查本地缓存 (L1) ProductDetailDTO detail = localCache.get(productId); if (detail != null) { return detail; } // 2. 查Redis缓存 (L2) String redisKey = "product:" + productId + ":detail"; detail = redisTemplate.opsForValue().get(redisKey); if (detail != null && !isNullObject(detail)) { // 回填本地缓存 localCache.put(productId, detail); return detail; } // 3. 缓存未命中,使用互斥锁重建缓存 return rebuildProductDetailCache(productId, redisKey); } private ProductDetailDTO rebuildProductDetailCache(Long productId, String redisKey) { String lockKey = "lock:product:detail:" + productId; ProductDetailDTO detail = null; try { // 尝试获取锁,设置较短的超时时间防止死锁 Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 3, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { // Double Check detail = redisTemplate.opsForValue().get(redisKey); if (detail != null) { return detail; } // 查询数据库,这里可能关联多张表 detail = productMapper.selectDetailById(productId); if (detail == null) { // 防穿透:缓存空对象,时间可稍长 redisTemplate.opsForValue().set(redisKey, NULL_OBJECT, 2, TimeUnit.MINUTES); return null; } // 写入Redis,TTL差异化 long ttl = 1800 + ThreadLocalRandom.current().nextInt(-300, 301); // 30分钟 ± 5分钟 redisTemplate.opsForValue().set(redisKey, detail, ttl, TimeUnit.SECONDS); // 写入本地缓存,TTL更短 localCache.put(productId, detail, 10, TimeUnit.SECONDS); } else { // 未获取到锁,等待后重试或返回降级数据(如商品基本信息) Thread.sleep(100); // 方案A:重试(控制次数) // 方案B:降级,从Redis读取可能正在被其他线程写入的旧数据(如果业务允许短暂不一致) detail = redisTemplate.opsForValue().get(redisKey); if (detail == null || isNullObject(detail)) { // 如果还是没有,返回一个极简的降级数据(如仅包含商品ID和名称) return getDegradedProductInfo(productId); } } } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.error("重建缓存被打断", e); return getDegradedProductInfo(productId); } finally { // 释放锁,使用Lua脚本保证原子性 String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; redisTemplate.execute(new DefaultRedisScript<>(luaScript, Long.class), Collections.singletonList(lockKey), "1"); } return detail; }

更新流程

@Transactional public void updateProductDetail(ProductDetailDTO detail) { // 1. 更新数据库 productMapper.updateDetail(detail); // 2. 删除Redis缓存 String redisKey = "product:" + detail.getId() + ":detail"; redisTemplate.delete(redisKey); // 3. 发送MQ消息,通知所有应用节点失效本地缓存(最终一致性方案) mqTemplate.send("product-cache-invalidate", detail.getId()); // 4. (可选)延迟双删任务 scheduleDelayDelete(redisKey, 500); } // 监听MQ,失效本地缓存 @RabbitListener(queues = "product-cache-invalidate") public void handleCacheInvalidate(Long productId) { localCache.invalidate(productId); log.info("已失效商品 {} 的本地缓存", productId); }

4.3 监控与治理

一个健壮的缓存系统离不开监控。

  1. 缓存命中率监控:监控Redis和本地缓存的命中率。如果命中率持续走低,需要分析是Key设计问题、过期策略问题还是业务逻辑变化。
  2. 慢查询与BigKey监控:使用Redis的slowlog命令排查慢查询。定期扫描BigKey(如超过10KB的String,包含上万元素的Hash),大Key会导致操作阻塞和网络流量激增。
  3. 内存使用率告警:设置内存使用率阈值(如80%),超过后及时告警,排查内存泄漏或未设置TTL的Key。
  4. 热点Key发现:使用redis-cli --hotkeys命令或监控QPS,发现热点Key。对于热点Key,可以采取:
    • 本地缓存优先。
    • 在Redis层面进行分片(如product:hot:{id}放到独立实例)。
    • 业务层面做限流或队列化请求。

5. 常见问题排查与性能调优实录

在实际运维中,你会遇到各种各样的问题。这里记录几个我踩过的坑和解决方法。

问题一:Redis响应时间突然变长,CPU使用率不高。

  • 现象:应用监控显示Redis平均响应时间从1ms飙升到100ms,但Redis服务器CPU和内存使用率正常。
  • 排查
    1. 使用redis-cli --latency-history查看延迟历史,发现延迟呈周期性波动。
    2. 检查Redis日志,发现大量AOF fsync相关的日志。
    3. 检查配置,发现appendfsync设置为always(每个写命令都同步磁盘)。
  • 根因:AOF持久化策略过于激进,磁盘IO成为瓶颈。
  • 解决:根据业务对数据丢失的容忍度,将appendfsync改为everysec(每秒同步一次,推荐)或no(由操作系统决定)。同时,确保Redis部署在SSD磁盘上。

问题二:缓存内存持续增长,直至写满。

  • 现象:Redis内存使用率线性增长,used_memory接近maxmemory,触发淘汰策略,但业务感觉缓存效果变差。
  • 排查
    1. 使用INFO命令查看evicted_keys数量,发现确实有大量Key被淘汰。
    2. 使用redis-cli --bigkeys扫描,未发现异常大Key。
    3. 使用redis-cli --scan --pattern ‘*’ | head -1000抽样查看Key,发现大量未设置TTL的Key,格式为session:user:xxx
  • 根因:用户会话信息存入Redis时未设置过期时间,导致永久堆积。
  • 解决
    1. 为所有会话Key设置合理的TTL(如30分钟)。
    2. 在代码层面,对所有写入Redis的数据,强制要求指定过期时间,可以通过封装一个统一的RedisService来实现。
    3. 配置maxmemory-policyvolatile-lru(只淘汰设置了过期时间的Key中的最近最少使用Key),为无TTL的Key提供一层保护(但根本还是要设置TTL)。

问题三:缓存数据与数据库不一致,且持续时间较长。

  • 现象:用户反馈修改了商品价格,但前台看到的还是旧价格,清空浏览器缓存也没用。
  • 排查
    1. 直接查询Redis,发现Key存在且是旧值。
    2. 检查更新商品的代码逻辑,发现是“先删除缓存,再更新数据库”的顺序。
    3. 在高并发下模拟,发现线程A删除缓存后,线程B在数据库更新前读取了旧值并写回了缓存。
  • 根因:Cache-Aside策略在并发写读时存在的不一致窗口。
  • 解决:采用“先更新数据库,再删除缓存”的策略,并结合消息队列对删除失败进行重试。对于价格、库存等强一致性要求高的数据,可以考虑在读取时增加一个“缓存版本号”或“最后更新时间戳”的校验,如果数据太旧,则强制穿透缓存去数据库读取最新值。

性能调优小技巧

  1. 连接池优化:使用Lettuce连接池(Spring Boot 2.x默认),合理配置max-activemax-idlemin-idle参数,避免频繁创建连接。监控连接数使用情况。
  2. 序列化优化:默认的JdkSerializationRedisSerializer速度慢且体积大。优先使用StringRedisSerializer处理字符串,对于对象,使用GenericJackson2JsonRedisSerializer或更高效的KryoFST等序列化工具,并做好白名单配置。
  3. Pipeline批处理:对于需要连续执行多个不依赖中间结果的命令(如批量获取多个Key),使用Pipeline将多个命令打包一次发送,减少网络往返时间(RTT)。
  4. 避免大范围键扫描:生产环境禁止使用KEYS *命令,它会阻塞Redis。使用SCAN命令进行游标式迭代。

缓存的设计和优化是一个持续的过程,没有一劳永逸的银弹。核心在于理解业务的数据访问模式(读多写少?读写都多?),明确一致性要求(强一致还是最终一致),并针对性地组合运用缓存穿透、雪崩、击穿的解决方案,以及多级缓存、延迟双删等高级模式。每一次故障都是最好的学习机会,建立完善的监控和告警,才能让缓存系统真正成为业务的加速器,而不是火药桶。

← 返回列表