只有 Redis 一层,够不够
大多数项目的第一版缓存就是 Redis:查库前先查 Redis,没有再回源。流量不大时没问题,QPS 上去之后会遇到两个天花板:一是每次读都要一次网络往返,热点接口的 Redis 带宽和连接数先顶不住;二是同一个热点 key 落在单个分片上,把那个节点的 CPU 打满,扩副本也救不了。
多级缓存就是在这个背景下出现的:本地缓存挡掉绝大部分读,Redis 做第二层共享缓存,数据库做最终来源。结构简单,难的是“本地缓存怎么失效”。
两级缓存的分工
| 层级 | 实现 | 容量 | 一致性 | 适合存什么 |
|---|---|---|---|---|
| L1 | Caffeine | 单机内存,几千到几万条 | 弱,秒级到分钟级 | 热点字典、商品详情、配置 |
| L2 | Redis | 集群,容量大 | 较强,集中失效 | 跨实例共享数据、会话 |
| DB | MySQL | 最大 | 强 | 最终数据源 |
L1 的核心取舍是“用一致性换延迟”。本地缓存没有跨实例失效机制,A 实例更新了数据,B 实例可能还在返回旧值。可接受的做法有两种:一是把 TTL 设短(几秒到几十秒),接受短暂不一致;二是用 Redis 发布订阅广播失效消息,各实例收到后清本地缓存。前者简单,后者实时,但广播消息会丢、会乱序,不能当成强一致。
一个可落地的组合
用 Caffeine 做 L1,Redis 做 L2,回源时用 LoadingCache 合并并发请求,避免缓存击穿时一堆线程同时打库:
private final LoadingCache<Long, OrderVO> localCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofSeconds(30))
.refreshAfterWrite(Duration.ofSeconds(10))
.recordStats()
.build(this::loadFromRedisOrDb);
public OrderVO getOrder(Long orderId) {
try {
return localCache.get(orderId);
} catch (ExecutionException e) {
throw new BizException("CACHE_LOAD_FAILED", "订单查询失败", e);
}
}
get(key) 在缓存未命中时只会有一个线程执行 loader,其他线程等待结果,这就是单飞(singleflight)效果。refreshAfterWrite 让缓存在后台异步刷新,读请求始终拿旧值,不会出现“刷新瞬间全部阻塞”。
loadFromRedisOrDb 里先读 Redis,读不到再查库并回填。注意回填 Redis 的 TTL 要加随机抖动,避免大批 key 同时过期造成雪崩。
失效逻辑分两段:写数据库后先删 Redis,再通过 pub/sub 通知各实例清 L1。删除失败要重试或进补偿队列,不能删完库就完事。更进一步可以用“延迟双删”,但延迟时间很难定,实际项目里短 TTL 往往更省心。
热点 key 的识别与治理
热点 key 的典型特征是:某个 key 的 QPS 远高于其他 key,Redis 单分片 CPU 飙升,慢查询里出现大量 GET。识别可以用 redis-cli --hotkeys(需要 LFU 淘汰策略),也可以看 INFO commandstats 和分片监控。
治理手段按成本从低到高:
- 本地缓存:读多写少的热点最适合,直接把请求挡在 JVM 内。
- key 拆分:给 key 加随机后缀,把一份数据复制到多个分片,读时随机选一个;写时要更新所有副本,复杂度上升。
- 读写分离:热点读走从节点,但主从延迟要能接受。
- 请求合并与限流:同一 key 的并发回源合并成一次,超出承载能力的请求直接降级。
常见坑
坑一:Caffeine 不设上限。 只设过期时间不设 maximumSize,突发流量灌进来会把堆撑爆。本地缓存必须同时有容量和过期两个约束。
坑二:缓存大对象。 把整个订单详情、商品列表塞进 L1,单条几百 KB,几万条就是几个 GB。L1 只放小、热、读多的数据。
坑三:本地缓存与数据库双写。 更新时既写库又写缓存,两个写操作之间失败就会不一致。统一用“更新库 + 删缓存”,不要更新缓存。
坑四:@Cacheable 自调用失效。 同类内部调用不经过代理,缓存注解不生效。要么拆 Bean,要么用 Aspect 显式调用。
坑五:空值没缓存。 查询不存在的 ID 每次都打到库,典型的缓存穿透。空结果也缓存一个短 TTL 的占位值,或者用布隆过滤器前置拦截。
坑六:序列化不一致。 不同服务用不同的序列化方式写同一个 key,读出来就是乱码或者反序列化失败。统一序列化协议,并在 key 上带版本号。
排查步骤
- 看 L1 命中率和 L2 命中率,分开统计,不要只看一个总命中率。
- 看 Redis 分片 CPU 和慢查询,确认热点是否集中在单个 key 或 slot。
- 用
redis-cli --bigkeys和--hotkeys找大 key、热 key。 - 对比数据库和缓存的值,确认不一致是过期策略、失效广播还是写入顺序导致的。
- 压测时观察回源 QPS,如果缓存生效后回源仍然很高,检查 key 设计和 TTL 是否合理。
评论