Redis 与 Spring Cache:缓存抽象与实战
想象你在学校后门的小卖部买一瓶水:货架上就有,三秒钟拿到手;货架上没有,店员得去仓库搬,可能要两分钟;仓库也没有,就得打电话让供货商送,那是半小时以后的事。写程序时「问数据库要一条数据」就是那次跑腿——它慢,而且一次大促有几万人同时跑腿,仓库会被抬走。缓存做的事很朴素:把常被问的东西先摆到货架上,让绝大多数请求不必回仓库。代价是:一旦货架上的东西和仓库里的对不上(数据更新了、货架还没换),你就必须处理「两份数据」的麻烦——这一篇讲的就是这两件事:怎么把货摆上去(读写路径与注解)、摆错了怎么办(穿透/击穿/雪崩与一致性)。
先把五个词一句话解释清楚:
- 缓存:一份「好取但可能过期」的副本,放在比数据库快得多的地方(通常就在内存里)
- key:副本的门牌号,比如
app:user::42。取东西全靠这个号,号写错就取到别人的东西 - TTL:门牌号的有效期(time to live)。到期自动作废,逼着下一次读重新核对——不写 TTL 等于永久脏
- 回源:货架没有、于是跑去仓库(数据库)取的那一趟。缓存的全部艺术就是「尽量少回源,但一定要能回到正确的源」
- 序列化:把 Java 对象压成一段字节或 JSON 才能塞进 Redis;取出来时再反向还原。压法不对,redis-cli 里就是一堆乱码
整套缓存体系就是那家小卖部。Redis 是店里的货架(伸手就到,但空间有限、会过期),MySQL 是街后仓库(什么都有,取一次慢),其他服务或主从同步是供货商(最远、最容易出岔子)。而「穿透」是有客人反复问一个店里从来不卖的 SKU,每次都逼店员跑一趟仓库;「击穿」是爆款商品那一格刚好空了,几十个客人同时冲向后门;「雪崩」则是整排货架同时被清空,全店人一起挤进仓库通道。三种故障的区别只有一句话:问的东西压根不存在 / 只有一个热点没了 / 一大批同时没了。

上面这段动画把「命中」和四种翻车现场画在了同一条路径上:前两步所有请求都一模一样,分岔发生在第二步「缓存里到底有没有」。想快速定位自己的线上故障,就先在这张图里认出自己走的是哪条分支,第二节的表会给出对应解法。
学完这一篇,你应该能回答三个问题:
- 我的接口加了缓存为什么还是慢?(是真的没命中,还是命中了却仍去打库?)
- 运营在后台改了价格,用户手机上什么时候能看到新价格?我怎么把这个时间压到可控范围?
@Cacheable写成this.getXxx()自调用之后悄悄不生效,怎么用一行日志当场确认这件事?
缓存能成立,靠的是一个朴素事实:内存读写和磁盘读写之间,差了三到五个数量级。把常见操作的耗时放在一起对比,量级差距一目了然:
| 操作 | 典型耗时 | 量级印象 |
|---|---|---|
| CPU 访问寄存器 | ~0.3 ns | 1× |
| 读 L1 / L2 缓存 | ~1–10 ns | 数十倍 |
| Redis 单次 GET(同机房) | ~0.1–0.5 ms | 百万倍起 |
| MySQL 主键查询(走索引) | ~1–5 ms | 千万倍起 |
| MySQL 复杂 JOIN / 聚合 | ~50–500 ms | 更慢 |
一次 MySQL 查询,背后要走完「解析 SQL → 生成执行计划 → 走 B+ 树 → 可能回表 → 结果网络传输」一整条链路;而 Redis 只是一次内存里的哈希查找。差距不是「快一点」,而是会被并发放大:同样 1000 QPS,走 MySQL 意味着数据库每秒要扛 1000 次查询,走 Redis 则可能连数据库都不用碰。
Redis 快,不是靠什么黑科技,而是它把数据全放内存、单线程避免了锁竞争、用 IO 多路复用扛住并发。但别忘了,它的耗时里有一大块是网络往返——同机房 0.1 ms 已经算优秀,跨机房就可能退化到几毫秒。缓存最大的价值,是把读压力挡在数据库门外。

「慢」这件事在真实请求里长什么样?下面这个实验把一个请求从入口带到数据库,切到「慢请求与超时」那一档,你会看到耗时到底堆在哪一层——看清楚它,你才知道缓存要挡的是哪一段,而不是给每个接口都随手贴一个 @Cacheable:
但天下没有免费的午餐——一旦引入缓存,数据就有了两份,三件经典故障随之而来。
| 问题 | 现象 | 根因 | 常用解法 | 代价 |
|---|---|---|---|---|
| 缓存穿透 | 请求查一个根本不存在的数据 | 缓存与数据库都没有,每次都穿到库 | 空值缓存、布隆过滤器、参数校验 | 空值占内存 / 布隆有误判 |
| 缓存击穿 | 某个热点 key 恰好过期 | 高并发同时未命中,一起涌向数据库 | 互斥锁重建、逻辑过期 | 加锁有等待 / 逻辑过期实现复杂 |
| 缓存雪崩 | 大批 key 同时失效 | 统一 TTL 到点,或 Redis 整体宕机 | 随机化 TTL、多级缓存、限流降级 | 随机化范围要拿捏 |
三行表格读起来快,但线上出事时你要的是「先看指纹,再拿药」。把左右两列并排放好,故障现场与对症解法各占一格,贴在工位上比背三遍都有用:

缓存击穿现场:晚上八点整,大促开始。某个爆款商品的缓存 key 设了 3600 秒 TTL,而它恰好是一小时前整点写入的——于是整点一到,这个 key 集体过期。几万个请求在同一毫秒发现缓存为空,全部冲向数据库去查同一行数据,数据库瞬间被单行查询打满,连接池耗尽,整个商品页不可用。注意:数据库并没有坏,它只是被自己的缓存策略卖了。
三种解法各写一段代码思路。先是穿透与击穿:
// 穿透解法一:空值缓存——查不到也写一个短 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;}// 击穿解法:互斥锁重建——只让一个线程去查库,其余等待后重读缓存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;}雪崩通常不需要写代码,靠策略就能大幅缓解:
- 随机 TTL:写入时用
TTL + random(0, 300)秒,把过期时间打散,避免整点齐发 - 多级缓存:本地 Caffeine + Redis 两级,Redis 抖动时本地还能挡一阵
- 限流降级:给数据库加一层熔断(如 Resilience4j),宁可拒绝少量请求,也别让库整体雪崩
空值缓存一定要设短 TTL。如果把不存在的 id 也缓存 30 分钟,攻击者用 id=-1、-2、-3…… 刷一遍,就会把一堆无意义的空标记塞满 Redis——这就是「空值缓存」变成「缓存污染」的现场。
上面三段解法散在代码里,而真出事时你手里只有一句现象描述。所以这里不开表,改成一局闯关:左边是工单里会出现的原话,右边点它的病因与第一味药——配错当场告诉你为什么,比背表快。
小卖部的三种翻车也很好区分。穿透是有客人反复问「有没有卖 25 号的鞋」——店里从来不卖,店员每次都跑去仓库翻一遍再回来说没有;治法是门口贴张「本店不经营清单」(布隆过滤器),或干脆记一张「刚才有人问过,答案是没」(空值缓存)。击穿是唯一那箱爆款矿泉水刚好被搬空,几十个客人同时挤到后门要货,得只放一个店员去仓库、其余人在门口等(互斥锁)。雪崩则是整个货架的价签同时到期被撤下,全店人都往仓库通道跑——要么把价签的到期日错开写(随机 TTL),要么先在仓库门口限流(熔断降级)。
先认清 Redis 的「五把刀」,它们几乎覆盖所有缓存场景:
| 结构 | 一句话 | 典型用途 |
|---|---|---|
| String | 最基础的键值 | 对象 JSON、计数器(INCR)、分布式锁 |
| Hash | 字段-值映射 | 存对象的部分字段、购物车 |
| List | 有序、可两端进出 | 消息队列、最新 N 条动态 |
| Set | 无序去重集合 | 点赞去重、共同好友(交集) |
| ZSet | 带分数的有序集合 | 排行榜、延迟队列(按时间戳打分) |
整合只需要一个 starter 和几行配置:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId></dependency>这一行只是最小可用。真要落一个项目,Redis 之外还得连着数据源、序列化依赖与监控端点——勾一遍看看依赖树会长成什么样,特别注意 mysql 与 h2 的 scope 区别(后者只该活在测试里):
<?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>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同样一段配置,写全不容易:地址、超时、连接池、序列化的落点各在不同小节。**勾一遍「Redis + 数据源 + 日志 + Profile」,看 spring.data.redis. 与 logging.level 各自生成在哪一节*——第三节的连接池上限和第十五节那条「缓存集体失效」的排查,都从这几行开始。
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 }
Spring Boot 会自动装配两个模板,它们的区别是新手第一个易错点:
| 模板 | key / value 序列化方式 | 适用场景 |
|---|---|---|
RedisTemplate<K, V> | 默认 JDK 序列化(二进制) | 存 Java 对象,需自行配置序列化器 |
StringRedisTemplate | 全部按 String(UTF-8) | key/value 都是文本,要与 redis-cli 对得上 |
RedisTemplate<Object, Object> 的默认序列化器是 JdkSerializationRedisSerializer,它会把你存的对象变成一串二进制——这一点在下一节会亲眼看到。
很多人第一次这样写:
@Autowiredprivate RedisTemplate<String, Object> redisTemplate;public void save(User user) { redisTemplate.opsForValue().set("user:1", user); // 存一个 Java 对象}然后打开 redis-cli 一看:
127.0.0.1:6379> GET "user:1""\xac\xed\x00\x05sr\x00\x04com..User\x..."前面那串 \xac\xed 正是 Java 序列化流的魔数(0xACED)。JDK 序列化把对象连「全限定类名和字段元数据」一起写了进去,于是问题成堆:内容不可读、体积膨胀、一旦类名或字段改了老数据直接反序列化失败——而且它还是反序列化漏洞的重灾区。
正解是换成 JSON 序列化:
@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; }}改完之后,同一个 key 在 redis-cli 里变成:
127.0.0.1:6379> GET "user:1""{\"@class\":\"com.example.User\",\"id\":1,\"username\":\"alice\"}"要点:GenericJackson2JsonRedisSerializer 会额外写入 @class,好处是反序列化能还原成原类型;坏处是这段 JSON 与你的包名强耦合,一旦类被重命名或移动,老数据同样读不出来。若你只需要「纯 JSON、不要类型信息」,改用 Jackson2JsonRedisSerializer<T> 并显式指定目标类型,可读性更好,但要求调用方自己知道类型。
真正让缓存好用的,是 Spring Cache 这套基于注解的抽象——它把「查缓存 → 未命中查库 → 回填」的样板代码全部收走。开启方式就一个注解:
@Configuration@EnableCaching // 打开缓存抽象(代理拦截,与 @Transactional 同源)public class CacheConfig {}四个核心注解,各有各的触发时机:
| 注解 | 触发时机 | 典型用法 |
|---|---|---|
@Cacheable | 方法调用前先查缓存,命中则不执行方法 | 查询方法 |
@CachePut | 总是执行方法,再把结果写回缓存 | 更新后要同步刷新缓存 |
@CacheEvict | 方法执行后删除缓存 | 删除操作、更新后删缓存 |
@Caching | 组合多个缓存操作 | 一次更新同时删多个 key |
一个最常用的写法:
@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); }}key 的 SpEL 写法是使用门槛所在,常用表达式整理如下:
| SpEL 表达式 | 含义 |
|---|---|
#id | 参数名为 id 的值 |
#user.id | 参数对象的属性 |
#p0 / #a0 | 第一个参数(按下标,p / a 均可) |
#root.methodName | 当前方法名 |
#root.targetClass | 目标类 |
#result.id | 方法返回值(仅 @CachePut / @CacheEvict 可用) |
#id + ':' + #type | 拼接组合 key |
key 不写时,Spring 用 SimpleKeyGenerator 把参数拼成一个 key。零参数方法会生成一个固定的 SimpleKey.EMPTY,多个无参方法若共用同一个 cacheName,会互相覆盖。 无参方法务必显式写 key。
上面这些规矩,说到底都来自同一条执行路径。把它摊成一次单步执行:左边是那七行,右边同步刷新「此刻的变量」和「调用栈」——连点下一步,重点盯第 ⑤ 步:命中时你的方法体一行都不会执行,第九节那条「自调用静默失效」和第十一节沙盘最后一档「串数据」,源头都在这格:
userService.findById(42L); // ① 调用打到缓存代理,不是打到你的 ServiceCacheInterceptor.execute() // ② 把注解元数据读成一条 CacheOperationkey = evaluator.key("#id") -> app:user::42 // ③ SpEL 在这一格被求值cache = cacheResolver.resolve("user") // ④ cacheNames 在这里落到具体的 RedisCachevalue = cache.get(key) // ⑤ 命中就当场 return,方法体一行都不跑result = method.invoke(target) // ⑥ 只有未命中才真的去查库cache.put(key, result); return result // ⑦ 回填(带 TTL)并把结果交回去| userService | UserService$$SpringCGLIB$$0 |
| 调用方线程 | http-nio-8080-exec-5 |
UserController.detailproxy.findById注解虽好,但它有一条清晰的边界:它只能缓存「一个方法的返回值」。当你需要的不是缓存结果、而是操作——一次读多个 key(MGET/Pipeline)、给某个 key 单独续期、抢一把锁做互斥重建、只更新对象里的两个字段、或者一段必须原子执行的 Lua——注解就给不了你这些了,得回到 RedisTemplate。下面这张对照图把两条路各自的胜负手摆在一起(第五列尤其重要:注解靠代理,所以自调用会静默失效;手写没有代理,也就没有这个坑):

判据一句话就能记住——「一个方法 = 一个缓存对象」就用注解;需要「操作而不只是结果」就自己写。两者可以共存:主查询走 @Cacheable,热点 key 的互斥重建与分布式锁用 RedisTemplate,别为了统一风格把手写锁硬塞进注解里。
默认的 RedisCacheManager 永不过期——这是生产事故的常见来源。要给缓存设 TTL、统一 key 前缀、甚至按命名空间区分策略,就得自定义:
@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,避免「一刀切」把热点和冷数据绑死在一起
TTL 该设多久,是缓存里唯一一个真能拖的旋钮。它的两端各咬住一种故障——左边咬内存和一致性,右边咬数据库:
- 30 分钟到 2 小时是常见的默认量级,命中与新鲜度的平衡点
- 记得加抖动:TTL + random(0,300),别让整点对齐
- 更新路径要有 @CacheEvict,TTL 只是兜底不是主防线
- 监控看命中率与回源 QPS,别看这个数本身
@Cacheable 与 RedisCacheManager 之间是「抽象与实现」的关系——你写注解,CacheManager 决定落到哪个 Cache 实现、用什么 TTL 与序列化。换实现(比如从 Redis 换成 Caffeine)时,业务注解一行都不用改。若想为不同方法动态选择缓存实现,可以实现 CacheResolver,按方法元数据返回不同的 Cache。
先把两条路径画清楚(见开头的图 1)。核心结论一句话:读走「缓存优先」,写走「先更新数据库,再删除缓存」——这就是 Cache-Aside(旁路缓存)模式。

为什么是「删除缓存」而不是「更新缓存」?看一个并发场景就明白了:
| 时刻 | 线程 A(写) | 线程 B(读) |
|---|---|---|
| T1 | 更新数据库 = 20 | |
| T2 | 读到旧值 10(A 的写入尚未可见) | |
| T3 | 更新缓存 = 20 | |
| T4 | 写缓存 = 10(把 20 覆盖掉了) | |
| 结果 | 数据库 20、缓存 10 → 不一致 |
「更新缓存」的写法,等于把数据库的写并发原样复刻到缓存上,两个写线程的先后顺序无法保证,脏数据就来了。而「删除缓存」把问题简化成一个原子动作,代价只是下一次读要回源一次——用一次回源,换掉了不一致风险。
那张时刻表是四行字,动画把它按顺序播一遍——注意第 ④ 帧,覆盖发生的那一刻没有任何异常、没有任何日志,这是缓存最难查的一类故障:

那先删缓存、再更新数据库行不行?也有并发漏洞(读线程在删除后、更新前把旧值回填)。因此更稳的顺序是 Cache-Aside 的「先更新库、再删缓存」,配合延迟双删兜底:
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 与延迟双删把这个窗口压到毫秒级,而不是追求理论上的完美。
「删还是改」只是这五种读写策略里的一格。Spring Cache 默认走的是第一种(旁路),另外四种各有各的适用面,也各有各的翻车姿势——点着看,每一格都告诉你「谁去填缓存」这个唯一的关键问题:
前面互斥重建缓存用的其实就是分布式锁的雏形。一个可用的锁有三个要素:原子占位、过期兜底、只能删自己的锁:
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,它内置了看门狗自动续期、可重入与红锁语义,把上面这些坑全填了。这段手写锁的价值,是让你理解原理,而不是让你上生产。
@Cacheable 在同类内部自调用时会失效。 和 @Transactional 一模一样的道理——注解靠 AOP 代理生效,this.method() 走的是原始对象,绕过了代理,缓存查找与回填根本不会发生。修法:把方法拆到另一个 Bean,或注入自身代理。
缓存的空值也要设短 TTL。 否则一次「查不到」会被固化成长达数十分钟的「永远查不到」——数据后来补上了,缓存却还在说没有。
大 key 与热 key 要单独治理。 一个 value 几十 MB 的大 key 会拖慢 Redis 单线程、阻塞其他命令;一个被打到几万 QPS 的热 key 容易把单个分片打满。治理手段:拆大 key(Hash 分片)、用本地缓存挡住热 key、多副本 key 打散压力。
缓存与事务、日志失效的根源是同一个代理机制,下面这段演示让你亲眼看到「自调用绕过了什么」:
第九节三条坑全是文字,但它们各自都有能亲眼看见的样子。下面五个实验依次回答:三种故障在时序上有什么不同?缓存挡不住数据库时连接池会怎样?线上怎么第一时间知道 Redis 是不是活的?限流三兄弟该选哪个?以及——Spring Cache 和 MyBatis 那两层「缓存」到底是什么关系?
第一个实验是主角。请按 hit → miss → key → pen → bust 的顺序切五档,边切边对照第二节那局配对闯关:
第二个实验把第一节那句「缓存的价值是把读压力挡在数据库门外」变成可观察的曲线。切「命中空闲连接」与「达到上限排队」,感受缓存命中率从 95% 掉到 0 时,等待队列是怎么长起来的:
第三个解决一个很实际的焦虑:Redis 挂了,我怎么在用户发现之前知道? Actuator 的健康端点会把 Redis 连接状态聚合成一个 DOWN,配合监控就是最早的警报:
第四个把第二节那句「给数据库加一层限流」落到具体算法上。雪崩来临时,固定窗口会在窗口边界放过两倍流量,漏桶永远匀速、扛不住突发,令牌桶允许突发但要算桶深——切「怎么选与突发」这一档,三种算法的差异会直接画出来:
第五个回答一个评论区常年吵架的问题:MyBatis 的一级/二级缓存和 Spring Cache 是什么关系。切到二级缓存那一档你会看到它跨 SqlSession 共享、且默认按 namespace 整体失效——这跟 @CacheEvict 精确删一个 key 是两个模型,两套缓存同时开还可能互相掩盖脏读:
实验做到这里,可以换成命令行自己敲了。下面这台控制台连着浏览器里的同一个 Java 内核,回显全部由内核算出来——先 ping 确认容器活着,再逐条把三种故障与一次连接超时跑出来:
lab cache bust 之后紧接着敲 lab pool queue,才能看清雪崩的下游是什么样子——前者的时间线里几十万个请求同时未命中,后者的队列长度就是数据库那侧的真实遭遇。
策略选错的症状总是很晚才出现,而且出现时已经打到数据库了。这个沙盘把「这条数据经不起多久的旧值」做成一个开关,切一档就能同屏看到 TTL、并发保护和监控指标三处变化:
TTL = 1800s + random(0,300) ← 抖动防止齐发未命中重建:SETNX 互斥锁,只放一个线程回源监控重点:single-key QPS、GET_LOCK 等待时长# 判据:读写比高且就一行最热 → 保护那一个 key,而不是加一堆机器
四档对应四种真实诉求,而选择的顺序永远是先量「这份数据旧多久可以接受」,再决定防哪一种故障。反过来先想「我要不要用布隆过滤器」,通常说明你还没搞清楚自己挨的是哪一种打。
第一道热身题,考的是最容易在面试里说反的那对概念:
第二道综合题,把第五节的注解机制和第九节的坑绑在一起:
新手在这一章最常撞的四类墙:连不上、看不懂(乱码)、取错(串数据)、以及最阴的「什么都没发生」。下面这些片段都能原样复制去搜索。
| 报错原文(片段) | 真实原因 | 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>:6379 | Lettuce 建连失败,通常是 DNS/网络策略问题而非密码问题 | <unresolved> 说明主机名压根没解析出来——检查 compose 网络与 service 名 | 本篇第三节 |
SerializationException: Could not write JSON: Java 8 date/time type java.time.LocalDateTime not supported by default | Jackson 缺少 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 篇 |
搜这类问题时只搜冒号后的第一段英文原文(如 Unable to connect to Redis),不同 Spring Boot 版本会在后半句追加话术,整句搜索常常一条都命不中。
表里第一条是新手撞得最多、也最容易误判的一条:它看起来像密码错、像 Redis 崩,实际上常常只是容器里的 localhost 指向了自己。先别看答案,点出你认为的凶手行:
本地跑得好好的,一上容器环境接口全 500。日志里这句话翻来覆去,改密码、换版本、重启 Redis 都没用。
做一个「查两次只打一次库」的最小可复现现场。不依赖 Redis——用 Spring 自带的 ConcurrentMapCacheManager 就能看清注解的全部机制。完整可跑代码:
package com.example.cache;public record Product(Long id, String name, java.math.BigDecimal price) {}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; }}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()); } }}预期输出(db hits 必须是 1):
[db] SELECT * FROM product WHERE id=1[db] UPDATE product SET name=? WHERE id=1db hits = 1第三行没有新的 [db] SELECT,就是「第二次读命中了缓存」的铁证。把 @EnableCaching 注释掉重跑,你会看到 db hits = 2 且日志里一行错误都没有——这就是第十三节那条「静默失效」的现场版。
目标:证明「自调用会绕过缓存代理」。
提示:只在 ProductService 里加一个方法 public java.util.List<Product> findTwice(Long id),方法体里写 return List.of(find(id), find(id));(注意是直接调 find,等价于 this.find(id)),然后从 main 里调 svc.findTwice(9L) 两次。
你会观察到:第一次调用打印 两行 [db](本该只有一行),第二次调用又是两行——总共四次打库。原因不是缓存坏了,而是这四次调用压根没经过代理。再把 findTwice 拆到另一个 SearchFacade Bean 里注入 ProductService 调用,[db] 立刻缩成一行。把两种输出的 SQL 行数抄进笔记,这就是第九节第一条坑的证据。
给第一档的程序换成真 Redis,并按第二节的表把三种故障各防一遍。
验收清单:
- [ ]
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
不看资料,说出「命中率很高但只有一行数据被打爆」「整体命中率断崖下跌」「请求查的是不存在的 id」分别对应哪一种故障,以及各自的第一味药。答不全就回第二节那局配对闯关重新配一遍。
@Cacheable 生效需要哪三个前提?答案应包含 @EnableCaching、调用经过代理、方法是 public。缺一个就回第五、九节。
为什么默认序列化在 redis-cli 里是乱码?把它改成 JSON 需要设置几个序列化器(key / hashKey / value / hashValue)?回第四节数一遍。
Cache-Aside 为什么是「删缓存」而不是「更新缓存」?能用第七节那张 T1~T4 时刻表讲给同事听吗?
一个用户个性化接口该不该整块 @Cacheable?如果错了,错在哪一步(key 维度还是缓存粒度)?看第十一节沙盘最后一档。
货架挡在仓库门前,TTL 是它的保质期;不存在问穿它,热点到期击它,齐步过期崩它;注解靠代理,自调用即哑;先落库再删缓存,最终一致别贪强。
把这篇压缩成几句话——缓存的价值不是单次变快,而是把读压力挡在数据库门外;穿透用空值缓存或布隆过滤器、击穿用互斥重建或逻辑过期、雪崩用随机 TTL 与多级缓存;默认 JDK 序列化在 redis-cli 里是乱码,换成 JSON 序列化器;Spring Cache 用注解消灭样板,但 key 要显式写、TTL 必须设、自调用会失效;一致性走「先更新库、再删缓存」+ 延迟双删,追求最终一致而非强一致;分布式锁别手写,生产用 Redisson。这几句立住,你写的缓存就不会是「给数据库埋雷」。