采云台 · M5 缓存 · 深度分析
对应第三、四部分(完整执行链路 + 为什么这么设计)及第五、六、七部分中 M5 相关内容
依据:backend/cai-yun-tai实际源码逐行核验;mvn -o -DskipTests compile退出码 0
一句话定位:这个模块的正确性论证(删而非更新、空值短 TTL、TTL 抖动)是站得住的,但缓存失效的组织方式有两个真实问题:一处KEYS阻塞、一处 跨模块失效遗漏导致缓存与数据库永久不一致(已核实可复现)。
零、先说三个必须澄清的事实
事实 1:项目没有使用 Spring Cache(无 @Cacheable / @CacheEvict)
grep -rn "EnableCaching|Cacheable|CacheEvict" caiyuntai-server/src/main/java caiyuntai-server/pom.xml
→ 无任何匹配
| 项 | 状态 |
|---|---|
@EnableCaching | ❌ 不存在(CaiyuntaiApplication 上只有 @SpringBootApplication + @EnableTransactionManagement) |
spring-boot-starter-cache 依赖 | ❌ 不存在(pom.xml 里只有 spring-boot-starter-data-redis) |
| 缓存实现方式 | ✅ 全部手写 RedisTemplate,缓存逻辑内嵌在 Controller 里 |
为什么必须强调这一点:仓库里那份《采云台-Redis 四类场景改造方案.md》的”当前项目现状”一节(第 11-17 行)写的是「组合申领包 Spring Cache,comboCache::<categoryId>」——那是改造之前的状态。如果你照着旧文档说”我用了 Spring Cache 的 @Cacheable”,面试官让你指出代码位置时你会当场找不到。
面试正确的说法:
「组合包缓存最开始是用 Spring Cache 的
@Cacheable做的,但后来发现两个问题:一是 key 的生成不够直观(不传分类时categoryId是 null,@Cacheable(key = "#categoryId")会直接抛IllegalArgumentException: Null key returned for cache operation,这个 bug 我实际踩到过,回归脚本第 7 节还专门为它留了一条回归保护);二是@CacheEvict(allEntries = true)的失效范围不好精确控制,物资停用时还要联动清理组合包缓存,注解表达不了这种跨缓存依赖。所以我把两块缓存都改成了手写RedisTemplate+ Cache-Aside,并把 key 前缀统一收敛到RedisKeyConstant。代价是缓存逻辑散落在 Controller 里,没有 AOP 那么整洁。」
这段话里的”踩过 null key 的坑”是真实存在的——回归脚本 regress_api_test.sh 第 107-110 行有原文注释:
# 回归保护:不传 categoryId(查全部)曾因 @Cacheable(key="#categoryId") 拿到 null 抛
# IllegalArgumentException: Null key returned for cache operation,导致前端弹"操作失败"。
# 现在 key 用三元表达式把 null 归一成 'all',这里守住这条路径。
这是项目里最好用的一个”我踩过坑并修了”的素材,因为它有测试证据。
事实 2:.keys() 只有两处,但都是 KEYS 命令且都是阻塞调用
admin/GoodsController.java:158 redisTemplate.keys(pattern) ← KEYS goods:list:*
admin/ComboController.java:128 redisTemplate.keys("combo:list:*") ← KEYS combo:list:*
事实 3:GoodsServiceImpl.startOrStop 会级联修改 setmeal.status,但清缓存的代码不知道这件事(这是 M5 最严重的缺口,见第五节 M5-1)
一、业务场景:为什么这两块数据需要缓存
1.1 数据的读写特征
| 数据 | 读频率 | 写频率 | 读放大倍数 | 一致性容忍度 |
|---|---|---|---|---|
物资目录(dish + dish_flavor) | 极高:每个员工进入申领页都拉一次,还会按分类反复切换 | 极低:管理员偶尔新增/修改/停用 | 高(1 次写对应成百上千次读) | 高:物资信息变更慢、不影响资金,容忍分钟级延迟 |
组合申领包(setmeal) | 高:和物资目录同一个页面 | 极低 | 高 | 高:同上 |
这就是”读多写极少”的教科书场景——每次写操作之后,允许有短暂的旧值,因为物资名称、价格这类信息在业务上不会秒变。
1.2 不缓存的代价
物资列表的查询不是一条 SQL,而是 N+1:
// GoodsServiceImpl.listWithSpecs:212
List<Goods> goodsList = goodsMapper.list(goods); // ① 1 次
for (Goods d : goodsList) {
List<GoodsSpec> flavors = goodsFlavorMapper.getByGoodsId(d.getId()); // ② N 次
goodsVO.setFlavors(flavors);
}假设一个分类下有 20 条物资 → 21 次 SQL / 每次请求。20 个员工同时打开申领页 → 420 次 SQL。
所以缓存对这个项目是”刚需”,不是”为了用技术而用技术”。而且注意:缓存恰好掩盖了 N+1 的问题——缓存命中时一次库都不查。这也是为什么 N+1 这个缺陷一直没被感知到。
1.3 缓存的数据结构选型
| 候选结构 | 是否选 | 理由 |
|---|---|---|
| String(当前方案) | ✅ | 缓存的是整个列表序列化后的字节,不是”字段级”数据。读取时一把取出、一次反序列化,不需要 Redis 侧的字段操作 |
| Hash | ❌ | Hash 适合”字段级读写”(如购物车:HSET cart:{uid} goodsId number)。物资列表没有”只读某个字段”的需求,拆成 Hash 反而变成 N 次 field 读取或 HVALS 全取,收益为负 |
| ZSet | ❌ | ZSet 的价值是按 score 排序/范围查询。物资列表的排序是 ORDER BY create_time DESC,在 SQL 层已经排好序再序列化进去了,不需要 Redis 再排一次 |
| List | ❌ | 表面上最像(就是个列表),但 List 会带来一个具体问题:列表内容变化时无法整块替换,只能 DEL + 重新 RPUSH,语义比 SET(整体覆盖)更绕,还多出”上下文游离”的可能。用 String 存序列化后的整个列表,天然就是”整体替换”语义 |
| Set | ❌ | 无序,且要去重——物资列表本来就是有序不重复的 |
面试标准答法:
「我选 String,因为缓存的是整个列表的快照,不是字段级数据。判断依据是读写模式:如果我要频繁读/写列表里的某一项,那应该用 Hash;如果我要按某个分数排序或做范围查询,那应该用 ZSet。但我的场景是’每次都要整个列表、从来不动单项’,所以 String 存序列化后的整体是最直接的——读取一次、反序列化一次。Hash 和 ZSet 的能力我都不需要,用了只是增加复杂度。」
二、Redis 配置与序列化(面试问”RedisTemplate 怎么配的”要能答上)
@Configuration
public class RedisConfiguration {
@Bean
public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory){
RedisTemplate redisTemplate = new RedisTemplate();
redisTemplate.setConnectionFactory(redisConnectionFactory);
redisTemplate.setKeySerializer(new StringRedisSerializer()); // ★ 只设了 Key
return redisTemplate; // Value 用默认
}
}2.1 为什么只设 Key 的序列化器
如果不设,RedisTemplate 的 Key 会用默认的 JdkSerializationRedisSerializer,存进去的 key 是二进制乱码。后果:
redis-cli> KEYS *
1) "\xac\xed\x00\x05t\x00\x11goods:list:23" ← 想手工排查都看不懂
而且用 redis-cli 根本没法按模式查(KEYS goods:list:* 一个都匹配不到,因为真实 key 前面有 4 字节的序列化头)。
这一点直接影响运维和排查:项目的 migration_fix_defects_20260929.sql 注释里就写了要手工清缓存:
redis-cli ... --scan --pattern 'goods_*' | xargs redis-cli ... del如果 Key 是 JDK 序列化的,这条命令完全无效。 所以设 StringRedisSerializer 是一个很实用的必要配置。
2.2 Value 为什么没设(只靠 JDK 序列化)—— 这是取舍不是疏漏
RedisTemplate 的 Value 序列化器没有显式设置,所以走默认的 JdkSerializationRedisSerializer。结果:
| 后果 | 说明 |
|---|---|
| 存的是 Java 序列化二进制 | redis-cli get goods:list:23 看到的是乱码 |
实体必须实现 Serializable | GoodsVO、Combo、GoodsSpec 都实现了 ✅ |
| 可读性为零 | 排查时看不出缓存里是什么内容 |
| 体积大 | Java 序列化有类描述符开销;相比 JSON 通常大 1.5-3 倍 |
| 版本脆弱 | 改了类名/删了字段,旧缓存反序列化直接失败(InvalidClassException)。因为没设 serialVersionUID 的话会自动生成,类一变就变 |
| ⚠️ 有安全含义 | Java 序列化反序列化本身是众所周知的反序列化攻击面(虽然这里数据源是自己的写路径、风险很低) |
为什么用 JDK 而不用 JSON? 因为 RedisTemplate<String, Object> 的默认配置就是这样,用起来最省事(不用写自定义序列化器、不用处理泛型丢失)。代价是上面这些。
但是——一旦换成 Jackson2JsonRedisSerializer,立刻会遇到一个新的坑:缓存的是 List<GoodsVO>,JSON 反序列化时泛型信息丢失,默认会变成 List<LinkedHashMap>,于是:
List<GoodsVO> list = (List<GoodsVO>) redisTemplate.opsForValue().get(key);
list.get(0).getName(); // ClassCastException: LinkedHashMap cannot be cast to GoodsVO这是”要不要换 JSON 序列化”的关键权衡点(见 M5-8)。
2.3 RedisTemplate vs StringRedisTemplate 的选型(项目里两个都在用)
| 模板 | 序列化 | 项目里用它做什么 | 为什么 |
|---|---|---|---|
RedisTemplate(泛型版) | Key=String,Value=JDK | 只用于对象缓存(goods:list:*、combo:list:*、shop:status) | 需要存 List 对象和 Integer,JDK 序列化能直接处理 |
StringRedisTemplate | Key/Value 都是 String | 所有计数、白名单、限流(登录态、登录失败计数、单号 INCR、防重、催办限流) | 这些场景的值本来就该是字符串(token、数字),而且**INCR 要求值是整数字符串**——用 JDK 序列化的 RedisTemplate 做 INCR 会直接报错 |
面试可以主动讲的一个点:
「我在项目里分了两个模板用。对象缓存用泛型版
RedisTemplate,因为要存 List 和 Integer;但所有计数器、限流、登录态都用StringRedisTemplate,因为**INCR这类命令要求值是纯字符串数字**,如果用 JDK 序列化的 Value,存进去的是\xac\xed...开头的二进制,INCR会直接报”value is not an integer”。所以这不是随便选的,是被命令的约束逼出来的。」
⚠️ 注意 shop:status 是个例外:它用的是泛型 RedisTemplate(Value 走 JDK 序列化),存的是 Integer。而回归脚本第 260 行的断言是:
K3=$(redis-cli ... get 'shop:status')redis-cli get 拿到的是二进制乱码,[ -n "$K3" ] 只能判断”非空”,判断不了值是不是 1。所以这条断言实际上只能证明键存在,证明不了值插对了。这是一个测试有效性的小缺口(M5-11)。
三、完整代码执行链路
★★★ 链路 1:用户端物资列表(Cache-Aside 读)
GET /user/goods/list?categoryId=23
Header: authentication={JWT}
① JwtTokenUserInterceptor.preHandle() ← 认证(同级缓存一样要先登录)
② user/GoodsController.list(Long categoryId) ← 第 40-59 行
│
├─【1】拼 key
│ String key = RedisKeyConstant.GOODS_LIST_PREFIX + categoryId;
│ → "goods:list:23"
│ ⚠️ categoryId 为 null 时 key 变成字面量 "goods:list:null"
│ (这一点和 ComboController 不一样,见 M5-6)
│
├─【2】★ 读缓存
│ List<GoodsVO> list = (List<GoodsVO>) redisTemplate.opsForValue().get(key);
│ → Redis GET goods:list:23(JDK 反序列化)
│ if (list != null) return Result.success(list); ← ★ 命中即返回,全程不碰 MySQL
│ ※ 判据是 list != null,不是 !list.isEmpty()
│ —— 因为空列表也要被当作"命中"(防穿透,见【5】)
│
├─【3】★ 未命中 → 回源查库
│ Goods goods = new Goods();
│ goods.setCategoryId(categoryId);
│ goods.setStatus(StatusConstant.ENABLE); ← 只查启用中的
│ list = goodsService.listWithSpecs(goods);
│ → GoodsServiceImpl.listWithSpecs:212
│ ├─ goodsMapper.list(goods) ① 1 次 SQL
│ │ SELECT * FROM dish
│ │ <where> categoryId=? AND status=1 </where>
│ │ ORDER BY create_time DESC
│ └─ for (Goods d : goodsList) ② N 次 SQL ← ★ N+1
│ goodsFlavorMapper.getByGoodsId(d.getId())
│ BeanUtils.copyProperties(d, goodsVO)
│ goodsVO.setFlavors(flavors)
│
├─【4】空值兜底
│ if (list == null) list = new ArrayList<>();
│ ※ Mapper 返回的是空 List 不是 null,这里是为"防御未知实现"兜底
│
├─【5】★ 写缓存(含 TTL 策略)
│ long ttl = list.isEmpty() ? 60
│ : (30 * 60 + ThreadLocalRandom.current().nextInt(300));
│ 空结果 → 60 秒(防穿透)
│ 正常 → 1800 + random(0,300) 秒 = 30~35 分钟(防雪崩)
│ redisTemplate.opsForValue().set(key, list, ttl, TimeUnit.SECONDS);
│
└─【6】return Result.success(list)
数据流转:Goods(实体,查库)→ GoodsVO(+ List<GoodsSpec> flavors)→ JDK 序列化写入 Redis。
关键设计点:
- 判空的条件是
list != null,所以空列表([])也会被缓存 —— 这是防穿透的核心; - TTL 分两档:空结果 60 秒、正常 30~35 分钟;
- 缓存命中的路径完全不碰 MySQL,一次 Redis 往返即返回。
并发:无锁。两个请求同时未命中时会各自回源一次(缓存击穿,见 M5-4)。
事务:无。缓存操作在事务之外。
链路 2:用户端组合申领包列表(同构,但 key 处理不同)
GET /user/combo/list?categoryId=27
↓ user/ComboController.list(Long categoryId) ← 第 41-62 行
├─【1】String key = RedisKeyConstant.COMBO_LIST_PREFIX + (categoryId == null ? "all" : categoryId);
│ → "combo:list:27" 或 "combo:list:all"
│ ★ 和物资不一样:这里把 null 【归一成 "all"】
│ 代码注释写明这是为了避开 @Cacheable 时代 null key 抛异常的坑
├─【2】List<Combo> list = (List<Combo>) redisTemplate.opsForValue().get(key);
│ 命中 → return
├─【3】Combo combo = new Combo(); combo.setCategoryId(categoryId);
│ combo.setStatus(StatusConstant.ENABLE);
│ list = comboService.list(combo) → ComboServiceImpl.list:181
│ → ComboMapper.list → SELECT * FROM setmeal
│ <where> categoryId? AND status=1 </where>
│ ★ 注意:这里【没有 N+1】,一次查询就够(组合包本身不带明细)
├─【4】空值兜底 + TTL(同物资:空 60s / 正常 1800+jitter)
└─【5】return
注意一处不一致:物资缓存 key 用 "goods:list:" + null(得到 goods:list:null),组合包用 "combo:list:all"。
- 功能上都正确(只要员工端每次用的 key 一致,就能命中同一个缓存);
- 但命名风格不统一,而且
goods:list:null这个 key 名称本身是个语义噪音——它其实是”全部物资”的意思。这是 M5-6 的一个小瑕疵,见第七部分。
更值得注意的是:ComboMapper.list 里查出来的 Combo 对象只有包本身的信息,不包含包内物资。 而员工端要展示包内物资时有另一个接口 GET /user/combo/goods/{id}(ComboServiceImpl.getGoodsItemById),那个接口没有缓存。所以:
员工端组合包列表走了缓存,但点击查看包内物资每次都查库。缓存的覆盖范围是”半截”的。这是一个可以主动说的观察(M5-10)。
★★★ 链路 3:管理员写操作 → 清缓存(物资侧,4 个入口)
物资的 4 个写接口(新增/删除/修改/启停)在**Controller 层**统一调 cleanCache:
【入口 1】POST /admin/goods(新增)
↓ @RequireRole(ADMIN) → RoleAspect 校验
↓ goodsService.saveWithSpecs(dto) @Transactional
↓ cleanCache(RedisKeyConstant.GOODS_LIST_PREFIX + "*") ← 第 51 行
→ redisTemplate.keys("goods:list:*") 【KEYS 命令,O(N) 阻塞】
→ redisTemplate.delete(keys)
【入口 2】DELETE /admin/goods?ids=(批量删除)
↓ goodsService.deleteBatch(ids) @Transactional(含"在售不可删""被组合包引用不可删"校验)
↓ cleanCache("goods:list:*") ← 第 83 行
【入口 3】PUT /admin/goods(修改)
↓ goodsService.updateWithSpecs(dto) @Transactional(删旧规格 + 插新规格)
↓ cleanCache("goods:list:*") ← 第 116 行
【入口 4】POST /admin/goods/status/{status}(启停)
↓ goodsService.startOrStop(status, id) @Transactional
│ ★ 如果 status == DISABLE(0):
│ comboDishMapper.getComboIdsByGoodsIds([id]) ← 查出包含该物资的组合包
│ for (comboId : comboIds)
│ comboMapper.update(Combo{id, status=DISABLE}) ← ★★ 级联停用组合包!
↓ cleanCache("goods:list:*") ← 第 135 行
│ ⚠️⚠️⚠️ 只清了物资缓存,【没有清 combo:list:*】
│ 而这一步刚刚修改了 setmeal.status
↓ Result.success()
cleanCache 实现(第 157-160 行):
private void cleanCache(String pattern){
Set keys = redisTemplate.keys(pattern); ← Redis KEYS 命令
redisTemplate.delete(keys);
}
⚠️ 入口 4 就是 M5 最严重的缺口:startOrStop 级联改了 setmeal.status,但清缓存只清了 goods:list:*。详情见第五节 M5-1,那是本模块最值得准备的追问答案。
链路 4:管理员写操作 → 清缓存(组合包侧,4 个入口)
【入口 1】POST /admin/combo(新增) → comboService.saveWithItems(dto) @Transactional
【入口 2】DELETE /admin/combo?ids= → comboService.deleteBatch(ids) @Transactional
【入口 3】PUT /admin/combo(修改) → comboService.update(dto) @Transactional
【入口 4】POST /admin/combo/status/{status} → comboService.startOrStop(status, id)
★ 启用时先校验包内物资是否都启用(ComboServiceImpl:151)
有停用物资 → 抛 ComboEnableFailedException
↓ 四个入口都调:
cleanComboCache() ← 第 48/76/104/120 行
Set keys = redisTemplate.keys(RedisKeyConstant.COMBO_LIST_PREFIX + "*"); ← KEYS
redisTemplate.delete(keys);
组合包侧是自洽的:改了组合包就清组合包缓存。问题出在链路 3 入口 4——改了组合包却在物资的 Controller 里,清的是物资缓存。
链路 5:采购开关(唯一一个”永不过期”的缓存)
写:PUT /admin/shop/{status} @RequireRole(ADMIN)
→ redisTemplate.opsForValue().set(RedisKeyConstant.SHOP_STATUS, status)
★ 泛型 RedisTemplate,Value 走 JDK 序列化存 Integer
★ 【无 TTL】
读:GET /admin/shop/status
GET /user/shop/status(无拦截器,属于白名单路径)
→ Integer status = (Integer) redisTemplate.opsForValue().get(SHOP_STATUS);
→ log.info(...status != null && status == 1 ? "营业中" : "打烊中") ← 已做 null 安全
消费:OrderServiceImpl.submitOrder:68
Integer shopStatus = (Integer) redisTemplate.opsForValue().get(SHOP_STATUS);
if (shopStatus != null && shopStatus == 0) throw ...(SHOP_CLOSED);
这个”缓存”的性质和其他几个完全不同:
| 项 | 物资/组合包缓存 | shop:status |
|---|---|---|
| 数据来源 | MySQL(缓存是副本) | Redis 就是唯一数据源(数据库里没有这张表/这个字段) |
| 一致性要求 | 最终一致可接受 | 必须强一致(开关关了就立刻不能提交) |
| TTL | 30 分钟 + 抖动 | 无 TTL(永久,直到管理员再改) |
| 丢失后果 | 回源重建即可 | 丢失后行为改变:get 返回 null → submitOrder 里 shopStatus != null 判断为 false → 开关失效,变成”允许提交” |
⚠️ shop:status 的失效行为是”fail-open”(失败即放行)。如果 Redis 重启丢了这个键,采购开关会静默失效,关着采购也能提交申领单。
面试标准答法:
「
shop:status严格说不是缓存,它就是唯一数据源——数据库里没有这张表。所以它天然是’有状态’的。这里有个我意识到的隐患:键丢失时行为是 fail-open,get返回 null 后我的判断是shopStatus != null && shopStatus == 0,null 会走”允许提交”分支。也就是说 Redis 丢数据会静默地把采购开关失效。如果这个开关有合规意义,应该改成 fail-safe——比如判断改成”只有明确等于 1 才放行”,或者启动时从数据库初始化。当前是 fail-open,这是一个真实的取舍缺陷。」
链路 6:缓存与事务的关系(所有写路径都”先事务、后清缓存”)
PUT /admin/goods
├─ goodsService.updateWithSpecs(dto) ← @Transactional 在这里【开始】
│ goodsMapper.update(...)
│ goodsFlavorMapper.deleteByGoodsId(...)
│ goodsFlavorMapper.insertBatch(...)
│ ← @Transactional 在这里【提交】
└─ cleanCache("goods:list:*") ← 在事务【之外】、提交【之后】
★ 这个顺序是【正确的】,见第四部分 4.1 的论证
但有一个真实的失效窗口:cleanCache 本身可能失败(Redis 抖动、网络异常),而代码里没有 try-catch。一旦 KEYS/DEL 抛异常:
- 异常会向上抛到
GlobalExceptionHandler→ 但GlobalExceptionHandler只处理BaseException和SQLIntegrityConstraintViolationException,Redis 异常不在其中 → 走 Spring 默认的 500; - 此时数据库已经提交了,但缓存没清 → 缓存与数据库不一致,且会持续到 TTL 到期(最多 35 分钟)。
面试要点:这正是”先写库、后删缓存”策略的固有代价——删除失败就没有重试机制。工程上通常有两个补救:
- 删除重试(比如失败后投递到 MQ / 记入重试表,隔几秒再删一次);
- 兜底 TTL(本项目有,30~35 分钟)——这是本项目唯一的一致性保底手段,也是”为什么 TTL 不是为了省内存而是为了兜一致性”这个说法的由来。
链路 7:缓存与 N+1 的关系(listWithSpecs 为什么没被感知为性能问题)
冷启动 / 缓存被清后第一次请求:
GET /user/goods/list?categoryId=23
→ 未命中 → listWithSpecs → 1 + N 次 SQL(N = 该分类下物资数)
→ 写入缓存(TTL 30~35 分钟)
之后 30~35 分钟内的所有请求:
→ 命中 → 0 次 SQL
结论:N+1 只在"缓存重建"时发生。
· 正常情况下每 30 分钟发生一次,压力可忽略;
· 但如果管理员频繁改物资(每次写都清缓存),
或者 Redis 被清空、缓存大面积过期,
N+1 就会在短时间内被反复触发。
面试标准答法:
「项目的 N+1 有两处,物资这处之所以一直没暴露问题,是因为缓存掩盖了它——命中缓存时一次库都不查。但这是脆弱的掩盖:一旦缓存被清空、或者管理员短时间内连续改物资(每次写都会清
goods:list:*,也就是全部分类的缓存一起失效),N+1 就会在缓存重建时被集中触发。所以正确的判断是:缓存降低了对 N+1 的敏感度,但没有消除这个缺陷,应该独立修复。」
这句话里还藏着另一个问题:cleanCache("goods:list:*") 清的是所有分类的缓存。管理员改一个分类下的一条物资,会让所有分类的缓存一起失效 → 后续请求全部回源、同时打库。这是”缓存雪崩”的一个人造版本(人为的集中失效),比 TTL 到期的雪崩更直接。见 M5-5。
四、为什么这么设计(核心部分)
4.1 为什么写路径”删缓存”而不是”更新缓存” —— M5 最核心的设计论证
【业务场景】
管理员修改一条物资(比如把”A4 复印纸”的价格从 25 改成 28)。此时缓存里存着旧列表。
【原始方案 A:更新缓存】
// 反例,项目里没有这么写
goodsService.update(goods);
List<GoodsVO> fresh = goodsService.listWithSpecs(goods); // 重新查库
redisTemplate.opsForValue().set("goods:list:" + categoryId, fresh);【原始方案 A 的问题】
① 并发写会产生”旧值覆盖新值”(脏写)
时刻 请求 A:改价格 25→28 请求 B:改名称
──────────────────────────────────────────────────────────
t1 UPDATE dish SET price=28(提交)
t2 UPDATE dish SET name=...(提交)
t3 listWithSpecs → 查到 price=28,name=新(最新)
t4 SET 缓存 = {price=28, name=新}
t5 listWithSpecs → 查到 price=28,name=新
t6 SET 缓存 = {price=28, name=新} ← 看起来没事?
上面这个”恰好没事”,但只要两步的顺序换一下就会脏:
t1 UPDATE dish SET price=28(提交)
t2 listWithSpecs → {price=28}
t3 UPDATE dish SET name=X(提交)
t4 listWithSpecs → {price=28, name=X}
t5 SET 缓存 = {price=28, name=X}
t6 SET 缓存 = {price=28} ← ★ 用【缺了 name=X】的旧快照覆盖了新值
──────────────────────────────────────────────────────────
结果:缓存里 name 是旧的,直到 TTL 过期
根因:更新缓存需要”先读库、再写缓存”——这中间又是一个复合操作,而它的两个子步骤之间没有任何互斥。信息越完整、步骤越多,脏写窗口越大。
② 白做功(写多读少的浪费)
如果一次修改之后接下来 1 小时没有任何人读这个分类,那么”更新缓存”查库 + 序列化 + 写 Redis 的所有成本都白花了。而”删缓存”只发一条 DEL,成本恒定且极低,等真的有人读的时候再重建(懒加载)。
③ 逻辑复杂
“更新缓存”要重新组装 VO、要处理分页、要处理关联数据(比如物资的规格)。而物资的缓存 key 是按分类分片的(goods:list:{categoryId}),一次修改可能影响多个 key(改分类就会),“更新”要精确算出该更新哪些 key,很容易算漏。
【当前方案:删除缓存(Cache-Aside)】
goodsService.updateWithSpecs(dto); // ① 先写库(事务内)
cleanCache("goods:list:*"); // ② 再删缓存(事务外)【为什么删优于更新】
| 维度 | 删缓存(当前) | 更新缓存 |
|---|---|---|
| 并发安全性 | ✅ 天然安全。DEL 是幂等操作,两个请求同时删没有副作用;也不会产生”旧值覆盖新值”——因为缓存里没值,下次读必须回源拿最新 | ❌ 需要”读库+写缓存”两步,中间无互斥,存在脏写窗口 |
| 代价 | ✅ 恒定,一条 DEL | ❌ 一次完整查询 + 序列化 + 网络写 |
| 写多读少时 | ✅ 不浪费(没人读就不重建) | ❌ 白做功 |
| 实现复杂度 | ✅ 简单,KEYS/DEL 一把清 | ❌ 要精确计算受影响 key 并重新组装数据 |
| 代价(缺点的另一面) | ❌ 第一次读会慢(要回源,也就是”缓存未命中惩罚”) | ✅ 读的时候永远命中 |
核心结论:
「选删不选更新,本质是在**“写的时候多做一点”和”读的时候可能慢一次”之间做选择。因为这是读多写极少的场景,写的次数比读少几个数量级,所以把成本推到’第一次读’上、并且只在这个 key 真的会被读到的时候才付出**,是最经济的。更重要的是并发正确性:删缓存不会出现”用旧快照覆盖新值”,因为它根本不写值。」
【替代方案全景对比】(面试必问”还有哪些一致性方案”)
| 方案 | 做法 | 优点 | 缺点 | 本项目是否适用 |
|---|---|---|---|---|
| A. Cache-Aside,写后删(当前) | 写库 → DEL 缓存 | 简单、并发安全、代价恒定 | 删除失败则不一致(靠 TTL 兜底);首次读慢 | ✅ 选它 |
| B. 写后更新 | 写库 → 重查 → SET 缓存 | 缓存始终热 | 脏写风险;白做功;逻辑复杂 | ❌ |
| C. 延迟双删 | 写库 → DEL → 睡 200ms → 再 DEL | 能覆盖”读请求在写之前拿到旧值、在删之后才写入缓存”的窗口 | 多一次删除 + 一次 sleep(要起线程或用延迟队列);sleep 时长靠猜 | ⚠️ 本项目未实现。对物资目录这种一致性要求不高的场景收益不明显 |
| D. 先删缓存再写库 | DEL → 写库 | 理论上窗口更小 | 更糟:删完之后、写库之前,读请求会把旧值加载回缓存,然后写库才生效 → 缓存里是旧值且没有后续删除 | ❌ 明确错误 |
| E. 订阅 binlog(Canal)异步失效 | 监听 MySQL binlog → 投递失效消息 | 业务代码完全解耦,不依赖开发者记得删;能覆盖所有写路径(含手工改库) | 引入 Canal + MQ + 消费端,组件复杂度大幅上升 | ⚠️ 未实现。这恰好能解决 M5-1 那个跨模块漏删的问题,是很值得在面试里提的”如果上量我会怎么做” |
| F. 不设 TTL,靠强失效 | 只删 + 不清 | 不会读到过期数据(除非删失败) | 删失败就永久不一致 | ❌ 必须有 TTL 兜底 |
| G. 读写都走 Redis,MySQL 只做持久化 | 把 Redis 当主库 | 极快 | 一致性/可靠性风险,不适合做权威数据源 | ❌ |
【关于”删缓存之后、并发读会不会把旧值写回去”这个经典问题——必须能答】
这是面试官最爱追的一层。准确答案是:先写库、后删缓存 这个顺序下,理论上存在一个极小的窗口,但概率极低:
时刻 线程 W(写) 线程 R(读)
──────────────────────────────────────────────────────────────
t1 UPDATE dish(提交)
t2 GET 缓存 → 未命中
t3 SELECT dish → 读到【新值】
t4 DEL 缓存
t5 SET 缓存 = 新值 ← 巧合,写入的是新值,没问题
要真正出问题,需要这个顺序:
t1 GET 缓存 → 未命中
t2 SELECT dish → 读到【旧值】
t3 UPDATE dish(提交)
t4 DEL 缓存
t5 SET 缓存 = 【旧值】 ← ★ 旧值被写回,且没有后续删除
──────────────────────────────────────────────────────────────
结果:缓存里是旧值,直到 TTL 过期
触发条件非常苛刻:读请求必须在 SELECT 之后、SET 之前被长时间挂起(超过”写库 + 删缓存”的耗时),比如发生了 GC 停顿、或者线程调度被抢占。概率极低但理论上存在。
这就是”延迟双删”要解决的问题——再删一次,把 t5 写回去的旧值也清掉。
面试标准答法:
「严格说’写后删’有一个理论窗口:读请求在写库之前读到旧值,但在删缓存之后才把旧值写回缓存,这样旧值就留在缓存里直到 TTL。要触发需要读请求被长时间挂起(比如 GC),概率很低。业界用延迟双删解决——写完删一次、延迟几百毫秒再删一次,把后写回的值也清掉。我没有做延迟双删,因为我这个场景是物资目录,一致性要求不高、且 TTL 只有 30 分钟,我认为收益不抵成本。如果是价格或者库存这种场景,我会加上。」
这个答法的价值在于:主动说出理论缺口 + 说清为什么不修 + 说明什么条件下会修。
4.2 为什么缓存空值(防穿透)
【业务场景】
员工端可以传一个不存在的 categoryId(比如手工改 URL、或者恶意遍历 categoryId=1,2,3,...99999)。
【原始方案】 只缓存非空结果:
// 反例:改造前的写法
if (list != null && list.size() > 0) {
redisTemplate.opsForValue().set(key, list);
}【原始方案的问题】
每次查询不存在的分类都会穿透到数据库:
遍历 categoryId = 1..99999
→ 每个都走:GET 缓存(未命中)→ SELECT dish WHERE category_id=? AND status=1(返回空)→ 不写缓存
→ 99999 次数据库查询,全部落到 MySQL
这就是缓存穿透:查询一个必然不存在的数据,缓存永远不命中,请求全部打到数据库。
【当前方案】
if (list == null) {
list = new ArrayList<>(); // ① 先把 null 归一成空列表
}
long ttl = list.isEmpty() ? 60 : (30 * 60 + ThreadLocalRandom.current().nextInt(300));
redisTemplate.opsForValue().set(key, list, ttl, TimeUnit.SECONDS); // ② 空列表也写缓存读取侧配合:
List<GoodsVO> list = (List<GoodsVO>) redisTemplate.opsForValue().get(key);
if (list != null) { // ← ★ 判 null 而不是判 isEmpty
return Result.success(list); // 空列表也算命中,直接返回空
}【三个关键设计点,缺一不可】
if (list == null) list = new ArrayList<>()—— 把null变成[]。没有这一步,set(key, null)在 Spring Data Redis 里会抛异常或写入空值,缓存里不会有”这个 key 查过了、结果是空”的信息;- 缓存空列表而不是”打标记” —— 这样读的时候判断逻辑统一:
list != null即命中,命中就直接返回(哪怕是空)。不需要为”空值标记”写额外的分支; - 空值用短 TTL(60 秒)而不是正常 TTL(30 分钟) —— 这是关键权衡:
- 如果空值也用 30 分钟:管理员新建了这个分类的物资,员工要等 30 分钟才能看到(虽然写操作会清缓存,但如果是直接改数据库、或者新增分类的物资没触发清理,就会卡 30 分钟);
- 60 秒短 TTL 是”在防穿透和数据新鲜度之间取平衡”——把恶意遍历的数据库压力从 100% 降到 1/60(每秒 1 次/每个 key),同时把脏数据的存活时间控制在 1 分钟。
【替代方案对比】
| 方案 | 做法 | 优点 | 缺点 | 本项目是否适用 |
|---|---|---|---|---|
| A. 空值缓存 + 短 TTL(当前) | 缓存 [],TTL 60s | 实现简单(3 行代码);无需额外组件;能防”同一个不存在的 key 被反复查” | 只能防”重复的同一 key”——如果攻击者每次用不同的 categoryId(1..99999),每个 key 都是”第一次查”,照样穿透;还会占用 Redis 内存(恶意构造大量 key) | ✅ 当前规模够用 |
| B. 布隆过滤器 | 所有存在的 categoryId 预加载到布隆过滤器,查询前先判断”可能存在吗” | 内存极小(每个元素几 bit);能防”任意不存在的 key”,不占业务缓存空间 | 有误判率(说存在可能不存在,但说不存在一定不存在——这个特性正好适合”拒绝”);删除困难(标准布隆过滤器不能删元素,需要 Counting Bloom);要维护数据同步(新增分类要加进过滤器) | ⚠️ 未实现。如果能重做,我会加,因为 categoryId 是有限、可枚举的小集合,特别适合布隆过滤器 |
| C. 参数合法性校验(白名单/范围校验) | 先查 category 表确认 id 存在,不存在直接返回错误 | 能从根上挡住——不存在的 id 根本不进查询逻辑 | 多一次查询(但如果分类表本身缓存了就不算成本) | ⚠️ 未实现。对 categoryId 这种小集合,这其实是性价比最高的方案 |
| D. 接口层限流 | 限 IP / 限用户请求频率 | 能挡住遍历式攻击 | 会误伤正常用户;不能防”低频但持续的穿透” | ⚠️ 项目只有登录失败限流和催办限流,没有接口级限流 |
面试标准答法(注意要主动说边界):
「我用了空值缓存加 60 秒短 TTL。要说清它的能力边界:它只能防”同一个不存在的 key 被反复查”——比如前端传了个已删除的分类 id,这个 key 被缓存后就不打库了。但如果攻击者每次传不同的 categoryId(1、2、3…遍历),每个 key 都是第一次查,照样穿透,而且还会在我的 Redis 里塞进大量空值 key。所以严格说这不是完整的防穿透。
我的场景里
categoryId是有限、可枚举的小集合(分类表就十几条),所以如果要做完整防护,性价比最高的是参数白名单校验——先查分类表确认 id 存在再查物资。布隆过滤器也合适,因为小集合、误判可接受。这两个我都没实现。」
能说出”空值缓存只防同一个 key、防不住遍历不同 key”,比背”缓存穿透三件套”有说服力得多。
4.3 为什么 TTL 加随机抖动(防雪崩)
【业务场景】
物资缓存的基础 TTL 是 30 分钟。如果所有 key 都是同一时刻写入的(比如 Redis 重启后缓存重建、或者管理员一次改了物资导致全部清空),那么它们会在30 分钟后同一时刻集体过期。
【原始方案】 set(key, value) 或 set(key, value, 30, TimeUnit.MINUTES) 固定 TTL。
【原始方案的问题】
T=0min Redis 重启 / 管理员清缓存 → 100 个分类的缓存全部回源重建
(每个回源 = 1 + N 次 SQL,假设平均 20 条物资 → 21 次)
→ 重建瞬间:100 × 21 = 2100 次 SQL
T=30min 这 100 个 key 【同一秒】全部过期
→ 同一秒内所有请求全部未命中 → 再次回源
→ 同时 2100 次 SQL 集中打到 MySQL
这就是缓存雪崩:大量 key 在同一时刻失效,请求全部涌向数据库。
【当前方案】
long ttl = list.isEmpty() ? 60 : (30 * 60 + ThreadLocalRandom.current().nextInt(300));
// ↑ 空值固定 60s ↑ 30*60=1800s + [0,300) 秒随机正常缓存的 TTL 落在 1800~2099 秒(30~35 分钟)之间,均匀分布。
【效果量化】
假设 100 个 key 在 T=0 时集中重建:
| 方案 | 过期分布 | T=1800s 时的回源请求数 |
|---|---|---|
| 固定 1800s | 全部在 T=1800s | 100 个 key 同时失效,全部回源 |
| 1800 + random(0,300)s | 均匀分布在 1800~2100s | T=1800s 只有约 1/300 的 key(约 0.33 个)失效 |
峰值被摊平了约 300 倍(从”同一秒 100 个”变成”平均每秒 0.33 个”)。
【为什么是 300 秒这个量级】
- 太小(比如 10 秒):摊平效果不够;
- 太大(比如 30 分钟):缓存的”有效新鲜期”波动太大——有的 key 30 分钟过期,有的 60 分钟,数据新鲜度不一致,不利于运维预期;
- 取基础 TTL 的 1/6 左右(300/1800),既能把峰值摊平一个数量级,又不会让 TTL 范围失控。这是经验值,不是精确计算出来的。
【替代方案对比】
| 方案 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| A. TTL + 随机抖动(当前) | 1800 + random(0,300) | 零成本,两行代码;对”TTL 自然到期”导致的雪崩有效 | 对”人为集中失效”无效——见下面 |
| B. 逻辑过期(不给 Redis key 设 TTL,value 里存逻辑过期时间,发现过期异步重建) | 缓存永不自动删,读到逻辑过期就返回旧值 + 异步刷新 | 读请求永远不会阻塞在重建上;彻底消除并发重建 | 实现复杂(要异步线程池);返回的是旧值(容忍度要求高) |
| C. 互斥锁重建(击穿方案) | 未命中时先抢锁,只有抢到的回源,其他等待或返回旧值 | 防止同一个 key 被并发重建 | 只防单 key;需要分布式锁(多实例) |
| D. 多级缓存(本地 Caffeine + Redis) | 本地缓存挡一层,减少 Redis 压力 | 极快 | 一致性更难保证(本地缓存无法被集中失效);引入新组件 |
| E. 缓存预热 | 启动时/定时任务主动把热点 key 刷进去 | 避免冷启动雪崩 | 要维护”哪些是热点”;项目没有定时任务 |
【⚠️ 必须主动指出的边界 —— TTL 抖动在这个项目里”防不住主要风险”】
这是 M5 最需要想清楚的一点:随机抖动解决的是”TTL 自然到期”导致的雪崩,而这个项目最可能的雪崩来源是”人为集中失效”:
cleanCache(RedisKeyConstant.GOODS_LIST_PREFIX + "*"); // ← 一次删掉所有分类的缓存- 管理员改一条物资 →
KEYS goods:list:*→ 把全部分类的缓存一起删了; - 所有 key 变成”同时不存在”→ 后续所有请求同时回源;
- 随机抖动在这里完全无用——key 不是自然过期,是被同时删除的。
所以这个项目里:
| 雪崩来源 | 抖动是否有效 |
|---|---|
| TTL 自然到期 | ✅ 有效(这就是当前方案覆盖的) |
| 写操作集中删缓存 | ❌ 无效(key 是被删的,不是过期的) |
| Redis 重启 / 清空 | ❌ 无效(同上) |
| 冷启动(第一次部署) | ❌ 无效(完全没有缓存) |
面试标准答法:
「我加了 0
300 秒的随机抖动,把 TTL 从固定的 30 分钟变成 3035 分钟均匀分布,这样能避免同一时刻写入的 key 在同一时刻过期。但我要说清它的边界:随机抖动只对’TTL 自然到期’这一种雪崩有效。而我的项目里更可能的雪崩来源是写操作导致的集中失效——管理员改一条物资,cleanCache("goods:list:*")会把全部分类的缓存一起删掉,所有请求同时回源。这种情况下 key 是被删的、不是过期的,抖动完全帮不上忙。要解决人为集中失效,得换思路:要么精确失效(只删受影响的分类,而不是通配全部),要么加互斥重建(只让一个请求回源,其他等结果),要么做缓存预热。这三个我都没做。」
这个回答把”我加了抖动”和”但抖动没防住我项目里最主要的雪崩源”都说了 —— 后者才是加分项。
4.4 为什么缓存击穿没有处理(主动承认)
【业务场景】
某个 key(比如”办公耗材”这个分类)是绝对热点——所有新员工入职第一天都会打开这个页面。
【当前代码的事实】 未命中时的处理是:
List<GoodsVO> list = (List<GoodsVO>) redisTemplate.opsForValue().get(key);
if (list != null) return Result.success(list); // 命中返回
list = goodsService.listWithSpecs(goods); // ★ 未命中 → 所有并发请求都回源
redisTemplate.opsForValue().set(key, list, ttl, TimeUnit.SECONDS);没有任何互斥。 所以:
T=0 "goods:list:23" 这个 key 过期(或被打架删掉)
T=0+ 100 个并发请求同时到达
→ 100 个请求全部 GET 未命中
→ 100 个请求全部执行 listWithSpecs
→ 100 × (1 + N) 次 SQL 同时打到 MySQL
→ 100 个请求全部 SET 缓存(后写的覆盖先写的,值相同所以无害)
这就是缓存击穿:单个热点 key 失效的瞬间,大量并发请求同时穿透到数据库。
【为什么没做 —— 诚实的理由】
不是疏忽,是有意的取舍,理由有三层:
- 数据本身不热:物资目录是低频变更的数据,一个分类的 key 过期后,接下来的请求是”陆续”到达的,不是”同一毫秒”到达的。真正的击穿风险需要”极高的瞬时并发 + 单个热点 key”,这个项目是企业内部系统(用户是公司员工,几十到几百人),不存在秒杀级别的瞬时并发;
- 回源的代价可控:即使真的并发回源,代价是”1 + N 次 SQL”,N 是分类下的物资数(约 10-20),单次回源 20 次 SQL。这不是一个昂贵的操作(对比:如果回源要调第三方接口或者做复杂聚合,就必须防击穿);
- 实现的复杂度不划算:互斥重建需要一把分布式锁(多实例部署时 JVM 锁无效),引入 Redisson 或者自己做
SET NX+ 轮询等待。为”可能不会发生的问题”引入一个组件和一套失败处理逻辑,投入产出不划算。
【替代方案对比】(如果面试官问”那要怎么做”)
| 方案 | 做法 | 优点 | 缺点 |
|---|---|---|---|
A. 互斥重建(SET NX 自旋) | 未命中的请求抢 goods:list:{id}:lock,抢到的回源,没抢到的 sleep 50ms 后重试 GET | 实现简单(不需要 Redisson,SET NX + TTL 就够) | 请求会等待(增加响应时间);要处理”抢锁的挂了”(靠锁 TTL);高并发下大量线程 sleep 自旋,线程池有耗尽风险 |
| B. 逻辑过期 | 缓存里存 {data, expireAt},读到逻辑过期就返回旧数据 + 起一个异步线程刷新 | 请求永不阻塞;数据库压力只有 1 次 | 返回旧数据;要引入线程池(项目没有 @Async、没有自定义线程池) |
| C. 永不过期 + 后台定时刷新 | key 不设 TTL,定时任务定期重建 | 永远命中 | 项目没有定时任务(全项目无 @Scheduled);数据新鲜度受影响 |
| D. 本地缓存挡一层(Caffeine) | 热点 key 在本地缓存,Redis 只做二级 | 极快,Redis 压力骤降 | 本地缓存无法被集中失效(清 Redis 清不掉各实例的本地缓存),一致性显著变差 |
【面试标准答法 —— 这段话要背下来】
「缓存击穿我确实没有做,这是我主动承认的缺失。原因是这个场景的数据不热——企业内部的物资目录,用户是公司员工,不存在秒杀级别的瞬时并发;而且单次回源只有 1+N 次 SQL(N 约 10-20),不是昂贵操作。为一个可能不会发生的问题引入分布式锁和一套锁失败处理,投入产出不划算。
如果要做,我会选两种方案之一:一是互斥重建——未命中时用
SET NX抢一把带 TTL 的锁,抢到的回源,没抢到的短睡眠后重试读缓存,这个不需要引入 Redisson;二是逻辑过期——缓存里存数据和逻辑过期时间,读到过期就返回旧数据、异步起线程刷新,请求永不阻塞,但要引入线程池。我倾向第一种,因为项目里已经有SET NX的用法(防重复提交、催办限流),风格一致、零新组件。」
这段话比硬说”我解决了缓存三兄弟”要值钱得多 —— 因为面试官一旦追问实现细节,没做过的人立刻露出破绽。
4.5 为什么用 KEYS 而不是 SCAN(这是缺陷,不要合理化)
【业务场景】 管理端改了物资,需要清掉 goods:list:* 所有缓存。
【当前实现】
Set keys = redisTemplate.keys(pattern); // ← Redis KEYS 命令
redisTemplate.delete(keys);【KEYS 的真实代价,要能准确说】
Redis 是单线程处理命令的。
KEYS命令的时间复杂度是 O(N),而且它在执行期间【阻塞整个 Redis 实例】——期间所有其他客户端的命令(读缓存、INCR、SET NX、登录态校验)全部排队等待。
具体到本项目的后果(这是最有力的论证):
管理员调 PUT /admin/goods
→ KEYS goods:list:*
→ 假设 Redis 里有 5000 个 key
→ 这期间:
· 所有员工的 GET goods:list:* 全部阻塞
· 所有登录请求的 GET login:token:* 全部阻塞 ← ★ 登录态校验也走 Redis!
· 所有提交申领单的 SET NX / INCR 全部阻塞 ← ★ 直接卡住核心业务
→ 表现为"整个系统卡住几百毫秒到几秒"
关键点:KEYS 阻塞的不是”缓存功能”,而是”所有依赖 Redis 的功能”——包括登录态校验、防重、单号生成、限流。这是一个”清缓存”操作能拖垮整个系统可用性的典型例子。
【正确的做法:SCAN 游标分批遍历】
SCAN 的特点:
- 时间复杂度同样是 O(N)(遍历所有 key),但每次只返回一小批(默认 10 个),不阻塞;
- 用游标(cursor)续遍历,是非阻塞的增量迭代;
- 代价:遍历期间新增/删除的 key 行为不确定(可能重复返回、可能漏掉),所以必须允许”漏删部分 key”——这靠 TTL 兜底(本项目有 30~35 分钟 TTL,所以漏删是可接受的!)。
// 正确实现(本项目未实现)
public void cleanCache(String pattern) {
ScanOptions options = ScanOptions.scanOptions().match(pattern).count(200).build();
try (Cursor<String> cursor = redisTemplate.scan(options)) {
List<String> batch = new ArrayList<>(200);
while (cursor.hasNext()) {
batch.add(cursor.next());
if (batch.size() >= 200) {
redisTemplate.delete(batch); // 分批删,每批之间让出执行权
batch.clear();
}
}
if (!batch.isEmpty()) redisTemplate.delete(batch);
}
}【替代方案对比】
| 方案 | 优点 | 缺点 | 本项目适用性 |
|---|---|---|---|
A. SCAN 分批删(推荐) | 不阻塞;O(N) 但摊薄到多次调用 | 遍历期间可能漏 key(靠 TTL 兜底);代码比 KEYS 长 | ✅ 最直接的修法 |
| B. 维护”分类 → key”索引集合 | 不用扫键,直接按集合删;O(受影响 key 数) 而不是 O(总 key 数) | 要维护一份索引(写缓存时 SADD,删缓存时读集合再删);索引本身也可能不一致(且它不是缓存,没有 TTL 兜底) | ⚠️ 可行但复杂 |
| C. Key 设计上就直接可枚举 | 分类是有限集合,可以 SELECT id FROM category 拿到所有分类 id,然后逐个 DEL 精确的 key(goods:list:{id} + goods:list:null) | 完全不用 KEYS/SCAN;精确失效,不做无用功;还能顺带解决”改一个分类却清了全部”的问题(M5-5) | ✅✅ 最优解!分类表就十几行,一次查询 + 十几次 DEL,代价极小 |
D. Redis 6+ 的 UNLINK 异步删除 | 删除本身不阻塞(后台线程回收内存) | KEYS 的阻塞在”扫描”这一步,不在”删除”,所以 UNLINK 解决不了主要问题 | ⚠️ 只能配合 B/C,不能替代 |
【面试标准答法 —— 主动承认 + 给出最优解】
「这里用
redisTemplate.keys(),也就是 Redis 的KEYS命令,是一个明确的缺陷。KEYS是 O(N) 的,而且 Redis 单线程处理命令,执行期间会阻塞整个实例。更要紧的是,它阻塞的不只是缓存功能——我的登录态白名单校验、防重复提交的
SET NX、单号生成的INCR全都走 Redis。所以管理员改一条物资,可能造成所有员工的请求集体卡顿。这是”一个清缓存操作拖垮整个系统”的典型场景。修法我想到三层,按优先级:
第一,最优解是根本不扫键——分类是个有限、可枚举的小集合(
category表就十几行)。我可以先SELECT id FROM category拿到所有分类 id,然后逐个精准DELgoods:list:{id}和goods:list:null。这样既不用KEYS、也不用SCAN,还顺带解决了另一个问题——现在是通配清除,改一个分类会把所有分类的缓存都清掉,导致大面积回源。第二,如果一定要按模式清,就换成
SCAN——用游标分批遍历,每批删一批,不阻塞。SCAN的代价是遍历期间可能漏掉部分 key,但我可以接受,因为有 30 分钟的 TTL 兜底。第三,维护一个”分类 → key”的索引集合,但这要么多一份状态、要么索引本身也会不一致,而且索引没有兜底,我不倾向。
我倾向第一层,因为它从根上避免了扫描。」
4.6 为什么从 Spring Cache 改成手写 RedisTemplate(旧文档会误导你,必须弄清)
【原始方案(改造前)】
@Cacheable(cacheNames = "comboCache", key = "#categoryId")
public List<Combo> list(Combo combo) { ... }
@CacheEvict(cacheNames = "comboCache", allEntries = true)
public void saveWithItems(ComboDTO comboDTO) { ... }【原始方案的问题 —— 两个真实踩过的坑】
坑 1:null key 直接抛异常(这个坑有测试证据)
@Cacheable(key = "#categoryId") 当 categoryId 为 null 时,Spring Cache 会抛:
java.lang.IllegalArgumentException: Null key returned for cache operation
(maybe you are using named params on classes without debug info?)
为什么安全场景下这很严重:员工端”查全部组合包”就是不传 categoryId 的。所以这是一个必然触发的 bug——只要用户点了”全部”分类,接口就 500,前端弹”操作失败”。
证据:regress_api_test.sh 第 107-110 行原文:
# 回归保护:不传 categoryId(查全部)曾因 @Cacheable(key="#categoryId") 拿到 null 抛
# IllegalArgumentException: Null key returned for cache operation,导致前端弹"操作失败"。
# 现在 key 用三元表达式把 null 归一成 'all',这里守住这条路径。
修法是 key 用三元表达式:(categoryId == null ? "all" : categoryId)。
坑 2:注解表达不了”跨缓存的失效依赖”
GoodsServiceImpl.startOrStop 停用一个物资时,会级联停用包含它的组合包(comboMapper.update(status=DISABLE))。这时需要同时失效组合包缓存。
用注解的话,@CacheEvict(cacheNames = "comboCache", allEntries = true) 得标在 Goods 的 Service 方法上——但 Goods 的 Service 去清 combo 的缓存,跨模块耦合,语义很怪;而且这个方法同时也要清 goods:list:*(但 goods:list:* 是按分类分片的,注解的 allEntries 清的是整个 cacheName)。
Annotation 的失效表达能力有限:它能表达”清某个 cacheName 的全部/某个 key”,但表达不了”清 goods:list:* 这种模式匹配的 key”(Spring Cache 不支持往 key 里放通配符)。
【当前方案:手写 Cache-Aside】
// 读
String key = RedisKeyConstant.COMBO_LIST_PREFIX + (categoryId == null ? "all" : categoryId);
List<Combo> list = (List<Combo>) redisTemplate.opsForValue().get(key);
if (list != null) return Result.success(list);
// ... 回源
redisTemplate.opsForValue().set(key, list, ttl, TimeUnit.SECONDS);
// 写后清
redisTemplate.keys(RedisKeyConstant.COMBO_LIST_PREFIX + "*") → delete【为什么手写更好(在这个项目里)】
| 维度 | Spring Cache 注解 | 手写 RedisTemplate(当前) |
|---|---|---|
| key 生成 | 由 SpEL 表达式推导,null 会抛异常 | 完全自己控制,null 归一成 all |
| 空值缓存 | @Cacheable 的 unless/condition 表达力有限,空列表缓存要绕 | 直接 if (list == null) list = new ArrayList<>() |
| TTL 差异化 | 需要为每个 cacheName 配 RedisCacheConfiguration,难以按”空/非空”给不同 TTL | 一行三元表达式就够 |
| TTL 随机抖动 | 非常难(要在 CacheManager 层定制,或者根本不支持按次变化) | 1800 + random(0,300) 一行 |
| 模式化失效 | 不支持通配符 key | KEYS goods:list:*(虽然 KEYS 本身有问题,但至少能做到) |
| 跨缓存失效 | 要在 A 模块的 Service 上标 B 模块的 cacheName,耦合 | 在自己能拿到的地方调,但容易漏(← 就漏了,见 M5-1) |
| 可读性 | ✅ 一个注解,很干净 | ❌ 缓存代码混在 Controller 里 |
| 一致性保障 | 框架统一处理 | ❌ 全靠开发者记得 |
核心结论:
「切到手写的原因是注解的表达能力不够:
@Cacheable的 key 用 SpEL 推导,categoryId为 null 时直接抛异常(这个 bug 我真踩到了,回归脚本里有保护);而且我需要”空结果用 60 秒、正常结果用 30 分钟加随机抖动”这种按条件区分的 TTL,以及”goods:list:*这种模式化失效”,这两件事注解都做不到。但手写的代价也很实在:缓存逻辑散落在 Controller 里,没有任何机制保证”改了数据一定记得清缓存”——这个代价我确实付出了,就是 M5-1 那个组合包缓存不一致的 bug。如果用注解,至少
@CacheEvict是标在方法上、更容易被 review 发现。所以这是一个权衡,不是纯粹的改进。如果重新设计,我会考虑用注解管”简单的一对一失效”,用 AOP 自定义注解管”跨缓存的联动失效”,而不是全手写散在 Controller 里。」
五、M5 复习优先级文件清单(第五部分 · M5 切片)
M5 只有 4 个文件、不到 250 行,全部看完也用不了 40 分钟。
A 类:必须能默写、能解释每一行为什么
| 优先级 | 文件 / 位置 | 看什么 | 为什么 |
|---|---|---|---|
| P0 | user/GoodsController.java 第 40-59 行(list 方法,20 行) | 完整 Cache-Aside:读 → 判 null → 回源 → 空值兜底 → TTL 分档 → 回填 | M5 的全部核心逻辑就在这 20 行。要能逐行解释 6 个步骤和每一行为什么这么写 |
| P0 | admin/GoodsController.java 第 128-138 行(startOrStop)+ 第 157-160 行(cleanCache) | ⚠️ 级联改了组合包状态、却只清物资缓存;KEYS 命令 | M5 最严重缺陷的现场。要能指着这两段说清”漏清了 combo:list:*” |
| P0 | GoodsServiceImpl.java 第 167-191 行(startOrStop) | comboMapper.update(status=DISABLE) 的级联停用 | 证明”数据库改了组合包”这件事,是 M5-1 论证的另一半 |
| P0 | RedisConfiguration.java(24 行) | 只设 Key 序列化器,Value 走 JDK | 要能说清”为什么必须设 Key 序列化器”(否则 KEYS/SCAN/redis-cli 都失效) |
B 类:知道流程即可
| 文件 / 位置 | 看什么 |
|---|---|
| user/ComboController.java 第 41-62 行 | 与物资同构,重点看 categoryId == null ? "all" : categoryId 和它上面的注释(记录 @Cacheable 的 null key 坑) |
| admin/ComboController.java 第 127-130 行 | cleanComboCache(同样是 KEYS) |
| RedisKeyConstant.java | 8 个 key 前缀,命名规范 业务:对象:标识 |
| admin/ShopController.java + user/ShopController.java | 采购开关:JDK 序列化的 Integer、无 TTL、fail-open |
| GoodsServiceImpl.java 第 212-229 行 | listWithSpecs 的 N+1(这正是缓存要掩盖的东西) |
sql/regress_api_test.sh | 第 107-110 行(@Cacheable null key 的回归保护注释)、第 256-261 行(缓存键断言) |
C 类:不用看
GoodsVO/Combo/GoodsSpec—— Lombok 数据类,只需知道都实现了Serializable(JDK 序列化的硬要求)application.yml/application-dev.yml的 Redis 连接段(database: 10)- 各个 Service 里的内存组装逻辑(属于 M6/M7)
40 分钟复习顺序
1. user/GoodsController.list 20 行 ← 8 分钟,逐行能解释
2. admin/GoodsController 第 128-160 行 ← 8 分钟,找出"漏清 combo 缓存"
3. GoodsServiceImpl.startOrStop 第 167-191 行 ← 4 分钟,确认级联改了什么
4. RedisConfiguration 24 行 ← 2 分钟,Key 序列化器的作用
5. user/ComboController 第 41-62 行 ← 4 分钟,对比 key 处理差异
6. 自己讲一遍"删缓存 vs 更新缓存" ← 8 分钟,这是必问
7. 自己讲一遍"穿透/雪崩/击穿各做了什么、没做什么" ← 6 分钟,重点是主动说没做击穿
六、M5 面试追问链(10 层)
追问链 1:缓存一致性(M5 最核心的一条,必问)
【面试官问题 1】
你这个物资列表用了缓存。管理员改了物资之后,缓存怎么办?
【推荐回答】
写后删除,也就是 Cache-Aside。管理员的新增、删除、修改、启停四个接口都会在数据库操作完成之后清掉 goods:list:* 的缓存,下次有人来读的时候回源重建。
【回答关键词】 Cache-Aside · 写后删除 · 懒加载重建
【可能继续追问】 为什么不直接更新缓存?
【面试官问题 2】
为什么不更新缓存,而是删掉?
【推荐回答】
三个理由。
第一是并发正确性,这是最关键的。更新缓存需要”先查库、再写缓存”两步,这两步之间没有互斥。两个并发写请求可能出现:A 查库拿到 {price=28},然后 B 查库拿到 {price=28, name=X} 并写入缓存,最后 A 把自己的 {price=28} 写回去——把 B 更新的 name 覆盖成了旧值。而删缓存不写值,所以不存在”用旧快照覆盖新值”的问题,DEL 本身也是幂等的。
第二是成本。删缓存恒定就是一条 DEL;更新缓存要重新查库、组装 VO、序列化、写 Redis。如果改完之后一小时都没人看这个分类,更新的成本全白花了——删缓存则是等到真的有人读时才重建,按需付费。
第三是实现复杂度。我的缓存 key 是按分类分片的(goods:list:{categoryId}),“更新”要精确算出改了哪些 key 并重新组装,容易漏;“删除”一把清掉,逻辑简单。
【回答关键词】 并发脏写(读库+写缓存非原子)· 幂等 · 按需重建 · 写多读少时更经济
【可能继续追问】 那先删缓存再写库行不行?
【面试官问题 3】
那反过来,“先删缓存、再写库”行不行?
【回答回答】
不行,这样更糟。 因为删完之后、数据库还没更新之前,如果有读请求进来,它会发现缓存未命中、回源查到的是【旧值】、然后把这个旧值写进缓存。等数据库更新完成后,缓存里是旧值,而且没有任何后续的删除动作去纠正它——只能等 TTL 过期。
所以顺序必须是先写库、后删缓存:这样即使有并发读,最坏情况也只是读到”写库前”的旧值(短暂),不会出现”新值已经在库里了、缓存里却被写回旧值”这种长时间不一致。
【回答关键词】 先删会引入”旧值回填”且无后续纠正 · 顺序必须是先写库后删缓存
【可能继续追问】 那”先写库后删缓存”就完全没有问题吗?
【面试官问题 4】
“先写库后删缓存”就完全没问题了吗?
【推荐回答】
理论上还有一个极小的窗口,我得说清楚。 要出问题需要这个时序:
读请求 R:GET 缓存未命中 → SELECT 读到【旧值】 → ……(被挂起)
写请求 W:UPDATE 数据库(提交) → DEL 缓存
读请求 R:(恢复执行)SET 缓存 = 【旧值】 ← 旧值被写回,且没有后续删除
触发条件是 R 在 SELECT 之后、SET 之前被长时间挂起(比如发生了 GC 停顿、或者线程被抢占),耗时超过了”写库 + 删缓存”。概率很低,但理论上存在。
业界解决它的办法是延迟双删:写完库、删一次缓存,然后延迟几百毫秒再删一次,把可能被写回的旧值也清掉。
我没有做延迟双删。 判断是:这是我这个场景的物资目录,一致性要求不高、TTL 只有 30 分钟,而且延迟双删要多一次删除 + 一次 sleep(要么起延迟线程、要么用延迟队列),引入的复杂度不划算。如果换成价格或者库存这种场景,我会加上。
【回答关键词】 主动说出理论窗口 · 需要读请求被挂起 · 延迟双删 · 权衡后不做的理由 · 什么条件下会做
【可能继续追问】 那如果删除缓存本身失败了呢?
【面试官问题 5】
如果删缓存这个操作失败了会怎样?
【推荐回答】
会出现”数据库是新的、缓存是旧的”的不一致,而且会持续到 TTL 到期。
我的代码里 cleanCache 没有 try-catch:redisTemplate.keys() 或 delete() 抛异常(Redis 抖动、网络异常)会直接向上抛,被 Spring 默认处理成 500。但数据库那边已经提交了——cleanCache 是在 @Transactional 之外、事务提交之后调的,所以事务不会回滚。
这是”写后删”策略的固有代价:删除失败没有重试。
补救通常有两个方向:
- 删除重试 —— 失败后把”需要删除的 key”投递到 MQ 或者写进一张重试表,隔几秒再删一次,删成功为止;
- TTL 兜底 —— 我设了 30~35 分钟的 TTL,最坏情况就是不一致持续这么久,之后自动恢复。
所以我给 TTL 的定位不只是”省内存”,它是我这个方案里唯一的一致性保底手段。 这两个补救我只有第二个(TTL),第一个(重试)没有做。
【回答关键词】 删失败则不一致 · cleanCache 无 try-catch · 在事务外所以库已提交 · 删除重试(未做) + TTL 兜底(做了)· TTL 是保底手段
【可能继续追问】 那更彻底的方案是什么?
【面试官问题 6】
有没有更彻底的方案,让业务代码不用操心清缓存?
【推荐回答】
有,订阅 MySQL 的 binlog 做异步失效。用 Canal(或者 Debezium)伪装成 MySQL 的从库,监听 binlog 变更,解析出”哪张表、哪个 id、什么操作”之后投递到 MQ,由消费端负责删掉对应的缓存 key。
它的最大优势是解耦:业务代码完全不用写清缓存的逻辑,也就不存在”新增接口忘记清缓存”这种问题——而我的项目恰好就犯了这个错(停用物资时会级联停用组合包,但代码只清了物资缓存、没清组合包缓存,导致员工端一直看到已停用的组合包)。用 binlog 方案,setmeal 表的变更被监听后会自动失效组合包缓存,这类跨模块的联动失效根本不用人记得。
代价是组件复杂度大幅上升:要部署 Canal、要有 MQ、要保证消费端的幂等和顺序、还要处理”binlog 解析失败”的告警。对一个课程/个人项目来说明显过度设计,但如果这是生产系统、缓存又不止两处,我觉得是值得的。
【回答关键词】 Canal/Debezium 订阅 binlog · 业务解耦 · 自动覆盖跨模块联动 · 恰好能解决我自己犯的漏删错 · 组件复杂度代价
【可能继续追问】 好,那你说的”漏清组合包缓存”是怎么回事?
【面试官问题 7】
你刚才说漏清了组合包缓存,具体是什么问题?
【推荐回答】
这是我这个模块里最严重的一个缺陷,而且能稳定复现。
GoodsServiceImpl.startOrStop 在停用物资时,会级联把包含这个物资的组合申领包也停用——它查 setmeal_dish 找到包含该物资的组合包 id,然后逐个 comboMapper.update(status = DISABLE)。
但调用它的是 admin/GoodsController.startOrStop,那个方法清缓存时只清了物资缓存:
goodsService.startOrStop(status, id);
cleanCache(RedisKeyConstant.GOODS_LIST_PREFIX + "*"); // ← 只有物资没有清 combo:list:*。
后果:员工端的组合包列表缓存里还留着那个已经停用的组合包,而且它是”启用”状态。更糟的是它可能包含那个刚被停用的物资。而 Redis 的 KEYS 是通配删除没错,但它删的是 goods:list:* 前缀,跟 combo:list:* 没关系。
最坏情况:员工看到并可以申领一个”包含已停用物资的、本该停用的组合包”,直到组合包缓存自然过期(30~35 分钟)。
根因不是写错了代码,而是架构问题:清缓存的逻辑散落在 Controller 层、靠人记得。Service 层改了数据,它不知道有哪些缓存需要失效。
【回答关键词】 级联改了 setmeal.status 却没清 combo 缓存 · 稳定可复现 · 脏缓存存活到 TTL · 根因是失效逻辑散落、靠人记得
【可能继续追问】 那怎么修?
【面试官问题 8】
这个怎么修?
【推荐回答】
短期修法是补一行——在 admin/GoodsController.startOrStop 里,如果状态是停用,就同时清组合包缓存:
goodsService.startOrStop(status, id);
cleanCache(RedisKeyConstant.GOODS_LIST_PREFIX + "*");
if (StatusConstant.DISABLE.equals(status)) {
cleanCache(RedisKeyConstant.COMBO_LIST_PREFIX + "*"); // 级联停用了组合包
}但这个修法我不满意,因为它还是”靠人记得”。根本问题是职责放错了位置:
正确的做法是把缓存失效从 Controller 挪到 Service,或者用一个统一的失效入口。我倾向两个方向之一:
- 把失效逻辑下沉到 Service 层 ——
GoodsServiceImpl.startOrStop里既改数据、也声明”我影响了哪些缓存”,Controller 不再管缓存。Service 最清楚自己动了什么,职责也对; - 自定义注解 + AOP 做声明式失效 —— 类似
@CacheEvict的思路,但支持多个 cacheName 和模式匹配。比如在startOrStop上标@EvictCache({"goods:list:*", "combo:list:*"}),这样”这个方法会影响哪些缓存”在方法签名上就能看到,review 时不容易漏。
我倾向第 2 种,因为它是声明式的、可见的——问题恰恰出在”这件事没有任何地方被声明出来”。
【回答关键词】 补一行是治标 · 根因是失效逻辑散在 Controller · 下沉到 Service · 或声明式注解+AOP · 可见性最重要
【可能继续追问】 那你怎么验证这个 bug 真的存在?
【面试官问题 9】
你怎么确认这个 bug 真的存在?测过吗?
【回答回答】
我是通过读代码确认的,链路是:admin/GoodsController.startOrStop(只清 goods:list:*)→ 它调的 GoodsServiceImpl.startOrStop(级联 comboMapper.update(status=DISABLE))。我把这三个文件对着看,确认了”改组合包状态”和”清组合包缓存”这两件事没有发生在一起。
但我必须承认:我没有写测试去验证它。
我的回归脚本 regress_api_test.sh 第 26 节只断言了缓存键存在(goods:list:* 和 combo:list:* 各找到一个),没有覆盖”停用物资后组合包缓存是否失效”这个场景。要验证它需要一个新用例:先访问组合包列表把缓存建起来 → 停用一个在某个组合包里的物资 → 再访问组合包列表 → 断言那个组合包不在返回结果里。这条用例我没有。
这是我测试覆盖上的一个缺口,而且是个比较典型的缺口——我的缓存测试只验证了”缓存能建起来”,没验证”缓存能被正确失效”。失效逻辑恰恰是缓存里最容易出错的部分。
【回答关键词】 靠读代码确认 · 承认没测 · 回归脚本只断言键存在 · 缺”失效正确性”的用例 · 失效是缓存最容易错的部分
【可能继续追问】 (通常转向穿透/雪崩/击穿)
追问链 2:缓存三大问题(高频,而且这一条能拉开差距)
【面试官问题 1】
说说缓存穿透、击穿、雪崩,你这个项目各做了什么?
【推荐回答】
我分开说做了什么和没做什么,因为这三个我并没有全做。
穿透:做了。空结果也缓存,用 60 秒的短 TTL,读取侧判 list != null 而不是判空列表,所以空列表也算命中、直接返回空、不打库。
雪崩:做了一半。正常结果的 TTL 是 30*60 + random(0,300) 秒,用随机抖动把过期时间摊开,避免同一时刻写入的 key 同一时刻失效。
击穿:没做,这是我主动承认的缺失。未命中时没有任何互斥,并发请求会各自回源。
【回答关键词】 分开说做了/没做 · 穿透:空值+短TTL · 雪崩:TTL抖动 · 击穿:未做
【可能继续追问】 那你的空值缓存真的能防住穿透吗?
【面试官问题 2】
空值缓存能完全防住穿透吗?
【推荐回答】
不能,我要说清它的能力边界。 它只能防”同一个不存在的 key 被反复查询”——比如前端传了一个已删除的分类 id,第一次查库返回空、写入缓存之后,后续这个 key 就不打库了。
但如果攻击者每次传不同的 categoryId(1、2、3……一直遍历),每个 key 都是”第一次查”,照样全部穿透到数据库,而且还会在我的 Redis 里塞进大量空值 key,占用内存、甚至可能把真正有用的缓存挤出去。
所以严格说,空值缓存是”防重复”而不是”防遍历”。
【回答关键词】 只防同一个 key · 遍历不同 key 照样穿透 · 会占内存 · 防重复 ≠ 防遍历
【可能继续追问】 那要怎么才能完全防住?
【面试官问题 3】
那完整的防穿透方案是什么?
【推荐回答】
两种主流方案,各有适用场景。
第一个是布隆过滤器。把所有存在的 categoryId 预先放到布隆过滤器里,查询前先过一遍——布隆过滤器的特性是”说不存在就一定不存在,说存在可能不存在”。这个”单向确定性”正好适合做拦截:只要它说不存在,就直接返回空、根本不用查库。优点是内存极小(每个元素几 bit)、能防”任意不存在的 key”。缺点是有误判率、删除元素困难(标准布隆过滤器不支持删除,要 Counting Bloom),而且要维护数据同步。
第二个是参数白名单校验。对我的项目来说这个性价比更高——因为 categoryId 是有限、可枚举的小集合(category 表就十几行)。我可以在查物资之前先确认这个分类 id 是否真实存在,不存在就直接返回错误,根本不会进入”查物资”的逻辑。
我的项目两个都没实现,只有空值缓存。如果要做,我会选参数白名单,因为分类集合小、逻辑直接、还顺带把”非法参数”和”合法但无数据”区分开了——现在这两者都返回空列表,前端没法区分。
【回答关键词】 布隆过滤器(单向确定性 + 内存小 + 不支持删除)· 参数白名单(小集合更合适)· 顺带区分非法参数和空数据
【可能继续追问】 那你的 TTL 抖动真的防住了雪崩吗?
【面试官问题 4】
你的随机抖动真的防住雪崩了吗?
【推荐回答】
只对一种雪崩有效,我要说清。 随机抖动解决的是”TTL 自然到期”导致的雪崩——同一时刻写入的 key 本来会同时过期,加了 0300 秒随机之后就摊开到 5 分钟区间里。粗略算一下:100 个 key 同时写入,因为抖动,TTL 落在 18002099 秒之间,那么在原来的”T=1800 秒”这个时刻,只有约 1/300 的 key(0.33 个)会过期,而不是全部 100 个,峰值被摊平了大约 300 倍。
但我的项目里,更可能的雪崩来源是”人为集中失效”,随机抖动对它完全无效。 因为:
cleanCache(RedisKeyConstant.GOODS_LIST_PREFIX + "*"); // 一次删掉所有分类的缓存管理员改一条物资,就会把全部分类的缓存一起删掉。这时所有 key 是”同时被删除”的,不是”过期”的——抖动在这条路径上一点用都没有。而且更糟的是,清完之后所有分类的 key 都不存在,接下来所有请求同时回源,正好撞上我那个 N+1 查询(每次回源 1+N 次 SQL)。
所以这个清缓存策略既是失效范围过大(改一个分类清全部),又是人为制造的集中失效。
【回答关键词】 抖动只对自然到期有效 · 100→0.33 的量化 · 人为集中失效对抖动无效 · 通配清除放大了问题 · 叠加 N+1 更严重
【可能继续追问】 那要怎么修?
【面试官问题 5】
那这个”改一条物资清掉全部缓存”的问题怎么修?
【推荐回答】
核心是改成精确失效,而不是通配清除。
因为我这个缓存 key 是按分类分片的(goods:list:{categoryId}),而分类是个有限、可枚举的小集合,所以我完全可以不扫键:
// 我倾向的修法(当前未实现)
goodsService.updateWithSpecs(dto);
// 只删这个物资真正影响到的分类
redisTemplate.delete(RedisKeyConstant.GOODS_LIST_PREFIX + dto.getCategoryId());
// 如果物资换了分类,旧分类也要删
if (oldCategoryId != null && !oldCategoryId.equals(dto.getCategoryId())) {
redisTemplate.delete(RedisKeyConstant.GOODS_LIST_PREFIX + oldCategoryId);
}
// "全部物资"那个共享 key 也要删(它包含所有在售物资)
redisTemplate.delete(RedisKeyConstant.GOODS_LIST_PREFIX + "null");这样一石三鸟:
- 不用
KEYS,变成几条精确的DEL,O(1) 而不是 O(N),不阻塞 Redis; - 失效范围最小,只让真正受影响的分类回源,避免大面积雪崩;
- 顺带解决”新增物资漏清全量缓存”的问题(见下一个追问)。
代价是要维护”这个操作影响哪些 key”的判断逻辑——物资换分类、物资停用(可能影响组合包)这些情况都要考虑全。但这正是缓存失效该付的成本,比”一把全清”更专业。
【回答关键词】 精确失效代替通配 · 分类是小集合可直接枚举 · 顺带解决 KEYS 和失效范围 · 代价是影响面判断要写全
【可能继续追问】 你刚才提到”新增物资漏清全量缓存”,那是什么问题?
【面试官问题 6】
“新增物资漏清全量缓存”是什么问题?
【推荐回答】
这是一个真实的不一致,而且四个入口里只有新增是这个行为。
员工端不传 categoryId 的时候(也就是点”全部”分类),key 是 goods:list:null,这个缓存里包含所有在售物资。
但我看了代码,物资的四个写入口里,新增和另外三个对”全量缓存”的处理不一致:
| 入口 | 清除模式 | 是否覆盖 goods:list:null |
|---|---|---|
新增(save) | cleanCache(GOODS_LIST_PREFIX + "*") | ✅ 前缀通配,这里恰好覆盖了 |
| 删除 | 同上 | ✅ |
| 修改 | 同上 | ✅ |
| 启停 | 同上 | ✅ |
等一下 —— 这四个入口用的都是 GOODS_LIST_PREFIX + "*" 通配。 所以当前代码里其实是一致的,都覆盖了 goods:list:null。
我要纠正一下自己的说法:这个问题在当前代码里不存在。它存在过——仓库里的《Redis 改造复核清单》第 J 条记录:
「新增物资只清单个分类缓存,漏清「全部」列表 ——
controller/admin/GoodsController.java:51-52,新增时只清goods:list:<categoryId>;但员工端不传分类时用的是goods:list:null… 不一致之处:修改/删除/启停三类操作用的都是goods:list:*(全清),只有新增是单清」
也就是说这一条已经被修复了(现在四个入口都是通配全清)。我刚才把它说成现存问题是我没核实清楚,属于我的错误。
【诚实说明】 面试时说”我项目里有这个问题”之前一定要去代码里确认——我差点因为读过旧文档就把一个已修复的问题说成现存的,这比不知道更糟。
真正现存的相关问题是另一个:这四个入口都是通配全清,也就是我上面讲的”改一条物资清掉所有分类缓存”——方向不是”清少了”,而是”清多了”。
【回答关键词】 主动纠正自己 · 那条已修复(有旧文档记录为证)· 真实现存问题是”清多了”不是”清少了” · 面试前必须核实代码
【可能继续追问】 (返回主线的击穿问题)
追问链 3:缓存击穿与 MQ 引入的取舍(5 层)
【面试官问题 1】
击穿你说没做,为什么不做?
【推荐回答】
三层理由。
第一,数据不热。 这是企业内部的物资目录,用户是公司员工,几十到几百人,不存在秒杀级别的瞬时并发。击穿的风险前提是”极高瞬时并发 + 单个热点 key 同时失效”,我这个场景不满足。
第二,回源代价可控。 单次回源是 listWithSpecs,也就是 1 + N 次 SQL(N 是分类下的物资数,约 10-20 条)。这不是昂贵操作——对比一下,如果回源要调第三方接口、或者做复杂的聚合计算,那就必须防击穿。
第三,成本不划算。 互斥重建需要一把分布式锁(多实例部署时 JVM 的 synchronized 无效),要么引入 Redisson,要么自己用 SET NX 加上轮询等待和锁超时处理。为一个可能不发生的问题引入新组件和一套失败处理逻辑,投入产出不划算。
【回答关键词】 数据不热 · 回源代价可控(1+N 次本地 SQL)· 分布式锁的引入成本 · 投入产出
【可能继续追问】 那如果要做,你会怎么做?
【面试官问题 2】
如果要做,你会怎么实现?
【推荐回答】
两个方案,我倾向前一个。
方案一:互斥重建(SET NX 自旋)
List<GoodsVO> list = (List<GoodsVO>) redisTemplate.opsForValue().get(key);
if (list != null) return Result.success(list);
String lockKey = key + ":lock";
Boolean got = stringRedisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS); // 带 TTL,防死锁
if (Boolean.TRUE.equals(got)) {
try {
list = goodsService.listWithSpecs(goods); // 只有抢到锁的回源
redisTemplate.opsForValue().set(key, list, ttl, TimeUnit.SECONDS);
} finally {
stringRedisTemplate.delete(lockKey);
}
} else {
Thread.sleep(50); // 没抢到的等一会儿再读缓存
list = (List<GoodsVO>) redisTemplate.opsForValue().get(key);
if (list == null) { // 兜底:还没好就自己回源,避免无限等待
list = goodsService.listWithSpecs(goods);
}
}优点:不需要引入 Redisson——项目里已经有 SET NX 的用法(防重复提交、催办限流),风格一致、零新组件。
缺点:请求要等待(增加响应时间);高并发下大量线程 sleep 自旋,有线程池耗尽的风险;还要处理”抢到锁的实例挂了”(靠锁的 TTL)。
方案二:逻辑过期
缓存里存 {data, expireAt} 这样的结构,Redis 的 key 不设 TTL(永不自动过期)。读到之后检查 expireAt:如果逻辑上过期了,就先返回旧数据,同时异步起一个线程去刷新缓存。
优点:请求永不阻塞,数据库压力只有 1 次。
缺点:返回的是旧数据(一致性容忍度要求高);要引入线程池(我项目里没有 @Async、没有自定义线程池)。
我倾向方案一,因为零新组件、风格一致;方案二的线程池对我这个项目来说是新引入的基础设施。
【回答关键词】 SET NX 自旋(零新组件、但有等待和自旋风险)· 逻辑过期(不阻塞、但返回旧值需线程池)· 倾向方案一
【可能继续追问】 你项目里为什么没有线程池?
【面试官问题 3】
你项目里有异步任务吗?为什么没有?
【推荐回答】
没有。全项目没有 @Async、没有自定义线程池、没有 @Scheduled 定时任务。 只有 Tomcat 自己的请求线程池。
没做的原因是业务上暂时不需要:
- 催办通知我改成了只写
remind_time(原来原项目有 WebSocket 实时推送,改造时移除了),管理端列表按这个字段排序优先处理,不需要异步推送; - 没有”下单后发短信/发邮件”这类附带动作;
- 没有”超时未审批自动取消”这类需要定时扫描的需求。
但如果要加,我会注意几件事:① 用自定义的 ThreadPoolExecutor 而不是 Executors.newFixedThreadPool(后者用无界队列,任务堆积时会 OOM);② 拒绝策略要明确(CallerRunsPolicy 还是直接拒绝);③ 异步任务里拿不到 ThreadLocal——我的 BaseContext 是 ThreadLocal 存的登录人,异步线程里读会是 null,必须显式把 userId 当参数传进去;④ 异步任务里的异常不会传播到主流程,必须自己 catch 和记日志。
第 ③ 点是这个项目特别容易踩的坑,因为我的 BaseContext 被 assertOwner、@AutoFill 切面、submitOrder 都依赖,一旦挪到异步线程就会静默失效。
【回答关键词】 无异步/无定时任务 · 业务暂不需要 · 自定义线程池不用 Executors · ThreadLocal 在异步线程失效(项目特有坑)· 异步异常要自己处理
【可能继续追问】 那这个项目里哪里适合用 MQ?
【面试官问题 4】
哪里适合用 MQ?为什么没用?
【推荐回答】
我识别到三个地方适合,但都没实现:
- 催办通知:现在只是写
orders.remind_time。如果以后要真的推送(短信、企业微信),应该走 MQ 异步,不该卡在员工请求的主链路上; - 单据完成后的下游动作:通知财务、同步 ERP、生成台账——这些是”主流程成功之后的附带动作”,适合 MQ 解耦,失败了也不影响主流程;
- 缓存失效:这就是我上面说的,写操作后把”要删哪些 key”投递到 MQ,消费端负责删除并失败重试——正好能解决我”删缓存失败没有重试”的问题(M5-4)。
没做 MQ 的原因是规模不匹配:这是个单机部署的内部系统,上面三件事目前要么不需要(推送)、要么是线下的(ERP 同步)、要么 TTL 能兜住(缓存失效)。引入 MQ 意味着要多维护一个中间件、要处理消息丢失和重复消费、要有死信队列的运维——对一个这个规模的项目是过度设计。
【回答关键词】 三个适用场景(催办推送/下游动作/缓存失效重试)· 都未实现 · 规模不匹配 · 引入成本 vs 收益 · 不要为了简历上写 MQ 而上 MQ
【可能继续追问】 那个”缓存失效投递 MQ”具体怎么做?
【面试官问题 5】
用 MQ 做缓存失效重试,具体怎么做?
【推荐回答】
大概是这样:
写操作 → 数据库提交 → 发一条消息到 MQ(内容是"要删的 key 列表")
↓
消费端收到 → 执行 DEL → 成功则 ack
→ 失败则 nack / 进重试队列
↓
重试若干次仍失败 → 进死信队列 + 告警
关键点有几个:
- 消息要在数据库提交之后发,否则会出现”消息发了但事务回滚了”——白删一次缓存(这个后果其实很轻,只是缓存多重建一次,可以接受);
- 消费端必须幂等——重复消费也就是多删一次缓存,
DEL天然幂等,所以这个场景对重复消费特别友好; - 不能保证”删除一定成功”,只能保证”最终会被重试”——所以 TTL 兜底仍然必须保留,它是最后一道防线;
- 顺序上,同一个 key 的失效消息如果乱序,影响也不大(最多是删早了、缓存重建时逻辑更乱一点,但因为最终都会删,不会永久不一致)。
这个方案的价值是”把删除失败从一个静默的问题变成一个有告警、有重试的问题”,但代价是引入 MQ。我当前的选择是”接受 TTL 兜底”,即不一致最多持续 35 分钟,我认为这是这个规模下更合理的取舍。
【回答关键词】 库提交后发消息 · 消费端幂等(DEL 天然幂等)· 不保证成功只保证重试 · TTL 仍需兜底 · 把静默问题变成可观测问题 · 仍选 TTL 的理由
【可能继续追问】 (通常转向序列化或 Redis 选型)
追问链 4:Redis 使用与序列化(5 层)
【面试官问题 1】
你的 RedisTemplate 是怎么配的?为什么只设了 Key 的序列化器?
【推荐回答】
我只设了 setKeySerializer(new StringRedisSerializer()),Value 用的是 RedisTemplate 的默认序列化器,也就是 JdkSerializationRedisSerializer。
必须设 Key 序列化器的原因是:如果不设,Key 会走 JDK 序列化,存进去是二进制。后果是 redis-cli 里 KEYS goods:list:* 一个都匹配不到(真实 key 前面有序列化头),手工排查和运维清缓存全都失效。我的迁移脚本里就要用 redis-cli --scan --pattern 'goods_*' 清缓存,如果 Key 是二进制序列化的,这条命令完全没用。
Value 没设是有意偷懒:默认的 JDK 序列化能直接存 List 对象和 Integer,不需要额外配置。代价是存进去是二进制乱码、体积大、且类结构变更后旧缓存反序列化会失败。
【回答关键词】 Key 必须 String 序列化否则 redis-cli/SCAN 失效 · Value 默认 JDK · 偷懒的代价
【可能继续追问】 那换成 JSON 序列化不是更好吗?
【面试官问题 2】
Value 换成 JSON 序列化不是更好吗?
【推荐回答】
可读性和体积上确实更好,但我的代码会立刻出问题,这是我换成 JSON 时会踩的坑。
我缓存的是 List<GoodsVO>:
List<GoodsVO> list = (List<GoodsVO>) redisTemplate.opsForValue().get(key);如果 Value 用 Jackson2JsonRedisSerializer,JSON 里不带泛型信息,反序列化时无法知道该转成什么类型,Jackson 会退化成 List<LinkedHashMap>。于是:
list.get(0).getName();
// java.lang.ClassCastException: java.util.LinkedHashMap cannot be cast to com.caiyuntai.vo.GoodsVO注意这个 ClassCastException 会因为泛型擦除而在运行时才暴露——(List<GoodsVO>) 这个强转本身不会报错(编译器会警告 unchecked),真正炸是在取字段的时候。
正确换法是:用 GenericJackson2JsonRedisSerializer(它会在 JSON 里写 @class 类型信息),或者用 Jackson2JsonRedisSerializer 时配合 TypeReference 显式指定反序列化类型(而不是直接强转)。
除了序列化配置,还有两个副作用要一起改:① 所有缓存实体需要能 JSON 序列化(LocalDateTime 要配 JavaTimeModule,否则抛异常,而我缓存的 GoodsVO 里正好有 updateTime 这个 LocalDateTime 字段);② 换序列化器等于缓存格式不兼容,上线时必须先清空旧缓存,否则旧数据反序列化失败。
所以”换 JSON”不是一个配置改动,是三个改动加一次缓存清空。 我当前没做,但如果要提升可维护性,我会用 GenericJackson2JsonRedisSerializer 一次性改完。
【回答关键词】 泛型擦除导致退化成 LinkedHashMap · ClassCastException 在取字段时才炸 · GenericJackson2JsonRedisSerializer 或 TypeReference · LocalDateTime 需要 JavaTimeModule · 换序列化器要清旧缓存
【可能继续追问】 那 StringRedisTemplate 和 RedisTemplate 你怎么分的?
【面试官问题 3】
项目里两个 Redis 模板都在用,怎么分的?
【推荐回答】
按”值是不是对象”和”要不要用数值命令”来分。
- 泛型
RedisTemplate:只用于对象缓存——goods:list:*、combo:list:*存List,shop:status存Integer; StringRedisTemplate:用于所有计数、限流、登录态——登录白名单、登录失败计数、单号INCR、防重复提交、催办限流。
这不是随便分的,是被命令约束逼出来的:INCR、SET NX 这类命令要求值是纯字符串数字,如果用 JDK 序列化的 Value,存进去的是 \xac\xed... 开头的二进制,INCR 会直接报错说值不是整数。而这些场景的值本来就是字符串(token、计数字符串),所以用 StringRedisTemplate 既正确又可读——redis-cli get login:fail:zhangsan 能直接看到数字。
【回答关键词】 对象缓存用泛型模板 · 计数/限流用 StringRedisTemplate · INCR 要求纯字符串值 · 可读性
【可能继续追问】 那缓存为什么不用 Hash 结构存?
【面试官问题 4】
为什么用 String 存,不用 Hash 或者 List?
【推荐回答】
因为我的读写模式是”每次要整个列表、从来不动单项”。
我的判断依据是三个问题:
- 要不要读写列表里的单项? 不要。每次都是整个分类的物资列表一把取出来。Hash 的价值在字段级读写(比如购物车
HSET cart:{uid} goodsId number,只改某一个商品的数量),我没有这个需求,拆成 Hash 只会变成 N 次 field 读取或者HVALS全取,收益为负; - 要不要按某个分数排序或范围查询? 不要。排序在 SQL 层就做完了(
ORDER BY create_time DESC),序列化进去时顺序已经固定。ZSet 的价值在按 score 排序,我不需要; - List 结构看起来最像,为什么不用? 因为
List的操作语义是”增删元素”(RPUSH/LPOP/LREM),而我的需求是”整块替换”——物资列表变了就应该整个换掉。用List得先DEL再重新RPUSH,是两步、不原子;而SET天然就是整体覆盖,一次操作、原子。
所以 String 是最贴合”整块快照”语义的选择。 判断数据结构选型的正确方式不是”数据长什么样”,而是”我会怎么读写它”。
【回答关键词】 按读写模式选型而非按数据形状 · 不需要字段级读写(排除 Hash)· 不需要排序(排除 ZSet)· 需要整块替换(String 的 SET 天然原子,List 要 DEL+RPUSH 两步)
【可能继续追问】 那 shop:status 那个缓存为什么没有 TTL?
【面试官问题 5】
shop:status 为什么没有 TTL?它和你其他缓存的定位一样吗?
【推荐回答】
不一样,这是我必须澄清的一点——shop:status 严格说不是缓存,它【就是唯一数据源】。 数据库里没有这张表、没有这个字段,采购开关的状态只存在于 Redis 里。
所以它的定位和其他几个完全不同:
| 项 | 物资/组合包缓存 | shop:status |
|---|---|---|
| 数据来源 | MySQL(Redis 是副本) | Redis 是唯一源 |
| TTL | 30~35 分钟 | 无 TTL |
| 丢失后果 | 回源重建,无影响 | 开关失效 |
因为它是唯一数据源,所以设 TTL 是错的——过期了就没有采购开关了。这也是它没有 TTL 的原因。
但这里有一个我意识到的隐患:它的失效行为是 fail-open(失败即放行)。 我的消费逻辑是:
Integer shopStatus = (Integer) redisTemplate.opsForValue().get(RedisKeyConstant.SHOP_STATUS);
if (shopStatus != null && shopStatus == 0) {
throw new OrderBusinessException(MessageConstant.SHOP_CLOSED);
}如果 Redis 重启、这把键丢了,get 返回 null,shopStatus != null 为 false,判断会走到”允许提交”分支。
也就是说:Redis 丢数据会静默地把”采购已关闭”变成”采购开放”。
这算一个真实的取舍缺陷。 如果这个开关有合规意义(比如”财务封账期间禁止申领”),应该改成 fail-safe:
// 只有明确等于 1(开放)才放行,其余一律拒绝
if (!Integer.valueOf(1).equals(shopStatus)) {
throw new OrderBusinessException(MessageConstant.SHOP_CLOSED);
}或者更稳的做法是把开关落到数据库,Redis 只作为加速层——这样 Redis 丢了也还能回源。当前是 fail-open,我知道这个方向是错的。
【回答关键词】 shop:status 是唯一数据源不是缓存 · 无 TTL 是对的 · fail-open 是隐患 · 应改 fail-safe(只认 1)· 或落库 + Redis 加速
【可能继续追问】 (通常收尾)
六、M5 容易被质疑的地方(严格视角,不强行合理化)
| # | 问题 | 面试官为什么可能质疑 | 当前代码实际情况 | 我应该怎么解释 | 是否建议修改 |
|---|---|---|---|---|---|
| M5-1 | 停用物资级联停用组合包,但没清组合包缓存 | ”你这个联动停用的意义是什么?缓存里还看得到吗?” | ✅ 成立,可稳定复现。GoodsServiceImpl.startOrStop:175-190 会 comboMapper.update(status=DISABLE) 级联停用组合包;但调用它的 admin/GoodsController.startOrStop:135 只清 goods:list:*,没清 combo:list:*。员工端组合包缓存里会继续显示已停用的组合包,最长 35 分钟(TTL) | 主动承认,并给出两层修法:表面修是补一行 cleanCache(COMBO_LIST_PREFIX + "*");根本修法是把缓存失效从 Controller 下沉到 Service,或用声明式注解(@EvictCache({"goods:list:*","combo:list:*"}))让”这个方法影响哪些缓存”在代码里可见。根因是失效逻辑散落在 Controller、靠人记得,不是写错了某一行 | 建议修(M5 最高优先级)。这是”缓存与数据库不一致”的实际案例,而且暴露的是架构问题(失效逻辑没有统一入口),比单个 bug 更有讲头 |
| M5-2 | cleanCache 用 KEYS 命令 | ”KEYS 在生产环境能用吗?” | ✅ 成立。两处 redisTemplate.keys(pattern)(admin/GoodsController:158、admin/ComboController:128)。KEYS 是 O(N) 且阻塞整个 Redis 实例 | 主动承认,并说清它阻塞的不只是缓存——登录态白名单、防重复提交的 SET NX、单号 INCR、催办限流全都走 Redis,所以一次清缓存可能让所有员工请求集体卡顿。最优修法不是换 SCAN,而是根本不扫键:分类是有限可枚举的小集合,直接查 category 表拿 id 逐个 DEL 精确 key(goods:list:{id} + goods:list:null),O(1) 且顺带解决”清多了”的问题 | 建议修(改精确 DEL,一举解决 KEYS 阻塞 + 失效范围过大两个问题) |
| M5-3 | 失效范围过大:改一条物资清掉所有分类缓存 | ”改一个分类的物资,其他分类的缓存为什么也要清?” | ✅ 成立。四个物资写入口都调 cleanCache(GOODS_LIST_PREFIX + "*"),通配清除所有分类的缓存。后果:一次写操作造成大面积人为集中失效→ 后续请求全部回源(且回源撞上 N+1) | 「这是方向错了——不是”清少了”而是”清多了”。因为缓存 key 是按分类分片的,改成精确删受影响的分类即可(物资改分类时新旧分类都要删,还要删 goods:list:null 那个全量 key)。这样既避免大面积回源,也不用 KEYS。」 | 建议修(与 M5-2 同一个修法,一次改动解决两个问题) |
| M5-4 | 删缓存失败没有重试,只有 TTL 兜底 | ”如果 Redis 挂了,删缓存失败,一致性怎么办?“ | ⚠️ 成立。cleanCache 无 try-catch;异常会走 Spring 默认 500,但数据库已提交(cleanCache 在 @Transactional 之外)→ 缓存与库不一致,持续到 TTL 到期(最长 35 分钟) | 「这是”写后删”策略的固有代价。补救有两个方向:删除重试(MQ / 重试表)和 TTL 兜底——我只有第二个。所以我给 TTL 的定位不只是省内存,它是我方案里唯一的一致性保底手段。如果上量,我会把”要删的 key”投递到 MQ 做重试,并把失败告警出来,把静默问题变成可观测问题。」 | 可选(TTL 已能兜住;上 MQ 重试是过度设计,但要能讲出这个取舍) |
| M5-5 | 空值缓存防不住遍历式穿透 | ”攻击者每次传不同的 categoryId 呢?“ | ⚠️ 成立。空值缓存只能防”同一个不存在的 key 被反复查”;遍历不同 categoryId 时每个 key 都是首次查询,照样穿透,而且会在 Redis 里堆积大量空值 key | 「说清能力边界:这是防重复,不是防遍历。完整方案是参数白名单校验(categoryId 是小集合,先查 category 表确认存在,性价比最高)或布隆过滤器(内存小、能防任意不存在的 key,但有误判率、不支持删除)。两个都没实现,顺带还能解决”非法参数”和”合法但无数据”都返回空列表、前端无法区分的问题。」 | 建议加参数校验(成本低、顺带改善语义) |
| M5-6 | 物资缓存 key 用 goods:list:null,组合包用 combo:list:all | ”这两个 key 命名为什么不一致?“ | ⚠️ 成立,功能正确但不一致。物资是 GOODS_LIST_PREFIX + categoryId(null 时得到字面量 "goods:list:null");组合包显式归一为 "combo:list:all"。两者各自内部自洽(写和读用同一个 key),所以不影响功能 | 「goods:list:null 这个 key 名其实是个语义噪音——它的真实含义是”全部物资”。组合包那边显式归一成 all 更清楚。物资这边也应该统一成 all,但要注意改 key 名等于缓存格式变更,上线时要清一次旧 key(goods:list:null),否则会留下一个永不命中的僵尸 key(TTL 到期后自然消失)。」 | 建议统一(改动小,但要记得清旧 key) |
| M5-7 | 缓存击穿未处理 | ”热点 key 失效瞬间怎么办?” | ✅ 成立(有意未做)。未命中时无任何互斥,并发请求各自回源 | 主动承认是加分不是减分。理由:数据不热(企业内部系统、几十到几百人)· 回源代价可控(1+N 次本地 SQL,N≈10-20)· 互斥重建需要分布式锁,引入成本不划算。如果要做,倾向 SET NX 自旋(项目已有 SET NX 用法,零新组件),而不是引入 Redisson;并说清它的缺点(请求等待、高并发下 sleep 自旋有线程池耗尽风险) | 不建议改(但要能完整讲出取舍与实现方案) |
| M5-8 | Value 用 JDK 序列化 | ”为什么用 Java 序列化?换成 JSON 不是更好?“ | ⚠️ 成立。GoodsVO/Combo 走 JdkSerializationRedisSerializer:二进制不可读、体积大 1.5-3 倍、类结构变更后旧缓存反序列化失败 | 「用 JDK 是图省事(默认配置,能直接存 List 和 Integer)。换 JSON 有具体的坑:缓存的是 List<GoodsVO>,JSON 不带泛型信息,反序列化退化成 List<LinkedHashMap>,list.get(0).getName() 会抛 ClassCastException——而且因为泛型擦除,这个错误在取字段时才炸,强转本身不报错。正确换法是用 GenericJackson2JsonRedisSerializer,或者配合 TypeReference 显式指定类型。另外 GoodsVO 里有 LocalDateTime 字段,还要配 JavaTimeModule,否则序列化直接抛异常。换序列化器必须清空旧缓存(格式不兼容)。所以它不是配置改动,是三个改动加一次清空。」 | 可选(提升可维护性,但要一次性改完) |
| M5-9 | shop:status 丢失时是 fail-open | ”Redis 挂了,采购开关会怎样?” | ✅ 成立。get 返回 null → shopStatus != null && shopStatus == 0 为 false → 走”允许提交”。即 Redis 丢数据会静默把”已关闭”变成”开放” | 「shop:status 不是缓存,是唯一数据源(数据库里没有),所以无 TTL 是对的。但fail-open 这个方向是错的——如果开关有合规意义,应该改成 fail-safe:if (!Integer.valueOf(1).equals(shopStatus)) throw ...(只有明确等于 1 才放行)。更稳的是把开关落库、Redis 只做加速层,这样丢了也能回源。」 | 建议修(一行判断,消除一个安全语义上的 fail-open) |
| M5-10 | 组合包明细接口(/user/combo/goods/{id})没有缓存 | ”缓存覆盖范围是怎么定的?“ | ⚠️ 成立。ComboServiceImpl.getGoodsItemById 无缓存,每次都查库。而组合包列表有缓存 | 「缓存的覆盖范围是半截的:列表走了缓存、点进去看包含哪些物资却每次查库。当时只缓存了列表接口,没系统性梳理”哪些读接口值得缓存”。判断标准应该是一致的:读多写极少、数据非个性化、回源代价随时间累积——按这个标准,包内物资明细同样值得缓存,而且它的 key 天然可以是 combo:goods:{comboId},失效时机和组合包列表完全一致(改组合包时一起清)。」 | 建议加(一致化的缓存覆盖) |
| M5-11 | 回归脚本对 shop:status 的断言只能证明”键存在" | "这条断言验证了什么?“ | ⚠️ 成立。regress_api_test.sh:260 是 K3=$(redis-cli ... get 'shop:status'),然后 [ -n "$K3" ]。但 shop:status 是用泛型 RedisTemplate(JDK 序列化)写的,redis-cli get 拿到的是二进制乱码,只能判非空、判不了值是不是 1 | 「这条断言实际上只能证明键存在,证明不了值插对了。要真正验证,要么通过接口断言(GET /user/shop/status 返回的 status 应该等于设置的值),要么用 redis-cli --no-raw 看序列化内容。这属于测试有效性不足——断言写了但没断言到点子上。」 | 建议改(改成接口级断言更有意义) |
| M5-12 | cleanCache 对 keys() 返回 null 未判空 | ”如果 Redis 里一个 key 都没有呢?“ | ⚠️ 潜在。Set keys = redisTemplate.keys(pattern); redisTemplate.delete(keys); 没有判 keys 是否为空/null。Spring Data Redis 的 keys() 在无匹配时返回空 Set(不是 null),delete(emptySet) 也不报错,所以当前不触发 | 「当前代码是安全的:无匹配时 keys() 返回空 Set,delete(空集合) 是无害操作。但不判空是脆弱写法——如果哪天换成别的实现返回 null,delete(null) 就可能抛异常。加一行 if (CollectionUtils.isEmpty(keys)) return; 是零成本的防御。」 | 建议改(防御性) |
明确不是问题、但可能被问的点
| 点 | 回答 |
|---|---|
| 项目没有用 Spring Cache | 是主动改掉的,不是遗漏。原因是 @Cacheable(key="#categoryId") 在 categoryId 为 null 时会抛 IllegalArgumentException: Null key returned for cache operation——这是必然触发的 bug(员工端”查全部”就是不传分类),回归脚本第 107-110 行专门留了回归保护;另外注解表达不了”空值 60s / 正常 30min+jitter”的差异化 TTL,也表达不了 goods:list:* 这种模式化失效。代价是缓存逻辑散在 Controller、没有机制保证”改了数据一定记得清缓存”——这个代价我确实付出了(M5-1) |
缓存里存的是二进制,redis-cli 看不出来 | JDK 序列化的必然结果。但 Key 是可读的(设了 StringRedisSerializer),所以 KEYS/SCAN/DEL 都能正常用——这是最关键的一点 |
shop:status 用泛型 RedisTemplate 存 Integer,和计数类用 StringRedisTemplate 不一致 | 可以统一成 StringRedisTemplate 存 "0"/"1",这样 redis-cli 可读、回归脚本也能真正断言值。当前没统一,属于一致性上的小瑕疵(关联 M5-11) |
| 为什么缓存写在 Controller 而不是 Service | 有争议的取舍:缓存和限流是接入层关注点,放 Controller 符合分层、也能直接拿 BaseContext;但代价是 Service 层拿不到缓存复用,且失效逻辑散落、无人守卫(这正是 M5-1 的根因)。如果要重做,我会把缓存下沉到 Service,让”改数据”和”声明失效”在同一个地方 |
七、M5 关键数字与事实速查(面试前扫一眼)
| 事实 | 值 / 位置 |
|---|---|
| 缓存实现方式 | 手写 RedisTemplate,无 Spring Cache(无 @EnableCaching、无 @Cacheable、无 spring-boot-starter-cache 依赖) |
| 缓存 key 总数 | 2 类:goods:list:{categoryId}、combo:list:{categoryId|all};另有 shop:status(非缓存,是唯一数据源) |
| Key 序列化 | StringRedisSerializer(必须设,否则 KEYS/SCAN/redis-cli 全部失效) |
| Value 序列化 | 默认 JdkSerializationRedisSerializer(未显式设置)→ 二进制、体积大、类变更即失效 |
| 两个模板的分工 | RedisTemplate(泛型)→ 对象缓存;StringRedisTemplate → 计数/限流/登录态(INCR 要求纯字符串值) |
| TTL 策略 | 空结果 60s;正常 1800 + ThreadLocalRandom.nextInt(300) = 30~35 分钟;shop:status 无 TTL |
| 防穿透 | ✅ 空值缓存 + 短 TTL + 读侧判 list != null(只防同一 key,防不住遍历) |
| 防雪崩 | ⚠️ 只做了 TTL 抖动(对自然到期有效,对人为集中失效无效) |
| 防击穿 | ❌ 未做(有意,主动承认) |
| 一致性策略 | Cache-Aside,先写库(事务内)→ 后删缓存(事务外);删而非更新 |
| 删除失败 | 无重试,靠 TTL 兜底(最长 35 分钟不一致) |
KEYS 使用处 | 2 处:admin/GoodsController:158、admin/ComboController:128 |
| 清缓存入口 | 物资 4 个(新增/删除/修改/启停,全在 Controller)、组合包 4 个(同,全在 Controller) |
| 已知 bug | 停用物资级联停用组合包,但没清 combo:list:* → 员工端看到已停用组合包,最长 35 分钟(M5-1) |
| 回源 N+1 | GoodsServiceImpl.listWithSpecs:212 → 1 + N 次 SQL,被缓存掩盖 |
shop:status 失效行为 | fail-open(键丢失即放行),应改 fail-safe |
| 回归验证 | regress_api_test.sh 第 26 节(只断言缓存键存在,未验证失效正确性)、第 7 节(@Cacheable null key 回归保护)、第 107-110 行注释 |
| 未实现 | Spring Cache、布隆过滤器、延迟双删、互斥重建、逻辑过期、Canal/binlog 失效、MQ 重试、缓存预热、接口级限流、多级缓存 |
八、如果只给我 3 分钟讲 M5
背景:物资目录和组合申领包是读多写极少的数据——员工每次进申领页都要拉,管理员偶尔才改。而且物资列表的回源不是一条 SQL,是 1+N(查物资 + 循环查规格),所以缓存不只是”优化”,是刚需。
方案:Cache-Aside,手写
RedisTemplate。读的时候先GET,判list != null(不是判空列表)——因为空列表也要当命中;未命中就回源,回源后空结果用 60 秒短 TTL、正常结果用 30 分钟加 0~300 秒随机抖动。为什么写路径删缓存而不是更新缓存:更新缓存要”先查库、再写缓存”两步且无互斥,并发下会出现用旧快照覆盖新值;
DEL不写值所以天然安全、还是幂等的,成本恒定,而且写多读少时不会白做功。顺序必须是先写库、后删缓存——反过来会让读请求把旧值加载回缓存且没有后续纠正。我知道的问题,必须主动说:
- 有一个真实的一致性 bug——停用物资时会级联停用包含它的组合包,但清缓存的代码只清了
goods:list:*、没清combo:list:*,所以员工端会继续看到已停用的组合包,最长 35 分钟。根因不是写错一行,而是缓存失效逻辑散落在 Controller、靠人记得;cleanCache用了KEYS命令,O(N) 且阻塞整个 Redis 实例——而我的登录态校验、防重复提交、单号INCR全都走 Redis,所以一次清缓存可能让所有员工请求集体卡顿。最优修法不是换SCAN,而是根本不扫键:分类是有限小集合,直接逐个DEL精确 key,顺带把”改一条物资却清掉所有分类缓存”这个失效范围过大的问题一起解决;- 防击穿没做——未命中时没有互斥,并发请求各自回源。理由是企业内部系统数据不热、回源只有 1+N 次本地 SQL,为它引入分布式锁不划算。要做的话我会用
SET NX自旋(项目里已有这个用法),而不是引入 Redisson;- 空值缓存只能防”同一个 key 重复查”,防不住遍历不同
categoryId,完整的做法是参数白名单校验或布隆过滤器,都没实现;shop:status是 fail-open 的——它不是缓存而是唯一数据源,Redis 丢键会静默地把”采购已关闭”变成”开放”,应该改成只认1的 fail-safe。