用好缓存的10条军规

向量数据库NoSQL数据库MySQL

大家好,我是苏三,又跟大家见面了。

前言

"苏工!首页崩了!"

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

打开监控一看:

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

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

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

直连数据库,未加缓存。

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

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

picture.image

军规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);  

失效时间分布

picture.image

军规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(); // 返回兜底数据    
}  

熔断状态机

picture.image

▶ 军规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;    
}  

空值缓存原理

picture.image

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

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

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

军规6: 分布式锁用Redisson

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

感兴趣的小伙伴可以看看我的这篇文章《聊聊redis分布式锁的8大坑》。

建议大家使用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(3, 30, TimeUnit.SECONDS)) {    
                product = productDao.findById(id);    
                redis.setex(key, 3600, product);    
            }    
        } finally {    
            lock.unlock();    
        }    
    }    
    return product;    
}  

锁竞争流程图

picture.image

军规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());    
    }, 500, TimeUnit.MILLISECONDS);    
}  

军规8: 最终一致性方案

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

对延迟双删问题比较感兴趣的小伙伴可以看看我的《如何保证数据库和缓存双写一致性?》,里面有详细的介绍。

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

基于Binlog的方案

picture.image

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));    
  

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

导致问题:

  1. 序列化/反序列化开销大
  2. 更新单个字段需读写整个对象
  3. 内存占用高 正确实践:
  
// 使用Hash存储    
redis.opsForHash().putAll("user:123", userToMap(user));    
  
// 局部更新    
redis.opsForHash().put("user:123", "age", "25");   

数据结构选择矩阵:

picture.image

各数据结构最佳实践:

1.String

计数器

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

分布式锁

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

2.Hash

存储商品信息

  
Map<String, String> 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", 0, 99);   

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", 0, 9);    

延迟队列

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

总结

缓存治理黄金法则

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

picture.image

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

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

  1. 真的需要缓存吗?

  2. 缓存方案是否完整?

  3. 有没有兜底措施?

最后欢迎加入苏三的星球,你将获得:AI开发项目课程、苏三AI项目、商城微服务实战、秒杀系统实战、商城系统实战、秒杀系统实战、代码生成工具、系统设计、性能优化、技术选型、底层原理、Spring源码解读、工作经验分享、痛点问题、面试八股文等多个优质专栏。

还有1V1答疑、修改简历、职业规划、送书活动、技术交流。

扫描下方二维码,即可加入星球:

picture.image

目前星球已经更新了5200+篇优质内容,还在持续爆肝中.....

星球已经被官方推荐了3次,收到了小伙伴们的一致好评。戳我加入学习,已有1700+小伙伴加入学习。

最后推荐一下我的技术专栏《性能优化35讲》,里面包含了:接口调用、Java、JVM、并发编程、MySQL、Redis、ElasticSearch、Spring、SpringBoot等多个性能优化技巧。无论在工作,还是在面试中,都会经常遇到,非常有参考价值。

picture.image

最近建了一些工作内推群,各大城市都有,欢迎各位HR和找工作的小伙伴进群交流,群里目前已经收集了不少的工作内推岗位。加苏三的微信:li_su223,备注:所在城市,即可进群。

picture.image

picture.image

0
0
0
0
关于作者
关于作者

文章

0

获赞

0

收藏

0

相关资源
字节跳动 NoSQL 的实践与探索
随着 NoSQL 的蓬勃发展越来越多的数据存储在了 NoSQL 系统中,并且 NoSQL 和 RDBMS 的界限越来越模糊,各种不同的专用 NoSQL 系统不停涌现,各具特色,形态不一。本次主要分享字节跳动内部和火山引擎 NoSQL 的实践,希望能够给大家一定的启发。
相关产品
评论
未登录
看完啦,登录分享一下感受吧~
暂无评论