作为开发人员,你大概率写过这样的代码:从数据库查出一批数据,往 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 到底是个什么?

RedisREmote 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 脚本的三个核心优势

  1. 原子性:整个脚本是一个原子操作,执行期间不会被插队
  2. 减少网络开销:多条命令一次发送,一次网络往返
  3. 可复用:脚本可以缓存在 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,确认是用户行为数据堆积。

解决三板斧

  1. 限制长度:LTRIM history:uid 0 199,只保留最近 200 条
  2. 分页读取:LRANGE history:uid 0 50,每次只取一页
  3. 冷热分离:不常用的老数据迁到 MySQL,Redis 只做热数据缓存

教训:Redis 单线程,大 key 操作 = 阻塞 = 线上事故。定期跑 redis-cli --bigkeys,发现大 key 尽早拆分或清理。


写在最后

到这里,这份 Redis 学习路线就完整了——从 5 种基础数据类型到底层数据结构,从缓存三大问题到分布式锁源码,从面试高频考题到生产环境血泪复盘。如果你是一路顺着读下来的,恭喜你,你对 Redis 的理解已经超过了绝大多数 Java 面试者。

但有一说一,技术这件事,读一遍文章只能让你"知道",真正让你"掌握"的,是亲手写代码、踩坑、排查、修复的过程。建议找一个小项目,把这些知识点一个一个地实践一遍——搭个 Cluster,写个分布式锁的 demo,压测一下缓存穿透——这些东西只有亲手摸过,才会变成你的肌肉记忆。

最后送大家一句话:

"别等到 Redis 挂了才想起这篇文档。" ——不过如果你真的遇到了,也没关系,这篇文档里应该能找到答案。

如果你觉得这篇文章对你有帮助,欢迎收藏、分享给身边的同事。毕竟,下次线上 Redis 出问题的时候,隔壁工位少一个人慌慌张张,你就能少被打断一次。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐