30. 技术专题-‌应用框架组件-缓存-Redis

文章目录

  • 前言
  • [一、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 持久化:断电了数据怎么办?)
    • [2.2 过期策略与内存淘汰:数据什么时候消失?](#2.2 过期策略与内存淘汰:数据什么时候消失?)
    • [2.3 事务与 Lua 脚本:Redis 也有"原子操作"](#2.3 事务与 Lua 脚本:Redis 也有"原子操作")
      • [Redis 事务 ≠ MySQL 事务](#Redis 事务 ≠ MySQL 事务)
      • [Lua 脚本:Redis 的"存储过程"](#Lua 脚本:Redis 的"存储过程")
    • [2.4 发布订阅与 Stream:消息传递的两代方案](#2.4 发布订阅与 Stream:消息传递的两代方案)
  • [三、高可用架构篇: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 脚本的三个核心优势:

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

雪崩有两种触发方式:

    1. 时间撞车:批量预热时给所有 key 设了同一个 TTL,到期时间完全相同;
    1. 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,确认是用户行为数据堆积。

解决三板斧:

  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 缓存问题全解:穿透、击穿、雪崩与解决方案


相关推荐
雪芽蓝域zzs2 小时前
第 2 章:整合 MySQL + MyBatis,搭建数据库基础、实体类、Mapper、测试查询
数据库·mysql·mybatis
实泽有之,无泽虚之2 小时前
SQL Server数据库备份与还原详解
数据库·sqlserver
DevOpenClub2 小时前
高考一分一段 API:分数查位次、科类枚举与历史对照
linux·数据库·高考
Java后端的Ai之路2 小时前
Java并发编程07-等待通知机制优化循环等待
java·数据库·python
AllData公司负责人2 小时前
AllData 可定义数据中台完成信创适配|银河麒麟 + 海光X86 CPU / 华为鲲鹏ARM CPU+ OceanBase / 人大金仓国产化环境部署落地
大数据·arm开发·数据库·人工智能·oceanbase·kingbase
bksczm2 小时前
C++Redis开发(Ubuntu 22.04)
数据库·redis·缓存
斯普信专业组3 小时前
Kafka 3.9.1 三节点集群部署方案(ZooKeeper 模式)
分布式·zookeeper·kafka
zhangjw343 小时前
1-6 PostgreSQL 特色类型实战:数组、JSON & JSONB、枚举、范围类型
数据库·postgresql
杨云龙UP3 小时前
Oracle 同义词(SYNONYM)详解及 Toad for Oracle SQL 智能补全配置实战
运维·数据库·sql·oracle·同义词·toad·toad for oracle