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

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

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

学习

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

更多

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

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
Redis 缓存入门/课时/综合练习
RawBlame
课时目录12 节

Redis 缓存入门

  1. 01Redis 是什么
  2. 02CLI 与键命名
  3. 03String 与计数
  4. 04Hash / List / Set / ZSet
  5. 05过期与淘汰
  6. 06缓存模式
  7. 07Spring Data Redis
  8. 08会话与 Sa-Token
  9. 09Docker 运行 Redis
  10. 10Pub/Sub 入门
  11. 11测试与监控
  12. 12综合练习

综合练习

为 tracks API 加 Redis 缓存层。

40 分钟

综合实战:给本站加上 Redis 缓存层

这一课你将能做到

  1. 独立写出一个生产级 TrackCacheService:JSON 读写、TTL 抖动、空安全降级(enabled=false 时零影响)
  2. 把它挂进 TrackService 的读写路径:读时填充、写时先库后删、列表与详情成组失效
  3. 用 curl + redis-cli 完成端到端验收,并用 Testcontainers 测试收尾——本 track 毕业作品

为什么要学

前 11 课你集齐了所有零件:命令(2~4)、TTL 与三防(5)、Cache-Aside(6)、Spring 接入(7)、会话(8)、部署(9)、广播(10)、测试(11)。但零件不等于整车。

这一课把零件装起来:给 TrackService.listAll() 加 Redis 缓存层。这是本站首页最高频的读接口,现在只有 Caffeine 30 秒本地缓存。做完这次实战,你将拥有一段可以写进简历、可以在面试里完整讲清楚的真实工程经验——不是背的,是你亲手写的。

先建立图像

改造后的完整链路:

GET /api/tracks
  → TrackService.listAll()
      → Caffeine(30s,本站已有)
          → miss → TrackCacheService.getTracks()
              → Redis GET coderoadmap:tracks:list(5min±抖动)
                  → hit  → JSON 反序列化返回
                  → miss → MySQL 查询 → 序列化写回 Redis → 返回

POST/PATCH/DELETE 管理(deleteTrack 等)
  → MySQL 落库成功
  → @CacheEvict(清 Caffeine)
  → trackCacheService.evictListAndTrack(slug)  ← DEL Redis 两个 key

跟着做

步骤 1:写 TrackCacheService

新建 backend/src/main/java/com/codeforge/cache/TrackCacheService.java:

@Service
public class TrackCacheService {

    private static final String LIST_KEY = "coderoadmap:tracks:list";
    private static final String TRACK_KEY_PREFIX = "coderoadmap:track:";
    private static final Duration BASE_TTL = Duration.ofMinutes(5);
    private static final long JITTER_SECONDS = 60;

    private final StringRedisTemplate redis;      // enabled=false 时为 null
    private final ObjectMapper objectMapper;

    public TrackCacheService(ObjectProvider<StringRedisTemplate> redisProvider,
                             ObjectMapper objectMapper) {
        this.redis = redisProvider.getIfAvailable();   // 开关关 → null,不报错
        this.objectMapper = objectMapper;
    }

    public boolean isAvailable() {
        return redis != null;
    }

    /** 读列表缓存:miss 或任何异常都返回 null(由调用方回源 MySQL) */
    public String getListRaw() {
        if (redis == null) return null;
        try {
            return redis.opsForValue().get(LIST_KEY);
        } catch (Exception e) {
            return null;                     // Redis 挂了不拦路:降级直查库
        }
    }

    /** 写列表缓存:值 + 基础 TTL + 随机抖动,防雪崩 */
    public void putListJson(String json) {
        if (redis == null || json == null) return;
        try {
            redis.opsForValue().set(LIST_KEY, json, listTtl());
        } catch (Exception ignored) { }
    }

    /** 成组失效:列表 + 单个详情 */
    public void evictListAndTrack(String trackSlug) {
        if (redis == null) return;
        try {
            redis.delete(LIST_KEY);
            if (trackSlug != null) {
                redis.delete(TRACK_KEY_PREFIX + trackSlug);
            }
        } catch (Exception ignored) { }
    }

    private Duration listTtl() {
        return BASE_TTL.plusSeconds(ThreadLocalRandom.current().nextLong(JITTER_SECONDS));
    }
}

三个设计决策回看前面课程:ObjectProvider 空安全(第 7 课降级)、listTtl 抖动(第 5 课防雪崩)、异常全吞不拦路(第 6 课「缓存挂了系统不能挂」)。

步骤 2:挂进 TrackService 读路径

@Cacheable(cacheNames = CacheConfig.TRACKS, unless = "#result == null || #result.isEmpty()")
public List<Track> listAll() {
    // ① 先问 Redis
    String cached = trackCacheService.getListRaw();
    if (cached != null) {
        try {
            return objectMapper.readValue(cached, new TypeReference<List<Track>>() {});
        } catch (Exception ignored) { /* 脏 JSON → 回源 */ }
    }
    // ② miss → MySQL(原有逻辑不动)
    List<Track> list = trackMapper.selectListByAll();
    // ③ 回填 Redis
    trackCacheService.putListJson(objectMapper.writeValueAsString(list));
    return list;
}

步骤 3:挂进写路径

deleteTrack / saveTrack 等方法里,在 MySQL 写成功、Caffeine @CacheEvict 之后补一行:

trackCacheService.evictListAndTrack(slug);   // 先库后删(第 6 课铁律)

步骤 4:端到端验收脚本

# 1. 开关打开 + Redis 运行中(第 9 课起的容器带密码),重启 backend
docker start redis-dev
# application.yml: codeforge.redis.enabled=true 后重启

# 2. 冷启动:确认 miss
docker exec redis-dev redis-cli -a devpass123 EXISTS coderoadmap:tracks:list    # 0

# 3. 首次请求(回源 + 填充)
curl -s localhost:8080/api/tracks | head -c 120

# 4. 确认填充成功 + TTL 在 300~360 之间(抖动证据)
docker exec redis-dev redis-cli -a devpass123 EXISTS coderoadmap:tracks:list    # 1
docker exec redis-dev redis-cli -a devpass123 TTL coderoadmap:tracks:list       # 300~360

# 5. 二次请求走缓存(看 INFO stats 命中数上涨)
docker exec redis-dev redis-cli -a devpass123 INFO stats | grep keyspace_hits
curl -s localhost:8080/api/tracks > /dev/null
docker exec redis-dev redis-cli -a devpass123 INFO stats | grep keyspace_hits   # +1

# 6. 写路径失效:管理端删除/更新一门 track 后
docker exec redis-dev redis-cli -a devpass123 EXISTS coderoadmap:tracks:list    # 0(已被 DEL)

# 7. 降级验收:enabled=false 重启 → 接口正常返回,无任何 Redis 报错

步骤 5:Testcontainers 收尾测试

@SpringBootTest
@Testcontainers
class TrackCacheServiceIT {

    @Container
    static GenericContainer<?> redis =
        new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);

    @DynamicPropertySource
    static void props(DynamicPropertyRegistry r) {
        r.add("spring.data.redis.host", redis::getHost);
        r.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
        r.add("codeforge.redis.enabled", () -> "true");
    }

    @Autowired TrackCacheService cache;

    @Test
    void putThenGetThenEvict() {
        cache.putListJson("[{\"slug\":\"t1\"}]");
        assertThat(cache.getListRaw()).isNotNull();
        cache.evictListAndTrack("t1");
        assertThat(cache.getListRaw()).isNull();
    }

    @Test
    void nullSafeWhenDisabled() {
        // 另一个测试上下文:enabled=false → ObjectProvider 拿到 null
        // 断言:所有方法安静返回,不抛任何异常
    }
}

讲清楚要点

  1. 缓存组件必须是「透明加速层」:任何 Redis 故障都转为「回源 MySQL」,绝不让缓存异常变成接口异常——三层 try-catch 就是这个哲学。
  2. TTL 抖动必须做在写入侧统一封装:散在业务代码里一定会漏;listTtl() 私有方法就是收口。
  3. 失效成组:列表 key 里嵌着每门 track 的摘要,只删详情不删列表 = 首页旧数据 bug;evictListAndTrack 把成组关系固化成 API。
  4. 读路径顺序:Caffeine → Redis → MySQL;写路径顺序:MySQL → Caffeine Evict → Redis DEL——两个顺序都不能反。
  5. ObjectProvider 而非 @Autowired(required=false):等价但更符合 Spring 惯用法,测试时也更好替换。
  6. 验收要看证据:EXISTS/TTL/INFO stats 的数字就是测试报告,比「感觉快了」可信。

你可能会懵的地方

  • 为什么 Redis 层不也用 @Cacheable 注解? 注解缓存基于 CacheManager,本站 CacheManager 是 Caffeine;接 Redis 要换 RedisCacheManager 并重配序列化器,还会失去第 6 课讲的显式控制权。手写服务层更直白可控,也更适合面试讲细节。

  • JSON 反序列化 Track 失败怎么办? 代码里 catch 后当 miss 回源重建——结构演进的自然修复路径;极端情况 DEL 全部 coderoadmap: 前缀 key。

  • 写路径为什么只删不写? 回填永远发生在读路径;写路径只删不写,避免「写路径为了算缓存值再做一遍聚合查询」的重活,也避免并发写乱序(第 6 课)。

  • 多实例下这个实现还有什么缺口? Caffeine 层跨实例不同步(30 秒窗口)——第 10 课的广播是进阶补丁;Redis 层天然共享,无此问题。

  • 这个 key 需要防穿透吗? listAll 无入参、必然返回(可能空列表),穿透风险为零;按 slug 查详情的接口才需要空值缓存/入参校验(第 5 课)。

错法对照

错法问题正解
Redis 异常向上抛缓存故障拖垮接口catch → 返回 null → 回源
直接 @Autowired StringRedisTemplate开关关时启动报错ObjectProvider.getIfAvailable()
TTL 写死 300s同批 key 集体过期300 + random(60) 抖动
删缓存时只删详情列表旧数据成组失效 list + detail
写路径先删缓存后写库回源旧值脏缓存先 MySQL 成功后 DEL
验收只 curl 不看 Redis缓存没生效也发现不了EXISTS/TTL/INFO stats 三件套

卡点急救

  1. 启动报 NoSuchBeanDefinition:没用 ObjectProvider,或开关 true 但 Redis 没起。查 PING + 开关。
  2. TTL 始终 -1:putListJson 走了无 Duration 的重载。检查代码用了 listTtl()。
  3. 改了数据缓存不更新:写路径漏挂 evictListAndTrack;或只删了详情没删列表。
  4. 接口偶发变慢:Redis 网络抖动 + 无超时配置。给 Lettuce 配 command-timeout(进阶)。
  5. 测试里容器起了但连不上:@DynamicPropertySource 忘了注入 enabled 或端口,对照步骤 5。

面试常问

  • 讲一个你做过的缓存改造。 用本实战答:背景(首页热点读)→ 方案(Caffeine 30s + Redis 5min±60s 抖动 + Cache-Aside)→ 关键决策(先库后删、成组失效、异常降级、ObjectProvider 空安全)→ 验证(EXISTS/TTL/INFO stats + Testcontainers)。全程有真实代码与数据支撑。

  • 缓存和数据库的一致性你落地时怎么处理的? 读时填充 + 写时先更新 MySQL 再成组 DEL(列表+详情)+ TTL 兜底最终一致;能讲清并发窗口与「为什么不用更新缓存」。

  • Redis 挂了你的系统会怎样? 缓存层所有异常被捕获转回源,接口变慢不变错;会话实例另议(会话丢失用户重登)。体现降级设计意识。

自测三问

  1. TrackCacheService 为什么用 ObjectProvider 注入?开关关时每个方法的行为是什么?
  2. 写路径的三步顺序是什么?漏删列表 key 会出什么问题?
  3. 端到端验收要收集哪三类 Redis 侧证据?

小结一句话

综合实战 = 透明降级的缓存服务 + 先库后删的成组失效 + 抖动 TTL + 可验证的测试与观测——12 课所学在此收束成一次完整交付。

延伸

  • Spring Boot Caching
  • Redis 官方文档
  • 🎉 本 track(Redis 实战)完结。你的下一步:
    • 把 TrackCacheService 真实合入本站仓库,跑通步骤 4 的验收脚本
    • 继续进阶:spring-boot-essentials(Spring Boot 核心机制)或 nextjs-roadmap(前端侧)
    • 复习方法:把 12 课的「面试常问」汇总成一份问答清单,自测一轮再收尾
加载本节练习…
← 上一课测试与监控