首页/文章列表/文章详情

高效缓存的10条军规

编程知识1012025-05-21评论

前言

"苏工!首页崩了!"

凌晨三点接到电话时,我正梦见自己成了缓存之神。

打开监控一看:

缓存命中率: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()) { // 空对象标识 return null; } return product; } product = productDao.findById(id); if (product == null) { redis.setex(key, 300,"empty"); // 缓存空值5分钟 return null; } redis.setex(key, 3600, product); return product; }

空值缓存原理

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

后面如果有相同的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; }

锁竞争流程图

军规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的方案

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

数据结构选择矩阵:

各数据结构最佳实践:

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

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

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

  1. 真的需要缓存吗?
  2. 缓存方案是否完整?
  3. 有没有兜底措施?

最后说一句(求关注,别白嫖我)

如果这篇文章对您有所帮助,或者有所启发的话,帮忙关注一下我的同名公众号:苏三说技术,我的所有文章都会在公众号上首发,您的支持是我坚持写作最大的动力。

求一键三连:点赞、转发、在看。

关注公众号:【苏三说技术】,在公众号中回复:进大厂,可以免费获取我最近整理的10万字的面试宝典,好多小伙伴靠这个宝典拿到了多家大厂的offer。

神弓

苏三说技术

这个人很懒...

用户评论 (0)

发表评论

captcha