Redis 与 Spring Cache:缓存抽象与实战

bee2026-10-0867 分钟0 次阅读
从缓存穿透、击穿、雪崩三个经典问题讲起,落到 Spring Cache 注解 + Redis 的完整落地:缓存key设计、序列化、过期策略与一致性取舍。
1 / 154
小节
〇、30 秒看懂
2 / 154

想象你在学校后门的小卖部买一瓶水:货架上就有,三秒钟拿到手;货架上没有,店员得去仓库搬,可能要两分钟;仓库也没有,就得打电话让供货商送,那是半小时以后的事。写程序时「问数据库要一条数据」就是那次跑腿——它慢,而且一次大促有几万人同时跑腿,仓库会被抬走。缓存做的事很朴素:把常被问的东西先摆到货架上,让绝大多数请求不必回仓库。代价是:一旦货架上的东西和仓库里的对不上(数据更新了、货架还没换),你就必须处理「两份数据」的麻烦——这一篇讲的就是这两件事:怎么把货摆上去(读写路径与注解)、摆错了怎么办(穿透/击穿/雪崩与一致性)。

3 / 154

先把五个词一句话解释清楚:

4 / 154
  • 缓存:一份「好取但可能过期」的副本,放在比数据库快得多的地方(通常就在内存里)
  • key:副本的门牌号,比如 app:user::42。取东西全靠这个号,号写错就取到别人的东西
  • TTL:门牌号的有效期(time to live)。到期自动作废,逼着下一次读重新核对——不写 TTL 等于永久脏
  • 回源:货架没有、于是跑去仓库(数据库)取的那一趟。缓存的全部艺术就是「尽量少回源,但一定要能回到正确的源」
  • 序列化:把 Java 对象压成一段字节或 JSON 才能塞进 Redis;取出来时再反向还原。压法不对,redis-cli 里就是一堆乱码
5 / 154
类比

整套缓存体系就是那家小卖部。Redis 是店里的货架(伸手就到,但空间有限、会过期),MySQL 是街后仓库(什么都有,取一次慢),其他服务或主从同步是供货商(最远、最容易出岔子)。而「穿透」是有客人反复问一个店里从来不卖的 SKU,每次都逼店员跑一趟仓库;「击穿」是爆款商品那一格刚好空了,几十个客人同时冲向后门;「雪崩」则是整排货架同时被清空,全店人一起挤进仓库通道。三种故障的区别只有一句话:问的东西压根不存在 / 只有一个热点没了 / 一大批同时没了。

6 / 154
原理动画
动图 · 一次请求的五种结局
动图 · 一次请求的五种结局
7 / 154

上面这段动画把「命中」和四种翻车现场画在了同一条路径上:前两步所有请求都一模一样,分岔发生在第二步「缓存里到底有没有」。想快速定位自己的线上故障,就先在这张图里认出自己走的是哪条分支,第二节的表会给出对应解法。

8 / 154

学完这一篇,你应该能回答三个问题:

9 / 154
  • 我的接口加了缓存为什么还是慢?(是真的没命中,还是命中了却仍去打库?)
  • 运营在后台改了价格,用户手机上什么时候能看到新价格?我怎么把这个时间压到可控范围?
  • @Cacheable 写成 this.getXxx() 自调用之后悄悄不生效,怎么用一行日志当场确认这件事?
10 / 154
小节
一、为什么要缓存:一次数据库查询到底贵在哪
11 / 154

缓存能成立,靠的是一个朴素事实:内存读写和磁盘读写之间,差了三到五个数量级。把常见操作的耗时放在一起对比,量级差距一目了然:

12 / 154
对照表
操作典型耗时量级印象
CPU 访问寄存器~0.3 ns1×
读 L1 / L2 缓存~1–10 ns数十倍
Redis 单次 GET(同机房)~0.1–0.5 ms百万倍起
MySQL 主键查询(走索引)~1–5 ms千万倍起
MySQL 复杂 JOIN / 聚合~50–500 ms更慢
13 / 154

一次 MySQL 查询,背后要走完「解析 SQL → 生成执行计划 → 走 B+ 树 → 可能回表 → 结果网络传输」一整条链路;而 Redis 只是一次内存里的哈希查找。差距不是「快一点」,而是会被并发放大:同样 1000 QPS,走 MySQL 意味着数据库每秒要扛 1000 次查询,走 Redis 则可能连数据库都不用碰。

14 / 154
说明

Redis 快,不是靠什么黑科技,而是它把数据全放内存、单线程避免了锁竞争、用 IO 多路复用扛住并发。但别忘了,它的耗时里有一大块是网络往返——同机房 0.1 ms 已经算优秀,跨机房就可能退化到几毫秒。缓存最大的价值,是把读压力挡在数据库门外。

15 / 154
架构图
图 1 · 缓存读写的两条路径
图 1 · 缓存读写的两条路径
16 / 154

「慢」这件事在真实请求里长什么样?下面这个实验把一个请求从入口带到数据库,切到「慢请求与超时」那一档,你会看到耗时到底堆在哪一层——看清楚它,你才知道缓存要挡的是哪一段,而不是给每个接口都随手贴一个 @Cacheable:

17 / 154
内核实验
TeaVM一个请求穿全站:慢在哪一格未启动
先「成功链路」看正常节奏,再切这一档看慢请求怎么把上游一起拖住;最后对照「数据库故障」,那就是缓存集体失效后数据库独自面对的样子
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
18 / 154

但天下没有免费的午餐——一旦引入缓存,数据就有了两份,三件经典故障随之而来。

19 / 154
小节
二、三大经典问题与解法
20 / 154
对照表
问题现象根因常用解法代价
缓存穿透请求查一个根本不存在的数据缓存与数据库都没有,每次都穿到库空值缓存、布隆过滤器、参数校验空值占内存 / 布隆有误判
缓存击穿某个热点 key 恰好过期高并发同时未命中,一起涌向数据库互斥锁重建、逻辑过期加锁有等待 / 逻辑过期实现复杂
缓存雪崩大批 key 同时失效统一 TTL 到点,或 Redis 整体宕机随机化 TTL、多级缓存、限流降级随机化范围要拿捏
21 / 154

三行表格读起来快,但线上出事时你要的是「先看指纹,再拿药」。把左右两列并排放好,故障现场与对症解法各占一格,贴在工位上比背三遍都有用:

22 / 154
架构图
图 · 三坑的指纹与第一味药
图 · 三坑的指纹与第一味药
23 / 154

缓存击穿现场:晚上八点整,大促开始。某个爆款商品的缓存 key 设了 3600 秒 TTL,而它恰好是一小时前整点写入的——于是整点一到,这个 key 集体过期。几万个请求在同一毫秒发现缓存为空,全部冲向数据库去查同一行数据,数据库瞬间被单行查询打满,连接池耗尽,整个商品页不可用。注意:数据库并没有坏,它只是被自己的缓存策略卖了。

24 / 154

三种解法各写一段代码思路。先是穿透与击穿:

25 / 154
java
// 穿透解法一:空值缓存——查不到也写一个短 TTL 的空标记,挡住后续请求public User findById(Long id) {    String key = "user:" + id;    String cached = redis.opsForValue().get(key);    if (cached != null) {        return "NULL".equals(cached) ? null : JSON.parseObject(cached, User.class);    }    User user = userMapper.selectById(id);    if (user == null) {        // 短 TTL,别留太久,否则变成「缓存污染」        redis.opsForValue().set(key, "NULL", Duration.ofMinutes(2));        return null;    }    redis.opsForValue().set(key, JSON.toJSONString(user), Duration.ofMinutes(30));    return user;}
26 / 154
java
// 击穿解法:互斥锁重建——只让一个线程去查库,其余等待后重读缓存public User findByIdWithLock(Long id) {    String key = "user:" + id;    User user = getFromCache(key);    if (user != null) return user;    String lockKey = "lock:user:" + id;    // SETNX 抢锁:谁抢到谁去重建,抢不到就稍后重试读缓存    Boolean locked = redis.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(10));    try {        if (Boolean.TRUE.equals(locked)) {            user = userMapper.selectById(id);                     // 只有持锁线程查库            redis.opsForValue().set(key, JSON.toJSONString(user), Duration.ofMinutes(30));        } else {            Thread.sleep(50);                                     // 等一等,再去读缓存            return getFromCache(key);        }    } finally {        if (Boolean.TRUE.equals(locked)) redis.delete(lockKey);    }    return user;}
27 / 154

雪崩通常不需要写代码,靠策略就能大幅缓解:

28 / 154
  • 随机 TTL:写入时用 TTL + random(0, 300) 秒,把过期时间打散,避免整点齐发
  • 多级缓存:本地 Caffeine + Redis 两级,Redis 抖动时本地还能挡一阵
  • 限流降级:给数据库加一层熔断(如 Resilience4j),宁可拒绝少量请求,也别让库整体雪崩
29 / 154
坑

空值缓存一定要设短 TTL。如果把不存在的 id 也缓存 30 分钟,攻击者用 id=-1、-2、-3…… 刷一遍,就会把一堆无意义的空标记塞满 Redis——这就是「空值缓存」变成「缓存污染」的现场。

30 / 154

上面三段解法散在代码里,而真出事时你手里只有一句现象描述。所以这里不开表,改成一局闯关:左边是工单里会出现的原话,右边点它的病因与第一味药——配错当场告诉你为什么,比背表快。

31 / 154
配对闯关
闯关缓存现场:一句现象配一个病因已配对 0/7 · 配错 0
七条都是真实工单里的描述,右列同时给出第一味药。别靠位置猜——两列都打乱了
先点左边一个
32 / 154
类比

小卖部的三种翻车也很好区分。穿透是有客人反复问「有没有卖 25 号的鞋」——店里从来不卖,店员每次都跑去仓库翻一遍再回来说没有;治法是门口贴张「本店不经营清单」(布隆过滤器),或干脆记一张「刚才有人问过,答案是没」(空值缓存)。击穿是唯一那箱爆款矿泉水刚好被搬空,几十个客人同时挤到后门要货,得只放一个店员去仓库、其余人在门口等(互斥锁)。雪崩则是整个货架的价签同时到期被撤下,全店人都往仓库通道跑——要么把价签的到期日错开写(随机 TTL),要么先在仓库门口限流(熔断降级)。

33 / 154
小节
三、Redis 基础速览:五种数据结构与 Spring Boot 整合
34 / 154

先认清 Redis 的「五把刀」,它们几乎覆盖所有缓存场景:

35 / 154
对照表
结构一句话典型用途
String最基础的键值对象 JSON、计数器(INCR)、分布式锁
Hash字段-值映射存对象的部分字段、购物车
List有序、可两端进出消息队列、最新 N 条动态
Set无序去重集合点赞去重、共同好友(交集)
ZSet带分数的有序集合排行榜、延迟队列(按时间戳打分)
36 / 154

整合只需要一个 starter 和几行配置:

37 / 154
xml
<dependency>    <groupId>org.springframework.boot</groupId>    <artifactId>spring-boot-starter-data-redis</artifactId></dependency>
38 / 154

这一行只是最小可用。真要落一个项目,Redis 之外还得连着数据源、序列化依赖与监控端点——勾一遍看看依赖树会长成什么样,特别注意 mysql 与 h2 的 scope 区别(后者只该活在测试里):

39 / 154
生成器
生成器缓存项目的依赖组合怎么勾pom.xml3 / 8
先勾 Redis 看它自带 Lettuce 与连接池;再叠 JDBC / MySQL 对照第三节的配置项;Actuator 那一行是第十节那个「用健康端点盯住 Redis」实验的前提
产物
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.3.4</version> <!-- 版本由 BOM 统管,子依赖不写 version -->
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>demo-service</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <properties>
        <java.version>17</java.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
勾了这些,代价与理由在这里
parent继承 3.3.4 的 starter-parent 之后,所有 spring-boot-starter-* 都不用写版本号;一旦有人手写给某个 starter 加 version,就以那条为准——这是依赖版本漂移最常见的原因。
Web做接口就绕不开它: DispatcherServlet、内嵌 Tomcat、JSON 序列化全在这个 starter 里。
Data Redis带来 Lettuce 客户端与 RedisTemplate;换 Jedis 需要排除 + 另引。
Actuatorhealth/metrics/info 等端点;暴露面记得走白名单,别写 *。
40 / 154
yaml
spring:  data:    redis:      host: localhost      port: 6379      password: ${REDIS_PASSWORD:}      database: 0      timeout: 2s      lettuce:        pool:          max-active: 16      # 连接池上限,别设太大,Redis 本身是单线程          max-idle: 8          min-idle: 2
41 / 154

同样一段配置,写全不容易:地址、超时、连接池、序列化的落点各在不同小节。**勾一遍「Redis + 数据源 + 日志 + Profile」,看 spring.data.redis. 与 logging.level 各自生成在哪一节*——第三节的连接池上限和第十五节那条「缓存集体失效」的排查,都从这几行开始。

42 / 154
生成器
生成器Redis 与数据源一次配齐application.yml2 / 5
只勾 Redis 拿到最小可用;叠 DataSource 会看到缓存与库两套连接池并存,这正好解释第十节那个「命中率掉下来时谁在排队」;勾 Profile 看 dev 与 prod 的地址和超时该差多少
产物
server:
  port: 8080

spring:
  application:
    name: demo-service
  datasource:
    url: jdbc:mysql://127.0.0.1:3306/bee_order?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
    username: ${DB_USER:root}          # ${} 占位符:环境变量优先,冒号后是默认值
    password: ${DB_PASS:}
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 30000
      max-lifetime: 1740000            # 必须小于 MySQL 的 wait_timeout
      pool-name: beeHikari
  data:
    redis:
      host: ${REDIS_HOST:127.0.0.1}
      port: 6379
      timeout: 3000ms
      lettuce:
        pool: { max-active: 16, max-idle: 8, min-idle: 2, max-wait: 2000ms }
勾了这些,代价与理由在这里
datasource池参数写在这里才生效;写在代码里 new HikariDataSource() 就白配了。
redisBoot 3 默认不启用池;要用 lettuce.pool.* 必须引 commons-pool2。
43 / 154

Spring Boot 会自动装配两个模板,它们的区别是新手第一个易错点:

44 / 154
对照表
模板key / value 序列化方式适用场景
RedisTemplate<K, V>默认 JDK 序列化(二进制)存 Java 对象,需自行配置序列化器
StringRedisTemplate全部按 String(UTF-8)key/value 都是文本,要与 redis-cli 对得上
45 / 154
提示

RedisTemplate<Object, Object> 的默认序列化器是 JdkSerializationRedisSerializer,它会把你存的对象变成一串二进制——这一点在下一节会亲眼看到。

46 / 154
小节
四、序列化问题现场:为什么 redis-cli 里全是乱码
47 / 154

很多人第一次这样写:

48 / 154
java
@Autowiredprivate RedisTemplate<String, Object> redisTemplate;public void save(User user) {    redisTemplate.opsForValue().set("user:1", user);   // 存一个 Java 对象}
49 / 154

然后打开 redis-cli 一看:

50 / 154
text
127.0.0.1:6379> GET "user:1""\xac\xed\x00\x05sr\x00\x04com..User\x..."
51 / 154

前面那串 \xac\xed 正是 Java 序列化流的魔数(0xACED)。JDK 序列化把对象连「全限定类名和字段元数据」一起写了进去,于是问题成堆:内容不可读、体积膨胀、一旦类名或字段改了老数据直接反序列化失败——而且它还是反序列化漏洞的重灾区。

52 / 154

正解是换成 JSON 序列化:

53 / 154
java
@Configurationpublic class RedisConfig {    @Bean    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {        RedisTemplate<String, Object> template = new RedisTemplate<>();        template.setConnectionFactory(factory);        // 泛型化 JSON 序列化器:会写入 @class 信息,反序列化时能还原具体类型        GenericJackson2JsonRedisSerializer jsonSerializer =                new GenericJackson2JsonRedisSerializer();        StringRedisSerializer keySerializer = new StringRedisSerializer();        template.setKeySerializer(keySerializer);        // key 用纯字符串,redis-cli 可读        template.setHashKeySerializer(keySerializer);        template.setValueSerializer(jsonSerializer);     // value 用 JSON        template.setHashValueSerializer(jsonSerializer);        template.afterPropertiesSet();        return template;    }}
54 / 154

改完之后,同一个 key 在 redis-cli 里变成:

55 / 154
代码对照
代码text
127.0.0.1:6379> GET "user:1""{\"@class\":\"com.example.User\",\"id\":1,\"username\":\"alice\"}"
解读

要点:GenericJackson2JsonRedisSerializer 会额外写入 @class,好处是反序列化能还原成原类型;坏处是这段 JSON 与你的包名强耦合,一旦类被重命名或移动,老数据同样读不出来。若你只需要「纯 JSON、不要类型信息」,改用 Jackson2JsonRedisSerializer<T> 并显式指定目标类型,可读性更好,但要求调用方自己知道类型。

56 / 154
小节
五、Spring Cache 抽象:注解才是日常
57 / 154

真正让缓存好用的,是 Spring Cache 这套基于注解的抽象——它把「查缓存 → 未命中查库 → 回填」的样板代码全部收走。开启方式就一个注解:

58 / 154
java
@Configuration@EnableCaching        // 打开缓存抽象(代理拦截,与 @Transactional 同源)public class CacheConfig {}
59 / 154

四个核心注解,各有各的触发时机:

60 / 154
对照表
注解触发时机典型用法
@Cacheable方法调用前先查缓存,命中则不执行方法查询方法
@CachePut总是执行方法,再把结果写回缓存更新后要同步刷新缓存
@CacheEvict方法执行后删除缓存删除操作、更新后删缓存
@Caching组合多个缓存操作一次更新同时删多个 key
61 / 154

一个最常用的写法:

62 / 154
java
@Servicepublic class UserService {    // key 用 SpEL 拼:user::42    @Cacheable(cacheNames = "user", key = "#id")    public User findById(Long id) {        return userMapper.selectById(id);      // 只有未命中才会执行这一行    }    // 更新后刷新缓存:返回值会被写入 user::#user.id    @CachePut(cacheNames = "user", key = "#user.id")    public User update(User user) {        userMapper.updateById(user);        return user;    }    // 删除:清掉指定 key    @CacheEvict(cacheNames = "user", key = "#id")    public void delete(Long id) {        userMapper.deleteById(id);    }}
63 / 154

key 的 SpEL 写法是使用门槛所在,常用表达式整理如下:

64 / 154
对照表
SpEL 表达式含义
#id参数名为 id 的值
#user.id参数对象的属性
#p0 / #a0第一个参数(按下标,p / a 均可)
#root.methodName当前方法名
#root.targetClass目标类
#result.id方法返回值(仅 @CachePut / @CacheEvict 可用)
#id + ':' + #type拼接组合 key
65 / 154
坑

key 不写时,Spring 用 SimpleKeyGenerator 把参数拼成一个 key。零参数方法会生成一个固定的 SimpleKey.EMPTY,多个无参方法若共用同一个 cacheName,会互相覆盖。 无参方法务必显式写 key。

66 / 154

上面这些规矩,说到底都来自同一条执行路径。把它摊成一次单步执行:左边是那七行,右边同步刷新「此刻的变量」和「调用栈」——连点下一步,重点盯第 ⑤ 步:命中时你的方法体一行都不会执行,第九节那条「自调用静默失效」和第十一节沙盘最后一档「串数据」,源头都在这格:

67 / 154
单步调试台
单步台单步走完 @Cacheable:命中时你的代码到底跑没跑1 / 7
按 ①→⑦ 走一遍。第 ③ 步的 key 是谁拼的,第 ⑤ 步为什么直接返回,第 ⑦ 步为什么要带 TTL
被调试的代码
1userService.findById(42L); // ① 调用打到缓存代理,不是打到你的 Service
2CacheInterceptor.execute() // ② 把注解元数据读成一条 CacheOperation
3key = evaluator.key("#id") -> app:user::42 // ③ SpEL 在这一格被求值
4cache = cacheResolver.resolve("user") // ④ cacheNames 在这里落到具体的 RedisCache
5value = cache.get(key) // ⑤ 命中就当场 return,方法体一行都不跑
6result = method.invoke(target) // ⑥ 只有未命中才真的去查库
7cache.put(key, result); return result // ⑦ 回填(带 TTL)并把结果交回去
此刻的变量
userServiceUserService$$SpringCGLIB$$0
调用方线程http-nio-8080-exec-5
调用栈
1UserController.detail
2proxy.findById
1和 @Transactional 完全同源的开场:注入进来的对象是壳,不是你自己 new 的那个。类名里没有 SpringCGLIB,后面六步全部不会发生——这就是第九节第一条坑的全部机理。
68 / 154

注解虽好,但它有一条清晰的边界:它只能缓存「一个方法的返回值」。当你需要的不是缓存结果、而是操作——一次读多个 key(MGET/Pipeline)、给某个 key 单独续期、抢一把锁做互斥重建、只更新对象里的两个字段、或者一段必须原子执行的 Lua——注解就给不了你这些了,得回到 RedisTemplate。下面这张对照图把两条路各自的胜负手摆在一起(第五列尤其重要:注解靠代理,所以自调用会静默失效;手写没有代理,也就没有这个坑):

69 / 154
架构图
图 · @Cacheable 一把梭 vs 手动 RedisTemplate 的边界
图 · @Cacheable 一把梭 vs 手动 RedisTemplate 的边界
70 / 154
提示

判据一句话就能记住——「一个方法 = 一个缓存对象」就用注解;需要「操作而不只是结果」就自己写。两者可以共存:主查询走 @Cacheable,热点 key 的互斥重建与分布式锁用 RedisTemplate,别为了统一风格把手写锁硬塞进注解里。

71 / 154
小节
六、缓存配置:TTL、key 前缀与过期策略
72 / 154

默认的 RedisCacheManager 永不过期——这是生产事故的常见来源。要给缓存设 TTL、统一 key 前缀、甚至按命名空间区分策略,就得自定义:

73 / 154
代码对照
代码java
@Configuration@EnableCachingpublic class CacheConfig {    @Bean    public RedisCacheManager cacheManager(RedisConnectionFactory factory) {        RedisCacheConfiguration base = RedisCacheConfiguration.defaultCacheConfig()                .entryTtl(Duration.ofMinutes(10))                 // 默认 10 分钟过期                .prefixCacheNameWith("app:")                      // key 前缀:app:user::42                .serializeKeysWith(RedisSerializationContext.SerializationPair                        .fromSerializer(new StringRedisSerializer()))                .serializeValuesWith(RedisSerializationContext.SerializationPair                        .fromSerializer(new GenericJackson2JsonRedisSerializer()))                .disableCachingNullValues();                      // 不缓存 null,穿透自行处理        Map<String, RedisCacheConfiguration> perCache = Map.of(                "user", base.entryTtl(Duration.ofMinutes(30)),    // 用户信息 30 分钟                "config", base.entryTtl(Duration.ofHours(6))       // 配置类 6 小时        );        return RedisCacheManager.builder(factory)                .cacheDefaults(base)                .withInitialCacheConfigurations(perCache)                .build();    }}
解读
  • entryTtl:必须设。没有 TTL 的缓存 = 内存泄漏 + 永远的数据不一致
  • prefixCacheNameWith:给所有 key 加统一前缀,方便按业务批量清理,也避免多应用共用一个 Redis 时 key 冲突
  • disableCachingNullValues:与前面的空值缓存策略相反——这里选择不缓存 null,穿透防护交给布隆过滤器或参数校验
  • 不同业务用不同 TTL,避免「一刀切」把热点和冷数据绑死在一起
74 / 154

TTL 该设多久,是缓存里唯一一个真能拖的旋钮。它的两端各咬住一种故障——左边咬内存和一致性,右边咬数据库:

75 / 154
参数调节台
调节台默认 TTL:一端是脏数据,一端是数据库
spring.cache.redis.time-to-live
1800秒当前 0 – 172800
甜蜜区:绝大多数读多写少业务
  • 30 分钟到 2 小时是常见的默认量级,命中与新鲜度的平衡点
  • 记得加抖动:TTL + random(0,300),别让整点对齐
  • 更新路径要有 @CacheEvict,TTL 只是兜底不是主防线
  • 监控看命中率与回源 QPS,别看这个数本身
回源压力18%
脏数据窗口35%
先问「这份数据旧多久会出事」,答案就是 TTL 的上界;再用抖动和 CacheEvict 把风险压回去。
76 / 154
注意

@Cacheable 与 RedisCacheManager 之间是「抽象与实现」的关系——你写注解,CacheManager 决定落到哪个 Cache 实现、用什么 TTL 与序列化。换实现(比如从 Redis 换成 Caffeine)时,业务注解一行都不用改。若想为不同方法动态选择缓存实现,可以实现 CacheResolver,按方法元数据返回不同的 Cache。

77 / 154
小节
七、缓存与数据一致性:Cache-Aside 为什么是主流
78 / 154

先把两条路径画清楚(见开头的图 1)。核心结论一句话:读走「缓存优先」,写走「先更新数据库,再删除缓存」——这就是 Cache-Aside(旁路缓存)模式。

79 / 154
原理动画
动图 · Cache-Aside 的读写时序
动图 · Cache-Aside 的读写时序
80 / 154

为什么是「删除缓存」而不是「更新缓存」?看一个并发场景就明白了:

81 / 154
对照表
时刻线程 A(写)线程 B(读)
T1更新数据库 = 20
T2读到旧值 10(A 的写入尚未可见)
T3更新缓存 = 20
T4写缓存 = 10(把 20 覆盖掉了)
结果数据库 20、缓存 10 → 不一致
82 / 154

「更新缓存」的写法,等于把数据库的写并发原样复刻到缓存上,两个写线程的先后顺序无法保证,脏数据就来了。而「删除缓存」把问题简化成一个原子动作,代价只是下一次读要回源一次——用一次回源,换掉了不一致风险。

83 / 154

那张时刻表是四行字,动画把它按顺序播一遍——注意第 ④ 帧,覆盖发生的那一刻没有任何异常、没有任何日志,这是缓存最难查的一类故障:

84 / 154
原理动画
动图 · 更新缓存 vs 删除缓存:一次并发覆盖
动图 · 更新缓存 vs 删除缓存:一次并发覆盖
85 / 154

那先删缓存、再更新数据库行不行?也有并发漏洞(读线程在删除后、更新前把旧值回填)。因此更稳的顺序是 Cache-Aside 的「先更新库、再删缓存」,配合延迟双删兜底:

86 / 154
代码对照
代码java
public void updateUser(User user) {    userMapper.updateById(user);                  // 1. 先落库    redis.delete("app:user::" + user.getId());    // 2. 立即删除缓存    // 3. 延迟再删一次,覆盖掉「删除后、提交前被读线程回填的旧值」    delayedExecutor.schedule(            () -> redis.delete("app:user::" + user.getId()),            500, TimeUnit.MILLISECONDS);}
解读

说明:缓存一致性无法做到强一致,只能做到最终一致——除非你在读路径上也加分布式锁,那代价通常不值得。工程上的取舍是:接受一个「读窗口内可能读到旧值」,用 TTL 与延迟双删把这个窗口压到毫秒级,而不是追求理论上的完美。

87 / 154

「删还是改」只是这五种读写策略里的一格。Spring Cache 默认走的是第一种(旁路),另外四种各有各的适用面,也各有各的翻车姿势——点着看,每一格都告诉你「谁去填缓存」这个唯一的关键问题:

88 / 154
交互图解
流程五种读写策略:谁负责把数据填进缓存1 / 5
从 ① 点到 ⑤。判据只有一个:回源与写入的责任落在应用、缓存组件,还是数据库
→
→
→
→
① Cache-Aside 旁路(Spring Cache 默认)
读写都由**应用**负责:未命中时应用查库并回填,写时应用先更库再删缓存。好处是最简单、缓存挂了应用还能跑;代价是缓存组件永远不主动帮你填,命中率全靠应用自觉。本站讲的所有坑与解法都建立在这一格上。
全部看懂了选型只问一句话:回源和写入的责任,你愿意交给应用、缓存组件,还是数据库?
89 / 154
小节
八、分布式锁初体验:SETNX + 唯一值 + Lua 删除
90 / 154

前面互斥重建缓存用的其实就是分布式锁的雏形。一个可用的锁有三个要素:原子占位、过期兜底、只能删自己的锁:

91 / 154
代码对照
代码java
public class RedisLock {    private final StringRedisTemplate redis;    private static final String UNLOCK_LUA =            "if redis.call('get', KEYS[1]) == ARGV[1] then " +            "  return redis.call('del', KEYS[1]) " +            "else return 0 end";    public boolean tryLock(String key, String token, Duration expire) {        // SETNX + 过期时间一步写入(别分成 setnx 再 expire,中间宕机就死锁)        Boolean ok = redis.opsForValue().setIfAbsent(key, token, expire);        return Boolean.TRUE.equals(ok);    }    public boolean unlock(String key, String token) {        // 用 Lua 保证「比对 + 删除」是原子的:只有 token 匹配才删        Long r = redis.execute(                new DefaultRedisScript<>(UNLOCK_LUA, Long.class),                Collections.singletonList(key), token);        return r != null && r > 0;    }}
解读
  • 原子占位:setIfAbsent(key, token, expire) 对应 SET key value NX PX ms,一次完成「不存在才设置 + 设过期」
  • 过期兜底:万一持锁线程宕机,锁到期自动释放,不会永久死锁
  • 唯一值删除:token 是本次加锁生成的唯一串;解锁时先比对再删,防止「A 的锁超时释放后 B 拿到锁,A 却把 B 的锁删了」

坑:自己手写分布式锁,几乎一定会漏掉某个边界——锁续期(业务比锁的过期时间还慢)、可重入、主从切换导致的锁丢失。生产环境请直接用 Redisson 的 RLock,它内置了看门狗自动续期、可重入与红锁语义,把上面这些坑全填了。这段手写锁的价值,是让你理解原理,而不是让你上生产。

92 / 154
小节
九、三个必须知道的坑 + 亲手验证注解为什么失效
93 / 154
坑

@Cacheable 在同类内部自调用时会失效。 和 @Transactional 一模一样的道理——注解靠 AOP 代理生效,this.method() 走的是原始对象,绕过了代理,缓存查找与回填根本不会发生。修法:把方法拆到另一个 Bean,或注入自身代理。

94 / 154
坑

缓存的空值也要设短 TTL。 否则一次「查不到」会被固化成长达数十分钟的「永远查不到」——数据后来补上了,缓存却还在说没有。

95 / 154
坑

大 key 与热 key 要单独治理。 一个 value 几十 MB 的大 key 会拖慢 Redis 单线程、阻塞其他命令;一个被打到几万 QPS 的热 key 容易把单个分片打满。治理手段:拆大 key(Hash 分片)、用本地缓存挡住热 key、多副本 key 打散压力。

96 / 154

缓存与事务、日志失效的根源是同一个代理机制,下面这段演示让你亲眼看到「自调用绕过了什么」:

97 / 154
内核实验
TeaVM为什么缓存注解会失效未启动
切到「自调用」,看代理被绕过后缓存/事务/日志如何集体失效
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
98 / 154
小节
十、上手实验:把三种故障和一次静默失效都按出来
99 / 154

第九节三条坑全是文字,但它们各自都有能亲眼看见的样子。下面五个实验依次回答:三种故障在时序上有什么不同?缓存挡不住数据库时连接池会怎样?线上怎么第一时间知道 Redis 是不是活的?限流三兄弟该选哪个?以及——Spring Cache 和 MyBatis 那两层「缓存」到底是什么关系?

100 / 154

第一个实验是主角。请按 hit → miss → key → pen → bust 的顺序切五档,边切边对照第二节那局配对闯关:

101 / 154
内核实验
TeaVM命中、未命中、穿透、击穿、雪崩现场未启动
先「命中直接返回」看数据库一次都没被碰;再切「key 怎么生成」理解串数据的根源;最后两档分别是单个热点过期与大批 key 同时过期
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
102 / 154

第二个实验把第一节那句「缓存的价值是把读压力挡在数据库门外」变成可观察的曲线。切「命中空闲连接」与「达到上限排队」,感受缓存命中率从 95% 掉到 0 时,等待队列是怎么长起来的:

103 / 154
内核实验
TeaVM缓存挡不住时,连接池开始排队未启动
先用「命中空闲连接」看顺畅借还,再用「达到上限排队」模拟缓存集体失效后请求全压在数据库通道上的样子
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
104 / 154

第三个解决一个很实际的焦虑:Redis 挂了,我怎么在用户发现之前知道? Actuator 的健康端点会把 Redis 连接状态聚合成一个 DOWN,配合监控就是最早的警报:

105 / 154
内核实验
TeaVM用健康端点盯住 Redis未启动
切到「健康指示器聚合」看 redis 这一栏怎么把整体状态拉成 DOWN,再看「暴露面控制」确认这个端点没有对外裸奔
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
106 / 154

第四个把第二节那句「给数据库加一层限流」落到具体算法上。雪崩来临时,固定窗口会在窗口边界放过两倍流量,漏桶永远匀速、扛不住突发,令牌桶允许突发但要算桶深——切「怎么选与突发」这一档,三种算法的差异会直接画出来:

107 / 154
内核实验
TeaVM限流三兄弟:谁挡得住雪崩那一波未启动
先看固定窗口与漏桶各自的形状,再切这一档对比突发流量下的表现;它正是第二节雪崩那一档里「前置限流降级」的具体实现
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
108 / 154

第五个回答一个评论区常年吵架的问题:MyBatis 的一级/二级缓存和 Spring Cache 是什么关系。切到二级缓存那一档你会看到它跨 SqlSession 共享、且默认按 namespace 整体失效——这跟 @CacheEvict 精确删一个 key 是两个模型,两套缓存同时开还可能互相掩盖脏读:

109 / 154
内核实验
TeaVM框架里其实不止一层缓存未启动
先切「一级缓存」看 SqlSession 内的复用,再看这一档的跨会话共享;对照第九节第三条坑想想:大 key、热 key 之外,还有这种「看不见的那一层」
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
110 / 154

实验做到这里,可以换成命令行自己敲了。下面这台控制台连着浏览器里的同一个 Java 内核,回显全部由内核算出来——先 ping 确认容器活着,再逐条把三种故障与一次连接超时跑出来:

111 / 154
内核控制台
112 / 154
坑

lab cache bust 之后紧接着敲 lab pool queue,才能看清雪崩的下游是什么样子——前者的时间线里几十万个请求同时未命中,后者的队列长度就是数据库那侧的真实遭遇。

113 / 154
小节
十一、沙盘:这段代码该配哪种缓存策略
114 / 154

策略选错的症状总是很晚才出现,而且出现时已经打到数据库了。这个沙盘把「这条数据经不起多久的旧值」做成一个开关,切一档就能同屏看到 TTL、并发保护和监控指标三处变化:

115 / 154
沙盘
沙盘缓存策略选择器:先定容忍度,再挑解法
运行结果
TTL = 1800s + random(0,300) ← 抖动防止齐发
未命中重建:SETNX 互斥锁,只放一个线程回源
监控重点:single-key QPS、GET_LOCK 等待时长
# 判据:读写比高且就一行最热 → 保护那一个 key,而不是加一堆机器
典型的大促爆款。核心是击穿防护:逻辑过期(库里带 expireAt 字段,过期也先返回旧值并异步刷新)是更彻底的替代方案。
116 / 154
说明

四档对应四种真实诉求,而选择的顺序永远是先量「这份数据旧多久可以接受」,再决定防哪一种故障。反过来先想「我要不要用布隆过滤器」,通常说明你还没搞清楚自己挨的是哪一种打。

117 / 154
小节
十二、随堂自测
118 / 154

第一道热身题,考的是最容易在面试里说反的那对概念:

119 / 154
随堂自测
随堂自测大促整点,某个爆款商品的详情接口突然把数据库打到 100% CPU,但 Redis 整体命中率仍然高达 97%。最可能的原因与对症解法是?
先自己选一个,选中立刻告诉你对不对
120 / 154

第二道综合题,把第五节的注解机制和第九节的坑绑在一起:

121 / 154
随堂自测
随堂自测`UserService` 里 `detail()` 标了 `@Cacheable(cacheNames="user", key="#id")`,另一个方法 `batch()` 内部用 `this.detail(id)` 调它。项目已加 `@EnableCaching`。以下说法正确的是?
先自己选一个,选中立刻告诉你对不对
122 / 154
小节
十三、常见报错速查
123 / 154

新手在这一章最常撞的四类墙:连不上、看不懂(乱码)、取错(串数据)、以及最阴的「什么都没发生」。下面这些片段都能原样复制去搜索。

124 / 154
对照表
报错原文(片段)真实原因30 秒自救深挖看第几篇
org.springframework.data.redis.RedisConnectionFailureException: Unable to connect to Redis地址/端口不通:Redis 没起、spring.data.redis.host 指错、容器里写了 localhost 指向了自己先 redis-cli -h <host> -p 6379 ping 通不通;Docker 部署把 host 改成服务名;再核对 password 是否为空串 vs 未设置本篇第三节
io.lettuce.core.RedisConnectionException: Unable to connect to localhost/<unresolved>:6379Lettuce 建连失败,通常是 DNS/网络策略问题而非密码问题<unresolved> 说明主机名压根没解析出来——检查 compose 网络与 service 名本篇第三节
SerializationException: Could not write JSON: Java 8 date/time type java.time.LocalDateTime not supported by defaultJackson 缺少 JSR-310 模块支持,LocalDateTime 直接炸注册 JavaTimeModule 并关掉 WRITE_DATES_AS_TIMESTAMPS;或实体里改用 Date/转成字符串存本篇第四节
SerializationException: Cannot deserialize value of type ... unknown java class id com.example.User老数据是 GenericJackson2JsonRedisSerializer 写的,带 @class 全限定类名,类被改名/移包后读不出来上线前避免重命名已进入缓存的类;真要改就先清缓存,或改用显式类型的序列化器本篇第四节
redis-cli 里 GET user:1 返回 "\xac\xed\x00\x05sr\x00..."默认 JdkSerializationRedisSerializer 的二进制魔数,不是错误但等于不可运维key 用 StringRedisSerializer、value 换 JSON 序列化器;已有数据只能整体作废重建本篇第四节
@Cacheable 加了没反应,一句报错都没有① 忘 @EnableCaching;② 同类自调用绕过代理;③ Bean 是自己 new 的没进容器开 logging.level.org.springframework.cache: TRACE;没有缓存相关日志 = 拦截器没跑,按 ①②③ 顺序查本篇第五、九节
不同用户/不同租户看到了彼此的数据(串数据)多个方法共用同一个 cacheNames 却没写 key,退化成 SimpleKey.EMPTY;或 key 漏了区分维度无参方法一律显式写 key;组合 key 用 #userId + ':' + #type;本地调试时打印实际生成的 key本篇第五节
运营改了价格,前台长时间仍显示旧价只更新了数据库、没有 @CacheEvict,或 evict 的 key 表达式与写入时的不一致更新路径必须成对出现(@CachePut/@CacheEvict + 同样的 key 规则);缩短 TTL 作为兜底本篇第七节
数据库连接池持续排队、Pending requests 不降缓存大面积失效(雪崩)导致所有请求回源,把读压力原样砸回数据库先限流保住数据库,再逐步预热关键 key;事后给 TTL 加随机抖动第 28 篇
125 / 154
提示

搜这类问题时只搜冒号后的第一段英文原文(如 Unable to connect to Redis),不同 Spring Boot 版本会在后半句追加话术,整句搜索常常一条都命不中。

126 / 154

表里第一条是新手撞得最多、也最容易误判的一条:它看起来像密码错、像 Redis 崩,实际上常常只是容器里的 localhost 指向了自己。先别看答案,点出你认为的凶手行:

127 / 154
报错急救
报错急救RedisConnectionFailureException: Unable to connect to Redis
应用连不上 Redis:一句 localhost 引发的血案

本地跑得好好的,一上容器环境接口全 500。日志里这句话翻来覆去,改密码、换版本、重启 Redis 都没用。

org.springframework.data.redis.RedisConnectionFailureException: Unable to connect to Redis
at org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory$ExceptionTranslatingConnectionProvider.translateException(LettuceConnectionFactory.java:1689)
at org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory$ExceptionTranslatingConnectionProvider.getConnection(LettuceConnectionFactory.java:1595)
at org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory.getConnection(LettuceConnectionFactory.java:622)
at org.springframework.data.redis.core.RedisTemplate.execute(RedisTemplate.java:224)
at com.bee.catalog.service.ProductService.getDetail(ProductService.java:52)
Caused by: io.lettuce.core.RedisConnectionException: Unable to connect to localhost/<unresolved>:6379
Caused by: java.net.ConnectException: Connection refused
点你认为的「凶手行」(可反复试)
不会也没关系:先猜异常名,再猜哪一行在做决定。
128 / 154
小节
十四、决策卡:一致性方案怎么选
129 / 154
决策
决策一个商品详情接口,读多写少(读:写 ≈ 100:1),但偶尔会有一个热点商品在整点被大促流量打爆。缓存一致性方案选哪种?
130 / 154
小节
十五、动手练习
131 / 154
小节
第一档 · 照做
132 / 154

做一个「查两次只打一次库」的最小可复现现场。不依赖 Redis——用 Spring 自带的 ConcurrentMapCacheManager 就能看清注解的全部机制。完整可跑代码:

133 / 154
java
package com.example.cache;public record Product(Long id, String name, java.math.BigDecimal price) {}
134 / 154
java
package com.example.cache;import org.springframework.stereotype.Service;@Servicepublic class ProductService {    private int dbHits = 0;    // key 显式写成 #id:无参方法才不会出现 SimpleKey.EMPTY 覆盖问题    @org.springframework.cache.annotation.Cacheable(cacheNames = "product", key = "#id")    public Product find(Long id) {        System.out.println("[db] SELECT * FROM product WHERE id=" + (++dbHits));        return new Product(id, "蜜蜂公仔", new java.math.BigDecimal("39.00"));    }    @org.springframework.cache.annotation.CachePut(cacheNames = "product", key = "#id")    public Product rename(Long id, String newName) {        System.out.println("[db] UPDATE product SET name=? WHERE id=" + id);        return new Product(id, newName, new java.math.BigDecimal("39.00"));    }    @org.springframework.cache.annotation.CacheEvict(cacheNames = "product", key = "#id")    public void evict(Long id) {        System.out.println("[cache] evict product::" + id);    }    public int dbHits() { return dbHits; }}
135 / 154
java
package com.example.cache;import org.springframework.boot.SpringApplication;import org.springframework.boot.autoconfigure.SpringBootApplication;import org.springframework.cache.annotation.EnableCaching;import org.springframework.context.ConfigurableApplicationContext;@SpringBootApplication@EnableCaching          // ← 忘这一行,下面四行输出会变成四行 [db],且一句报错都没有public class CacheDemo {    public static void main(String[] args) {        try (ConfigurableApplicationContext ctx = SpringApplication.run(CacheDemo.class, args)) {            ProductService svc = ctx.getBean(ProductService.class);            svc.find(1L);              // 未命中 → 查库并回填            svc.find(1L);              // 命中 → 方法根本不执行            svc.rename(1L, "限量公仔"); // @CachePut → 执行方法并覆盖缓存            svc.find(1L);              // 命中 → 拿到改名后的对象            System.out.println("db hits = " + svc.dbHits());        }    }}
136 / 154

预期输出(db hits 必须是 1):

137 / 154
text
[db] SELECT * FROM product WHERE id=1[db] UPDATE product SET name=? WHERE id=1db hits = 1
138 / 154

第三行没有新的 [db] SELECT,就是「第二次读命中了缓存」的铁证。把 @EnableCaching 注释掉重跑,你会看到 db hits = 2 且日志里一行错误都没有——这就是第十三节那条「静默失效」的现场版。

139 / 154
小节
第二档 · 变体
140 / 154

目标:证明「自调用会绕过缓存代理」。

141 / 154

提示:只在 ProductService 里加一个方法 public java.util.List<Product> findTwice(Long id),方法体里写 return List.of(find(id), find(id));(注意是直接调 find,等价于 this.find(id)),然后从 main 里调 svc.findTwice(9L) 两次。

142 / 154

你会观察到:第一次调用打印 两行 [db](本该只有一行),第二次调用又是两行——总共四次打库。原因不是缓存坏了,而是这四次调用压根没经过代理。再把 findTwice 拆到另一个 SearchFacade Bean 里注入 ProductService 调用,[db] 立刻缩成一行。把两种输出的 SQL 行数抄进笔记,这就是第九节第一条坑的证据。

143 / 154
小节
第三档 · 造一个
144 / 154

给第一档的程序换成真 Redis,并按第二节的表把三种故障各防一遍。

145 / 154

验收清单:

146 / 154
  • [ ] redis-cli 里 KEYS app:product* 能看到带前缀的可读 JSON,而不是 \xac\xed 开头的二进制
  • [ ] TTL 生效:TTL app:product::1 返回正数秒;不同 cacheName 配了不同 TTL
  • [ ] 穿透防护可用:连续查 20 个不存在的 id 后,Redis 里出现了对应的空值标记且 TTL ≤ 2 分钟
  • [ ] 击穿防护可用:删掉热点 key 后用 50 线程并发读,数据库那行 SELECT 日志只打印 1 次(互斥重建生效)
  • [ ] 雪崩缓解可用:批量写入的 key 的 TTL 不全相同(抽查 10 个,能看到抖动后的差值)
  • [ ] 更新走「先改库、再删缓存」,你能说出为什么不「更新缓存」(对照第七节那张并发时刻表)
  • [ ] Actuator /actuator/health 里能看到 redis 组件;手动停掉 Redis 后状态变 DOWN,并且应用不会因为缓存而整体 500
147 / 154
小节
十六、要点自查
148 / 154
自检

不看资料,说出「命中率很高但只有一行数据被打爆」「整体命中率断崖下跌」「请求查的是不存在的 id」分别对应哪一种故障,以及各自的第一味药。答不全就回第二节那局配对闯关重新配一遍。

149 / 154
自检

@Cacheable 生效需要哪三个前提?答案应包含 @EnableCaching、调用经过代理、方法是 public。缺一个就回第五、九节。

150 / 154
自检

为什么默认序列化在 redis-cli 里是乱码?把它改成 JSON 需要设置几个序列化器(key / hashKey / value / hashValue)?回第四节数一遍。

151 / 154
自检

Cache-Aside 为什么是「删缓存」而不是「更新缓存」?能用第七节那张 T1~T4 时刻表讲给同事听吗?

152 / 154
自检

一个用户个性化接口该不该整块 @Cacheable?如果错了,错在哪一步(key 维度还是缓存粒度)?看第十一节沙盘最后一档。

153 / 154
口诀

货架挡在仓库门前,TTL 是它的保质期;不存在问穿它,热点到期击它,齐步过期崩它;注解靠代理,自调用即哑;先落库再删缓存,最终一致别贪强。

154 / 154
总结

把这篇压缩成几句话——缓存的价值不是单次变快,而是把读压力挡在数据库门外;穿透用空值缓存或布隆过滤器、击穿用互斥重建或逻辑过期、雪崩用随机 TTL 与多级缓存;默认 JDK 序列化在 redis-cli 里是乱码,换成 JSON 序列化器;Spring Cache 用注解消灭样板,但 key 要显式写、TTL 必须设、自调用会失效;一致性走「先更新库、再删缓存」+ 延迟双删,追求最终一致而非强一致;分布式锁别手写,生产用 Redisson。这几句立住,你写的缓存就不会是「给数据库埋雷」。