CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
系统设计入门/课时/缓存模式
RawBlame
课时目录12 节

系统设计入门

  1. 01系统设计是什么
  2. 02性能与扩展基础
  3. 03一致性与 CAP
  4. 04负载均衡
  5. 05缓存模式
  6. 06异步与消息
  7. 07数据架构
  8. 08微服务与网关
  9. 09云设计模式
  10. 10可靠性模式
  11. 11性能反模式
  12. 12综合练习

缓存模式

cache-aside、write-through、CDN、refresh-ahead。

30 分钟

缓存模式

这一课你将能做到

  1. 说清 Cache-Aside、Read-Through、Write-Through、Write-Behind 四种模式的读写路径与适用边界
  2. 亲手写出防穿透、防击穿、防雪崩的 Redis 读路径 Java 代码(含互斥锁与空值缓存)
  3. 为本站题目详情页设计「CDN + Redis + MySQL」三层缓存并给出 TTL 与失效策略

为什么要学

10 万日活的平台上,题目详情、排行榜、热门题单这些读请求占 90% 以上,全打 MySQL 就是花钱买崩溃。缓存是用内存的贵换数据库的命,是所有读多写少系统的第一杠杆。

面试缓存是必考区,且问法越来越细:「先删缓存还是先更新数据库?」「缓存和数据库不一致怎么办?」「穿透击穿雪崩分别怎么防?」——要求能背能画图,这课按这个标准写。

人话:缓存像你桌上的便签。Cache-Aside 是「先翻便签,没有再查资料,查完顺手抄一份贴桌上」;Write-Through 是「改资料必须同时改便签,永远一致但每次都慢一点」;Write-Behind 是「先改便签,攒一攒再同步资料,快但可能丢」。

先建立图像

四种模式的读写路径:

模式读路径写路径一致性适用
Cache-Aside先缓存,miss 再查 DB 并回填先写 DB,再删缓存最终一致通用业务(默认选它)
Read-Through缓存层代理,业务无感 DB—同上有封装好的缓存 SDK
Write-Through—同步写缓存+DB强写后读必须最新
Write-Behind—写缓存,异步批量刷 DB弱(可能丢)写爆炸但可容忍丢失(点赞计数)

本站三层缓存全景:

GET /api/problems/1024
   │
   ├─ ① CDN/浏览器:题目描述静态部分,TTL 1h(公开内容才放)
   │
   ├─ ② Redis:problem:1024 → 完整 JSON,TTL 10min + 随机抖动
   │            rank:weekly  → ZSet 排行榜
   │
   └─ ③ MySQL:problem 表(buffer pool 天然是最后一层)

命中路径的延迟量级:CDN ~10ms(用户就近)→ Redis 0.5ms → MySQL 1-5ms。**每往下一层,延迟差 520 倍**,这就是分层的意义。

容量估算(10 万日活):

热门题目 2000 道 × 平均 30KB JSON ≈ 60MB
全量题目 5000 道 × 30KB ≈ 150MB —— Redis 4GB 实例绰绰有余
命中率目标 >95%,剩余 5% 回源 DB ≈ 175 QPS × 5% ≈ 9 QPS,单库轻松

跟着做

步骤 1:写出标准 Cache-Aside 读路径(含三防)

@Service
@RequiredArgsConstructor
public class ProblemCacheService {
    private final StringRedisTemplate redis;
    private final ProblemMapper problemMapper;
    private final ObjectMapper objectMapper;

    private static final Duration BASE_TTL = Duration.ofMinutes(10);

    public ProblemDTO getProblem(long id) {
        String key = "problem:" + id;
        String json = redis.opsForValue().get(key);
        if (json != null) {
            // 空值哨兵:防穿透命中
            return "".equals(json) ? null : parse(json);
        }
        return loadFromDb(id, key);
    }

    private ProblemDTO loadFromDb(long id, String key) {
        // 防击穿:miss 后先抢互斥锁,只放一个线程回源
        String lockKey = key + ":lock";
        Boolean locked = redis.opsForValue()
                .setIfAbsent(lockKey, "1", Duration.ofSeconds(3));
        try {
            if (Boolean.TRUE.equals(locked)) {
                ProblemDTO dto = problemMapper.selectDetailById(id);
                if (dto == null) {
                    // 防穿透:不存在的 id 缓存空串 60s,挡住恶意扫描
                    redis.opsForValue().set(key, "", Duration.ofSeconds(60));
                    return null;
                }
                // 防雪崩:TTL 加随机抖动,避免同一秒集体过期
                Duration ttl = BASE_TTL.plusSeconds(
                        ThreadLocalRandom.current().nextLong(0, 120));
                redis.opsForValue().set(key, toJson(dto), ttl);
                return dto;
            }
            // 没抢到锁:稍等后重读缓存(别人正在回填)
            Thread.sleep(80);
            String json = redis.opsForValue().get(key);
            return json == null || json.isEmpty() ? null : parse(json);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return problemMapper.selectDetailById(id); // 兜底直查
        } finally {
            if (Boolean.TRUE.equals(locked)) redis.delete(lockKey);
        }
    }
}

步骤 2:Redis 命令行验证每一步

# 查看缓存与 TTL(-2 不存在,-1 永不过期)
GET problem:1024
TTL problem:1024
# 手动制造击穿演练:删缓存瞬间并发请求
DEL problem:1024
# 观察互斥锁的瞬间存在
SET problem:1024:lock 1 NX EX 3    # NX=不存在才设置,EX=过期秒数
# 命中率监控
INFO stats    # 看 keyspace_hits / keyspace_misses,命中率应 >95%

步骤 3:写路径——先更新 DB 再删缓存(含延迟双删)

面试经典题「先删缓存还是先更新库」。答案:先更新 DB,再删缓存(Cache-Aside 标准动作),因为「先删缓存」在删后写库前有个空窗,并发读会把旧值重新灌进缓存。

@Transactional
public void updateProblem(ProblemDTO dto) {
    problemMapper.update(dto);                       // ① 先写库
    redis.delete("problem:" + dto.getId());          // ② 再删缓存
    // 高并发下为兜底「删后旧读回填」,可加延迟双删:
    // 消息延迟 500ms 后再删一次(可用延迟队列/定时任务实现)
}

运营改题后想立刻生效(不等 TTL),就是这个 update 方法——主动失效优于被动过期。

步骤 4:排行榜用 ZSet 而不是缓存 JSON

# 判题 AC 时消费者加分(秒级延迟,最终一致)
ZINCRBY rank:weekly 100 "user:1024"
ZREVRANGE rank:weekly 0 9 WITHSCORES    # Top10
ZREVRANK rank:weekly "user:1024"        # 我的排名,O(logN)

排行榜的写频率高(每次 AC)读频率也高(全站刷新),缓存整页 JSON 会频繁失效,预聚合结构(ZSet)比页面缓存更合适——按数据形态选缓存结构,不是所有东西都存 String。

讲清楚要点

  1. Cache-Aside 是默认起点:业务代码自己管缓存,逻辑直白、可控。Read/Write-Through 需要缓存中间件支持,本地场景用 Spring Cache 注解近似实现。
  2. 三兄弟(穿透/击穿/雪崩)一句话记牢:穿透是查不存在的数据(缓存和 DB 都没有),击穿是单个热点 key 过期瞬间被集中轰击,雪崩是大批 key 同时过期或 Redis 整体宕机。对策分别是空值缓存/布隆过滤器、互斥锁/逻辑过期、TTL 抖动/多级缓存/集群高可用。
  3. TTL 永远加随机抖动:base + random(0, 20%),一行代码消灭「整点同一秒全量过期」这一最常见的雪崩诱因。
  4. 主动失效优于被动过期:运营改了题却要等 10 分钟 TTL 才生效,是产品事故。所有写路径必须顺手删缓存,TTL 只是兜底。
  5. 缓存不是依赖而是优化,必须可降级:Redis 全挂时读路径要能直查 DB(自动降级 + 对 DB 限流),宁可变慢不能全挂。设计文档里写清「Redis 故障时系统行为」。

你可能会懵的地方

  • 为什么是「删缓存」而不是「更新缓存」? 更新缓存有并发写覆盖风险(两个写请求交错导致缓存留旧值),且若缓存的值是聚合计算结果(如列表页),每次写都重算很浪费。删除后下次读自然回填最新值,语义简单安全。

  • 布隆过滤器是什么,什么时候用? 一个「可能误判存在、绝不漏判不存在」的位数组结构,放在缓存之前拦截恶意扫描不存在的 id。数据量大(亿级 key)、恶意请求多时上;本站题目 id 才几万,空值缓存足够,别过度设计。

  • 逻辑过期和互斥锁怎么选? 互斥锁保证不回源旧值但牺牲吞吐(排队);逻辑过期(value 里带过期时间,过期后由单线程异步刷新,先返回旧值)不阻塞但短期给旧数据。秒杀价格这类不能旧的字段用互斥锁,首页推荐这类可旧的数据用逻辑过期。

  • 多级缓存(本地 Caffeine + Redis)什么时候需要? Redis 一次 ~0.5ms,单机 5000 QPS 也够快;但当单 key QPS 达到数十万(首页热门榜单),网络往返和 Redis 带宽都成瓶颈,才加本地层。代价是本地层失效广播(Redis pub/sub 通知各实例)复杂度——先看数字再上。

  • Write-Behind 丢数据怎么办? 它就是用可丢失换吞吐的模式,只用于可重建的数据(浏览计数、点赞数)——源头行为日志在 Kafka 里保留,丢了可以从日志重放恢复。用户提交记录这种绝不能用 Write-Behind 直写。

错法对照

错法问题正解
先删缓存再更新 DB空窗期旧值被回填,长期不一致先更新 DB 再删缓存(+延迟双删兜底)
所有 key 统一 TTL 10 分钟整点集体过期 = 定时雪崩TTL 加 0~20% 随机抖动
查不到结果不缓存恶意 id 扫描每次都穿透到 DB空值短 TTL 或布隆过滤器
缓存大对象整个列表不分页大 key 序列化慢、淘汰阻塞按页拆 key,单 value 控制在几十 KB 内
把判题结果缓存 10 分钟用户看不到自己的最新结果状态类读己之写直查主库,不进缓存

卡点急救

  1. Redis 内存持续增长不释放:检查是否设置了 TTL(TTL key 返回 -1 的都是隐患)、有没有大 key 没拆分(redis-cli --bigkeys);生产必须配 maxmemory + 淘汰策略(推荐 allkeys-lru)。
  2. 命中率突然从 98% 掉到 60%:先看是否有批量运维操作(FLUSHDB、脚本扫描)、Redis 是否主从切换过(切换瞬间全 miss);再核对 TTL 分布是否被改过。命中率是缓存的第一健康指标,必须有监控告警。
  3. 缓存与 DB 出现持续不一致:核对写路径是否所有入口都删了缓存(多个写入口漏删是常犯错误)、是否有延迟双删兜底;极端情况用「版本号」——value 带 version,读时校验 DB 版本决定是否强制回源。

面试常问

  • 缓存穿透、击穿、雪崩的区别与对策? 穿透:查不存在的数据,缓存永远 miss——空值缓存、布隆过滤器、接口参数校验。击穿:热点 key 过期瞬间高并发回源——互斥锁重建、逻辑过期、热点 key 永不过期+主动刷新。雪崩:大批 key 同时过期或 Redis 宕机——TTL 抖动、多级缓存、集群哨兵、限流降级直查。答题时按「现象→根因→对策」三段说。

  • Cache-Aside 写路径为什么是先更新 DB 再删缓存? 先删缓存的问题是删后到写库完成前,并发读会把旧值回填且因缓存无 TTL 逻辑长期驻留;先写库再删,最坏情况是「删后读 miss 回填旧库值」的极小概率窗口,加延迟双删即可收敛。顺带能答出「更新缓存有并发覆盖问题」更佳。

  • 怎么设计一个高命中率缓存方案?(以题目详情页为例) 给出分层:CDN 承接静态描述、Redis 存热点题目 JSON(TTL 抖动 + 写时主动失效)、空值缓存防穿透、命中率与延迟指标监控、Redis 故障降级直查 DB 限流。最后补容量估算(60MB 热点集)体现量化能力。

自测三问

  1. 四种缓存模式的写路径分别是什么?判题计分适合哪种?
  2. 三兄弟的根因和对策各是什么?互斥锁方案的代码要点有哪几步?
  3. 「先删缓存再更新 DB」在什么时序下会产生长期不一致?画时间线说明。

小结一句话

缓存是读路径的第一杠杆:模式选 Cache-Aside 起步,写路径「先写库再删缓存」,三兄弟各备对策,永远准备 Redis 挂掉时的降级路径。

延伸

  • Redis 官方文档 — 数据类型与模式:ZSet/Hash 等结构权威参考
  • 阿里《Java 开发手册》缓存篇:缓存规约的工业界共识
  • Cache-Aside Pattern(Microsoft Azure 架构模式):模式定义出处
  • 下一课:异步与消息——Kafka 削峰填谷、消费者组与幂等消费,判题队列的完整设计。
加载本节练习…
← 上一课负载均衡下一课 →异步与消息