Skip to content

第73课:缓存策略

🎯 学习目标

  • 理解缓存的目标是降低延迟和保护后端资源,而不是简单“把数据放 Redis”。
  • 掌握本地缓存、分布式缓存、多级缓存、缓存旁路等常见模式。
  • 能处理缓存穿透、缓存击穿、缓存雪崩、数据一致性和热点 Key 问题。
  • 能根据业务一致性要求选择 TTL、主动更新、延迟双删、消息通知等策略。
  • 能识别缓存滥用、无界缓存、大 Key、缓存污染等风险。

📖 一、为什么需要缓存

缓存适合解决两类问题:

text
降低响应时间:热点数据不用每次查数据库或远程服务。
保护后端资源:高并发请求先被缓存吸收,减少数据库压力。

典型场景:

text
商品详情
用户基础资料
配置项
字典表
权限菜单
首页推荐
热点排行榜

不适合缓存的场景:

text
强一致账户余额
频繁变化且访问不高的数据
一次性查询
体积巨大且命中率低的数据

缓存不是银弹。引入缓存后,系统会多出一致性、容量、过期、穿透和监控问题。


📖 二、缓存层级

类型示例特点
JVM 本地缓存Caffeine、ConcurrentHashMap极快,但单机有效
分布式缓存Redis、Memcached多实例共享,适合集群
HTTP 缓存CDN、浏览器缓存适合静态资源和公开内容
数据库缓存Buffer Pool数据库内部机制

多级缓存示例:

text
请求 -> 本地 Caffeine -> Redis -> 数据库

读取路径越靠前越快,但一致性越复杂。


📖 三、Cache Aside 模式

最常见模式是缓存旁路:

java
public ProductDTO getProduct(Long productId) {
    String key = "product:" + productId;

    ProductDTO cached = redisTemplate.opsForValue().get(key);
    if (cached != null) {
        return cached;
    }

    Product product = productRepository.findById(productId)
        .orElseThrow(() -> new NotFoundException("product not found"));

    ProductDTO dto = ProductDTO.from(product);
    redisTemplate.opsForValue().set(key, dto, Duration.ofMinutes(10));
    return dto;
}

写入时通常:

java
@Transactional
public void updateProduct(ProductUpdateRequest request) {
    productRepository.update(request);
    redisTemplate.delete("product:" + request.getId());
}

为什么是删除缓存,而不是更新缓存?

text
删除更简单
避免多个写入并发时旧值覆盖新值
下次读取自然回源重建

📖 四、缓存穿透

缓存穿透指查询不存在的数据,缓存和数据库都没有,每次都打到数据库。

攻击者可能请求大量不存在的 ID:

text
product:-1
product:999999999

解决方案:

1. 参数校验

非法 ID 直接拒绝。

2. 缓存空值

java
if (product == null) {
    redisTemplate.opsForValue().set(key, NullValue.INSTANCE, Duration.ofMinutes(1));
    return null;
}

空值 TTL 要短,避免新数据创建后长期查不到。

3. 布隆过滤器

对海量 ID 判断“可能存在/一定不存在”。适合数据量大且恶意穿透风险高的场景。


📖 五、缓存击穿

缓存击穿指某个热点 Key 过期瞬间,大量请求同时回源数据库。

解决方案:

1. 互斥锁重建

java
public ProductDTO getProduct(Long productId) {
    String key = "product:" + productId;
    ProductDTO cached = getFromCache(key);
    if (cached != null) {
        return cached;
    }

    String lockKey = "lock:" + key;
    boolean locked = tryLock(lockKey, Duration.ofSeconds(5));
    if (locked) {
        try {
            ProductDTO secondCheck = getFromCache(key);
            if (secondCheck != null) {
                return secondCheck;
            }
            return rebuildCache(productId);
        } finally {
            unlock(lockKey);
        }
    }

    sleepBriefly();
    return getFromCacheOrFallback(key);
}

2. 逻辑过期

缓存值里存过期时间。发现逻辑过期时,先返回旧值,同时异步重建。

适合热点商品、首页配置等允许短暂旧数据的场景。


📖 六、缓存雪崩

缓存雪崩指大量 Key 同时失效,导致请求集中打到数据库。

常见原因:

text
大量 Key 使用相同 TTL
Redis 集群故障
缓存服务重启
预热失败

解决方案:

text
TTL 加随机抖动
热点数据不过期或逻辑过期
多级缓存
限流降级
Redis 高可用
缓存预热

TTL 抖动示例:

java
long ttlSeconds = 3600 + ThreadLocalRandom.current().nextLong(300);
redisTemplate.opsForValue().set(key, value, Duration.ofSeconds(ttlSeconds));

📖 七、缓存一致性

缓存和数据库很难做到强一致。常见策略:

策略说明适用场景
先更新 DB,再删除缓存简单常用大多数业务
延迟双删更新 DB 前后都删,第二次延迟删并发读写较多
消息通知失效DB 更新后发消息删除缓存多服务缓存同步
逻辑过期允许短暂旧值热点数据
强一致读 DB不走缓存账户余额等

常用方式:

text
更新数据库成功后删除缓存。
如果删除失败,记录日志并重试,或通过消息队列补偿。

不要幻想所有业务都能缓存强一致。关键是明确业务能接受多长时间的不一致。


📖 八、本地缓存 Caffeine

Caffeine 适合 JVM 内热点小数据:

java
Cache<Long, ProductDTO> cache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(Duration.ofMinutes(5))
    .recordStats()
    .build();

ProductDTO product = cache.get(productId, id -> loadFromRedisOrDb(id));

注意:

text
必须限制最大容量
集群内每个实例都有自己的本地缓存
更新时要考虑广播失效或短 TTL

⚠️ 九、常见陷阱

1. 无界缓存

java
Map<String, Object> cache = new ConcurrentHashMap<>();

没有容量限制就是内存泄漏。

2. 大 Key

一个 Key 存几 MB 数据,会导致网络传输慢、Redis 阻塞、删除成本高。

3. 热 Key

一个超级热点 Key 被大量请求访问,可能压垮 Redis 单分片。可以本地缓存、复制 Key、请求合并或热点隔离。

4. 缓存命中率低

命中率低的缓存只增加复杂度,没有收益。必须监控命中率。


🆚 十、Java vs C 对比

维度C 服务Java 后端
本地缓存手写哈希表或第三方库Caffeine、Guava Cache
分布式缓存hiredis 等客户端Spring Data Redis、Redisson
过期淘汰常需自行设计框架支持 TTL 和淘汰策略
一致性手动协调结合事务、消息、注解和 AOP

Java 生态提供了大量缓存组件,但缓存策略仍然必须由业务一致性要求决定。


💡 十一、最佳实践

  • 只缓存读多写少、命中率高、允许短暂不一致的数据。
  • 所有缓存必须设置容量或 TTL。
  • Key 命名统一,例如 业务:对象:ID
  • 缓存值不要过大,大对象拆分或只缓存必要字段。
  • 更新数据库后删除缓存,并设计删除失败的补偿。
  • 热点 Key 使用本地缓存、逻辑过期或请求合并保护数据库。
  • TTL 加随机抖动,避免大量 Key 同时过期。
  • 监控命中率、Redis 延迟、Key 大小、连接池、错误率。

🎓 小结

缓存的本质是用空间换时间,用一致性复杂度换性能。好的缓存策略不是“哪里慢就加 Redis”,而是根据数据特征、访问频率、一致性要求和失败模式设计完整方案。


🧭 十二、缓存设计检查清单

设计一个缓存前,先回答:

text
1. 这个数据读写比例是多少?
2. 允许多久的不一致?
3. 缓存命中率预期是多少?
4. Key 的数量级是多少?
5. Value 最大有多大?
6. TTL 怎么设置,是否需要随机抖动?
7. 数据更新时如何失效缓存?
8. 删除缓存失败怎么补偿?
9. 热点 Key 怎么保护?
10. Redis 故障时系统如何降级?

如果这些问题都答不上来,就不要急着加缓存。


📌 十三、缓存 Key 设计

推荐格式:

text
业务域:对象:标识

示例:

text
user:profile:1001
product:detail:2001
order:summary:3001
config:payment:channel

多租户系统要包含租户:

text
tenant:88:user:profile:1001

Key 设计要稳定、可读、可批量定位。不要使用无意义拼接或包含不确定顺序的对象字符串。


📌 十四、缓存监控指标

缓存上线后至少监控:

text
命中率
平均延迟
P99 延迟
Key 数量
内存使用
大 Key 数量
热 Key
连接池占用
错误率
回源数据库次数

命中率低时要问:

text
是不是 TTL 太短?
是不是 Key 设计过细?
是不是数据本来就不是热点?
是不是更新太频繁?
是不是存在缓存穿透?

缓存本身也要被观测,否则它会从优化手段变成新的故障来源。


🔍 十五、自测问题

text
缓存穿透、击穿、雪崩分别是什么?
为什么更新数据库后通常删除缓存,而不是直接更新缓存?
空值缓存的 TTL 为什么要短?
热点 Key 有哪些处理方式?
为什么本地缓存需要最大容量?
逻辑过期适合什么场景?
什么业务不能随便走缓存?
缓存一致性为什么很难做到绝对强一致?

📌 十六、学习建议

建议自己实现一个商品详情缓存,并模拟:

text
不存在商品的穿透请求
热点商品过期瞬间的并发请求
大量 Key 同时过期
更新数据库后删除缓存失败

这些场景比单纯会写 redisTemplate.opsForValue().set() 更接近真实项目。