楼层: 首页/ 软件技术/ Spring 核心框架/ 缓存抽象 @Cacheable 与 Redis 集成:让重复查询不必重复算
13

缓存抽象 @Cacheable 与 Redis 集成:让重复查询不必重复算

Spring Cache Abstraction · Redis Integration

一个接口第一次要 800 毫秒,加缓存后 3 毫秒——这是后端最立竿见影的优化。但缓存也是"引入 bug 最快"的技术:数据不一致、雪崩、内存爆掉,全是它的锅。这一章先讲 Spring 的缓存抽象层(让业务代码不感知具体缓存实现),再讲和生产级 Redis 打交道的那些真实细节。

缓存抽象是什么:一层接口,多种实现

Spring 从 3.1 开始提供缓存抽象。它的核心思想是:业务代码只声明"这个方法的结果可以缓存",至于缓存存在哪里、怎么存,由配置决定。今天用内存 Map 跑单元测试,明天换成 Redis,业务代码一行不改。

抽象层的四个核心接口

// 1) CacheManager:缓存的"工厂",按名字返回一个 Cache public interface CacheManager { Cache getCache(String name); // 不存在返回 null Collection<String> getCacheNames(); } // 2) Cache:一个具名缓存区域,本质是个 Map<Object, ValueWrapper> public interface Cache { String getName(); ValueWrapper get(Object key); // 读,注意返回的是包装对象 void put(Object key, Object value); // 写 void evict(Object key); // 删 void clear(); // 清空整个区域 } // 3) KeyGenerator:默认用 SimpleKeyGenerator,多个参数时拼成 SimpleKey // 4) CacheResolver:决定"这次调用用哪个 Cache",多租户场景常用
CacheManager 实现接入方式特点
ConcurrentMapCacheManager零依赖,默认就是 ConcurrentHashMap。没有过期、没有容量上限、进程内。只适合单机演示和测试,生产用了迟早 OOM。
RedisCacheManager加 spring-boot-starter-data-redis分布式缓存的主力。支持 TTL、支持多节点共享,是这一章的重点。
CaffeineCacheManager加 com.github.ben-manes.caffeine:caffeine进程内高性能缓存,有 TTL、有 LRU/LFU 淘汰、有容量上限。用作一级缓存非常合适。
CompositeCacheManager代码组合把多个 CacheManager 串起来,按顺序查找——两级缓存的官方组合方式。

论三个必须记住的抽象层规则

① 缓存注解基于 AOP,所以自调用失效:跟 @Transactional、@PreAuthorize 是同一个坑。this.cachedMethod() 不会走代理,缓存直接不生效。要自调用就注入自己(@Lazy 注入本类)或抽到另一个 Bean 里。

② 默认按"方法返回值"缓存 null 会出问题:默认 @Cacheable 会把 null 也缓存起来(cacheNullValues 在 Redis 实现里默认是 true),这恰好是防缓存穿透的免费方案。但如果你的缓存实现不支持存 null,就会每次都穿透。要用 unless = "#result == null" 明确表态。

③ 缓存的是"返回值对象",不是方法调用:所以序列化格式极其重要。对象改了字段、加了字段,反序列化时可能直接报 ClassCastException 或丢字段。缓存里的数据格式也是一种 API,改 DTO 要想着清缓存。

四个注解的语义差别:别再混用 @Cacheable 和 @CachePut

这四个注解长得像,但语义完全不同。用错了不会报错,只会静默地返回脏数据——这是最难受的一类 bug。

注解对缓存做什么典型场景与注意点
@Cacheable先查缓存:命中就直接返回不执行方法;未命中执行方法并写入缓存查询方法。标志性特征是"方法可能一次都不执行"。
@CachePut一定执行方法,然后把返回值写入缓存(不是跳过)更新方法:需要更新数据库并把新值刷进缓存。注意它不走"先查缓存"逻辑,所以绝不会返回旧值。
@CacheEvict删除指定 key;allEntries = true 时清空整个区域删除/更新方法。更新时最常用它而不是 @CachePut——理由见下一节的一致性讨论。
@Caching把上面三个组合在一个方法上一个方法要同时操作多个缓存区域,例如"更新商品后,既删商品缓存又删分类下的商品列表缓存"。

四个注解的真实用法

@Service public class ProductService { // 1) 查:命中即返回,不执行方法体 @Cacheable(cacheNames = "product", key = "#id", unless = "#result == null") // 不缓存 null(若你明确要防穿透则反过来) public Product getById(Long id) { return productMapper.selectById(id); } // 2) 改:方法一定执行,新值覆盖缓存 @CachePut(cacheNames = "product", key = "#p.id") public Product update(Product p) { productMapper.updateById(p); return productMapper.selectById(p.getId()); } // 3) 删:删掉指定 key @CacheEvict(cacheNames = "product", key = "#id") public void delete(Long id) { productMapper.deleteById(id); } // 4) 组合:一次改多块缓存。beforeInvocation = true 表示"方法执行前就删" @Caching( evict = { @CacheEvict(cacheNames = "product", key = "#p.id"), @CacheEvict(cacheNames = "productList", allEntries = true) } ) public void updateWithList(Product p) { productMapper.updateById(p); } }
@Cacheable 与 @CachePut 放在同一个方法上:等于没缓存

有人想"查询时缓存、更新时刷新",就在一个方法上同时贴 @Cacheable 和 @CachePut。结果是每次调用都执行方法、每次调用都写缓存——缓存命中率永远是 0,白搭一层开销。正确做法:查和改是两个方法,分别贴两个注解。

另一个经典错误:@CacheEvict 贴在 @Transactional 方法上,而 beforeInvocation 默认是 false(方法成功后才删)。这本身是对的,但如果事务后面回滚了,缓存已经被删了一次——空删除无害,反而安全。真正危险的是用 @CachePut 在事务里提前写缓存:事务回滚后,缓存里却留着没提交的数据,这就是脏数据。

key 与 condition:SpEL 的正确打开方式

默认的 key 生成器叫 SimpleKeyGenerator:无参方法用 SimpleKey.EMPTY,单参直接用该参数,多参拼成 SimpleKey。它能用,但可读性差、跨版本不稳定,所以生产代码基本都会显式写 key。

SpEL 写法说明
"#id"引用方法参数。需要编译时保留参数名(-parameters,Spring Boot 默认开启)。
"#p0" / "#a0"按下标引用第 1 个参数。参数名丢失时的保底写法,推荐用于关键缓存,不怕重构改参数名。
"#user.id"引用对象属性的 getter(getId())。
"#root.methodName"方法名。#root 还可取 target、args、caches。
"#id + ':' + #type"字符串拼接,构造复合 key。建议加分隔符,避免 "1"+"2" 和 "12" 撞车。
"'PRODUCT_' + #id"加业务前缀。多个缓存共用一个 Redis DB 时便于排查和批量清理。
condition = "#id > 0"调用前判断:不满足就完全不走缓存逻辑(既不查也不写)。适合"非法参数不缓存"。
unless = "#result == null"调用后判断:只看结果,不满足就不写缓存。注意 unless 里才有 #result,condition 里没有。

一个容易踩的写法对比

// 错误示范:condition 里用 #result,直接抛 SpEL 异常 // @Cacheable(cacheNames = "p", condition = "#result != null") ← 运行期报错 // 正确:判参数用 condition,判结果用 unless @Cacheable(cacheNames = "product", key = "'product:' + #p0", condition = "#p0 != null && #p0 > 0", unless = "#result == null || #result.status == 'DELETED'") public Product get(Long id) { return productMapper.selectById(id); }

论为什么强烈建议显式写 key

① 参数名丢失的隐性故障:如果编译时没带 -parameters(老项目 Gradle/Maven 配置漏了),#id 会解析失败,启动时不一定报错,运行时才炸。用 #p0 或 #a0 完全免疫这个问题。

② 默认 SimpleKey 的可读性太差:多参方法生成的 key 在 Redis 里长这样:SimpleKey [1001,SHANGHAI],还带不可见字符。运维排查时看到这个只能靠猜。显式写成 "order:1001:SHANGHAI" 一眼就懂。

③ 跨方法共享缓存时必须自定义:getById(Long id) 和 getDetail(Long id, String lang) 如果要复用同一份缓存数据,就必须让两者的 key 生成规则一致,否则永远命中不了。

缓存三大坑:穿透、击穿、雪崩

这三个词在面试里常被问,在线上则常被"三连击"——它们经常同时发生,所以也要一起防。

名字现象对策
穿透查询根本不存在的数据。缓存里没有,数据库里也没有,于是每个请求都落到数据库。典型是攻击者用 id=-1 疯狂刷接口。① 缓存空值:查不到也写一个 null(或哨兵值),TTL 设短(如 60 秒)。② 布隆过滤器:启动时把所有合法 ID 灌进去,请求先过过滤器。③ 参数前置校验,拒绝非法 ID。
击穿某一个热点 key 恰好在高并发瞬间过期,几千个请求同时发现缓存没了,同时去查数据库("缓存失效时的惊群")。① 互斥锁重建:只让一个线程去查库,其他线程等待或用旧值。② 逻辑过期:value 里存一个逻辑过期时间,过期后异步续,不删 key——保证永远有值可返回。③ 热点数据设置长 TTL 或永不过期。
雪崩大批 key 同时过期(例如凌晨统一预热,TTL 都设成 1 小时),或者缓存节点整体挂掉,流量瞬间全压到数据库。① TTL 加随机抖动:base + random(0, base*0.2),把过期时刻打散。② 多级缓存:本地 Caffeine 先扛一层。③ 缓存集群高可用(哨兵/Cluster)。④ 数据库侧限流与熔断兜底。

穿透与击穿的代码级对策

// 对策一:缓存空值 + 随机 TTL,防穿透也防雪崩 @Cacheable(cacheNames = "product", key = "'product:' + #p0", cacheManager = "redisCacheManagerWithJitter") // 自带抖动 TTL 的 manager public Product getForAntiPenetration(Long id) { // 查不到就返回哨兵对象(而不是 null),让缓存里始终有值 Product p = productMapper.selectById(id); return p != null ? p : Product.emptyOf(id); } // 对策二:手写互斥锁重建(不用注解,因为注解做不到"锁住重建过程") public Product getWithMutex(Long id) { String key = "product:" + id; Object cached = redisTemplate.opsForValue().get(key); if (cached != null) return (Product) cached; String lockKey = key + ":lock"; // setIfAbsent 等价于 Redis 的 SET NX,返回 true 表示抢到锁 Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", Duration.ofSeconds(10)); if (Boolean.TRUE.equals(locked)) { try { Product p = productMapper.selectById(id); // 只有抢到锁的线程查库 redisTemplate.opsForValue().set(key, p, jitterTtl(Duration.ofMinutes(30))); // TTL 带抖动 return p; } finally { redisTemplate.delete(lockKey); } } // 没抢到锁:短暂自旋等待,绝不直接打库(那锁就白加了) sleep(50); return getWithMutex(id); } private Duration jitterTtl(Duration base) { long extra = ThreadLocalRandom.current() .nextLong(0, base.toSeconds() / 5); // 最多加 20% 抖动 return base.plusSeconds(extra); }
这三坑的三种错误解法

① 用 synchronized 防击穿:单机有效,多实例部署时每个实例各锁各的,照样全量打库。分布式场景必须用 Redis 锁或本地"逻辑过期"方案。

② 把所有 TTL 设成永不过期防雪崩:那是把雪崩换成了"内存爆满 + 数据永久脏"。永不过期只适合真·热点且变更极少的数据(如省市字典),并且必须配合主动刷新。

③ 用布隆过滤器但忘了处理"删除":标准布隆过滤器不支持删除元素——你删了一个商品,过滤器里那个 ID 还在,请求会穿过过滤器去查库。要么用计数型布隆过滤器(占更多内存),要么接受这个"漏判"(它只会让少量请求漏过去,不会误杀合法请求)。记住布隆过滤器的特性是"说不存在就一定不存在,说存在可能不存在"。

Spring Cache + Redis:TTL 与序列化怎么配

这是落地时最花时间的一节。默认配置能跑,但几乎一定不能满足生产要求,主要卡在两个点:TTL 不受 Spring Cache 控制,默认序列化格式不可读。

序列化器Redis 里长什么样评价
JdkSerializationRedisSerializer\xac\xed\x00\x05sr\x00...(二进制乱码)RedisCacheManager 的默认值。缺点:① 不可读,运维无法排查;② 类必须先实现 Serializable;③ 类一改(加字段、改包名)就 InvalidClassException;④ 体积大、跨语言不可用。生产必须换掉。
GenericJackson2JsonRedisSerializer{"@class":"com.example.Product","id":1,...}最常用的替换方案。可读、跨语言、支持任意类型(靠 @class 元数据还原类型)。缺点:写入 @class 有额外体积,且包名改了老缓存就反序列化失败。
Jackson2JsonRedisSerializer{"id":1,"name":"x"}不带类型信息,体积最小。缺点:必须为每个类型单独构造序列化器,用一个序列化器缓存多种类型时会失败。适合缓存类型单一的场景。
StringRedisSerializer纯字符串缓存 JSON 字符串或简单值时用,key 一定要用它(否则 key 也是二进制乱码)。

生产级 RedisCacheManager 配置:JSON 序列化 + 全局 TTL + 按缓存名定制

@Configuration @EnableCaching // 关键:不写这行,所有缓存注解都不生效 public class CacheConfig { @Bean public RedisCacheManager cacheManager(RedisConnectionFactory factory, ObjectMapper objectMapper) { // 1) key 用字符串,value 用 JSON —— 这是最小可用的组合 RedisSerializer<String> keySer = RedisSerializer.string(); GenericJackson2JsonRedisSerializer valSer = new GenericJackson2JsonRedisSerializer(objectMapper); RedisCacheConfiguration base = RedisCacheConfiguration.defaultCacheConfig() .serializeKeysWith(SerializationPair.fromSerializer(keySer)) .serializeValuesWith(SerializationPair.fromSerializer(valSer)) .entryTtl(Duration.ofMinutes(30)) // 全局默认 TTL .computePrefixWith(name -> "cache:" + name + ":") // 统一前缀,便于运维 scan .disableCachingNullValues(); // 不缓存 null(要防穿透就删掉这行) // 2) 按缓存名定制:热点数据长 TTL,临时的短 TTL Map<String, RedisCacheConfiguration> configs = new HashMap<>(); configs.put("product", base.entryTtl(Duration.ofMinutes(30))); configs.put("dict", base.entryTtl(Duration.ofHours(12))); configs.put("captcha", base.entryTtl(Duration.ofMinutes(5))); configs.put("tempQuery", base.entryTtl(Duration.ofSeconds(60))); return RedisCacheManager.builder(factory) .cacheDefaults(base) .withInitialCacheConfigurations(configs) // 默认 true:启动时立刻创建 Cache 对象。设为 false 则懒加载 .disableCreateOnMissingCache() .build(); } }
# application.yml:Redis 连接与连接池(别忘了配池,否则高并发下会成为瓶颈) spring: data: redis: host: redis.internal port: 6379 password: ${REDIS_PASSWORD} # 别明文写死在 yml 里 timeout: 2s # 必须设超时!不设会无限阻塞 lettuce: pool: max-active: 32 max-idle: 16 min-idle: 4 max-wait: 1s cache: type: redis
Redis 缓存的五个高频坑

① @EnableCaching 漏了:注解全都不生效,而且没有任何警告。项目里第一次加缓存时最容易忘。

② key 用了 JDK 序列化:如果你只替换了 value 的序列化器而没管 key,Redis 里 key 也是二进制乱码,redis-cli KEYS * 看不见,运维无法清理。key 一定要用 RedisSerializer.string()。

③ TTL 设成 0 或负数:entryTtl(Duration.ZERO) 表示永不过期。很多人以为 0 是"立即过期",结果缓存永不失效,内存缓慢增长直到 Redis 被撑爆。

④ 忘了配 timeout:Redis 抖一下(比如主从切换),没设超时的客户端会让业务线程无限期阻塞,Tomcat 线程池几秒钟内被打满,整个服务雪崩。缓存是"可降级"的组件,连不上应该快速失败,而不是拖死主流程。

⑤ 大 key 与热 key:把整个列表(几万个元素)塞进一个 key,每次读写都要传输几 MB;或者一个热点 key 集中落在单个 Redis 节点上。大 key 用 Hash 分片或拆分,热 key 做本地缓存或 key 打散。

本地 Caffeine + 分布式 Redis:两级缓存怎么搭

Redis 再快也是一次网络往返(同机房约 0.5~1ms)。对于"每个请求都要读"的字典、配置类数据,进程内缓存能把这一跳也省掉。两级缓存的思路是:L1 本地兜住绝大部分流量,L2 Redis 保证多实例共享和一致性。

层实现职责与代价
L1Caffeine亚微秒访问、零网络开销。容量小(几千到几万条)。致命缺点:多个实例的 L1 之间无法互相感知,一致性最差。所以只适合"变更极少、短时不一致可接受"的数据。
L2Redis容量大、多实例共享。约 1ms 开销。是跨实例一致性的锚点。
回源数据库真正的数据源。两级都没命中才来,必须靠"空值缓存 + 互斥重建"保护。

两级缓存的两种实现路径

// 方案 A:用 CompositeCacheManager 组合(简单,但缺少"L1 命中则跳过 L2"的短路语义) @Bean @Primary public CacheManager compositeCacheManager(RedisConnectionFactory factory) { CaffeineCacheManager l1 = new CaffeineCacheManager(); l1.setCaffeine(Caffeine.newBuilder() .maximumSize(10_000) // 容量上限,必须设! .expireAfterWrite(Duration.ofSeconds(60)) // L1 故意设短,降低不一致窗口 .recordStats() // 打开命中率统计,方便观测 .build()); RedisCacheManager l2 = redisCacheManager(factory); CompositeCacheManager composite = new CompositeCacheManager(l1, l2); composite.setFallbackToNoOpCache(false); // 找不到 cache 时报错,别静默降级 return composite; }
// 方案 B:手写两级读取,控制权最大(生产上多用于热点数据) @Component public class TwoLevelCache { private final Cache<String, Object> l1 = Caffeine.newBuilder() .maximumSize(5_000) .expireAfterWrite(Duration.ofSeconds(30)) .build(); private final StringRedisTemplate redis; public Object get(String key, Supplier<Object> loader) { Object v = l1.getIfPresent(key); // L1 if (v != null) return v; v = readFromRedis(key); // L2 if (v != null) { l1.put(key, v); return v; } v = loader.get(); // 回源 writeRedisWithJitter(key, v); l1.put(key, v); return v; } // 写操作必须两级都失效,顺序:先更库(在调用方)→ 删 L2 → 删 L1 public void evict(String key) { redis.delete(key); // 先删 L2 l1.invalidate(key); // 再删 L1 } }

论两级缓存的代价:一致性窗口被拉长了

① 你以为的不一致只是"两个实例":没有 L1 时,删除 Redis 的 key,所有实例立刻都读不到缓存。有了 L1,你删了 Redis,但其他实例的 L1 里还留着旧值,最长能撑到 L1 的 TTL(上面例子是 30~60 秒)。

② 缓解手段:① 把 L1 TTL 设得很短(30 秒以内),把不一致窗口压到业务可接受;② 用 Redis 的 Pub/Sub 或消息队列广播"key 失效"事件,各实例收到后清本地 L1;③ 只对"几乎不变"的数据用 L1(数据字典、地区表、配置项)。

③ 判断标准很简单:问业务方一句话——"这个数据最多可以脏 30 秒吗?"能接受就用两级缓存,不能接受就只用 Redis,或者干脆不缓存。不要自己替业务方做这个决定。

缓存一致性:先删缓存还是先更新数据库

这是缓存领域最长寿的争论。先把结论说了:没有"完美"的方案,只有"在你的场景下足够好"的方案。下面把三种常见组合的失败场景摊开讲。

策略怎么执行失败场景
先更新库,再删缓存
推荐
① 更新数据库 ② 删除缓存 key极小概率:删缓存失败 → 缓存里留旧值直到 TTL 到期。这是业界默认方案(Cache-Aside 变体),因为出问题的概率最低、后果最轻(TTL 自动兜底)。
先删缓存,再更新库① 删除缓存 ② 更新数据库并发下必然出问题:线程 A 删了缓存 → 线程 B 读缓存未命中、读到旧库值并写回缓存 → 线程 A 才更新数据库。结果缓存里是旧值,且没有后续事件去纠正它。
先更新库,再更新缓存
(而不是删)
① 更新数据库 ② 把新值写进缓存两个并发更新会乱序:A 更新库为 v1 → B 更新库为 v2 → B 写缓存 v2 → A 写缓存 v1。缓存最终是 v1,比数据库还旧。所以更新缓存比删除缓存危险得多。
延迟双删① 删缓存 ② 更新库 ③ 延迟 500ms 再删一次能覆盖大部分并发脏读,但延迟时间靠猜,且第二次删除依赖异步任务,任务丢了就失效。属于"补丁式方案",不如直接用「先更库再删缓存」。
关于一致性的三个必须澄清的认知

① 删缓存失败怎么办:这是"先更库再删缓存"唯一的漏洞。解法是把删除操作放进消息队列/事务后置事件里重试,例如用 @TransactionalEventListener(phase = AFTER_COMMIT) 发一条 MQ 消息,消费端负责删除,失败自动重试。记住:不要因为"删缓存可能失败"就不要缓存——TTL 本身就是最终的兜底一致性。

② 缓存必须有 TTL,没有例外:TTL 是"最终一致"的最后一道保险。如果某个缓存设了永不过期,那么任何一次更新遗漏都会造成永久性脏数据,只能人工介入。宁可 TTL 短一点,也不要永不过期。

③ 强一致场景不要用缓存:账户余额、库存扣减、订单状态这类"读错就会造成资金损失"的数据,正确做法是不缓存,直接读库 + 合理加索引;或者缓存只用来做展示、真实校验走数据库。缓存是性能工具,不是一致性工具,别让它承担做不到的责任。

记
缓存抽象与 Redis 一句话总结

① Spring Cache 是抽象层:CacheManager + Cache,换实现不改业务代码;但它是 AOP,自调用一定失效。

② 四个注解语义不同:@Cacheable 先查缓存可能不执行方法;@CachePut 一定执行并覆盖;@CacheEvict 删;@Caching 组合。更新用 Evict,别用 Put。

③ 显式写 key,用 #p0 兜底;判参数用 condition,判结果用 unless。

④ 穿透靠空值缓存 + 布隆过滤器,击穿靠互斥重建,雪崩靠随机 TTL + 多级缓存。

⑤ Redis 一定换掉 JDK 序列化(key 用 String,value 用 JSON),一定配 TTL 和 timeout,一定配连接池。

⑥ 两级缓存用 L1 换延迟,代价是不一致窗口变长;只在"能脏几十秒"的数据上用。

⑦ 一致性选「先更库、再删缓存」,并且所有缓存都必须有 TTL。强一致数据干脆别缓存。

章末面试 · 缓存与 Redis(5 题)

1.(概念题)@Cacheable 和 @CachePut 最本质的区别是什么?

查看答案

答案:@Cacheable 是"读"语义:先查缓存,命中就直接返回、方法体不执行;@CachePut 是"写"语义:方法体一定执行,然后把返回值写入缓存。所以 @CachePut 永远不会返回旧缓存值,而 @Cacheable 有可能永远不执行方法。两者贴在同一个方法上会导致缓存完全失效。解析:记一句话——Cacheable 可能不执行,CachePut 一定执行。

2.(排错题)服务重启后 Redis 里 key 还在,但读缓存全部 miss,日志里有大量反序列化异常。最可能是什么原因?

查看答案

答案:序列化器不匹配。最常见的情况是:改代码时换了序列化器(比如从 JDK 换成 JSON),或者用了 GenericJackson2JsonRedisSerializer 而缓存对象改过包名/类名/字段类型,导致 @class 指向的类加载不到,抛 ClassNotFoundException 或 InvalidClassException。解决:① 换序列化格式时清空对应 cache 前缀的 key;② 缓存 DTO 只改字段不改类名包名,加字段用 @JsonIgnoreProperties(ignoreUnknown = true) 兜底;③ 给缓存加版本号前缀,如 cache:v2:product:。解析:缓存里的数据格式也是一种对外契约,改它要像改接口一样谨慎。

3.(选择题)凌晨 2 点,一批 TTL 为 1 小时的缓存同时过期,数据库连接数瞬间打满。这是哪种问题?A. 缓存穿透 B. 缓存击穿 C. 缓存雪崩

查看答案

答案:C,缓存雪崩。特征是"大批 key 同时失效"导致整体流量压向数据库。解析:对比记忆——穿透是查不存在的数据;击穿是单个热点 key 过期;雪崩是大批 key 同时过期或缓存节点宕机。修法是给 TTL 加随机抖动、搭多级缓存、保证 Redis 高可用。

4.(设计题)"先删缓存再更新数据库"和"先更新数据库再删缓存",你选哪个?为什么?

查看答案

答案:选先更新数据库、再删缓存。理由:① 先删缓存再更库,在"删完缓存、还没更新库"的窗口内,其他线程会读到旧库值并回填缓存,导致缓存长期存旧值;② 先更库再删缓存,最坏情况只是"删缓存失败,缓存里暂时是旧值",而TTL 会自动纠正,后果轻得多且自愈;③ 可以配合事务后置事件 + MQ 重试删除,把失败概率进一步压低。加分:强调"更新缓存"比"删除缓存"更危险,因为并发更新会乱序,最终缓存可能比库还旧。解析:面试官想听的是"你按失败概率和后果严重程度做选择",而不是背一个绝对答案。

5.(理解题)为什么缓存一定要设 TTL?"永不过期"在什么场景下才是可接受的?

查看答案

答案:TTL 是最终一致性的兜底:任何一次"更新数据库但没删缓存"的遗漏,都会在有 TTL 时自动恢复;没有 TTL 就变成需要人工介入的永久脏数据。只有同时满足三个条件时"永不过期"才勉强可接受:① 数据几乎不变(如省市字典、货币代码);② 有可靠的主动刷新机制;③ 能接受"变更后需要人工清缓存"。即便满足,也建议设一个很长(如 24 小时)的 TTL 而不是真的永不过期。解析:把"永不过期"当成一个需要论证的例外,而不是默认选项。