一篇文章掌握一个技能:Redis 从入门到精通与高频面试题精讲
作为开发人员,你大概率写过这样的代码:从数据库查出一批数据,往 Redis 里
set一下,下次请求来先get一把,查到了就直接返回。简单好用,所有人都这么干。直到有一天——首页突然白屏了三分钟,排查半天发现是缓存全部过期、瞬时流量把数据库打挂了。你突然意识到,自己好像只会
set和get,至于缓存什么时候该过期、过期了怎么兜底、多台服务器之间怎么协同,心里其实没底。
这篇文章就是为对于 Redis 还停留在
set和get的朋友准备的。我们从每天写的 Redis 代码出发,把那些"会用但说不清"的东西彻底讲明白。
一、Redis 基础入门:你好,这个"快如闪电"的家伙
1.1 你和 Redis 的第一次相遇
大多数 Java 开发第一次接触 Redis 的场景出奇一致:前端或者产品跑过来说"首页怎么这么慢,加载要三秒"。你打开慢查询日志一看,好家伙,同一个 SQL 在一次请求里被重复执行了七八次。
于是你灵机一动——把查询结果扔进 Redis,下次来直接拿。响应时间从 3000ms 降到 50ms,你觉得自己简直是个天才。然后……就没有然后了。你停在了 set 和 get 的舒适区里。
但 Redis 能做的事情,比"缓存数据库查询结果"多得多。它像一把瑞士军刀,而你只用它开瓶盖。
1.2 先搞明白:Redis 到底是个什么?
Redis(REmote DIctionary 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 是最快的姿势:
# 一行命令启动 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):
<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>
# 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 — 最万能,但不是最聪明的
// 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) 获取长度,杜绝缓冲区溢出,自带内存预分配。
SDS 结构简图:
┌─────────┬────────┬─────────┬────────┐
│ len │ alloc │ flags │ buf[] │
│ (已用) │ (分配) │ (类型) │ (数据) │
└─────────┴────────┴─────────┴────────┘
面试加分点:SDS 的
len字段让STRLEN命令的时间复杂度是 O(1),而不是 C 字符串的 O(n)。
Hash — 存对象,有时候比 String 更合适
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 — 能当队列,也能当栈
// 左边进,右边出 → 队列模式(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——双向链表的每个节点内部是一个压缩列表,兼顾了内存和性能。
quicklist 结构:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ ziplist 1 │──│ ziplist 2 │──│ ziplist 3 │
└──────────────┘ └──────────────┘ └──────────────┘
↑ 双向链表的节点,每个节点内部是个压缩列表
Set — 去重、交并差集
// 点赞用户集合
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)— 自带"排行榜"属性
// 游戏排行
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 | 地理位置计算 | 附近的人、附近商家 |
// 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(快照式持久化)
每隔一段时间,把内存里的所有数据拍一张"快照"存到磁盘。
触发时机(满足任意一个就触发):
├── 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 条。
# 自动触发(两个条件同时满足)
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 引入的混合持久化:
# 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。
# redis.conf
maxmemory 4gb
maxmemory-policy allkeys-lru
面试追问:LRU 是怎么实现的?
Redis 没有用教科书上的双链表实现严格 LRU(太耗内存了),而是做了一个近似 LRU:每个 key 维护一个 24 位的访问时钟戳,淘汰的时候随机采样 N 个 key,选中"最旧"的那个淘汰。
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 时一次性顺序执行。没有回滚能力!
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 实现一个滑动窗口限流器
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:简单到极致,也脆弱到极致
// 发布消息——一行搞定
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 的消息队列方案:
// 发送消息
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 主从复制:读写分离的第一步
┌───────────┐
│ Master │ ← 负责处理所有写操作
└─────┬─────┘
┌───────┼───────┐
(异步复制) │ (异步复制)
↓ │ ↓
┌─────────┐ │ ┌─────────┐
│ Slave 1 │ │ │ Slave 2 │ ← 负责处理读操作
└─────────┘ │ └─────────┘
主从复制的工作流程(面试常考):
1. Slave 连接 Master,发送 PSYNC 命令
2. Master fork 子进程,生成 RDB 快照
3. Master 把 RDB 发给 Slave(全量同步)
4. Slave 清空旧数据,加载 RDB
5. Master 把后续写命令持续同步给 Slave(增量同步)
# 从节点配置
replicaof 192.168.1.100 6379 # 指定主节点
replica-read-only yes # 从节点只读
面试常问:主从复制的延迟问题怎么处理?
监控延迟:
127.0.0.1:6379> INFO replication
# master_repl_offset: 123456 # 主节点的复制偏移量
# replica0: offset=123400 # 从节点偏移量 → 差了 56 字节
Java 层面的应对:
- 对一致性要求高的读操作,强制走主库
- 对一致性要求不高的(如列表展示),可以读从库
主从复制解决了"读写分离",但主节点挂了还是要手动切。大半夜的还得爬起来切主从?别急,往下看。
3.2 哨兵模式(Sentinel):自动化的"监工"
Sentinel 是一个独立进程,负责盯着所有 Redis 实例。主节点挂了?它自动完成故障转移。
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 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
# 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 节点上,每个节点只负责一部分数据。
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 负责一部分槽。好处是:加新节点时只需要迁移部分槽,不需要全量迁移。
// 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 缓存穿透:查的全是不存在的东西
问题场景:用户(或攻击者)请求一个数据库中根本不存在的数据。缓存里没有,数据库里也没有,但每次请求都会穿透缓存打到数据库上。
用户请求 id=-1 的商品
↓
Redis(没有)
↓
MySQL(也没有)
↓
返回 null → 但下次请求又来一遍!恶意攻击可以无限放大
方案一:缓存空值
最朴素但有效的办法:即使查不到,我缓存一个"空"的结果,下次再来直接返回 null。
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 分钟),否则会占着内存不干活。
方案二:布隆过滤器(推荐)
布隆过滤器是个神奇的"不存在判定器":它说"一定不存在",那就真的不存在;它说"可能存在",不一定真的存在(有小概率误判)。
// 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(比如秒杀商品的库存、首页的热门推荐)在过期的一瞬间,大量并发请求同时打到数据库。
热点 key 过期
↓
┌─ 请求 1 ─┐
├─ 请求 2 ─┤
├─ 请求 3 ─┼──→ 全部穿透到 MySQL!数据库瞬间被冲垮。
├─ 请求 4 ─┤
├─ .... ─┤
└─ 请求 N ─┘
方案一:互斥锁
同一时间只让一个请求去查数据库、重建缓存,其他请求等着。
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 物理上永不过期,但在值里面存一个逻辑过期时间。发现过期后,拿锁、开异步线程去刷新缓存,当前请求直接返回旧值——保证可用性优先。
@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 → 数据库崩盘
解决三板斧:
// 第一板斧:过期时间加随机值(防止大批 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 里的旧数据怎么办?
V1:先删缓存,再更新数据库 → 删了缓存之后、更新数据库之前,可能读到旧数据并写回缓存
V2:先更新数据库,再删缓存 → 如果删缓存失败了呢?
V3:延迟双删 → 先删 → 写数据库 → 等一会儿 → 再删(等的那一会儿可能读到旧数据)
V4:订阅 MySQL binlog → Canal 监听 binlog → 推送到 MQ → 异步更新 Redis
推荐的最终方案(V4): 
实际建议:大多数项目做到"先更新数据库再删除缓存 + 缓存设置合理过期时间"就已经达标了。Canal + MQ 是终极方案,适合金融交易这类对数据一致性有极端要求的场景。
缓存问题聊完了,接下来我们深入 Java 代码,看看怎么在 Spring Boot 里优雅地使用 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 前面会有一串奇怪的二进制前缀,看完怀疑人生。必须自定义序列化器:
@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 是目前最流行的实现,而且它的实现方式经常作为面试的"追问加分题"。
基本用法
@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 时自动续期:
业务执行中(持有锁)
│
├── 第 0 秒:拿到锁,默认过期 30 秒
├── 第 10 秒:看门狗自动续期到 30 秒
├── 第 20 秒:看门狗自动续期到 30 秒
├── 第 25 秒:业务执行完,调用 unlock()
└── 释放锁,看门狗线程停止
一个容易踩的坑:如果你手动指定了锁过期时间(
lock.tryLock(3, 10, TimeUnit.SECONDS)),看门狗不会启动。它会信任你指定的 10 秒。如果你不确定业务要执行多久,别手动指定过期时间,让看门狗帮你管。
2. 可重入 + Lua 原子操作
Redisson 加锁用的 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]); -- 锁被别人持有,返回剩余等待时间
对应的数据结构:
Key: lock:order:1001
└── Hash
├── <线程ID-1>: 1 (重入计数,key = 线程唯一标识)
└── <线程ID-2>: 1
一句话总结 Redisson 分布式锁:Hash 存线程标识 + 重入计数,Lua 保证原子性,看门狗自动续期。
封装一个 @RedisLock 注解
每次都写 try-catch-finally 太啰嗦了,封装成一个注解用起来多舒服:
@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 配置:
@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 雪上加霜。
// ❌ 错误写法:逐条 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 出问题的时候,隔壁工位少一个人慌慌张张,你就能少被打断一次。
更多推荐


所有评论(0)