缓存架构设计

缓存的本质是“用一份可能过期的副本换性能”。加对了能扛住十倍流量,加错了就是把数据库的复杂度搬到缓存里。本章讨论缓存怎么分层、key 怎么设计、容量怎么估、什么时候干脆不该用缓存。

缓存分层

层级位置访问延迟容量一致性问题
本地缓存应用进程内(Caffeine 等)纳秒~微秒小,受 JVM 堆限制多实例间不一致,需广播失效
分布式缓存Redis 等独立集群百微秒~毫秒大,可水平扩展与数据库有同步窗口
CDN / 边缘靠近用户毫秒级很大靠刷新与 TTL 收敛

典型组合是 CDN 挡静态资源、Redis 挡热点数据、本地缓存挡超热数据。层级越靠前越快也越小,因此只放“最热、最小、容忍短暂过期”的数据。

两级缓存示意:

// 以下片段需放进使用 Caffeine 与 Redis 客户端的工程中运行
Cache<String, String> local = Caffeine.newBuilder()
        .maximumSize(10_000)                  // 本地容量要克制,避免吃垮堆
        .expireAfterWrite(Duration.ofSeconds(3)) // 本地 TTL 短,减少不一致
        .build();

public String get(String key) {
    String v = local.getIfPresent(key);
    if (v != null) {
        return v;                            // 输出:命中本地缓存,直接返回
    }
    v = redis.get(key);                      // 本地未命中,查 Redis
    if (v == null) {
        v = loadFromDb(key);                 // 都未命中,回源数据库
        redis.setex(key, 600, v);
    }
    local.put(key, v);
    return v;
}

四种读写模式

模式一致性适用
Cache Aside缓存未命中则查库回填更新库 + 删缓存有窗口绝大多数业务,首选
Read Through由缓存组件自动回源更新库有窗口想对应用透明
Write Through同读走缓存同步写缓存和库较强写少、要求高
Write Behind同读走缓存只写缓存,异步批量落库弱,可能丢数据计数、埋点类高吞吐写

本文重点推荐 Cache Aside:逻辑在应用里,出问题容易排查。写路径的一致性问题下一章专门讨论。

TTL 设计

TTL 是缓存最后一道兜底,设计有几条经验:

  • 基础 TTL 按数据变更频率定:基础字典可以几小时,商品详情几分钟,库存几十秒。
  • 加随机抖动:同一批数据同时写入就不要给完全相同的 TTL,改成 TTL + random(0, TTL*0.1),避免整点集体失效造成雪崩。
  • 空结果也要缓存:查不到的数据缓存一个短 TTL 的空值(如 30 秒),防止穿透;更彻底的做法是配合布隆过滤器。
  • 热点数据长 TTL + 主动刷新:与其频繁失效,不如让后台任务定期刷新,前端始终命中。
  • 别把 TTL 当一致性方案:TTL 只是兜底,写路径该删缓存还是要删。

key 规范

好的 key 一眼能看出归属,也方便批量清理:

规范:业务模块:数据类型:业务ID:版本[:其他维度]
示例:
  goods:detail:10086:v2         商品详情,v2 用于结构变更后整体失效
  user:session:2000123          用户会话
  rank:daily:2024-06-01         某日榜单

约定与禁忌:

  • 统一用小写、冒号分隔,不用空格和中文。
  • 带版本号或环境前缀,结构升级时可以整批废弃旧 key。
  • 避免大 key:String 类型单个 value 控制在几十 KB 内,Hash/List/ZSet 元素数量控制在一定规模。大 key 会拖慢网络传输、造成主线程阻塞。
  • 排查大 key 可以用 redis-cli --bigkeys 扫描(具体参数以官方文档为准):
redis-cli --bigkeys
# 输出:各类数据结构中最大的 key 及大小统计

容量规划

估算公式(粗略量级,够用即可):

需要内存 ≈ key 数量 × (value 平均大小 + 每 key 固定开销约 50~100 字节)
         × 副本数 × 冗余系数(1.3~1.5)

举例:1000 万个 key,value 平均 1 KB,单副本即约 10 GB,加开销与冗余后按 13~15 GB 规划;如果 3 副本(主从)就得按 40 GB 以上准备。

同时要明确两点:

  • 淘汰策略:缓存用 allkeys-lruallkeys-lfu,锁、队列等不能丢的数据要放在独立实例,用 noeviction
  • 留出余量:内存使用率长期超过 70% 就该扩容,否则一旦写入放大就可能触发大量淘汰。

多级缓存的一致性

本地缓存的一致性问题最容易被忽略:10 个实例各有自己的副本,删了 Redis 不代表本地也失效了。做法有:

  1. 本地 TTL 设短(1~5 秒),靠过期自然收敛,最简单也最稳。
  2. 广播失效:数据变更时往 MQ / Redis Pub-Sub 发一条失效消息,各实例收到后清本地缓存。
  3. 只对“容忍过期”的数据做本地缓存:配置项、基础字典适合;余额、库存这类直接不放本地。
失效广播示意:
更新 DB → 删除 Redis → 发布 invalidate 消息(topic=goods:detail, id=10086)
       → 各实例订阅到消息 → 清除本地缓存中对应 key

注意广播消息本身可能丢失,所以短 TTL 兜底永远要保留。

缓存该不该用

判断维度适合用缓存不适合用缓存
读写比读远多于写(10:1 以上)写多读少
一致性要求容忍秒级不一致强一致(余额、库存精确值)
单条数据大小中小对象超大对象(图片、大文本用 CDN/OSS)
命中率能到 80% 以上访问极其随机,命中率很低
运维能力有人管监控、容量、故障加个组件却没人维护

命中率低的缓存是纯粹的负担:既增加了组件,又几乎每次都要回源。

小结:缓存分层遵循“越靠前越小越快”,读写模式优先 Cache Aside,TTL 加抖动、空值短缓存、热点主动刷新;key 要规范并警惕大 key,容量按公式估并留出余量;本地缓存必须配短 TTL 或失效广播。最后一步先算命中率和一致性要求——命中率低或要求强一致的数据,就别硬上缓存。

笔记加载中…