综合实战:给本站加上 Redis 缓存层
这一课你将能做到
- 独立写出一个生产级
TrackCacheService:JSON 读写、TTL 抖动、空安全降级(enabled=false 时零影响)
- 把它挂进 TrackService 的读写路径:读时填充、写时先库后删、列表与详情成组失效
- 用 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
// 断言:所有方法安静返回,不抛任何异常
}
}
讲清楚要点
- 缓存组件必须是「透明加速层」:任何 Redis 故障都转为「回源 MySQL」,绝不让缓存异常变成接口异常——三层 try-catch 就是这个哲学。
- TTL 抖动必须做在写入侧统一封装:散在业务代码里一定会漏;
listTtl() 私有方法就是收口。
- 失效成组:列表 key 里嵌着每门 track 的摘要,只删详情不删列表 = 首页旧数据 bug;
evictListAndTrack 把成组关系固化成 API。
- 读路径顺序:Caffeine → Redis → MySQL;写路径顺序:MySQL → Caffeine Evict → Redis DEL——两个顺序都不能反。
- ObjectProvider 而非 @Autowired(required=false):等价但更符合 Spring 惯用法,测试时也更好替换。
- 验收要看证据: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 三件套 |
卡点急救
- 启动报 NoSuchBeanDefinition:没用 ObjectProvider,或开关 true 但 Redis 没起。查 PING + 开关。
- TTL 始终 -1:putListJson 走了无 Duration 的重载。检查代码用了 listTtl()。
- 改了数据缓存不更新:写路径漏挂 evictListAndTrack;或只删了详情没删列表。
- 接口偶发变慢:Redis 网络抖动 + 无超时配置。给 Lettuce 配 command-timeout(进阶)。
- 测试里容器起了但连不上:@DynamicPropertySource 忘了注入 enabled 或端口,对照步骤 5。
面试常问
-
讲一个你做过的缓存改造。
用本实战答:背景(首页热点读)→ 方案(Caffeine 30s + Redis 5min±60s 抖动 + Cache-Aside)→ 关键决策(先库后删、成组失效、异常降级、ObjectProvider 空安全)→ 验证(EXISTS/TTL/INFO stats + Testcontainers)。全程有真实代码与数据支撑。
-
缓存和数据库的一致性你落地时怎么处理的?
读时填充 + 写时先更新 MySQL 再成组 DEL(列表+详情)+ TTL 兜底最终一致;能讲清并发窗口与「为什么不用更新缓存」。
-
Redis 挂了你的系统会怎样?
缓存层所有异常被捕获转回源,接口变慢不变错;会话实例另议(会话丢失用户重登)。体现降级设计意识。
自测三问
- TrackCacheService 为什么用 ObjectProvider 注入?开关关时每个方法的行为是什么?
- 写路径的三步顺序是什么?漏删列表 key 会出什么问题?
- 端到端验收要收集哪三类 Redis 侧证据?
小结一句话
综合实战 = 透明降级的缓存服务 + 先库后删的成组失效 + 抖动 TTL + 可验证的测试与观测——12 课所学在此收束成一次完整交付。
延伸
- Spring Boot Caching
- Redis 官方文档
- 🎉 本 track(Redis 实战)完结。你的下一步:
- 把 TrackCacheService 真实合入本站仓库,跑通步骤 4 的验收脚本
- 继续进阶:spring-boot-essentials(Spring Boot 核心机制)或 nextjs-roadmap(前端侧)
- 复习方法:把 12 课的「面试常问」汇总成一份问答清单,自测一轮再收尾