文章目录
- 前言
- [一、Redis 基础入门:你好,这个"快如闪电"的家伙](#一、Redis 基础入门:你好,这个"快如闪电"的家伙)
-
- [1.1 你和 Redis 的第一次相遇](#1.1 你和 Redis 的第一次相遇)
- [1.2 先搞明白:Redis 到底是个什么?](#1.2 先搞明白:Redis 到底是个什么?)
- [1.3 Redis 为什么这么快?(面试必问)](#1.3 Redis 为什么这么快?(面试必问))
- [1.4 三分钟搭好你的 Redis 环境](#1.4 三分钟搭好你的 Redis 环境)
- [1.5 五种基础数据类型------别再说 Redis 只能存字符串了](#1.5 五种基础数据类型——别再说 Redis 只能存字符串了)
-
- [String --- 最万能,但不是最聪明的](#String — 最万能,但不是最聪明的)
- [Hash --- 存对象,有时候比 String 更合适](#Hash — 存对象,有时候比 String 更合适)
- [List --- 能当队列,也能当栈](#List — 能当队列,也能当栈)
- [Set --- 去重、交并差集](#Set — 去重、交并差集)
- [Sorted Set(ZSet)--- 自带"排行榜"属性](#Sorted Set(ZSet)— 自带"排行榜"属性)
- [1.6 三种特殊类型:面试中的"加分项"](#1.6 三种特殊类型:面试中的"加分项")
- 二、核心进阶篇:从"会用"到"真懂"
-
- [2.1 持久化:断电了数据怎么办?](#2.1 持久化:断电了数据怎么办?)
-
- RDB(快照式持久化)
- AOF(追加式持久化)
- [所以该选 RDB 还是 AOF?](#所以该选 RDB 还是 AOF?)
- [2.2 过期策略与内存淘汰:数据什么时候消失?](#2.2 过期策略与内存淘汰:数据什么时候消失?)
-
- [过期 key 怎么清理?](#过期 key 怎么清理?)
- 内存满了怎么办?------内存淘汰策略
- [面试追问:LRU 是怎么实现的?](#面试追问:LRU 是怎么实现的?)
- [2.3 事务与 Lua 脚本:Redis 也有"原子操作"](#2.3 事务与 Lua 脚本:Redis 也有"原子操作")
-
- [Redis 事务 ≠ MySQL 事务](#Redis 事务 ≠ MySQL 事务)
- [Lua 脚本:Redis 的"存储过程"](#Lua 脚本:Redis 的"存储过程")
- [2.4 发布订阅与 Stream:消息传递的两代方案](#2.4 发布订阅与 Stream:消息传递的两代方案)
-
- Pub/Sub:简单到极致,也脆弱到极致
- [Stream:这才是能打的轻量级 MQ(Redis 5.0+)](#Stream:这才是能打的轻量级 MQ(Redis 5.0+))
- [三、高可用架构篇:Redis 挂了,天塌不下来](#三、高可用架构篇:Redis 挂了,天塌不下来)
-
- [3.1 主从复制:读写分离的第一步](#3.1 主从复制:读写分离的第一步)
- [3.2 哨兵模式(Sentinel):自动化的"监工"](#3.2 哨兵模式(Sentinel):自动化的"监工")
- [3.3 集群模式(Cluster):分片,才是终极方案](#3.3 集群模式(Cluster):分片,才是终极方案)
- 四、缓存实战篇:生产环境中的三大"夺命问题"
-
- [4.1 缓存穿透:查的全是不存在的东西](#4.1 缓存穿透:查的全是不存在的东西)
- [4.2 缓存击穿:热点数据突然过期](#4.2 缓存击穿:热点数据突然过期)
- [4.3 缓存雪崩:大规模的"集体阵亡"](#4.3 缓存雪崩:大规模的"集体阵亡")
- [4.4 缓存一致性:先更新数据库,还是先删缓存?](#4.4 缓存一致性:先更新数据库,还是先删缓存?)
- [4.5 穿透、击穿、雪崩与解决方案](#4.5 穿透、击穿、雪崩与解决方案)
-
- [1. 区分](#1. 区分)
- [2. 缓存穿透:数据库里根本没有这条数据](#2. 缓存穿透:数据库里根本没有这条数据)
- [3. 缓存击穿:一个热 key 的真空期](#3. 缓存击穿:一个热 key 的真空期)
- [4. 缓存雪崩:当一百万个 key 同时过期](#4. 缓存雪崩:当一百万个 key 同时过期)
- [5. 组合拳:真实系统的读路径长什么样](#5. 组合拳:真实系统的读路径长什么样)
- [6. 几个容易踩的细节](#6. 几个容易踩的细节)
- [7. 小结](#7. 小结)
- [五、Java 集成篇:Spring Boot 里的 Redis 最佳实践](#五、Java 集成篇:Spring Boot 里的 Redis 最佳实践)
-
- [5.1 Jedis vs Lettuce vs Redisson:三个客户端,到底选谁?](#5.1 Jedis vs Lettuce vs Redisson:三个客户端,到底选谁?)
- [5.2 配置最佳实践(序列化是关键!)](#5.2 配置最佳实践(序列化是关键!))
- [5.3 分布式锁:Redisson 是怎么做到的?](#5.3 分布式锁:Redisson 是怎么做到的?)
-
- 基本用法
- [面试官的追问:Redisson 的锁是怎么实现的?](#面试官的追问:Redisson 的锁是怎么实现的?)
- [封装一个 @RedisLock 注解](#封装一个 @RedisLock 注解)
- [5.4 布隆过滤器:Redisson 实战](#5.4 布隆过滤器:Redisson 实战)
- [六、面试冲刺篇:10 道高频题 + 2 个血泪事故复盘](#六、面试冲刺篇:10 道高频题 + 2 个血泪事故复盘)
-
- [6.1 10 道高频面试题](#6.1 10 道高频面试题)
-
- [Q1:Redis 为什么这么快?](#Q1:Redis 为什么这么快?)
- [Q2:Redis 是单线程的,为什么还能高并发?](#Q2:Redis 是单线程的,为什么还能高并发?)
- [Q3:RDB 和 AOF 的区别?生产环境怎么选?](#Q3:RDB 和 AOF 的区别?生产环境怎么选?)
- Q4:缓存穿透、击穿、雪崩是什么?怎么解决?
- [Q5:Redis 的过期 key 是怎么删除的?](#Q5:Redis 的过期 key 是怎么删除的?)
- Q6:你们用的哪个内存淘汰策略?为什么?
- [Q7:Redis Cluster 的哈希槽是怎么回事?](#Q7:Redis Cluster 的哈希槽是怎么回事?)
- [Q8:Redis 怎么做分布式锁?你遇到过什么坑?](#Q8:Redis 怎么做分布式锁?你遇到过什么坑?)
- Q9:缓存和数据库怎么保持一致性?
- [Q10:Redis String 的底层实现是什么?跟 Java String 的区别?](#Q10:Redis String 的底层实现是什么?跟 Java String 的区别?)
- [6.2 两个生产事故复盘](#6.2 两个生产事故复盘)
-
- [事故一:缓存预热把 CPU 打满了](#事故一:缓存预热把 CPU 打满了)
- [事故二:大 Key 导致 Redis 间歇性卡顿](#事故二:大 Key 导致 Redis 间歇性卡顿)
- 结语
前言
你现在打开项目代码,搜索 RedisTemplate,匹配到的地方是不是都有这么几行------查缓存、写缓存、删缓存?你对这套操作早已烂熟于心,闭着眼都能写出来。
可你有没有在某一个瞬间问过自己:缓存设了 30 分钟过期,那第 31 分钟会发生什么?如果那一秒恰好有一万个请求同时涌进来,Redis 扛得住吗?数据库扛得住吗?
这篇文章,正是为了回答那些你"隐约觉得重要,但又没人系统讲过"的问题。我们不谈虚的,就从你项目中那最常用的RedisTemplate 出发,把 Redis 真正吃透。
一、Redis 基础入门:你好,这个"快如闪电"的家伙
1.1 你和 Redis 的第一次相遇
大多数 Java 开发第一次接触 Redis 的场景出奇一致:前端或者产品跑过来说"首页怎么这么慢,加载要三秒"。你打开慢查询日志一看,好家伙,同一个 SQL 在一次请求里被重复执行了七八次。
于是你灵机一动------把查询结果扔进 Redis,下次来直接拿。响应时间从 3000ms 降到 50ms,你觉得自己简直是个天才。然后......就没有然后了。你停在了 set 和 get 的舒适区里。
但 Redis 能做的事情,比"缓存数据库查询结果"多得多。它像一把瑞士军刀,而你只用它开瓶盖。
1.2 先搞明白:Redis 到底是个什么?
Redis (RE mote DI ctionary Server)是一个基于内存的高速键值数据库,C 语言编写,读写速度可达每秒 10 万次以上。
一句话概括:把数据放在内存里,支持多种数据结构的键值数据库。
在 Java 技术栈中,它最常见的三种身份:
| 角色 | 典型场景 | 人话版解释 |
|---|---|---|
| 缓存 | 热点数据缓存、页面缓存、Session 共享 | "替数据库挡流量,让同样的 SQL 别再反复查" |
| 分布式锁 | 防重复提交、秒杀扣库存 | "多个服务实例之间,协调谁干活、谁排队" |
| 消息队列 | 异步解耦、延时任务 | "轻量级 MQ,服务间传递消息的小能手" |
如果你现在只把它当缓存用,没关系,读完这篇文章你会解锁剩下的两种身份。
1.3 Redis 为什么这么快?(面试必问)
这个问题几乎是 Redis 面试题的"开场白"。很多人张口就是"因为它是内存数据库",然后就词穷了。实际上,这是一个多管齐下的结果:
为什么 Redis 这么快?
│
├── 1. 纯内存操作(最主要的原因)
│ 数据在内存里,没有磁盘 I/O(持久化是异步干的,不挡路)
│
├── 2. 单线程模型(避免了上下文切换和锁竞争)
│ 一个线程专心干活,不用跟别人抢锁
│ 注意:Redis 6.0 之后网络 I/O 变多线程了,但命令执行还是单线程
│
├── 3. I/O 多路复用(epoll)
│ 一个线程同时盯好几个 socket,谁有数据就处理谁
│
├── 4. 高效的数据结构
│ SDS、ziplist、skiplist......都是为"快"而生的设计
│
└── 5. RESP 协议简洁
│ 不像 HTTP 那样带一大堆头信息,Redis 的协议精瘦得很
记住公式:纯内存 + 单线程无锁 + epoll + 高效数据结构 = 快。面试的时候把这四个点展开讲,面试官会在心里默默给你加分。
1.4 三分钟搭好你的 Redis 环境
还没有本地 Redis 的话,Docker 是最快的姿势:
bash
# 一行命令启动 Redis 7.x
docker run -d --name redis \
-p 6379:6379 \
redis:7-alpine redis-server --appendonly yes
# 进入 Redis 客户端玩玩
docker exec -it redis redis-cli
Java 项目引入依赖(Spring Boot 3.x):
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
yaml
# application.yml
spring:
data:
redis:
host: localhost
port: 6379
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
1.5 五种基础数据类型------别再说 Redis 只能存字符串了
很多新手对 Redis 的印象就是"一个能设置过期时间的 HashMap"。来,我们把这个印象升级一下。Redis 有 5 种基础数据类型,每一种都有独特的适用场景。
String --- 最万能,但不是最聪明的
java
// Spring Data Redis
redisTemplate.opsForValue().set("user:1001", userJson);
String userJson = redisTemplate.opsForValue().get("user:1001");
适合干嘛 :缓存 JSON、计数器(用 INCR 命令)、分布式锁的值、限流计数。
底层结构 :SDS(Simple Dynamic String),比 C 语言的 char[] 高级得多------O(1) 获取长度,杜绝缓冲区溢出,自带内存预分配。
textile
SDS 结构简图:
┌─────────┬────────┬─────────┬────────┐
│ len │ alloc │ flags │ buf[] │
│ (已用) │ (分配) │ (类型) │ (数据) │
└─────────┴────────┴─────────┴────────┘
面试加分点:SDS 的
len字段让STRLEN命令的时间复杂度是 O(1),而不是 C 字符串的 O(n)。
Hash --- 存对象,有时候比 String 更合适
java
redisTemplate.opsForHash().put("product:1001", "name", "iPhone 15");
redisTemplate.opsForHash().put("product:1001", "price", "6999");
String name = (String) redisTemplate.opsForHash().get("product:1001", "name");
适合干嘛:存储对象(如果经常要改对象的某个字段,用 Hash 比 String+JSON 更高效)、购物车(用户 ID → 商品 ID:数量)。
String 存 JSON vs Hash 存字段,怎么选?如果你只是整体存取,String 最简单;如果你需要频繁改对象的某个字段,用 Hash,它不需要把整个 JSON 序列化反序列化一遍。面试时能讲出这个区别,考官会多看你一眼。
底层结构:元素少时用 ziplist(压缩列表,省内存),元素多时自动切换为 hashtable(字典)。
List --- 能当队列,也能当栈
java
// 左边进,右边出 → 队列模式(FIFO)
redisTemplate.opsForList().leftPush("task:queue", taskJson);
String task = redisTemplate.opsForList().rightPop("task:queue");
// 左边进,左边出 → 栈模式(LIFO)
redisTemplate.opsForList().leftPush("history", item);
String last = redisTemplate.opsForList().leftPop("history");
适合干嘛:简单的消息队列、最新评论列表、分页展示。
底层结构 :Redis 3.2 之前用 linkedlist 或 ziplist,3.2 之后统一用 quicklist------双向链表的每个节点内部是一个压缩列表,兼顾了内存和性能。
textile
quicklist 结构:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ ziplist 1 │──│ ziplist 2 │──│ ziplist 3 │
└──────────────┘ └──────────────┘ └──────────────┘
↑ 双向链表的节点,每个节点内部是个压缩列表
Set --- 去重、交并差集
java
// 点赞用户集合
redisTemplate.opsForSet().add("like:article:1001", "user:1", "user:2", "user:3");
// 共同好友(求交集)
Set<Object> common = redisTemplate.opsForSet()
.intersect("friend:user1", "friend:user2");
// 可能认识的人(求差集)
Set<Object> suggest = redisTemplate.opsForSet()
.difference("friend:user2", "friend:user1");
适合干嘛:点赞去重、共同好友、标签系统、抽奖去重。
底层结构:元素全是整数时用 intset(整数集合),否则用 hashtable。
Sorted Set(ZSet)--- 自带"排行榜"属性
java
// 游戏排行
redisTemplate.opsForZSet().add("rank:game", "player:1", 980);
redisTemplate.opsForZSet().add("rank:game", "player:2", 750);
redisTemplate.opsForZSet().add("rank:game", "player:3", 1120);
// 获取 Top 3
Set<ZSetOperations.TypedTuple<Object>> top3 = redisTemplate.opsForZSet()
.reverseRangeWithScores("rank:game", 0, 2);
适合干嘛:排行榜、延时队列(score = 执行时间戳)、带权重的消息队列。
底层结构 :元素少时用 ziplist,元素多时用 skiplist(跳表) + dict(字典)。跳表是 Redis 面试里的高频词,后面会细讲。
1.6 三种特殊类型:面试中的"加分项"
掌握了 5 种基础类型,你已经可以应付大多数场景了。但如果你能在面试中提到下面这三种类型,辨识度直接拉满------大部分候选人只会说"Redis 有 5 种基本类型"。
| 类型 | 干什么的 | 典型场景 |
|---|---|---|
| Bitmap | 位存储,一个 bit 代表一个状态 | 用户签到(一年 365 天只需 46 字节)、布隆过滤器 |
| HyperLogLog | 基数统计,误差约 0.81% | 页面 UV 统计(12KB 就能统计上亿数据) |
| Geospatial | 地理位置计算 | 附近的人、附近商家 |
java
// Bitmap:记录用户签到
redisTemplate.opsForValue().setBit("sign:2026-07-08", userId, true);
Boolean signed = redisTemplate.opsForValue().getBit("sign:2026-07-08", userId);
// HyperLogLog:统计 UV(用过都说好)
redisTemplate.opsForHyperLogLog().add("uv:page:home", userId);
Long uv = redisTemplate.opsForHyperLogLog().size("uv:page:home");
阶段性小结 :到这里,你已经认识了 Redis 的基本面貌------它为什么快、5 种基础类型和 3 种特殊类型分别解决什么问题。如果现在只能记一句话,记住这句:Redis 不是"内存版 HashMap",它是一把数据结构的瑞士军刀。
接下来,我们要往深处走了------数据怎么持久化?过期 key 怎么清理?Redis 的事务跟 MySQL 的到底有什么不同?这些才是真正拉开差距的知识点。
二、核心进阶篇:从"会用"到"真懂"
2.1 持久化:断电了数据怎么办?
刚学会用 Redis 做缓存的时候,你可能觉得:数据丢了就丢了呗,反正是缓存,再查一次数据库不就得了?
等你把分布式锁、消息队列、计数器这些也交给 Redis 之后,你就不这么想了。锁丢了可能导致重复扣款,计数器丢了可能影响业务统计------这时候,持久化就不是"可选项"了。
Redis 提供了两种持久化方式,这是一个经典的"鱼与熊掌"问题,也是面试的重灾区。
RDB(快照式持久化)
每隔一段时间,把内存里的所有数据拍一张"快照"存到磁盘。
properties
触发时机(满足任意一个就触发):
├── save 900 1 # 900 秒内至少 1 次修改
├── save 300 10 # 300 秒内至少 10 次修改
└── save 60 10000 # 60 秒内至少 10000 次修改
工作原理 :主进程 fork() 出一个子进程,子进程负责把当前数据写进 RDB 文件,主进程继续处理请求,互不打扰。这背后依赖的是操作系统的 Copy-On-Write 技术。

| 优点 | 缺点 |
|---|---|
| 文件紧凑,适合备份和迁移 | 两次快照之间的数据可能丢失 |
| 恢复速度快(直接加载) | fork() 在大数据量时可能造成短暂卡顿 |
| 对性能影响小(子进程干活) | 不适合实时持久化场景 |
AOF(追加式持久化)
每执行一条写命令,就追加到 AOF 文件末尾。可以理解为 Redis 的"操作日志",类似 MySQL 的 binlog。

AOF 刷盘策略(appendfsync 配置):
| 策略 | 行为 | 安全性 | 性能 |
|---|---|---|---|
always |
每条命令都刷盘 | 最高,几乎不丢数据 | 最差 |
everysec |
每秒刷一次(默认,推荐) | 最多丢 1 秒数据 | 较好 |
no |
交给操作系统决定 | 不可控 | 最好 |
AOF 重写 :AOF 文件会越来越大,Redis 会定期"瘦身"。比如你对同一个 key 连续 INCR 了 100 次,重写后只保留最终的值------100 条命令压缩成 1 条。
properties
# 自动触发(两个条件同时满足)
auto-aof-rewrite-percentage 100 # 文件大小增长到上次重写后的 100%
auto-aof-rewrite-min-size 64mb # 至少 64MB
# 手动触发
127.0.0.1:6379> BGREWRITEAOF
所以该选 RDB 还是 AOF?
| 维度 | RDB | AOF |
|---|---|---|
| 数据安全性 | 较低(快照间隙数据可能丢失) | 较高(最多丢 1 秒) |
| 恢复速度 | 快(直接加载) | 慢(逐条回放命令) |
| 文件体积 | 小(压缩过的快照) | 大(全量命令记录) |
| 运行时性能影响 | 较小 | 有一定影响 |
生产环境建议 :别纠结了,两个都开!用 Redis 4.0 引入的混合持久化:
properties
# redis.conf
save 900 1
save 300 10
save 60 10000
appendonly yes
aof-use-rdb-preamble yes # 混合持久化:AOF 文件的前半段是 RDB,后半段是增量 AOF
RDB 负责"快速恢复",AOF 负责"少丢数据",混合持久化 = 鱼和熊掌兼得。
好了,现在你知道怎么保证数据不丢了。但你有没有想过另一个问题:设置了过期时间的数据,Redis 是怎么把它清掉的?
2.2 过期策略与内存淘汰:数据什么时候消失?
过期 key 怎么清理?
比如你存了一个登录 token:SET token:abc123 value EX 3600。1 小时后,这个 key 是怎么消失的?
Redis 用的不是单一策略,而是惰性删除 + 定期删除的组合拳:

一个真实案例 :某项目上线后,Redis 内存持续上涨,运维排查发现大量测试账号的 token 生成之后从未被再次访问------惰性删除不生效,定期删除每轮只随机抽查 25 个 key 也漏掉了它们。最后的解决方案是:开启内存淘汰策略兜底。
内存满了怎么办?------内存淘汰策略
这才是面试真正会考的地方。当 Redis 使用的内存超过 maxmemory 时,它必须决定:拒绝写入,还是淘汰一些旧数据?
| 策略 | 行为 | 适用场景 |
|---|---|---|
noeviction |
拒绝写入,直接报错(默认值) | 绝对不能丢数据的场景 |
allkeys-lru |
在所有 key 中淘汰最近最少使用的 | 纯缓存场景,最常用! |
volatile-lru |
只淘汰设置了过期时间的 key 中的 LRU | 混合场景 |
allkeys-lfu |
在所有 key 中淘汰使用频率最低的 | 热点数据分布不均时 |
volatile-lfu |
只淘汰设置了过期时间的 key 中的 LFU | --- |
allkeys-random |
在所有 key 中随机淘汰 | --- |
volatile-random |
随机淘汰设置了过期时间的 key | --- |
volatile-ttl |
淘汰即将过期的 key | --- |
一句话建议 :如果你的 Redis 主要做缓存,用
allkeys-lru或allkeys-lfu。
properties
# redis.conf
maxmemory 4gb
maxmemory-policy allkeys-lru
面试追问:LRU 是怎么实现的?
Redis 没有用教科书上的双链表实现严格 LRU(太耗内存了),而是做了一个近似 LRU:每个 key 维护一个 24 位的访问时钟戳,淘汰的时候随机采样 N 个 key,选中"最旧"的那个淘汰。
properties
Redis 近似 LRU 示意:
随机采样 5 个 key → 比较它们的访问时钟 → 淘汰最小的那个
key_1: clock=1700 ─┐
key_2: clock=1500 ─┤──── 这个最小,淘汰!
key_3: clock=2300 ─┤
key_4: clock=1800 ─┤
key_5: clock=2000 ─┘
面试官问这个,其实是在考察你知不知道"工程上的取舍"。严格 LRU 精确但费内存,近似 LRU 省内存且效果差不多------Redis 选了后者,这就是工程智慧。
这些听起来都很"单机"------一个 Redis 实例自己管自己的。但生产环境不可能只跑一个实例。接下来我们看看 Redis 怎么保证高可用。
2.3 事务与 Lua 脚本:Redis 也有"原子操作"
Redis 事务 ≠ MySQL 事务
很多从 MySQL 转过来的同学会自然地以为 Redis 的事务也支持 ACID。这是一个美丽的误会。
Redis 的事务更像是"打包执行"------把几条命令用 MULTI 包起来,到 EXEC 时一次性顺序执行。没有回滚能力!
bash
127.0.0.1:6379> MULTI # 开始打包
OK
127.0.0.1:6379> SET k1 v1
QUEUED
127.0.0.1:6379> SET k2 v2
QUEUED
127.0.0.1:6379> EXEC # 一口气执行
1) OK
2) OK
Redis 事务的特点:
| 特点 | 说明 |
|---|---|
| 一次性 | 多条命令打包,一次发给服务端 |
| 顺序性 | 事务期间不会被其他客户端的命令插队 |
| 排他性 | 事务执行时其他命令排队等 |
| 无回滚 | 中间某条命令失败,前面的不会回滚 |
一句话:Redis 事务 = 不含回滚的"打包执行"。如果真需要原子性保证,请用 Lua 脚本。
Lua 脚本:Redis 的"存储过程"
Lua 脚本在 Redis 中执行时,其他命令必须排队等待------相当于数据库的"存储过程",是真正的原子操作。
lua
// 用 Lua 实现一个滑动窗口限流器
String script = """
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local current = redis.call('INCR', key)
if current == 1 then
redis.call('EXPIRE', key, window)
end
if current > limit then
return 0
end
return 1
""";
Long result = redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
List.of("rate_limit:user:" + userId),
"10", "60" // 60 秒内最多 10 次
);
Lua 脚本的三个核心优势:
- 原子性:整个脚本是一个原子操作,执行期间不会被插队
- 减少网络开销:多条命令一次发送,一次网络往返
- 可复用:脚本可以缓存在 Redis 服务端(
SCRIPT LOAD+EVALSHA)
2.4 发布订阅与 Stream:消息传递的两代方案
有时你需要在服务之间传递消息,但又不想引入 Kafka 这样的"重型武器"。Redis 提供了两个选择。
Pub/Sub:简单到极致,也脆弱到极致
java
// 发布消息------一行搞定
redisTemplate.convertAndSend("order:notify", "新订单:123456");
// 订阅消息
@Component
public class OrderMessageListener implements MessageListener {
@Override
public void onMessage(Message message, byte[] pattern) {
System.out.println("收到消息:" + new String(message.getBody()));
}
}
Pub/Sub 的致命缺陷:消息不持久化。如果消费者离线,消息直接丢弃,像个只有播出没有回放的电视台。
Stream:这才是能打的轻量级 MQ(Redis 5.0+)
Stream 支持消息持久化、消费者组(概念类似 Kafka 的 Consumer Group),是一个可用性远高于 Pub/Sub 的消息队列方案:
java
// 发送消息
Map<String, Object> message = Map.of("userId", "1001", "amount", "99.00");
redisTemplate.opsForStream().add(
StreamRecords.newRecord().ofMap(message).withStreamKey("stream:order")
);
// 消费者组消费
List<MapRecord<String, Object, Object>> records = redisTemplate.opsForStream()
.read(Consumer.from("group-order", "consumer-1"),
StreamReadOptions.empty().count(10).block(Duration.ofSeconds(2)),
StreamOffset.create("stream:order", ReadOffset.lastConsumed()));
一句话判断:消息量不大(日均几十万以内),不想引入额外的中间件,用 Redis Stream 完全够用。但如果每天消息量达到百万级,或者对消息可靠性有极端要求,还是老老实实用 RocketMQ 或 Kafka。
三、高可用架构篇:Redis 挂了,天塌不下来
到目前为止,我们聊的都是单个 Redis 实例的事情。但在生产环境,只有一个实例意味着:它挂了,你的系统就挂了。这就是所谓的单点故障(SPOF,Single Point of Failure)。
Redis 社区提供了三层递进的解决方案------主从、哨兵、集群。我们来一层一层拆。
3.1 主从复制:读写分离的第一步
textile
┌───────────┐
│ Master │ ← 负责处理所有写操作
└─────┬─────┘
┌───────┼───────┐
(异步复制) │ (异步复制)
↓ │ ↓
┌─────────┐ │ ┌─────────┐
│ Slave 1 │ │ │ Slave 2 │ ← 负责处理读操作
└─────────┘ │ └─────────┘
主从复制的工作流程(面试常考):
textile
1. Slave 连接 Master,发送 PSYNC 命令
2. Master fork 子进程,生成 RDB 快照
3. Master 把 RDB 发给 Slave(全量同步)
4. Slave 清空旧数据,加载 RDB
5. Master 把后续写命令持续同步给 Slave(增量同步)
properties
# 从节点配置
replicaof 192.168.1.100 6379 # 指定主节点
replica-read-only yes # 从节点只读
面试常问:主从复制的延迟问题怎么处理?
textile
监控延迟:
127.0.0.1:6379> INFO replication
# master_repl_offset: 123456 # 主节点的复制偏移量
# replica0: offset=123400 # 从节点偏移量 → 差了 56 字节
Java 层面的应对:
- 对一致性要求高的读操作,强制走主库
- 对一致性要求不高的(如列表展示),可以读从库
主从复制解决了"读写分离",但主节点挂了还是要手动切。大半夜的还得爬起来切主从?别急,往下看。
3.2 哨兵模式(Sentinel):自动化的"监工"
Sentinel 是一个独立进程,负责盯着所有 Redis 实例。主节点挂了?它自动完成故障转移。
textile
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Sentinel │ │ Sentinel │ │ Sentinel │
│ 1 │ │ 2 │ │ 3 │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ 监控 │ │
└──────────────┼────────────┘
↓
┌──────────────┐
│ Redis 集群 │
│ M / S1 / S2 │
└──────────────┘
故障转移流程:
1. Sentinel 发现 Master 没有响应 → 标记为"主观下线"(SDOWN)
2. 多个 Sentinel 都确认后 → 标记为"客观下线"(ODOWN)
3. Sentinel 之间投票,选出 Leader
4. Leader 执行故障转移:
a. 从 Slave 中选出新 Master
b. 通知其他 Slave 去复制新 Master
c. 通知客户端连接新 Master
yaml
# Spring Boot 连接 Sentinel
spring:
data:
redis:
sentinel:
master: mymaster
nodes:
- 192.168.1.100:26379
- 192.168.1.101:26379
- 192.168.1.102:26379
Sentinel 解决了"自动故障转移",但数据还是全存在一个 Master 上------内存和写并发都有天花板。这时候,你需要集群模式。
3.3 集群模式(Cluster):分片,才是终极方案
Redis Cluster 的核心思想是数据分片:把数据分散到多个 Master 节点上,每个节点只负责一部分数据。
textile
Redis Cluster(3 主 3 从):
┌───────────────────────────────────────────┐
│ Client │
└───────┬───────────┬───────────┬───────────┘
↓ ↓ ↓
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Master 1 │ │ Master 2 │ │ Master 3 │
│ Slot │ │ Slot │ │ Slot │
│ 0-5460 │ │ 5461- │ │ 10923- │
│ │ │ 10922 │ │ 16383 │
└────┬─────┘ └────┬─────┘ └────┬─────┘
↓ ↓ ↓
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Slave 1 │ │ Slave 2 │ │ Slave 3 │
└──────────┘ └──────────┘ └──────────┘
16384 个哈希槽 是 Cluster 的精髓。每个 key 通过 CRC16(key) % 16384 计算出一个槽位,每个 Master 负责一部分槽。好处是:加新节点时只需要迁移部分槽,不需要全量迁移。
java
// Spring Data Redis 配置 Cluster
@Configuration
public class RedisClusterConfig {
@Bean
public RedisConnectionFactory redisConnectionFactory() {
RedisClusterConfiguration config = new RedisClusterConfiguration();
config.clusterNode("192.168.1.100", 6379);
config.clusterNode("192.168.1.101", 6379);
config.clusterNode("192.168.1.102", 6379);
return new LettuceConnectionFactory(config);
}
}
三种模式速查表:
| 模式 | 高可用 | 数据分片 | 复杂度 | 适用规模 |
|---|---|---|---|---|
| 主从 | ❌ | ❌ | 低 | 开发/测试环境 |
| 哨兵 | ✅ | ❌ | 中 | 中小项目(几十 GB 以内) |
| 集群 | ✅ | ✅ | 高 | 中大型项目 |
四、缓存实战篇:生产环境中的三大"夺命问题"
架构搭好了,现在我们来聊聊生产环境里让你半夜惊醒的那些问题。缓存虽然好用,但用不好就是"定时炸弹"。经典的三大问题------穿透、击穿、雪崩------是面试和实际工作中都绕不开的坎。
4.1 缓存穿透:查的全是不存在的东西
问题场景:用户(或攻击者)请求一个数据库中根本不存在的数据。缓存里没有,数据库里也没有,但每次请求都会穿透缓存打到数据库上。
textile
用户请求 id=-1 的商品
↓
Redis(没有)
↓
MySQL(也没有)
↓
返回 null → 但下次请求又来一遍!恶意攻击可以无限放大
方案一:缓存空值
最朴素但有效的办法:即使查不到,我缓存一个"空"的结果,下次再来直接返回 null。
java
public Product getProduct(Long id) {
String key = "product:" + id;
Product product = (Product) redisTemplate.opsForValue().get(key);
if (product != null) {
return product;
}
product = productMapper.selectById(id);
if (product != null) {
redisTemplate.opsForValue().set(key, product, 30, TimeUnit.MINUTES);
} else {
// 缓存空对象,过期时间设短一些,防止占用太多内存
redisTemplate.opsForValue().set(key, new Product(), 3, TimeUnit.MINUTES);
}
return product;
}
注意:空值缓存的过期时间要短(通常 3-5 分钟),否则会占着内存不干活。
方案二:布隆过滤器(推荐)
布隆过滤器是个神奇的"不存在判定器":它说"一定不存在",那就真的不存在;它说"可能存在",不一定真的存在(有小概率误判)。
java
// Redisson 布隆过滤器
RBloomFilter<Long> bloomFilter = redissonClient.getBloomFilter("product:bloom");
bloomFilter.tryInit(1_000_000, 0.0003); // 预计 100 万条,误判率 0.03%
// 预热:把数据库里所有商品 ID 加进去
List<Product> products = productMapper.selectList(null);
products.forEach(p -> bloomFilter.add(p.getId()));
// 查询时先用布隆过滤器拦截
public Product getProduct(Long id) {
if (!bloomFilter.contains(id)) {
return null; // 布隆说过不存在,直接返回
}
// 可能存在,继续查 Redis 和数据库......
}
| 方案 | 优点 | 缺点 |
|---|---|---|
| 缓存空值 | 实现简单,一行代码的事 | 空值多了占内存 |
| 布隆过滤器 | 内存极省,效率极高 | 有误判率,不能删除元素 |
4.2 缓存击穿:热点数据突然过期
问题场景:一个热点 key(比如秒杀商品的库存、首页的热门推荐)在过期的一瞬间,大量并发请求同时打到数据库。
textile
热点 key 过期
↓
┌─ 请求 1 ─┐
├─ 请求 2 ─┤
├─ 请求 3 ─┼──→ 全部穿透到 MySQL!数据库瞬间被冲垮。
├─ 请求 4 ─┤
├─ .... ─┤
└─ 请求 N ─┘
方案一:互斥锁
同一时间只让一个请求去查数据库、重建缓存,其他请求等着。
java
public Product getProduct(Long id) {
String key = "product:" + id;
Product product = (Product) redisTemplate.opsForValue().get(key);
if (product != null) {
return product;
}
String lockKey = "lock:product:" + id;
try {
if (redissonClient.getLock(lockKey).tryLock(3, 10, TimeUnit.SECONDS)) {
// 双重检查:拿到锁后再查一次缓存(可能别人已经重建完了)
product = (Product) redisTemplate.opsForValue().get(key);
if (product != null) return product;
product = productMapper.selectById(id);
if (product != null) {
redisTemplate.opsForValue().set(key, product, 30, TimeUnit.MINUTES);
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
redissonClient.getLock(lockKey).unlock();
}
return product;
}
方案二:逻辑过期
缓存 key 物理上永不过期,但在值里面存一个逻辑过期时间。发现过期后,拿锁、开异步线程去刷新缓存,当前请求直接返回旧值------保证可用性优先。
java
@Data
public class RedisData {
private LocalDateTime expireTime;
private Object data;
}
public Product getProduct(Long id) {
String key = "product:" + id;
RedisData redisData = (RedisData) redisTemplate.opsForValue().get(key);
if (redisData == null) return dbQuery(id);
Product product = (Product) redisData.getData();
if (redisData.getExpireTime().isAfter(LocalDateTime.now())) {
return product; // 没过期,正常返回
}
// 逻辑过期了,尝试获取锁
String lockKey = "lock:product:" + id;
if (redissonClient.getLock(lockKey).tryLock()) {
// 异步重建缓存,不阻塞当前请求
CompletableFuture.runAsync(() -> {
Product newProduct = productMapper.selectById(id);
RedisData newData = new RedisData();
newData.setExpireTime(LocalDateTime.now().plusMinutes(30));
newData.setData(newProduct);
redisTemplate.opsForValue().set(key, newData);
});
}
// 不管拿没拿到锁,都返回旧值(保证可用性)
return product;
}
| 方案 | 优点 | 缺点 |
|---|---|---|
| 互斥锁 | 简单直接,保证数据一致性 | 拿不到锁的请求要排队等待 |
| 逻辑过期 | 无等待,可用性高 | 返回的可能是旧数据 |
4.3 缓存雪崩:大规模的"集体阵亡"
问题场景:大量缓存 key 在同一时间过期,或者 Redis 服务本身挂掉了------所有请求突然全部砸到数据库上。
场景一:大批 key 同时过期
┌─ key_1 过期 ─┐
├─ key_2 过期 ─┤
├─ key_3 过期 ─┼──→ 瞬间全部打到 MySQL → 数据库崩盘
├─ .... ─┤
└─ key_N 过期 ─┘
场景二:Redis 直接挂了
所有请求 → Redis 不可用 → 全量走 MySQL → 数据库崩盘
解决三板斧:
java
// 第一板斧:过期时间加随机值(防止大批 key 同时过期)
int baseExpire = 30 * 60; // 基础过期 30 分钟
int randomDelta = new Random().nextInt(5 * 60); // 随机 0~5 分钟
redisTemplate.opsForValue().set(key, value, baseExpire + randomDelta, TimeUnit.SECONDS);
// 第二板斧:本地缓存兜底(Caffeine 作为二级缓存)
Cache<String, Object> localCache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(1, TimeUnit.MINUTES)
.build();
// 第三板斧:限流 + 降级
public Product getProduct(Long id) {
String key = "product:" + id;
// 一级:本地缓存
Product product = (Product) localCache.getIfPresent(key);
if (product != null) return product;
try {
// 二级:Redis
product = (Product) redisTemplate.opsForValue().get(key);
if (product != null) {
localCache.put(key, product);
return product;
}
} catch (Exception e) {
log.error("Redis 不可用,走降级逻辑", e);
// 可以走限流 → 数据库,或者直接返回兜底数据
}
// 三级:数据库
product = productMapper.selectById(id);
// ...
}
三大问题速查表:
| 问题 | 现象 | 核心原因 | 核心解法 |
|---|---|---|---|
| 缓存穿透 | 不存在的 key 反复查 | 数据本就不存在 / 恶意攻击 | 布隆过滤器 + 空值缓存 |
| 缓存击穿 | 热点 key 过期 | 单个热点 key 失效 | 互斥锁 / 逻辑过期 |
| 缓存雪崩 | 大批 key 同时过期或 Redis 挂掉 | 系统级故障 | 随机 TTL + 多级缓存 + 限流 |
4.4 缓存一致性:先更新数据库,还是先删缓存?
穿透、击穿、雪崩都解决了,还有一个更细的问题:当你更新了数据库,Redis 里的旧数据怎么办?
textile
V1:先删缓存,再更新数据库 → 删了缓存之后、更新数据库之前,可能读到旧数据并写回缓存
V2:先更新数据库,再删缓存 → 如果删缓存失败了呢?
V3:延迟双删 → 先删 → 写数据库 → 等一会儿 → 再删(等的那一会儿可能读到旧数据)
V4:订阅 MySQL binlog → Canal 监听 binlog → 推送到 MQ → 异步更新 Redis

实际建议:大多数项目做到"先更新数据库再删除缓存 + 缓存设置合理过期时间"就已经达标了。Canal + MQ 是终极方案,适合金融交易这类对数据一致性有极端要求的场景。
缓存问题聊完了,接下来我们深入 Java 代码,看看怎么在 Spring Boot 里优雅地使用 Redis。
4.5 穿透、击穿、雪崩与解决方案
1. 区分
1.1 一句话区分
三个问题的名字很像,但触发条件完全不同:
| 问题 | 触发条件 | 影响范围 | 数据库承受什么 |
|---|---|---|---|
| 缓存穿透 | 查询的数据根本不存在 | 单个或海量不存在的 key | 每一次请求都直查数据库 |
| 缓存击穿 | 单个热点 key 过期 | 一个 key | 瞬时高并发全部压到数据库 |
| 缓存雪崩 | 大量 key 同时过期 ,或 Redis 整体不可用 | 大面积、全站 | 整体流量整体压到数据库 |
记住这张表,后面所有方案的出发点都是它。
很多资料把"缓存没命中"等同于穿透,这是不准确的。没命中只是现象,穿透的本质是"这个数据在数据库里也不存在"------所以缓存永远无法通过"再查一次"来填上它,每次请求都会穿过去。
1.2 一条判断路径

看懂这张图,方案就好对号入座了:穿透要在"查库之前"拦,击穿要在"回源的时候"串行化,雪崩要在"失效方式"和"整体可用性"上做文章。
2. 缓存穿透:数据库里根本没有这条数据
穿透最常见的来源有两类:
一是业务上的正常空查。比如用户 ID 已经被删了,但客户端拿着旧 ID 一直请求;或者商品下架了,前端页面还缓存着链接。
二是恶意攻击 。攻击者用随机 ID 疯狂请求,这些 ID 一个都不存在,缓存永远建立不起来,每个请求都直达数据库。这类攻击真正可怕的地方在于:缓存对它是完全失防的,你加多少台 Redis 都没用。
2.1 方案一:缓存空值
最直接的办法------查不到也缓存起来,存一个特殊标记。
java
@Service
public class UserQueryService {
private static final String USER_KEY = "user:";
private static final Duration EMPTY_TTL = Duration.ofMinutes(2);
private static final Duration NORMAL_TTL = Duration.ofMinutes(30);
private final RedisTemplate<String, Object> redisTemplate;
private final UserMapper userMapper;
public UserQueryService(RedisTemplate<String, Object> redisTemplate,
UserMapper userMapper) {
this.redisTemplate = redisTemplate;
this.userMapper = userMapper;
}
public Optional<User> findById(long id) {
String key = USER_KEY + id;
Object cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return cached instanceof User user ? Optional.of(user) : Optional.empty();
}
User user = userMapper.selectById(id);
if (user == null) {
// 空值也要缓存,但 TTL 要短
redisTemplate.opsForValue().set(key, NullValue.MARK, EMPTY_TTL);
return Optional.empty();
}
redisTemplate.opsForValue().set(key, user, NORMAL_TTL);
return Optional.of(user);
}
}
Tip
空值的 TTL 一定要短,通常 1~5 分钟。原因有两个:
• 数据可能随后被创建(比如用户刚注册),TTL 太长会导致"明明有数据却查不到";
• 攻击者用随机 ID 打过来时,每条空值都占一份内存,TTL 太长会把 Redis 内存撑爆。
Warning
缓存空值能挡住"同一个不存在 ID 被反复请求",但挡不住"每次换一个随机 ID"。后者要交给布隆过滤器。
2.2 方案二:布隆过滤器
布隆过滤器解决的是"这个 key 到底有没有可能存在于数据库"的问题。它的判断规则是:
-
• 说不存在 → 一定不存在,直接拒绝;
-
• 说存在 → 可能存在(有误判),放行查缓存和数据库。
它的原理其实很朴素:一个很长的位数组 + 若干哈希函数。

查询时,把同一个元素用同样的哈希函数算一遍,只要有一个位置是 0,就说明这个元素肯定没写入过。全都为 1 才判定"可能存在"。
误判就是这么来的:别的元素恰好把这些位置都置成了 1,于是没写入过的元素被误判为存在。
误判率可以算,也可以控制。 经典公式是:
markdown
P ≈ (1 - e^(-kn/m))^k
其中 m 是位数组长度,n 是预期元素个数,k 是哈希函数个数。当 k = (m/n) × ln2 时误判率最低。
实践里不需要手算,直接用 Redisson 的封装:
java
@Component
public class UserBloomFilter {
private final RBloomFilter<Long> bloomFilter;
public UserBloomFilter(RedissonClient redissonClient) {
this.bloomFilter = redissonClient.getBloomFilter("bloom:user");
// 预期 1000 万个元素,允许 1% 的误判率
this.bloomFilter.tryInit(10_000_000L, 0.01);
}
public boolean mightExist(long userId) {
return bloomFilter.contains(userId);
}
public void add(long userId) {
bloomFilter.add(userId);
}
}
在查询链路的最前面插一道:
java
public Optional<User> findById(long id) {
if (!userBloomFilter.mightExist(id)) {
// 布隆过滤器说"不存在",那就一定不存在,直接返回
return Optional.empty();
}
// 后面的流程不变:查缓存 → 查库 → 回填
return doFind(id);
}
布隆过滤器的三个硬约束,选型前必须想清楚:
| 约束 | 后果 | 应对 |
|---|---|---|
| 标准布隆过滤器不支持删除 | 数据删除后仍被判"存在",只能靠空值缓存兜底 | 用计数布隆过滤器,或接受误判 |
| 误判率随元素增多而上升 | 超出预估值后拦截效果变差 | 预留足够容量,定期重建 |
| 需要预热全量数据 | 冷启动阶段等于没有防护 | 启动时批量加载,或降级为空值缓存 |
Warning
第三条尤其容易翻车。新建一个布隆过滤器,里面是空的------所有人都会被判"不存在"。 上线前必须先把数据库里的有效 ID 灌进去,否则等于给全站加了一把锁。
如果用的是 Redis Stack,也可以直接走 RedisBloom 模块:
markdown
BF.RESERVE bloom:user 0.01 10000000
BF.ADD bloom:user 1001
BF.EXISTS bloom:user 1001
Note
RedisBloom 是 Redis 的模块,不是 Redis 原生命令。用之前先确认你的实例或云服务版本支持
BF.*系列命令。
2.3 方案三:参数校验与限流
有些穿透根本不需要技术方案,是接口设计的问题:
java
@GetMapping("/user/{id}")
public ResponseEntity<UserVO> getUser(@PathVariable long id) {
if (id <= 0) {
return ResponseEntity.badRequest().build();
}
return userQueryService.findById(id)
.map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
再配合网关层或 Sentinel 做 IP 维度限流,就能把"随机 ID 扫库"这类攻击的破坏力压下去。
三道防线是叠加的,不是三选一:
| 防线 | 挡住什么 | 成本 |
|---|---|---|
| 参数校验 | 明显非法的 ID | 极低 |
| 布隆过滤器 | 绝大多数不存在的 ID | 中(需预热、有误判) |
| 空值缓存 | 通过前两道但仍不存在的少量请求 | 低(少量内存) |
3. 缓存击穿:一个热 key 的真空期
假设某个秒杀商品的缓存 TTL 是 30 分钟。到点的一瞬间,这个 key 被删除,而此时正有 5 万个请求在读它。
这 5 万个请求会同时发现缓存没了,然后一起去查数据库。 数据库可能因此被打满,而它本来只需要处理 1 个查询。
这就是缓存击穿:单个热点 key 失效,瞬间把并发原封不动转嫁给数据库。
注意它和雪崩的差别------击穿只涉及一个 key,但那个 key 的 QPS 可能占全站的一大半。
3.1 方案一:互斥锁重建
思路很直接:只放一个线程去查库重建,其余线程等待或者拿旧值。
java
@Service
public class HotProductService {
private static final String KEY_PREFIX = "product:";
private static final Duration TTL = Duration.ofMinutes(30);
private final RedisTemplate<String, Object> redisTemplate;
private final RedissonClient redissonClient;
private final ProductMapper productMapper;
public Product get(long productId) throws InterruptedException {
String key = KEY_PREFIX + productId;
Product cached = (Product) redisTemplate.opsForValue().get(key);
if (cached != null) {
return cached;
}
RLock lock = redissonClient.getLock("lock:rebuild:" + productId);
// 最多等 200ms,拿不到就不等了
if (!lock.tryLock(200, TimeUnit.MILLISECONDS)) {
// 降级:短暂等待后重读一次缓存,仍然读不到就快速失败,绝不回头查库
Thread.sleep(50);
Product fallback = (Product) redisTemplate.opsForValue().get(key);
if (fallback == null) {
throw new IllegalStateException("缓存重建中,请稍后重试: " + productId);
}
return fallback;
}
try {
// 双重检查:可能上一个持锁线程已经重建完成
cached = (Product) redisTemplate.opsForValue().get(key);
if (cached != null) {
return cached;
}
Product product = productMapper.selectById(productId);
if (product != null) {
redisTemplate.opsForValue().set(key, product, TTL);
}
return product;
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
这段代码里有四个容易被忽略的细节:
第一,双重检查不能省。 抢到锁的线程可能不是第一个------前一个持锁线程可能刚刚重建完并释放了锁。不重查一次,就会重复查库。
第二,没抢到锁的线程要有兜底。 上面代码是"睡 50ms 再读一次",如果还是读不到,应该返回降级数据或抛业务异常,而不是继续查库。否则互斥锁形同虚设。
第三,锁的 leaseTime 要覆盖重建耗时。 上面用的是不带 leaseTime 的重载,靠 Redisson 看门狗自动续期。如果显式传了 leaseTime,而重建耗时超过它,锁会提前释放,并发又回来了。
第四,锁的粒度要跟着 key 走。 用 lock:rebuild:{id} 而不是一把全局锁,否则不同商品的重建也会互相排队。
3.2 方案二:逻辑过期
互斥锁的代价是"等待"。如果业务不能等------比如首页推荐位,宁可返回 1 秒前的旧数据也不能让用户转圈------那就换逻辑过期。
思路是:物理上永不过期,把过期时间写进 value 里,由业务代码自己判断。
java
public class CacheEntry<T> implements Serializable {
private T data;
private long expireAtMillis;
public CacheEntry() {
}
public CacheEntry(T data, long expireAtMillis) {
this.data = data;
this.expireAtMillis = expireAtMillis;
}
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
public long getExpireAtMillis() {
return expireAtMillis;
}
public void setExpireAtMillis(long expireAtMillis) {
this.expireAtMillis = expireAtMillis;
}
public boolean isExpired() {
return System.currentTimeMillis() > expireAtMillis;
}
}
Warning
这里刻意不用
record。GenericJackson2JsonRedisSerializer默认的DefaultTyping.NON_FINAL不会给 final 类型(record、枚举)写类型信息,读回来会变成LinkedHashMap或String,强转直接抛ClassCastException。缓存对象要么用非 final 普通类,要么显式配置序列化器保留类型信息;如果用的是 JDK 序列化器,类还要实现Serializable(上面已经加上)。
java
private static final ThreadPoolExecutor REBUILD_POOL = new ThreadPoolExecutor(
4, 4, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(1000),
Thread.ofPlatform().name("cache-rebuild-", 0).factory(),
(task, pool) -> log.warn("重建任务队列已满,丢弃本次重建任务"));
public Product getWithLogicalExpire(long productId) {
String key = KEY_PREFIX + productId;
CacheEntry<Product> entry = (CacheEntry<Product>) redisTemplate.opsForValue().get(key);
if (entry == null) {
// 冷启动:缓存里压根没有,用同一把锁保证只有一个线程回源
RLock coldStartLock = redissonClient.getLock("lock:rebuild:product:" + productId);
coldStartLock.lock();
try {
CacheEntry<Product> loaded = (CacheEntry<Product>) redisTemplate.opsForValue().get(key);
if (loaded != null) {
return loaded.getData();
}
return loadAndCache(productId);
} finally {
if (coldStartLock.isHeldByCurrentThread()) {
coldStartLock.unlock();
}
}
}
if (!entry.isExpired()) {
return entry.getData();
}
// 已逻辑过期:先返回旧值,再异步重建
REBUILD_POOL.execute(() -> {
RLock rebuildLock = redissonClient.getLock("lock:rebuild:product:" + productId);
if (!rebuildLock.tryLock()) {
// 已有线程在重建,直接放弃
return;
}
try {
loadAndCache(productId);
} finally {
if (rebuildLock.isHeldByCurrentThread()) {
rebuildLock.unlock();
}
}
});
return entry.getData();
}
核心在于:只要缓存里还有旧值,读请求就永远不阻塞。命中的哪怕是过期数据,也先返回;重建在后台悄悄进行。
这个方案换来了可用性,代价是一致性------在重建完成之前,用户看到的是旧数据。所以它适合"数据变化不频繁、短暂不一致可接受"的场景,比如商品详情、排行榜快照、首页配置。
还有一个坑:重建失败了怎么办? 上面代码里 loadAndCache 抛异常会直接结束重建任务,逻辑过期时间没被更新,下一个请求会再触发一次重建。如果数据库持续故障,就会变成"每个请求都在扔一个重建任务"。重建线程池必须有界,重建失败要有退避和告警。 上面的池子用 ArrayBlockingQueue(1000) 限制排队长度,队列满时丢弃任务并打告警日志------宁可这一次不重建,也不能让任务无限堆积把内存撑爆。
冷启动路径同理:拿到锁回源后,如果数据库里也没有这条数据,要缓存一个 data 为 null 的 CacheEntry(逻辑过期时间照设),否则等待线程会一个接一个地串行查库。
3.3 互斥锁 vs 逻辑过期
| 维度 | 互斥锁重建 | 逻辑过期 |
|---|---|---|
| 一致性 | 强(重建完成前请求等待) | 最终一致(期间返回旧值) |
| 可用性 | 重建慢时会阻塞请求 | 有旧值时请求不阻塞 |
| 实现复杂度 | 中(要处理锁超时、双重检查) | 较高(要处理重建风暴、失败重试) |
| 适用场景 | 数据不能脏读,如库存、余额 | 可容忍短暂旧值,如商品详情、配置 |
| 冷启动 | 正常同步加载 | 缓存为空时仍需加锁同步加载 |
Tip
没有绝对更优的方案,只有更匹配的场景。 同一套系统里两种方案并存也很正常:交易链路用互斥锁,展示链路用逻辑过期。
4. 缓存雪崩:当一百万个 key 同时过期
雪崩有两种触发方式:
-
- 时间撞车:批量预热时给所有 key 设了同一个 TTL,到期时间完全相同;
-
- Redis 故障:实例宕机、主从切换、网络分区,缓存整体不可用。
第一种是"自己埋的雷",第二种是"基础设施问题",应对手段不一样。
4.1 把过期时间打散
最便宜也最有效的一招:在基础 TTL 上加一个随机抖动。
java
private static final Duration BASE_TTL = Duration.ofMinutes(30);
private static final long JITTER_SECONDS = 300;
private Duration randomizedTtl() {
// 30 分钟 ± 5 分钟
long offset = ThreadLocalRandom.current().nextLong(-JITTER_SECONDS, JITTER_SECONDS);
return BASE_TTL.plusSeconds(offset);
}
java
redisTemplate.opsForValue().set(key, value, randomizedTtl());
只要过期时间不再整齐划一,就不会在同一秒集体失效。 抖动范围通常取 TTL 的 10%~20%,太小起不到打散作用,太大会让部分数据过早失效。
4.2 预热与分批加载
大促前把热点数据提前灌进缓存,能避免"活动开始瞬间全部 miss"。
java
@EventListener(ApplicationReadyEvent.class)
public void warmUp() {
List<Long> hotIds = hotItemMapper.selectTopN(1000);
// 分批加载,避免一次性打爆数据库和 Redis
for (List<Long> batch : Lists.partition(hotIds, 100)) {
List<Product> products = productMapper.selectBatchIds(batch);
for (Product p : products) {
redisTemplate.opsForValue().set(KEY_PREFIX + p.getId(), p, randomizedTtl());
}
}
}
预热要回答三个问题:
-
• 预哪些? 按历史访问量、活动配置、运营名单来定,不要拍脑袋;
-
• 预多少? 只预热点,全量预热可能比不预热还慢;
-
• 怎么预? 分批、限速,别在业务高峰期抢资源。
4.3 Redis 故障时怎么办
TTL 打散管不了"Redis 挂了"。这时要做的是让流量别全砸到数据库上。
第一层:高可用部署。 主从 + 哨兵,或者 Cluster 分片,保证单点故障能自动切换。
第二层:本地缓存兜底。 用 Caffeine 在应用进程内留一份数据,Redis 不可用时还能撑一阵。
java
@Configuration
public class CacheConfig {
@Bean
public Cache<String, Object> localCache() {
return Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofSeconds(30))
.build();
}
}
java
public Product getWithLocalCache(long id) {
String key = KEY_PREFIX + id;
Object local = localCache.getIfPresent(key);
if (local != null) {
return (Product) local;
}
try {
Product remote = (Product) redisTemplate.opsForValue().get(key);
if (remote != null) {
localCache.put(key, remote);
return remote;
}
} catch (DataAccessException e) {
// 连接失败、命令超时都在这里:降级,不再等
log.warn("Redis 不可用,降级本地缓存,key={}", key, e);
}
return loadFromDb(id);
}
这里"不阻塞"有前提:命令超时要配短。Lettuce 默认命令超时是 60 秒,Redis 卡住时一次 get 会一直等到超时才抛异常;给缓存读配置 100~500ms 的命令超时,降级代码才能真正快速生效。
Warning
本地缓存的 TTL 必须很短(10~30 秒),而且多实例之间不共享。它只能当"应急缓冲垫",不能当主缓存用,否则数据一致性会出大问题。
第三层:限流与熔断。 数据库的承载能力是固定的。缓存失效时,必须主动把超过数据库承载能力的请求挡在外面,哪怕拒绝一部分用户,也好过数据库整体崩溃、所有用户都不可用。
具体做法是在数据访问层加一个熔断器(Sentinel、Resilience4j 都可以),当错误率或响应时间超过阈值时快速失败,返回降级数据。
第四层:持久化与快速恢复。 Redis 重启后如果是空库,等于全站雪崩。开启 RDB + AOF 混合持久化,能让实例恢复后迅速带回大部分数据。
5. 组合拳:真实系统的读路径长什么样
把三套方案拼起来,一个生产级的缓存读链路大致是这样:

这条链路上每一层都在做同一件事:把不必要的流量挡在数据库之外。
Tip
但要提醒一句:不是所有系统都需要全套。 一个日活几千的内部管理系统,加布隆过滤器纯属给自己找麻烦;一个秒杀系统少了任何一层都可能出事。方案的复杂度要跟着流量和故障成本走。
6. 几个容易踩的细节
6.1 缓存的到底是 null 还是空对象
redisTemplate.opsForValue().set(key, null) 的行为取决于序列化器:JDK 和 Jackson 系序列化器通常会把 null 序列化成空字节数组,SET 其实写成功了,但读回来仍然是 null;StringRedisSerializer 则可能把 null 传到连接层直接抛异常。也就是说,问题不是"写不进去",而是"写进去了也读不出来",和 key 不存在分不清。
所以要显式存一个标记对象:
java
public class NullValue implements Serializable {
public static final NullValue MARK = new NullValue();
}
读取时判断 cached instanceof NullValue 而不是 == null。这里还有一个容易忽略的前提:默认的 GenericJackson2JsonRedisSerializer 用 DefaultTyping.NON_FINAL 写类型信息,不会给 final 类型(枚举、record)加类型标记,NullValue 读回来可能变成 String,instanceof NullValue 恒为 false------这恰恰会触发"每次都没写进去"。标记对象要么用非 final 普通类(JDK 序列化器还要实现 Serializable,上面的类已经实现),要么显式配置序列化器保留类型信息,二者至少占一个。
6.2 逻辑过期的数据必须自带过期时间
逻辑过期方案的 value 不是裸的业务对象,而是 CacheEntry(data, expireAt)。如果只存了数据忘了存时间,这个 key 就永远不会被重建。 这是一次性错误,但上线后很难被发现------数据一直"正常"返回,只是永远是旧的。
6.3 互斥锁的失败路径比成功路径更重要
Tip
很多人只写了"抢到锁就查库",没写"没抢到锁怎么办"。结果要么直接查库(锁白加),要么抛异常(用户看到错误)。一个锁方案的质量,取决于没抢到锁的那 99% 请求是怎么处理的。
6.4 布隆过滤器要跟着数据变
新增数据时要往里加,删除数据时删不掉------这是标准布隆过滤器的固有限制。如果业务有大量删除,要么用计数布隆过滤器,要么接受误判并用空值缓存兜底,要么定期重建。
6.5 别让缓存成为唯一的真相源
Note
缓存的所有方案,前提都是"数据库里的数据是准的"。一旦有人把缓存当主存储用(比如只写 Redis 不写库),上面所有防护都会变成"在保护一个错误的数据源"。
7. 小结
回到开头那三个词。
穿透、击穿、雪崩,名字像三胞胎,本质上却在回答三个不同的问题:
-
穿透问的是"这个数据该不该被查";
-
击穿问的是"重建的活儿该由谁干";
-
雪崩问的是"最坏情况下,系统还能不能活着"。
缓存从来不是"加了就快",而是一整套失效与重建的策略设计。 真正决定系统稳不稳的,不是 Redis 有多少内存,而是你有没有认真对待过上面这三个问题。
五、Java 集成篇:Spring Boot 里的 Redis 最佳实践
5.1 Jedis vs Lettuce vs Redisson:三个客户端,到底选谁?
| 客户端 | 特点 | 线程安全 | 连接方式 | 适合干什么 |
|---|---|---|---|---|
| Jedis | API 贴近 Redis 原生命令 | ❌ 需要连接池 | 同步阻塞 | 遗留项目维护 |
| Lettuce | 基于 Netty,Spring Boot 默认 | ✅ 天然线程安全 | 单连接复用 | 日常 Redis 操作 |
| Redisson | 分布式对象、锁、集合的完整封装 | ✅ | 基于 Netty | 分布式锁、高级特性 |
选择建议:
-
Spring Boot 项目默认就是 Lettuce,不用换
-
需要分布式锁、布隆过滤器等高级功能,直接加 Redisson
-
不要再单独用 Jedis 了,除非是老项目的祖传代码
5.2 配置最佳实践(序列化是关键!)
RedisTemplate 默认用的是 JDK 序列化------存进去的 key 前面会有一串奇怪的二进制前缀,看完怀疑人生。必须自定义序列化器:
java
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// Key 用 String 序列化,这样在 Redis 客户端里看到的是可读的 key
template.setKeySerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
// Value 用 Jackson 序列化
Jackson2JsonRedisSerializer<Object> jsonSerializer =
new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
om.activateDefaultTyping(
LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL
);
jsonSerializer.setObjectMapper(om);
template.setValueSerializer(jsonSerializer);
template.setHashValueSerializer(jsonSerializer);
template.afterPropertiesSet();
return template;
}
// 如果你只需要处理字符串,用 StringRedisTemplate 更简单
@Bean
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) {
return new StringRedisTemplate(factory);
}
}
血的教训:不改序列化器,你的 Redis key 会在客户端里显示成
\xAC\xED\x00\x05t\x00\x04user这种鬼东西。线上排查问题的时候根本看不懂。
5.3 分布式锁:Redisson 是怎么做到的?
分布式锁是 Redis 在 Java 生态中除了缓存之外第二重要的应用场景。Redisson 是目前最流行的实现,而且它的实现方式经常作为面试的"追问加分题"。
基本用法
java
@Service
public class OrderService {
@Autowired
private RedissonClient redissonClient;
public void createOrder(Long userId, Long productId) {
String lockKey = "lock:order:" + userId;
RLock lock = redissonClient.getLock(lockKey);
try {
// 尝试加锁:最多等 3 秒,锁自动过期 10 秒
if (lock.tryLock(3, 10, TimeUnit.SECONDS)) {
doCreateOrder(userId, productId);
} else {
throw new RuntimeException("操作太频繁,请稍后再试");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
// 一定要判断是不是当前线程持有的锁!
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
面试官的追问:Redisson 的锁是怎么实现的?
Redisson 的分布式锁有两个关键机制:看门狗(Watch Dog)自动续期 和Hash + Lua 实现可重入。
1. 看门狗自动续期
如果你设置锁过期时间 10 秒,但业务执行了 15 秒------锁在第 10 秒释放了,另一个线程拿到了锁,并发安全问题就来了。
Redisson 的看门狗线程会在锁过期时间过了 1/3 时自动续期:
textile
业务执行中(持有锁)
│
├── 第 0 秒:拿到锁,默认过期 30 秒
├── 第 10 秒:看门狗自动续期到 30 秒
├── 第 20 秒:看门狗自动续期到 30 秒
├── 第 25 秒:业务执行完,调用 unlock()
└── 释放锁,看门狗线程停止
一个容易踩的坑:如果你手动指定了锁过期时间(
lock.tryLock(3, 10, TimeUnit.SECONDS)),看门狗不会启动。它会信任你指定的 10 秒。如果你不确定业务要执行多久,别手动指定过期时间,让看门狗帮你管。
2. 可重入 + Lua 原子操作
Redisson 加锁用的 Lua 脚本(简化版):
lua
-- 加锁 Lua 脚本
if (redis.call('exists', KEYS[1]) == 0) then
-- 锁不存在,创建锁
redis.call('hincrby', KEYS[1], ARGV[2], 1); -- 重入计数 +1
redis.call('pexpire', KEYS[1], ARGV[1]); -- 设置过期时间
return nil;
end;
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
-- 锁存在,且是当前线程持有(可重入)
redis.call('hincrby', KEYS[1], ARGV[2], 1); -- 重入计数再 +1
redis.call('pexpire', KEYS[1], ARGV[1]); -- 续期
return nil;
end;
return redis.call('pttl', KEYS[1]); -- 锁被别人持有,返回剩余等待时间
对应的数据结构:
lua
Key: lock:order:1001
└── Hash
├── <线程ID-1>: 1 (重入计数,key = 线程唯一标识)
└── <线程ID-2>: 1
一句话总结 Redisson 分布式锁:Hash 存线程标识 + 重入计数,Lua 保证原子性,看门狗自动续期。
封装一个 @RedisLock 注解
每次都写 try-catch-finally 太啰嗦了,封装成一个注解用起来多舒服:
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RedisLock {
String key(); // 锁的 key(支持 SpEL)
long waitTime() default 3; // 等待时间(秒)
long leaseTime() default -1; // -1 表示用看门狗自动续期
}
@Aspect
@Component
public class RedisLockAspect {
@Autowired
private RedissonClient redissonClient;
@Around("@annotation(redisLock)")
public Object around(ProceedingJoinPoint joinPoint, RedisLock redisLock) {
String lockKey = parseSpel(redisLock.key(), joinPoint);
RLock lock = redissonClient.getLock(lockKey);
try {
if (!lock.tryLock(redisLock.waitTime(), redisLock.leaseTime(), TimeUnit.SECONDS)) {
throw new RuntimeException("获取锁失败");
}
return joinPoint.proceed();
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
// 使用:一个注解搞定
@RedisLock(key = "'lock:order:' + #userId")
public void createOrder(Long userId, Long productId) {
// 业务逻辑,专心写就行
}
5.4 布隆过滤器:Redisson 实战
前面在讲缓存穿透时提到了布隆过滤器,这里给出完整的 Redisson 配置:
java
@Component
public class ProductBloomFilter {
@Autowired
private RedissonClient redissonClient;
private RBloomFilter<Long> bloomFilter;
@PostConstruct
public void init() {
bloomFilter = redissonClient.getBloomFilter("product:bloom");
// 预计 100 万条数据,误判率 0.03%
bloomFilter.tryInit(1_000_000L, 0.0003);
// 预热:把数据库已有的 ID 全加进去
List<Product> products = productMapper.selectAllIds();
products.forEach(p -> bloomFilter.add(p.getId()));
}
public boolean mightExist(Long id) {
return bloomFilter.contains(id);
}
public void addProduct(Long id) {
bloomFilter.add(id);
}
}
注意:布隆过滤器不能删除元素(Redisson 的
RBloomFilter不支持)。如果数据频繁增删,可以考虑定期重建,或者用 Cuckoo 过滤器。
六、面试冲刺篇:10 道高频题 + 2 个血泪事故复盘
前面的内容已经把 Redis 从基础到实战完整过了一遍。最后这部分是"应试版"------把面试中最可能被问到的问题拎出来逐个击破,再用两个真实的生产事故告诉你:学这些到底有什么用。
6.1 10 道高频面试题
Q1:Redis 为什么这么快?
答 :四个方面------(1) 纯内存操作,没有磁盘 I/O(持久化是异步的);(2) 单线程模型,没有上下文切换和锁竞争的开销;(3) I/O 多路复用(epoll),一个线程高效处理大量并发连接;(4) 精心优化的数据结构(SDS、ziplist、skiplist 等)。
面试官想听的不仅仅是"因为内存快",而是你能从多个维度分析。把上面四个点讲清楚,这道题你就赢了一半。
Q2:Redis 是单线程的,为什么还能高并发?
答 :首先纠正一个误区------Redis 6.0 之后网络 I/O 已经多线程了 ,但命令执行依然是单线程。高并发的根基在于:纯内存操作本身就是微秒级、epoll 多路复用让一个线程能同时盯住大量连接、单线程不需要加锁。对 Redis 来说,CPU 很少是瓶颈,网络带宽才是。
Q3:RDB 和 AOF 的区别?生产环境怎么选?
答 :RDB 是快照、文件小、恢复快、但可能丢数据;AOF 是操作日志、恢复慢、文件大、但更安全。生产环境建议两个都开,用混合持久化 (aof-use-rdb-preamble yes)------RDB 负责快速恢复,AOF 负责不丢数据。
Q4:缓存穿透、击穿、雪崩是什么?怎么解决?
答 :三个不同层面的问题------穿透 是查不存在的数据(布隆过滤器 + 空值缓存),击穿 是热点 key 过期(互斥锁 / 逻辑过期),雪崩是大批 key 同时过期或 Redis 挂掉(随机 TTL + 多级缓存 + 限流)。核心是区分清楚三者的"触发条件"不同。
Q5:Redis 的过期 key 是怎么删除的?
答 :惰性删除 (访问时顺带检查)+ 定期删除 (定时随机抽查一批),两者互补。如果都不够用,还有内存淘汰策略兜底。
Q6:你们用的哪个内存淘汰策略?为什么?
答 :纯缓存场景推荐 allkeys-lru,既保留热点又淘汰冷数据。如果能区分冷热数据不明显但热点集中,用 allkeys-lfu 更好。重点是解释清楚"为什么选这个",而不是"我们配置的就是这个"。
Q7:Redis Cluster 的哈希槽是怎么回事?
答 :Cluster 预分配了 16384 个哈希槽,key 通过 CRC16(key) % 16384 映射到槽,每个 Master 负责一部分槽。相比一致性哈希的好处是:加节点时只需迁移部分槽,不用全量重新分配。客户端可以缓存槽映射,减少重定向。
Q8:Redis 怎么做分布式锁?你遇到过什么坑?
答 :底层用 SET key value NX PX 实现原子加锁。Redisson 在此基础上加了看门狗自动续期 + Hash 支持可重入 + Lua 保证原子性。
踩过的坑:
-
锁被别的线程释放 → 用
UUID:ThreadId做线程标识,解锁时校验 -
主从切换锁丢失 → RedLock 算法可以缓解,但一致性要求极高时建议用 ZooKeeper
Q9:缓存和数据库怎么保持一致性?
答 :写入时先更新数据库,再删除缓存 ,同时设置合理的缓存过期时间作为兜底。极致一致性场景可以接入 Canal 订阅 binlog 异步更新缓存。不推荐先删缓存再更新数据库------中间的时间窗口会引入不一致。
Q10:Redis String 的底层实现是什么?跟 Java String 的区别?
答 :Redis 用的是SDS(Simple Dynamic String)。
| 特性 | Redis SDS | Java String |
|---|---|---|
| 长度获取 | O(1) len字段 |
O(1) value.length |
| 内存预分配 | 有(减少频繁分配) | 无(不可变对象) |
| 二进制安全 | 是(可存任意二进制) | 是 |
| 缓冲区溢出保护 | 有(自动检查空间) | N/A(不可变) |
6.2 两个生产事故复盘
事故一:缓存预热把 CPU 打满了
现象:新业务上线,一次性把 20 万条商品数据预热进 Redis。刚跑起来,Redis CPU 瞬间 100%,线上其他业务全受影响。
排查 :预热脚本用的 for 循环逐条 SET------20 万次网络往返,每次 0.1ms,总计 20 秒纯网络开销。每次 SET 还触发 maxmemory 检查,CPU 雪上加霜。
java
// ❌ 错误写法:逐条 SET
for (Product p : products) {
redisTemplate.opsForValue().set("product:" + p.getId(), p);
}
// ✅ 正确写法:Pipeline 批量写
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
for (Product p : products) {
connection.stringCommands()
.set(("product:" + p.getId()).getBytes(),
JSON.toJSONBytes(p));
}
return null;
});
// 从 20 秒 → 200ms,提升 100 倍
教训:批量操作一定用 Pipeline。Redis 是单线程执行命令的,你的慢操作不仅拖累自己,还会连累同一实例上的其他业务。
事故二:大 Key 导致 Redis 间歇性卡顿
现象 :运维反馈,Redis 每隔几分钟就有几百毫秒的延迟毛刺。排查发现,业务方用 List 存用户浏览历史,最多存 5000 条。某个接口直接用 LRANGE history:uid 0 -1 一次性全取出来------当列表真有几千条时,数据量太大,Redis 单线程直接被阻塞。
排查手法 :redis-cli --bigkeys 扫描发现几个巨型 key,确认是用户行为数据堆积。
解决三板斧:
-
限制长度:
LTRIM history:uid 0 199,只保留最近 200 条 -
分页读取:
LRANGE history:uid 0 50,每次只取一页 -
冷热分离:不常用的老数据迁到 MySQL,Redis 只做热数据缓存
教训 :Redis 单线程,大 key 操作 = 阻塞 = 线上事故。定期跑 redis-cli --bigkeys,发现大 key 尽早拆分或清理。
结语
到这里,这份 Redis 学习路线就完整了------从 5 种基础数据类型到底层数据结构,从缓存三大问题到分布式锁源码,从面试高频考题到生产环境血泪复盘。如果你是一路顺着读下来的,恭喜你,你对 Redis 的理解已经超过了绝大多数 Java 面试者。
但有一说一,技术这件事,读一遍文章只能让你"知道",真正让你"掌握"的,是亲手写代码、踩坑、排查、修复的过程。建议找一个小项目,把这些知识点一个一个地实践一遍------搭个 Cluster,写个分布式锁的 demo,压测一下缓存穿透------这些东西只有亲手摸过,才会变成你的肌肉记忆。
本文的引用仅限自我学习如有侵权,请联系作者删除。
参考知识
一篇文章掌握一个技能:Redis 从入门到精通与高频面试题精讲
(重发)Redis 缓存问题全解:穿透、击穿、雪崩与解决方案