ElIauk

多级缓存与热点 key

只有 Redis 一层,够不够 大多数项目的第一版缓存就是 Redis:查库前先查 Redis,没有再回源。流量不大时没问题,QPS 上去之后会遇到两个天花板:一是每次读都要一次网络往返,热点接口的 Redis 带宽和连接数先顶不住;二是同一个热点 key 落在单个分片上,把那个节点的 CPU 打满

评论
多级缓存与热点 key

只有 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 和分片监控。

治理手段按成本从低到高:

  1. 本地缓存:读多写少的热点最适合,直接把请求挡在 JVM 内。
  2. key 拆分:给 key 加随机后缀,把一份数据复制到多个分片,读时随机选一个;写时要更新所有副本,复杂度上升。
  3. 读写分离:热点读走从节点,但主从延迟要能接受。
  4. 请求合并与限流:同一 key 的并发回源合并成一次,超出承载能力的请求直接降级。

常见坑

坑一:Caffeine 不设上限。 只设过期时间不设 maximumSize,突发流量灌进来会把堆撑爆。本地缓存必须同时有容量和过期两个约束。

坑二:缓存大对象。 把整个订单详情、商品列表塞进 L1,单条几百 KB,几万条就是几个 GB。L1 只放小、热、读多的数据。

坑三:本地缓存与数据库双写。 更新时既写库又写缓存,两个写操作之间失败就会不一致。统一用“更新库 + 删缓存”,不要更新缓存。

坑四:@Cacheable 自调用失效。 同类内部调用不经过代理,缓存注解不生效。要么拆 Bean,要么用 Aspect 显式调用。

坑五:空值没缓存。 查询不存在的 ID 每次都打到库,典型的缓存穿透。空结果也缓存一个短 TTL 的占位值,或者用布隆过滤器前置拦截。

坑六:序列化不一致。 不同服务用不同的序列化方式写同一个 key,读出来就是乱码或者反序列化失败。统一序列化协议,并在 key 上带版本号。

排查步骤

  1. 看 L1 命中率和 L2 命中率,分开统计,不要只看一个总命中率。
  2. 看 Redis 分片 CPU 和慢查询,确认热点是否集中在单个 key 或 slot。
  3. 用 redis-cli --bigkeys 和 --hotkeys 找大 key、热 key。
  4. 对比数据库和缓存的值,确认不一致是过期策略、失效广播还是写入顺序导致的。
  5. 压测时观察回源 QPS,如果缓存生效后回源仍然很高,检查 key 设计和 TTL 是否合理。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者