血泪教训复盘:10条不可违背的缓存军规

苏三 2025-10-09 15:23:50

前言

 

"苏工!首页崩了!"

 

几年前的一天晚上,我接到电话时,我正梦见自己成了缓存之神。

 

打开监控一看:

 

  •  
  •  
  •  
缓存命中率:0%  数据库QPS:10万+  线程阻塞数:2000+

 

根本原因竟是之前有同事写的这段代码:

 

  •  
  •  
  •  
public Product getProduct(Long id) {      return productDao.findById(id); }

 

直连数据库,未加缓存。

 

这一刻我意识到:不会用缓存的程序员,就像不会刹车的赛车手。

 

今天这篇文章跟大家一起聊聊使用缓存的10条军规,希望对你会有所帮助。

 

 

军规1:避免大key

 

反例场景:

 

  •  
  •  
  •  
  •  
@Cacheable(value = "user", key = "#id")  public User getUser(Long id) {      return userDao.findWithAllRelations(id); }

 

这里一次查询出了用户及其所有关联对象,然后添加到内存缓存中。

 

如果通过id查询用户信息的请求量非常大,会导致频繁的GC。

 

正确实践:

 

  •  
  •  
  •  
  •  
  •  
@Cacheable(value = "user_base", key = "#id")  public UserBase getBaseInfo(Long id) { /*...*/ }  
@Cacheable(value = "user_detail", key = "#id")  public UserDetail getDetailInfo(Long id) { /*...*/ }

 

这种情况,需要拆分缓存对象,比如:将用户基本信息和用户详细信息分开缓存。

 

缓存不是存储数据的垃圾桶,需要根据数据访问频率、读写比例、数据一致性要求进行分级管理。

 

大对象缓存会导致内存碎片化,甚至触发Full GC。

 

建议将基础信息(如用户ID、名称)与扩展信息(如订单记录)分离存储。

 

军规2:永远设置过期时间

 

血泪案例:

 

某系统将配置信息缓存设置为永不过期,导致修改配置后三天才生效。

 

正确配置:

 

  •  
  •  
  •  
  •  
  •  
  •  
@Cacheable(value = "config", key = "#key",             unless = "#result == null",             cacheManager = "redisCacheManager")  public String getConfig(String key) {      return configDao.get(key);  }

 

Redis配置如下:

 

  •  
  •  
spring.cache.redis.time-to-live=300000 // 5分钟  spring.cache.redis.cache-null-values=false

 

需要指定key的存活时间,比如:time-to-live设置成5分钟。

 

TTL设置公式:

 

  •  
最优TTL = 平均数据变更周期 × 0.3

 

深层思考:

 

过期时间过短会导致缓存穿透风险,过长会导致数据不一致。

 

建议采用动态TTL策略。

 

例如电商商品详情页可设置30分钟基础TTL+随机5分钟抖动。

 

军规3:避免批量失效

 

典型事故:

 

所有缓存设置相同TTL,导致每天凌晨集中失效,数据库瞬时被打爆。

 

解决方案:

 

使用基础TTL + 随机抖动的方案:

 

  •  
  •  
  •  
public long randomTtl(long baseTtl) {      return baseTtl + new Random().nextInt(300); }  

 

TTL增加0-5分钟随机值。

 

使用示例

 

  •  
redisTemplate.opsForValue().set(key, value, randomTtl(1800), TimeUnit.SECONDS);

 

失效时间分布:

 

 

军规4:需要增加熔断降级

 

我们在使用缓存的时候,需要增加熔断降级策略,防止万一缓存挂了,不能影响整个服务的可用性。

 

Hystrix实现示例:

 

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
@HystrixCommand(fallbackMethod = "getProductFallback",                 commandProperties = {                     @HystrixProperty(name = "circuitBreaker.requestVolumeThreshold", value = "20"),                     @HystrixProperty(name = "circuitBreaker.sleepWindowInMilliseconds", value = "5000")                 })  public Product getProduct(Long id) {      return productDao.findById(id);  }  public Product getProductFallback(Long id) {      return new Product().setDefault(); // 返回兜底数据  }

 

熔断状态机:

 

 

军规5:空值缓存

 

在用户请求并发量大的业务场景种,我们需要把空值缓存起来。

 

防止大批量在系统中不存在的用户id,没有命中缓存,而直接查询数据库的情况。

 

典型代码:

 

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
public Product getProduct(Long id) {      String key = "product:" + id;      Product product = redis.get(key);      if (product != null) {          if (product.isEmpty()) { // 空对象标识              returnnull;          }          return product;      }          product = productDao.findById(id);      if (product == null) {          redis.setex(key, 300"empty"); // 缓存空值5分钟          returnnull;      }         redis.setex(key, 3600, product);      return product;  }

 

空值缓存原理:

 

 

需要将数据库中返回的空值,缓存起来。

 

后面如果有相同的key查询数据,则直接从缓存中返回空值。

 

而无需再查询一次数据库。

 

军规6:分布式锁用Redisson

 

用Redis做分布式锁的时候,可能会遇到很多问题。

 

建议大家使用Redisson做分布式锁。

 

Redisson分布式锁实现:

 

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
public Product getProduct(Long id) {      String key = "product:" + id;      Product product = redis.get(key);      if (product == null) {          RLock lock = redisson.getLock("lock:" + key);          try {              if (lock.tryLock(330, TimeUnit.SECONDS)) {                  product = productDao.findById(id);                  redis.setex(key, 3600, product);              }          } finally {              lock.unlock();          }      }      return product;  }

 

锁竞争流程图:

 

 

军规7:延迟双删策略

 

在保证数据库和缓存双写数据一致性的业务场景种,可以使用延迟双删的策略。

 

例如:

 

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
@Transactional  public void updateProduct(Product product) {      // 1. 先删缓存      redis.delete("product:" + product.getId());      // 2. 更新数据库      productDao.update(product);      // 3. 延时再删      executor.schedule(() -> {          redis.delete("product:" + product.getId());      }, 500TimeUnit.MILLISECONDS);  }

 

 

军规8:最终一致性方案

 

延迟双删可能还有其他的问题。

 

我们可以使用最终一致性方案。

 

基于Binlog的方案:

 

 

DB更新数据之后,Canal会自动监听数据的变化,它会解析数据事件,然后发送一条MQ消息。

 

在MQ消费者中,删除缓存。

 

军规9:热点数据预加载

 

对于一些经常使用的热点数据,我们可以提前做数据的预加载。

 

实时监控方案:

 

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
// 使用Redis HyperLogLog统计访问频率  public void recordAccess(Long productId) {      String key = "access:product:" + productId;      redis.pfadd(key, UUID.randomUUID().toString());      redis.expire(key, 60); // 统计最近60秒  }  
// 定时任务检测热点  @Scheduled(fixedRate = 10000)  public void detectHotKeys() {      Set<String> keys = redis.keys("access:product:*");      keys.forEach(key -> {          long count = redis.pfcount(key);          if (count > 1000) { // 阈值              Long productId = extractId(key);              preloadProduct(productId);          }      });  }

 

定时任务检测热点,并且更新到缓存中。

 

军规10:根据场景选择数据结构

 

血泪案例:

 

某社交平台使用String类型存储用户信息。

 

错误用String存储对象:

 

  •  
redis.set("user:123"JSON.toJSONString(user));  

 

每次更新单个字段都需要反序列化整个对象。

 

导致问题:

 

  • 序列化/反序列化开销大

  • 更新单个字段需读写整个对象

  • 内存占用高 正确实践:

 

  •  
  •  
  •  
  •  
  •  
// 使用Hash存储  redis.opsForHash().putAll("user:123"userToMap(user));  
// 局部更新  redis.opsForHash().put("user:123""age""25"); 

 

数据结构选择矩阵:

 

 

各数据结构最佳实践:

 

 
1、String

 

计数器

 

  •  
redis.opsForValue().increment("article:123:views");  

 

分布式锁

 

  •  
redis.opsForValue().set("lock:order:456""1""NX""EX"30);  

 

 
2、Hash

 

存储商品信息

 

  •  
  •  
  •  
  •  
Map<StringString> productMap = new HashMap<>();  productMap.put("name""iPhone15");  productMap.put("price""7999");  redis.opsForHash().putAll("product:789", productMap);  

 

部分更新

 

  •  
redis.opsForHash().put("product:789""stock""100");  

 

 
3、List

 

消息队列

 

  •  
redis.opsForList().leftPush("queue:payment", orderJson);  

 

最新N条记录

 

  •  
redis.opsForList().trim("user:123:logs"099); 

 

 
4、Set

 

标签系统

 

  •  
redis.opsForSet().add("article:123:tags""科技""数码");  

 

共同好友

 

  •  
redis.opsForSet().intersect("user:123:friends", "user:456:friends"); 

 

 
5、ZSet

 

排行榜

 

  •  
  •  
redis.opsForZSet().add("leaderboard""player1"2500);  redis.opsForZSet().reverseRange("leaderboard"09);  

 

延迟队列

 

  •  
redis.opsForZSet().add("delay:queue""task1", System.currentTimeMillis() + 5000); 

 

总结

 

缓存治理黄金法则

 

问题类型
推荐方案
工具推荐
缓存穿透
空值缓存+布隆过滤器
Redisson BloomFilter
缓存雪崩
随机TTL+熔断降级
Hystrix/Sentinel
缓存击穿
互斥锁+热点预加载
Redisson Lock
数据一致性
延迟双删+最终一致性
Canal+RocketMQ

 

 

最后忠告:缓存是把双刃剑,用得好是性能利器,用不好就是定时炸弹。

 

当你准备引入缓存时,先问自己三个问题:

 

  • 真的需要缓存吗?

  • 缓存方案是否完整?

  • 有没有兜底措施?

 

作者丨苏三
来源丨公众号:苏三说技术(ID:susanSayJava)
dbaplus社群欢迎广大技术人员投稿,投稿邮箱:editor@dbaplus.cn
最新评论
访客 2024年04月08日

如果字段的最大可能长度超过255字节,那么长度值可能…

访客 2024年03月04日

只能说作者太用心了,优秀

访客 2024年02月23日

感谢详解

访客 2024年02月20日

一般干个7-8年(即30岁左右),能做到年入40w-50w;有…

访客 2023年08月20日

230721

活动预告