缓存架构设计
缓存的本质是“用一份可能过期的副本换性能”。加对了能扛住十倍流量,加错了就是把数据库的复杂度搬到缓存里。本章讨论缓存怎么分层、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-lru或allkeys-lfu,锁、队列等不能丢的数据要放在独立实例,用noeviction。 - 留出余量:内存使用率长期超过 70% 就该扩容,否则一旦写入放大就可能触发大量淘汰。
多级缓存的一致性
本地缓存的一致性问题最容易被忽略:10 个实例各有自己的副本,删了 Redis 不代表本地也失效了。做法有:
- 本地 TTL 设短(1~5 秒),靠过期自然收敛,最简单也最稳。
- 广播失效:数据变更时往 MQ / Redis Pub-Sub 发一条失效消息,各实例收到后清本地缓存。
- 只对“容忍过期”的数据做本地缓存:配置项、基础字典适合;余额、库存这类直接不放本地。
失效广播示意:
更新 DB → 删除 Redis → 发布 invalidate 消息(topic=goods:detail, id=10086)
→ 各实例订阅到消息 → 清除本地缓存中对应 key
注意广播消息本身可能丢失,所以短 TTL 兜底永远要保留。
缓存该不该用
| 判断维度 | 适合用缓存 | 不适合用缓存 |
|---|---|---|
| 读写比 | 读远多于写(10:1 以上) | 写多读少 |
| 一致性要求 | 容忍秒级不一致 | 强一致(余额、库存精确值) |
| 单条数据大小 | 中小对象 | 超大对象(图片、大文本用 CDN/OSS) |
| 命中率 | 能到 80% 以上 | 访问极其随机,命中率很低 |
| 运维能力 | 有人管监控、容量、故障 | 加个组件却没人维护 |
命中率低的缓存是纯粹的负担:既增加了组件,又几乎每次都要回源。
小结:缓存分层遵循“越靠前越小越快”,读写模式优先 Cache Aside,TTL 加抖动、空值短缓存、热点主动刷新;key 要规范并警惕大 key,容量按公式估并留出余量;本地缓存必须配短 TTL 或失效广播。最后一步先算命中率和一致性要求——命中率低或要求强一致的数据,就别硬上缓存。