Redis深度探索
目录
一、Redis 核心数据结构(结合 Java 使用场景)
1.1 String
1. 基础:底层实现(SDS)、最大容量?
1.1. 为什么不使用C语言原生字符串?
1.2. SDS结构(以Redis 3.2+优化版本为例)
1.3. SDS的优势
1.4. String的最大容量是多少?
1.5. 为什么限制512MB?
1.6. Java开发中实战注意事项:
1.7. 总结:
2. 场景:String一般用来做什么?比如 token、计数器?
2.1. 分布式会话/Token存储
2.2. 计数器:
2.3. 分布式锁(简易版):
2.4. 缓存简单对象(序列化后)
2.5. 为什么String不是万能的?
3. 扩展:INCR 实现分布式 ID?原子性如何保证?和 Java 的 AtomicInteger 有何异同?
3.1. 基本思路:
3.2. 原子性如何保证:
3.3. 与Java的AtomicInteger 对比
1.2 Hash
1. 底层结构:ziplist vs hashtable?什么时候转换?
1.1. Hash的两种底层编码
2. ziplist(压缩列表)详解
2.1. 设计目标:
2.2. 内存结构(简化):
2.3. 特点:
3. hashtable(哈希表)详解
3.1. 底层结构:
3.2. 内存结构:
3.3. 特点:
3.4. 什么时候从ziplist转换为hashtable?
4. Redis7.0+的变化:ziplist->listpack
5. 性能与内存权衡总结:
6. Java开发实战推荐:
7. 底层数据类型总结:
8. Java 对应:为什么不用多个 String 而用 Hash?节省内存的原理?
8.1. 场景对比:String vs Hash
8.1.1. 方案1:多个String (key-value)拆分
8.1.2. 方案2: 用一个Hash
8.2. 为什么Hash更节省内存?--核心原理
8.3. 其他优势(不止内存):
8.3.1. 网络开销更小
8.3.2. 原子性操作
8.3.3. 管理更简单
8.3.4. 底层编码优化:
8.4. Java代码对比
8.4.1. 多个String
8.4.2. Hash
8.5. 什么情况下应该使用多个String?
9. 场景:用户信息缓存用 Hash 合适吗?如果字段非常多(比如上百个)还合适吗?
1.3 List
1. 底层:quicklist 是什么?为什么不用单纯的 linkedlist 或 ziplist?
1.1. quicklist是什么?
1.1.1. 为什么不使用单纯的linkedlist?
1.1.2. 为什么不使用单纯的ziplist?
1.1.3. quicklist如何权衡二者?
1.1.4. Redis 7.0+:ziplist → listpack
2. 场景:消息队列用 List 实现?有什么问题?(无 ACK、无持久化保障等)
2.1. 缺乏确认机制(ACK)
2.2. 持久化保障不足
2.3. 队列容量限制
2.4. 不适合复杂的消息路由与过滤
2.5. 扩展性和高可用性挑战
1.4 Set
1. 底层:intset vs hashtable?
1.1. 原理:
2. 场景:标签系统、共同好友?
2.1. 标签系统:
2.2. 共同好友:
1.5 ZSet(Sorted Set)
1. 底层:跳表 + 哈希表?
1.1. 什么是跳表?
1.2. 跳表的结构:
1.3. 查找过程示例:
1.4. 插入操作(以插入5为例)
1.5. 为什么跳表适合Redis?
1.6. 为什么要有哈希表?
1.7. 跳表和哈希表如何配合?
二、持久化机制(RDB / AOF)
1. RDB 和 AOF 的区别?各自优缺点?
1.1. RDB
1.1.1. 工作原理:
1.1.2. 优点:
1.1.3. 缺点:
1.2. AOF
1.2.1. 工作原理:
1.2.2. 优点:
1.2.3. 缺点:
1.3. 对比总结:
1.4. 生产环境最佳实践
2. 混合持久化(Redis 4.0+)是什么?开启后文件结构?
2.1. 什么是混合持久化?
2.2. 如何开启:
2.3. 开启后的AOF文件结构:
2.4. 混合持久化的优势:
2.5. 注意事项:
2.6. 验证是否启用成功:
2.7. 总结:
3. 如果 Redis 宕机,如何最大限度减少数据丢失?(结合 AOF fsync 策略)
3.1. 启用AOF持久化(基础前提)
3.2. 选择安全的appendfsync策略
3.3. 启用混合持久化
3.4. 配合AOF重写
3.5. 架构层面:主从+哨兵/Redis Cluster
3.6. 总结:
三、IO 模型与单线程事件循环
1. Redis 为什么是单线程?单线程如何处理高并发?
1.1. 为什么Redis采用单线程模型?
1.2. 单线程为什么支撑高并发?
1.2.1. 基于I/O多路复用的(epoll/kqueue)的时间驱动模型
1.2.2. 非阻塞I/O
1.2.3. 高效的内存数据结构
1.2.4. 纯内存操作
1.2.5. Pipeline和批量操作
四、主从复制与哨兵机制
1. 主从复制流程(全量 + 增量)?
1.1. 全量复制:
1.2. 增量复制:
2. 哨兵机制:如何选主?quorum 和 majority 的区别?
2.1. 哨兵如何选主?
2.1.1. 筛选候选从节点
2.1.2. 排序候选从节点(按优先级打分)
2.1.3. 执行故障转移
2.2. quorum 和 majority 的区别
2.2.1. 详细解释:
2.2.2. 举例说明:
3. 脑裂问题:什么情况下会发生?如何通过配置 min-replicas-to-write 避免?
3.1. 什么情况下会发生脑裂?
3.1.1. 网络分区:
3.1.2. 脑裂发生过程:
3.2. 如何避免脑裂?
3.2.1. 配置示例:
3.2.2. 作用机制:
五、高并发缓存问题
5.1 缓存一致性
1. 先更新 DB 还是先删缓存?为什么?
1.1. 两种方案对比:
1.1.1. 方案A:先删除缓存,再更新数据库(风险高)
1.1.2. 方案B:先更新数据库,再删除缓存
1.2. 为什么“先更新DB再删缓存”更安全?
1.2.1. 失败影响可控
1.2.2. 符合“写后失效”原则
1.2.3. 与旁路缓存模式天然契合
1.3. 极端情况:删除缓存失败怎么办?
1.3.1. 解决方案:
1.3.2. 生产环境最佳实践:
2. Cache-Aside Pattern 的标准流程?有没有更好的方案(如双删、延迟双删)?
2.1. 标准流程:
2.1.1. 读操作:
2.1.2. 写操作:
2.2. 为什么“删除缓存”而不是“更新缓存”?
2.3. “双删”和“延迟双删”是什么?有必要吗?
2.3.1. 方案1:双删
2.3.2. 延迟双删
2.3.3. 更好的解决方案(比双删更可靠)
2.3.4. 如何抉择?
5.2 缓存穿透
1. 定义?举例(查一个不存在的 user_id)
1.1. 定义:
1.2. 举例:
1.3. 常见解决方案;
2. 生产环境最佳实践
2.1. 第一层:参数校验
2.2. 布隆过滤器
2.3. 空值缓存
2.4. 完整请求处理流程图
2.5. 为什么这个方案行?
2.6. Java项目中生产实例:
2.6.1. 引入依赖:
2.6.2. 配置类:布隆过滤器+Redis
2.6.3. 用户服务:三层防御逻辑
2.6.4. 注意事项:
2.6.5. 位图是否可以替代呢?
5.3 缓存雪崩
1. 定义?大量 key 同时过期 + 高并发查询 DB
1.1. 定义
1.2. 典型场景:
1.3. 危害:
1.4. 与缓存击穿、缓存穿透的区别?
2. 生产级别解决方案
2.1.1. 设置随机过期时间(TTL)最常用
2.1.2. 永不过期+后台异步更新(适合核心数据)
2.1.3. 高可用架构
2.1.4. 服务降级&熔断限流
2.1.5. 热点数据永不过期+互斥重建
2.1.6. 总结:缓存雪崩防御checklist
3. Java 实现:如何用 synchronized 或 ReentrantLock 实现缓存重建的互斥?
3.1. 方案1:使用synchronized
3.2. 方案二:使用ReentrantLock(更灵活)
3.3. 生产环境最佳实践:
5.4 缓存击穿
1. 定义?热点 key 过期瞬间大量请求打到 DB
2. 解决方案:永不过期?逻辑过期 + 后台异步刷新?
2.1. 永不过期(物理不过期)
2.2. 逻辑过期+后台异步刷新(推荐)
2.3. 互斥锁重建缓存
2.4. 提前刷新(预热/定时任务)
2.5. 总结对比
一、Redis 核心数据结构(结合 Java 使用场景)
1.1 String
1. 基础:底层实现(SDS)、最大容量?
Redis没有使用C语言原生的字符串(即以\0结尾的char数组),而是自己封装了一个名为SDS的数据结构来表示字符串。
1.1. 为什么不使用C语言原生字符串?
- 获取长度需要O(n):必须遍历到\0才知道长度
- 缓存区溢出风险:拼接字符串时若目标缓存区不够,会越界
- 二进制不安全:中间不能包含\0,否则可能会被截断
- 内存重分配效率低:每次修改都可能realloc,频繁系统调用
1.2. SDS结构(以Redis 3.2+优化版本为例)
Redis 为了节省内存,对 SDS 做了多种优化,根据字符串长度使用不同结构体:
// 对于长度 < 2^5 - 1 (即 31 字节) 的字符串,使用 sdshdr5(但 Redis 3.2+ 实际弃用了 sdshdr5)
// 常见的是以下几种:struct __attribute__ ((__packed__)) sdshdr8 {uint8_t len; // 已使用字节数(字符串真实长度)uint8_t alloc; // 已分配的总字节数(不包括 header 和 \0)unsigned char flags; // 标志位,标识类型(如 sdshdr8)char buf[]; // 柔性数组,存储实际字符串,末尾自动加 \0
};// 更长的字符串会用 sdshdr16、sdshdr32、sdshdr64,len 和 alloc 字段变大
1.3. SDS的优势
特性 | 说明 |
O(1) 获取长度 |
|
杜绝缓冲区溢出 | 修改前会检查 |
二进制安全 | 可以存储任意字节(包括 |
空间预分配 & 惰性释放 | • 扩容时:若 len < 1MB,分配 2×len;否则+1MB |
举例:执行 SET name "Alice"
,Redis 会创建一个 SDS,len=5
,alloc≥5
,buf = "Alice\0"
。
1.4. String的最大容量是多少?
Redis的String类型最大能存储512MB的数据
官方文档说明:
Strings are the most basic kind of Redis value. Redis Strings are binary safe, this means that a Redis string can contain any kind of data, for instance a JPEG image or a serialized Ruby object. A String value cannot be larger than 512 MB.
1.5. 为什么限制512MB?
- 内存安全:防止单个key占用过多内存,导致Redis OOM或者响应变慢
- 性能考虑:大value会导致网络传输慢、阻塞主线程(Redis是单线程执行的命令)
- 实际场景:缓存、计数器、分布式锁等都不需要这么大的value,超大的value应该用其他存储对象,如(对象存储Minio、OSS + redis存URL)
1.6. Java开发中实战注意事项:
- 不要使用Redis String存大对象:比如直接反序列化一个100MB的List,会导致Redis卡顿;
- 推荐做法:大对象拆分(如用Hash分片存储),或只存ID,数据放DB
- 监控大Key:可以用
redis-cli --bigkeys
或者memory usage key
检测
1.7. 总结:
- Redis的String结构底层使用的是SDS实现的,没有采用C语言原生字符串。
- SDS通过len记录长度,支持O(1)获取长度、二进制安全、自动扩容和放缓冲区溢出。为了优化内存,Redis根据字符串长度使用不同类型的SDS结构。
- String类型的最大容量是512MB,这是Redis的硬性限制,主要是为了避免单个key过大占用过多内存影响性能和稳定性
2. 场景:String一般用来做什么?比如 token、计数器?
2.1. 分布式会话/Token存储
- 场景:用户登陆后生成JWT或者Session ID,存入Redis
- 为什么用String:
-
- 一对一映射(token -> 用户信息)
- 可设置TTL自动过期时间,避免内存泄露
- 查询快O(1),适合高频验证
- Java示例:
// 登录成功
String token = UUID.randomUUID().toString();
redisTemplate.opsForValue().set("token:" + token, userId, 30, TimeUnit.MINUTES);// 请求校验
String userId = redisTemplate.opsForValue().get("token:" + token);
if (userId == null) throw new AuthException("Token expired");
2.2. 计数器:
- 场景:接口限流、点赞数、阅读量、库存扣减等
- 为什么使用String + INCR:
-
INCR
/DECR
是原子操作,天然支持并发安全;- 比数据库自增更高效(避免行锁);
- 支持带过期时间的计数(如“1分钟内最多100次请求”)。
- Java示例(限流):
String key = "rate_limit:" + ip;
Long count = redisTemplate.opsForValue().increment(key);
if (count == 1) {redisTemplate.expire(key, 60, TimeUnit.SECONDS); // 首次设置60秒过期
}
if (count > 100) {throw new TooManyRequestsException();
}
优势:无需加锁,Redis 单线程保证原子性。
2.3. 分布式锁(简易版):
- 场景:防止重复提交、定时任务防重跑
- 实现方式:
SET key value NX EX seconds
-
NX
:仅当 key 不存在时才设置(保证互斥);EX
:自动过期,防止死锁。
- Java示例:
Boolean locked = redisTemplate.opsForValue().setIfAbsent("lock:order_create", "locked", 10, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {try {// 执行业务逻辑} finally {redisTemplate.delete("lock:order_create"); // 注意:可能误删,生产建议用 Lua 脚本}
}
注意:简单 String 锁有缺陷(如锁过期但业务未完成),生产环境推荐用 Redisson 的 RLock(基于 Lua + 看门狗)
2.4. 缓存简单对象(序列化后)
- 场景:缓存用户基本信息、商品信息等
- 做法:将Java对象JSON序列化后存为String
- Java示例:
User user = userService.getById(userId);
String json = JSON.toJSONString(user);
redisTemplate.opsForValue().set("user:" + userId, json, 1, TimeUnit.HOURS);
- 优点:简单直接,读取方便
- 缺点:无法部分更新(要替换整个字段),大对象慎用
如果对象字段多且常更新部分字段,用Hash更节省内存和网络带宽
2.5. 为什么String不是万能的?
场景 | 更优选择 | 原因 |
存储对象且需部分更新 | Hash | 避免全量序列化/反序列化 |
存储列表(如消息队列) | List | 支持 LPUSH/RPOP 原子操作 |
去重集合(如标签) | Set | 自动去重,支持交并差 |
排行榜、带权重队列 | ZSet | 支持按分数排序 |
原则:简单、高频、整体读写 → 用 String;结构化、部分操作 → 用复合类型。
3. 扩展:INCR 实现分布式 ID?原子性如何保证?和 Java 的 AtomicInteger 有何异同?
3.1. 基本思路:
- 核心思想:利用Redis的 INCR key原子命令的原子性,让多个服务实例并发请求时,都能获取到全剧唯一、单调递增的ID
- 实现步骤:
-
- 预先设置一个 key(如
global:id:user
),初始值可为 0 或某个起始值; - 每次需要生成 ID 时,调用
INCR global:id:user
; - Redis 返回递增后的值,即为新 ID。
- 预先设置一个 key(如
- Java示例:
public long generateUserId() {return redisTemplate.opsForValue().increment("global:id:user");
}
💡 increment()
方法底层执行的就是 Redis 的 INCR
命令。
3.2. 原子性如何保证:
关键点:Redis本质上是单线程执行命令的(在6.0之前完全单线程,6.0+网络IO多线程但执行命令仍然是单线程)
INCR
是一个原子操作命令;- 即使有 1000 个客户端同时执行
INCR
,Redis 也会串行化执行这些命令; - 每次
INCR
都会:
-
- 读取当前值;
- +1;
- 写回新值;
- 返回结果;
- 整个过程不可中断,因此天然线程安全 & 分布式安全。
所以,不需要额外加锁,INCR
本身就能保证分布式环境下的 ID 唯一性和递增性。
3.3. 与Java的AtomicInteger
对比
维度 | Redis | Java |
作用范围 | 分布式(跨 JVM、跨机器) | 单机(仅限当前 JVM 内) |
底层机制 | Redis 单线程事件循环 + 原子命令 | CAS(Compare-And-Swap) + volatile |
持久性 | 可通过 RDB/AOF 持久化(重启后可恢复) | 纯内存,JVM 重启后归零 |
性能 | 网络开销(毫秒级),受 Redis 性能影响 | 本地内存操作(纳秒级),极快 |
可靠性 | 依赖 Redis 可用性(需高可用架构) | 依赖 JVM 存活 |
ID 连续性 | 全局连续(除非 Redis 故障) | 仅本机连续 |
适用场景 | 分布式系统全局 ID(如订单号、用户 ID) | 单机计数器、线程池任务计数等 |
举个例子:
- 如果你有 3 台订单服务,每台都用
AtomicInteger
生成订单 ID,那么 ID 会重复(如三台都从 1 开始); - 而用 Redis
INCR
,三台服务共享同一个计数器,ID 全局唯一。
Redis INCR 适合:对 ID 连续性要求高、QPS 中等(< 10w/s)、已有 Redis 高可用架构的场景。
1.2 Hash
1. 底层结构:ziplist vs hashtable?什么时候转换?
1.1. Hash的两种底层编码
Redis 的 Hash 类型在内部有两种底层实现方式(通过 encoding
字段标识):
编码类型 | 适用场景 | 底层结构 |
| 小 Hash(字段少、值小) | 压缩列表(连续内存) |
| 大 Hash(字段多或值大) | 哈希表(dict,类似 Java HashMap) |
127.0.0.1:6379> HSET user:1002 name "Alice" age "25"
(integer) 2
127.0.0.1:6379> OBJECT ENCODING user:1002
"listpack" // 因为我本机版本是8.0,redis官方统一在7.0之后将ziplist改为listpack
127.0.0.1:6379>
2. ziplist(压缩列表)详解
2.1. 设计目标:
- 节省内存:用于存储小 Hash,避免 hashtable 的指针开销;
- 连续内存:所有数据紧凑排列,缓存友好。
2.2. 内存结构(简化):
[<zlbytes><zltail><zllen>][entry1][entry2]...[entryN][zlend]
zlbytes
:总字节数;zltail
:最后一个 entry 的偏移;zllen
:entry 数量(字段数 × 2,因为 key 和 value 各算一个 entry);- 每个
entry
存储一个 field 或 value(交替出现)。
2.3. 特点:
- 不是真正的“哈希”:查找需遍历(O(n));
- 插入/删除可能触发 realloc(内存重分配);
- 适合小而静态的数据。
3. hashtable(哈希表)详解
3.1. 底层结构:
其底层结构是Redis的dict,就是类似Java中的HashMap
- 由 两个哈希表(dict.ht[0] 和 dict.ht[1]) 组成,用于渐进式 rehash;
- 每个 bucket 是一个 链表(或 Redis 6.0+ 的 listpack 优化);
- 查找、插入、删除平均 O(1)。
3.2. 内存结构:
dict {dictht ht[2]; // 两个哈希表long rehashidx; // rehash 进度...
}
→ 每个 key-value 对存为 dictEntry { void *key; void *value; dictEntry *next; }
3.3. 特点:
- 高性能:适合频繁读写
- 内存开销大:每个entry都有指针、结构体对齐等overhead;
- 支持大容量
3.4. 什么时候从ziplist转换为hashtable?
Redis可以通过两个参数配置转换阈值,在redis.config中:
hash-max-ziplist-entries 512 # 最大字段数(field-value 对数)
hash-max-ziplist-value 64 # 每个 field 或 value 的最大字节数
转换条件(任一满足即转换):
- Hash 中的 field-value 对数量 >
hash-max-ziplist-entries
(默认 512); - 任意一个 field 或 value 的长度 >
hash-max-ziplist-value
(默认 64 字节)。
举例:
# 情况1:字段太多# 第513对插入时 → 转 hashtable# 情况2:值太大
HSET myhash name "Alice" bio "这是一段超过64字节的个人简介..." # bio 长度>64 → 立刻转 hashtable
4. Redis7.0+的变化:ziplist->listpack
重要更新:从 Redis 7.0 开始,ziplist 已被废弃,Hash 的紧凑编码改用 listpack
。
- listpack 是 ziplist 的改进版:
-
- 每个 entry 自包含长度信息,避免 ziplist 的“连锁更新”问题;
- 更安全、更高效;
- 配置参数名也变了:
hash-max-listpack-entries 512
hash-max-listpack-value 64
5. 性能与内存权衡总结:
维度 | ziplist / listpack | hashtable |
内存占用 | 极低(连续存储,无指针) | 高(每个 entry 有结构体+指针) |
查找性能 | O(n)(遍历) | O(1) 平均 |
适用场景 | 小对象缓存(如用户 profile) | 大 Hash、高频读写 |
修改开销 | 可能 realloc + 内存拷贝 | 指针操作,开销小 |
6. Java开发实战推荐:
- 缓存用户信息用Hash很合适:
// 存储用户基本信息(字段少、值小)
redisTemplate.opsForHash().putAll("user:1001", Map.of("name", "Alice", "age", "25"));
// 此时底层是 ziplist,内存效率高
- 避免大字段:
-
- 不要把头像base64、长文本存进Hash的field
- 否则会立即转换hashtable,浪费内存
- 监控编码类型:
redis-cli --bigkeys # 可查看大 key 及其 encoding
OBJECT ENCODING user:1001
7. 底层数据类型总结:
- Redis的Hash在底层有两种编码:ziplist(Redis7.0之前)或者listpack(Redis7.0之后)还有hashtable。
- 当Hash中的field字段数量不超过512且每个field或者value的长度不超过64字节时,使用ziplist/listpack,它将所有数据紧凑存储在连续的内存中,极大节省内存;
- 一旦超过上述提到的任何一个阈值,Redis会自动将编码转换为hashtable,以保证O(1)的读写性能。
8. Java 对应:为什么不用多个 String 而用 Hash?节省内存的原理?
8.1. 场景对比:String vs Hash
假设我们需要缓存一个用户信息:
User {id = 1001,name = "Alice",age = 25,email = "alice@example.com"
}
8.1.1. 方案1:多个String (key-value)拆分
SET user:1001:name "Alice"
SET user:1001:age "25"
SET user:1001:email "alice@example.com"
8.1.2. 方案2: 用一个Hash
HSET user:1001 name "Alice" age "25" email "alice@example.com"
8.2. 为什么Hash更节省内存?--核心原理
关键:Redis的每个key都有固定的开销
每个 Redis key(无论 String、Hash、List)在底层都对应一个 redisObject
+ sds
(key 名)+ 元数据,包括:
开销项 | 说明 |
| 16 字节(包含 type、encoding、refcount、lru等) |
key 的 SDS 字符串 | 如 |
哈希表 entry 指针 | Redis 全局 dict 中的 bucket 指针(约 8~16 字节) |
内存对齐 & 分配器 overhead | jemalloc 会按内存块分配(如 32/64/128 字节对齐) |
📌 实测数据(Redis 6.x,64 位系统):
- 一个空 String key ≈ 40~50 字节 固定开销;
- 一个 Hash key ≈ 40~50 字节(只算一次!);
- Hash 内部的 field-value 对,在
listpack
编码下,每对仅需 ~10~20 字节 额外开销。
举例计算(3 个字段):
方案 | key 数量 | 总内存估算 |
多个 String | 3 个 key | 3 × (50 + value_len) ≈ 150 + 3×value_len |
一个 Hash | 1 个 key | 50 + (3 ×field_value_overhead) ≈ 50 + 60 = 110 字节(假设 value 小) |
因此:字段越多的情况下,使用Hash节省内存更明显
8.3. 其他优势(不止内存):
8.3.1. 网络开销更小
- String方案:读取用户信息会需要多次往返
- Hash方案:读取一次请求就可以返回所有字段
8.3.2. 原子性操作
HMSET
/HSET
可一次性设置多个字段,保证数据一致性;- 多个 String 无法原子更新(除非用 Lua 或事务,但复杂度高)。
8.3.3. 管理更简单
- 删除用户:
DEL user:1001
(Hash) vsDEL user:1001:name user:1001:age ...
(String); - TTL 设置:Hash 只需设一次过期时间;String 要为每个 key 单独设(易遗漏)。
8.3.4. 底层编码优化:
- 小 Hash 使用
listpack
(Redis 7.0+),内存极度紧凑; - 多个 String 无法享受这种紧凑编码(每个都是独立对象)。
8.4. Java代码对比
8.4.1. 多个String
// 写入
redisTemplate.opsForValue().set("user:1001:name", "Alice");
redisTemplate.opsForValue().set("user:1001:age", "25");
redisTemplate.opsForValue().set("user:1001:email", "alice@example.com");// 读取
String name = redisTemplate.opsForValue().get("user:1001:name");
String age = redisTemplate.opsForValue().get("user:1001:age");
// ... 多次网络调用
8.4.2. Hash
// 写入
Map<String, String> userFields = Map.of("name", "Alice", "age", "25", "email", "alice@example.com");
redisTemplate.opsForHash().putAll("user:1001", userFields);// 读取(一次网络请求)
Map<Object, Object> user = redisTemplate.opsForHash().entries("user:1001");
8.5. 什么情况下应该使用多个String?
虽然 Hash 优势明显,但也有例外:
场景 | 建议 |
字段需要独立设置 TTL | 用 String(Hash 整体过期) |
某些字段极大(> 1KB) | 单独存 String,避免 Hash 转 hashtable |
字段访问模式差异极大 | 如 |
需要对单个字段加锁 | String 更灵活(但一般不推荐在 Redis 层做字段级锁) |
9. 场景:用户信息缓存用 Hash 合适吗?如果字段非常多(比如上百个)还合适吗?
对于用户信息缓存,字段较少的时候采用Hash非常合适,因为它内存紧凑、支持原子更新、网络高效。
但是当字段非常多的时候(比如上百个)或包含较大文本的时候,Hash会因为出发hashtable编码而导致内存膨胀,同时HGETALL
可能造成大key问题,影响Redis性能
一般来说,在项目中遇到上述场景,我们可以按照下面的解决办法:
- 如果是整体读写,改用String存JSON
- 如果是部分字段高频访问(如只查询name等字段),可以选择拆分存储,核心字段使用Hash,大字段单独存String
这样的话我们可以保证性能、又避免内存浪费
1.3 List
1. 底层:quicklist 是什么?为什么不用单纯的 linkedlist 或 ziplist?
1.1. quicklist是什么?
quicklist 是 Redis 3.2 引入的 List 底层数据结构,它是 双向链表(linkedlist)
和 压缩列表(ziplist,Redis 7.0+ 为 listpack)
的混合体。
结构定义(简化):
typedef struct quicklist {quicklistNode *head; // 头节点quicklistNode *tail; // 尾节点unsigned long count; // 元素总个数unsigned int len; // ziplist/listpack 节点数量signed int fill : QL_FILL_BITS; // 每个节点的填充因子(控制 ziplist 大小)// ... 其他字段
} quicklist;typedef struct quicklistNode {struct quicklistNode *prev;struct quicklistNode *next;unsigned char *zl; // 指向一个 ziplist 或 listpacksize_t sz; // zl 的字节大小unsigned int count : 16; // zl 中的元素个数// ...
} quicklistNode;
核心思想:
- 外层是双向链表:支O(1)的头尾插入/删除;
- 每个链表节点是一个 ziplist(Redis ≤6.x)或 listpack(Redis ≥7.0):内部紧凑存储多个元素,节省内存。
- 你可以理解为:quicklist = linkedlist of ziplists(或 listpacks)
1.1.1. 为什么不使用单纯的linkedlist?
- 内存开销巨大:
-
- 每个元素需要一个listNode结构题(含有prev/next指针+value指针)
- 在 64 位系统上,每个节点至少 24~32 字节 overhead,远大于元素本身(如一个 "1" 字符串);
- 内存不连续:缓存局部性差,遍历时CPU cache miss 多。
纯linkedlist内存效率太低,不适合存储大量小元素
1.1.2. 为什么不使用单纯的ziplist?
- 修改性能差:
-
- ziplist 是连续内存数组,在中间插入/删除需要移动后续所有元素;
- 时间复杂度 O(n),数据量大时延迟高;
- 连锁更新风险(Redis ≤6.x):
-
- 某个 entry 变大 → 后续 entry 的长度字段需更新 → 可能引发整块重写;
- 内存分配限制:
-
- 单个 ziplist 过大时,realloc 可能失败或导致内存碎片。
📌 Redis 官方实测:ziplist 超过 8KB 后,性能显著下降。
结论:纯 ziplist 只适合小而静态的数据,不适合频繁修改或大数据量的 List。
1.1.3. quicklist如何权衡二者?
维度 | linkedlist | ziplist | quicklist(混合) |
内存效率 | 差(指针开销大) | 极好(连续紧凑) | ✅ 好(分段紧凑) |
头尾操作 | O(1) | O(1)(但可能 realloc) | ✅ O(1) |
中间操作 | O(n) | O(n)(需移动) | O(n),但局部移动(只在一个 ziplist 内) |
缓存友好 | 差(内存分散) | 好(连续) | ✅ 较好(每个节点连续) |
扩展性 | 好 | 差(大 ziplist 性能崩) | ✅ 好(可动态增减节点) |
1.1.4. Redis 7.0+:ziplist → listpack
- Redis 7.0 起,quicklist 的节点从
ziplist
升级为listpack
; - 原因:listpack 解决了 ziplist 的“连锁更新”问题;
- 结构不变:quicklist 仍是 linkedlist of listpacks;
- 配置参数变化:
# Redis 6.x
list-max-ziplist-size -2# Redis 7.0+
list-max-listpack-size -2 # 含义相同,底层换实现
💡 -2
表示每个 listpack 节点最大约 8KB(具体见下表)。
list-max-listpack-size
含义:
值 | 含义 |
正数 N | 最多 N 个元素 per 节点 |
负数 -1 | 节点最大 4KB |
负数 -2 | 节点最大 8KB(默认) |
负数 -3 | 16KB |
负数 -4 | 32KB |
负数 -5 | 64KB |
2. 场景:消息队列用 List 实现?有什么问题?(无 ACK、无持久化保障等)
2.1. 缺乏确认机制(ACK)
- 丢失消息风险:当消费者从列表中读取到消息后,该消息即被删除。如果在处理过程中消费者奔溃或出现错误,这条消息将无法再次被处理,导致消息丢失
- 重复消费问题:若不采用适当的重试逻辑,可能会出现由于网络故障等原因导致的消息重复消费
2.2. 持久化保障不足
- 数据丢失风险:Redis默认配置下,数据主要存储于内存中。如果Redis实例意外关闭且没有启用持久化选项(RDB或AOF),那么所有未持久化的数据都将会丢失
- 部分持久化局限性:即使启用了持久化,根据所选的策略不同(例如 RDB 的周期性快照),也可能存在一定的数据丢失窗口期,在此期间发生故障同样会导致数据丢失。
2.3. 队列容量限制
- 内存限制:由于 Redis 主要基于内存工作,因此能存储的消息数量受到物理内存大小的限制。过量生产而消费速度跟不上时可能导致 Redis 内存溢出。
- 阻塞操作:虽然
BLPOP
和BRPOP
提供了阻塞式的读取方式来等待新消息的到来,但如果生产者速率远高于消费者的处理能力,则可能造成消息堆积,影响系统性能。
2.4. 不适合复杂的消息路由与过滤
- 简单模式匹配:Redis 列表并不支持基于内容的高级筛选或者路由规则,对于需要根据不同条件分发消息到不同消费者的应用场景不太适用。
2.5. 扩展性和高可用性挑战
- 单点故障:除非部署主从复制、哨兵或者集群模式,否则单实例 Redis 存在单点故障的风险。
- 水平扩展困难:Redis 本身并不直接支持对单一 List 进行分布式处理,这意味着随着业务增长,可能难以通过增加节点的方式来提升处理能力。
1.4 Set
1. 底层:intset vs hashtable?
1.1. 原理:
维度 | intset | hashtable |
内存占用 | 极低(连续整数数组) | 高(每个元素有 dictEntry + SDS) |
查找性能 | O(log n)(二分查找) | O(1) 平均 |
插入性能 | O(n)(需移动+可能升级 encoding) | O(1) |
适用场景 | 小整数集合(如用户 ID 白名单) | 通用集合(含字符串、大集合) |
Redis的Set底层有两种编码:intset和hashtable
- 当集合中存储的全部是整数,且数量不超过512个时,使用intset--它将整数按升序紧凑存储在连续内存中,内存占用极小
- 一旦加入非整数元素,或整数数量超过512,Redis会自动转换为hashtable,以支持O(1)的通用操作
2. 场景:标签系统、共同好友?
2.1. 标签系统:
- 错误做法:
// 存储用户兴趣标签(1=科技, 2=体育...)
redisTemplate.opsForSet().add("user:tags:1001", "1", "2", "5");
// 注意:这里传的是字符串!会触发 hashtable!
- 正确做法:
// 传 Long 类型,Redis 会识别为整数
redisTemplate.opsForSet().add("user:tags:1001", 1L, 2L, 5L);
2.2. 共同好友:
- 每个用户的好友列表使用一个Set存储,共同好友=两个Set的交集
功能 | Redis 命令 | 说明 |
添加好友(双向) |
| 好友关系需双向维护 |
查询共同好友 |
| 返回两个 Set 的交集 |
判断是否为好友 |
| O(1) 高效判断 |
获取所有好友 |
| 注意:大数据量慎用,可改用 |
注意:踩坑点:
- 混合类型:不要把整数和字符串混在一个Set;
- 超大整数集合:>512个整数时,内存优势将会消息,考虑是否真需要Set。
一句话总结:用Set存储每个用户的好友列表,通过SINTER命令在Redis服务端高效计算两个用户的共同好友,兼具性能和简洁性
1.5 ZSet(Sorted Set)
1. 底层:跳表 + 哈希表?
1.1. 什么是跳表?
普通链表只能顺序遍历O(N),而跳表通过增加“高速公路”层,让查找可以“跳着走”,大幅减少比较次数
类比:
想象你在一栋楼里找某个人
- 普通链表:从1楼开始,一层层向上问
- 跳表:先做电梯到10楼、20楼快速跳过,再局部搜索
1.2. 跳表的结构:
假设我们有一个有序集合:[1, 3, 4, 6, 8, 9, 12]
跳表可能长这样(简化版,共3层):
Level 2: -∞ -----------------------------> 12 -> +∞
Level 1: -∞ --------> 4 --------> 8 -----> 12 -> +∞
Level 0: -∞ -> 1 -> 3 -> 4 -> 6 -> 8 -> 9 -> 12 -> +∞
- Level 0 是完整的有序链表,包含所有元素。
- Level 1 是 Level 0 的子集(比如每隔几个节点选一个“索引”)。
- Level 2 是 Level 1 的子集,以此类推。
- 每个节点有 forward 指针数组,指向同层的下一个节点。
- 每个节点还包含 value 和 score(在 Redis ZSet 中就是 member 和 score)。
每个元素“晋升”到上一层的概率通常是 50%(可配置),所以高层节点越来越少。
1.3. 查找过程示例:
从最高层最左(-∞)开始:
- Level 2: -∞ → 12,但 12 > 6,不能跳,下到 Level 1
- Level 1: -∞ → 4,4 < 6,继续;4 → 8,8 > 6,下到 Level 0
- Level 0: 4 → 6,找到!
总共比较了:12(×)、4(√)、8(×)、6(√) → 仅 4 次,而普通链表要 4 次(1→3→4→6),但数据量越大优势越明显。
平均查找复杂度:O(log n)
1.4. 插入操作(以插入5为例)
- 查找插入位置(类似查找过程,记录每层“最后一个小于5的节点”)。
- 随机决定层数(比如抛硬币,直到出现反面,正面次数 = 新层数)。
-
- 假设
5
随机到 Level 1。
- 假设
- 在 Level 0 和 Level 1 插入节点,并更新前后指针。
1.5. 为什么跳表适合Redis?
特性 | 说明 |
✅ 支持有序 | 天然按 score 排序 |
✅ 范围查询快 | 从起点线性遍历底层链表即可(如 |
✅ 插入/删除 O(log n) | 比平衡树简单 |
✅ 实现简单 | 无旋转、无颜色,代码易维护 |
✅ 内存局部性好 | 链表节点连续,缓存友好 |
跳表 = 多层有序链表+随机晋升机制,用空间换时间,在保持链表灵活性的同时,实现了接近二分查找的效率
1.6. 为什么要有哈希表?
- 作用:通过member快速查找对应的score(即:member -> socre的映射)
- 支持的操作:
-
- 检查member是否存在
- 获取某个member的score
- 时间复杂度:O(1)
哈希表并不按照score进行查询,而是member,它只做了member->score的单向映射,不支持按score范围查询!
1.7. 跳表和哈希表如何配合?
举个例子:
ZADD myzset 10 "apple"
ZADD myzset 20 "banana"
底层结构如下:
- 哈希表:
"apple" → 10
"banana" → 20
- 跳表(按score排序):
Level 1: -∞ -------------> banana → +∞
Level 0: -∞ → apple → banana → +∞
(每个节点包含 member + score)
当你执行:
ZSCORE myzset apple
→ 直接查哈希表,O(1)ZRANGE myzset 0 -1
→ 遍历跳表底层,O(N)ZRANK myzset banana
→ 在跳表中查找并统计排名,O(log N)
二、持久化机制(RDB / AOF)
1. RDB 和 AOF 的区别?各自优缺点?
1.1. RDB
1.1.1. 工作原理:
- RDB通过快照的方式,在指定时间点将内存中的数据以二进制格式保存到磁盘(默认文件为dump.rdb)
- 可通过配置(如
save 900 1
)自动触发,也可手动执行BGSAVE
或SAVE
命令。
1.1.2. 优点:
- 恢复速度快:加载二进制快照文件比逐条重放命令快得多。
- 文件紧凑:RDB 文件体积小,适合备份、迁移和灾难恢复。
- 对性能影响小:使用
BGSAVE
时,快照在子进程中完成,主线程几乎不受影响。
1.1.3. 缺点:
- 可能丢失数据:两次快照之间的数据在 Redis 宕机时会丢失(例如配置为每5分钟一次快照,则最多丢失5分钟数据)。
- fork 开销大:在数据量大时,
fork()
子进程可能消耗较多内存和 CPU 资源。
1.2. AOF
1.2.1. 工作原理:
- AOF 将每个写操作命令以文本形式追加到日志文件(默认为
appendonly.aof
)。 - Redis 重启时通过重放这些命令来重建数据。
- 同步策略由
appendfsync
控制:
-
always
:每次写都同步(最安全,性能差)everysec
:每秒同步(默认,平衡)no
:由操作系统决定(性能最好,最不安全)
1.2.2. 优点:
- 数据安全性高:在
everysec
模式下最多丢失1秒数据。 - 可读性强:AOF 是文本文件,便于人工查看、编辑或修复。
- 可修复性好:若文件损坏,可通过
redis-check-aof
工具修复。
1.2.3. 缺点:
- 文件体积大:记录所有写操作,文件增长快。
- 恢复速度慢:需逐条重放命令,启动时间长。
- 性能开销大:频繁写磁盘(尤其
always
模式)会影响吞吐量。
1.3. 对比总结:
特性 | RDB | AOF |
持久化方式 | 定时快照 | 记录所有写命令 |
文件格式 | 二进制(紧凑) | 文本日志(可读) |
恢复速度 | 快 | 慢 |
数据丢失风险 | 高(取决于快照间隔) | 低(最多1秒,取决于同步策略) |
文件大小 | 小 | 大 |
性能影响 | 小(异步快照) | 较大(频繁写盘) |
适用场景 | 备份、冷启动、容灾 | 高数据安全性、实时性要求高的系统 |
1.4. 生产环境最佳实践
生产环境一般同时开启RDB+AOF
- Redis重启的时候优先使用AOF
- RDB可用于定期备份和快速恢复
启用混合持久化(Redis 4.0+)
- 配置
aof-use-rdb-preamble yes
- AOF 文件前半部分是 RDB 快照,后半部分是增量命令,兼顾速度与安全。
合理配置 appendfsync
:推荐 everysec
,平衡性能与安全。
定期执行 BGREWRITEAOF
:压缩 AOF 文件,避免无限增长。
2. 混合持久化(Redis 4.0+)是什么?开启后文件结构?
2.1. 什么是混合持久化?
混合持久化(RDB-AOF Hybrid Persistence)是 Redis 4.0 引入的一项重要特性,旨在结合 RDB 的快速恢复优势与 AOF 的数据安全性优势。它通过在 AOF 文件中嵌入 RDB 格式的快照数据,显著提升 Redis 重启时的加载速度,同时保留 AOF 的高数据可靠性。
- 默认情况下是关闭的,需要手动开启
- 开启后,AOF文件不再完全是文本命令格式,而是由两部分组成
-
- 文件开头是一个RDB快照(二进制格式)
- 后面追加的是自上次RDB快照之后的增量写命令(文本格式,即AOF增量日志)
- Redis重启时,先加载RDB部分快速重建大部分数据,再重放后面AOF命令恢复最新状态
-
- 本质:用RDB做“全量备份”,同AOF做“增量日志”,合并在一个AOF文件中
2.2. 如何开启:
在redis.conf中配置;
# 启用 AOF
appendonly yes# 启用混合持久化(Redis 4.0+)
aof-use-rdb-preamble yes
注意:只有在 appendonly yes
的前提下,aof-use-rdb-preamble
才生效。
2.3. 开启后的AOF文件结构:
一个启用混合持久化的AOF文件(如appendonly.aof)结构如下:
+---------------------+
| RDB 格式快照 | ← 二进制数据,包含执行 BGREWRITEAOF 时的完整数据集
+---------------------+
| AOF 增量命令 | ← 文本格式,记录 RDB 快照之后的所有写操作
| *3 |
| $3 |
| SET |
| $3 |
| foo |
| $3 |
| bar |
| ... |
+---------------------+
- RDB 部分:由
BGREWRITEAOF
触发生成(不是BGSAVE
),包含当前内存的完整快照。 - AOF 部分:从 RDB 快照生成时刻开始,所有新写入的命令以 RESP(Redis Serialization Protocol)格式追加。
- 文件整体仍以 AOF 方式管理(如重写、同步策略等),但内容是混合的。
2.4. 混合持久化的优势:
优势 | 说明 |
✅ 恢复更快 | 不再需要重放全部历史命令,只需加载 RDB + 少量增量命令 |
✅ 数据更安全 | 相比纯 RDB,几乎不丢失数据(取决于 |
✅ 文件更小 | 相比纯 AOF,避免了大量冗余命令(如多次 SET 同一个 key) |
✅ 兼容性好 | 对客户端透明,无需修改应用代码 |
2.5. 注意事项:
- 仅在 AOF 重写时生效
混合格式只在执行BGREWRITEAOF
(或自动触发重写)时生成。初始 AOF 文件仍是纯文本。 - 旧版本 Redis 无法识别
若用 Redis < 4.0 打开混合 AOF 文件,会报错(因无法解析开头的 RDB 数据)。 - 文件仍叫
appendonly.aof
虽然包含 RDB 内容,但文件名不变,管理方式仍按 AOF 处理。 - 推荐生产环境开启
Redis 官方推荐在需要高可靠性和快速恢复的场景下启用此功能。
2.6. 验证是否启用成功:
- 查看配置:
redis-cli config get aof-use-rdb-preamble
# 返回 "yes" 表示已启用
- 观察AOF文件开头(Shi用hexdump或xxd):
xxd appendonly.aof | head
若看到REDIS字样(RDB文件魔数),说明为混合格式:
00000000: 5245 4449 5330 3030 39fa ... → "REDIS0009"
2.7. 总结:
混合持久化 = RDB的速度 + AOF安全
开启后,AOF文件 = RDB快照(二进制)+ 增量命令(文本)
这个是现代Redis生产部署的最佳实践之一,强烈建议启用
3. 如果 Redis 宕机,如何最大限度减少数据丢失?(结合 AOF fsync 策略)
3.1. 启用AOF持久化(基础前提)
Redis默认不开启AOF,必须显式启用:
appendonly yes
RDB无法保证低丢失(快照间隔内的数据全丢),AOF是减少丢失的关键
3.2. 选择安全的appendfsync策略
AOF 的数据落盘行为由 appendfsync
控制,有三种选项:
配置项 | 含义 | 数据丢失风险 | 性能影响 | 适用场景 |
| 每次写操作都同步刷盘 | 几乎为 0(除非磁盘故障) | ⚠️ 极高(每秒几百~几千 QPS) | 对数据一致性要求极高的金融、支付系统 |
(默认) | 每秒同步一次 | 最多丢失 1 秒数据 | ✅ 平衡(推荐) | 绝大多数生产环境 |
| 由操作系统决定何时刷盘 | 可能丢失数秒甚至更多 | ⚡ 最高 | 不关心数据丢失的缓存场景 |
最大限度减少丢失 → 使用 appendfsync always
但需注意:
- 性能代价大:每次写都要等待磁盘 I/O,吞吐量显著下降。
- SSD 可缓解:使用高性能 SSD 可减轻
always
的性能损耗。
💡 如果业务能容忍 1 秒内丢失,everysec
是更现实的选择,兼顾安全与性能。
注意:此处的选择关键是必须结合业务场景!!!!!!任何技术只要是脱离业务,那都只是炫技
3.3. 启用混合持久化
aof-use-rdb-preamble yes
- 虽然不直接影响“宕机瞬间”的数据丢失量,但是可以加快恢复速度,减少服务不可用时间
- 在AOF重写后,文件包含RDB快照+增量命令,避免重放海量历史命令
3.4. 配合AOF重写
- 定期实行AOF重写可以压缩日志体积,避免文件过大影响恢复速度
- 重写过程不会丢失新写入的数据
- 可以通过配置自动触发:
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
3.5. 架构层面:主从+哨兵/Redis Cluster
- 单机Redis无法100%避免丢失(即使always,宕机瞬间依然会有极小的窗口期)
- 通过主从复制+哨兵(Sentinel)或RedisCluster实现高可用:
-
- 主节点宕机,从节点自动接管
- 配合
min-replicas-to-write
和min-replicas-max-lag
(旧称min-slaves-*
)可强制写入同步到从节点,进一步降低丢失风险(但会牺牲可用性):
# 要求至少 1 个从节点在线,且 lag <= 10 秒,才允许主节点接受写入
min-replicas-to-write 1
min-replicas-max-lag 10
3.6. 总结:
减少Redis宕机数据丢失的完整策略:
- 启用AOF
- 设置合理的appendfsync always(写入策略)
- 启用混合持久化
- 配置主从+哨兵/Cluster,并且强制数据同步到从节点
记住:没有绝对零丢失,但通过上述组合策略,可将 Redis 宕机数据丢失控制在 毫秒级甚至为零(在合理硬件和配置下)
三、IO 模型与单线程事件循环
1. Redis 为什么是单线程?单线程如何处理高并发?
Redis的核心网络I/O和命令执行是单线程的,但是其他功能(如持久化、异步删除、集群通信等)可能使用多线程
1.1. 为什么Redis采用单线程模型?
- 避免锁竞争,简化实现:多线程需要处理复杂的同步、锁、死锁等问题。Redis的数据结构(如哈希、跳表)在单线程下无需加锁,代码更简答、更稳定
- 内存操作本身非常快:Redis是基于内存的数据库,绝大多数的操作是O(1),单线程也能在微妙级完成。瓶颈通常不在CPU,而在网络和带宽中
- 上下文开销大:多线程在高并发下频繁切换上下文,反而可能降低性能。单线程避免了这一开销。
- 历史原因和设计哲学:Redis 初期由 Salvatore Sanfilippo(antirez)一人开发,追求简洁、高性能和可预测性,单线程符合这一理念。
1.2. 单线程为什么支撑高并发?
虽然命令执行是单线程的,但是Redis通过下面的机制高效处理高并发请求
1.2.1. 基于I/O多路复用的(epoll/kqueue)的时间驱动模型
- 使用
select
/epoll
(Linux)等机制,单线程可以同时监听成千上万个客户端连接。 - 当某个 socket 可读/可写时,Redis 才处理该连接,避免轮询开销。
- 实现了“一个线程处理多个连接”的高并发能力。
1.2.2. 非阻塞I/O
所有的网络操作都是非阻塞的,不会因为等待某个客户端而卡住整个服务
1.2.3. 高效的内存数据结构
Redis的内部使用SDS(简单动态字符串)、ziplist、quicklist、skiplist等高度优化的数据结构,操作速度极快
1.2.4. 纯内存操作
数据全部存储在内存中,避免了磁盘I/O延迟
1.2.5. Pipeline和批量操作
客户端可以通过Pipeline一次发送多个命令,减少网络往返次数,极大提升吞吐量。
举例:
假设我们需要执行三个命令:
SET name "Alice"
SET age "30"
SET city "Beijing"
普通方式(无 Pipeline):
- 客户端发
SET name "Alice"
→ 等待 Redis 回复 → 收到 OK - 客户端发
SET age "30"
→ 等待回复 → 收到 OK - 客户端发
SET city "Beijing"
→ 等待回复 → 收到 OK
使用 Pipeline:
- 客户端一次性把 3 个命令都发给 Redis(不等回复)
- Redis 依次执行这 3 个命令,并把 3 个回复一次性返回
- 客户端一次性收到 3 个结果
Redis 的“单线程”是其高性能的关键设计之一,配合事件驱动和内存操作,足以支撑每秒 10w+ 的 QPS。对于更高吞吐场景,可通过集群(Redis Cluster)横向扩展
四、主从复制与哨兵机制
1. 主从复制流程(全量 + 增量)?
1.1. 全量复制:
当从节点首次连接主节点,或主从断开后无法进行增量同步时,会出发全量同步;主从节点之间主要是通过从节点发起复制请求,主节点响应并且生成RDB快照,主节点发送快照RDB到从节点,从节点加载RDB文件并且进行复制。
1.2. 增量复制:
在主从连接正常或短暂断开后,若满足条件,可以进行增量同步,避免全量复制的开销;从节点重新连接主节点,并且需要带上目前的复制到的偏移量,主节点判断是否支持部分同步,若支持,则进行增量复制。
2. 哨兵机制:如何选主?quorum 和 majority 的区别?
2.1. 哨兵如何选主?
当哨兵集群判定主节点“客观下线”后,会启动故障转移,并按以下步骤筛选出新主:
2.1.1. 筛选候选从节点
哨兵首先排除不符合条件的从节点:
- 与主节点断开连接时间非常久的从节点
- 被配置为
slave-priority = 0
(Redis 5+ 叫replica-priority = 0
)的节点(明确禁止成为主) - 从节点自身处于不可用的状态
2.1.2. 排序候选从节点(按优先级打分)
对剩余从节点按以下优先级顺序排序,越靠前越优先:
排序规则 | 说明 |
1. replica-priority(原 slave-priority) | 值越小优先级越高(默认 100)。设为 0 表示永不参选 |
2. 复制偏移量(replication offset) | 数据越新(offset 越大)越优先 |
3. Run ID 字典序 | 如果前两项相同,选 runid 字典序最小的(保证确定性) |
最终得分最高的从节点被选为新主节点!
2.1.3. 执行故障转移
- 哨兵向选中的从节点发送
REPLICAOF NO ONE
,将其提升为新主 - 向其他从节点发送
REPLICAOF <new-master>
,让他们后续复制新主 - 更新哨兵内部配置,通知客户端新主地址
2.2. quorum
和 majority
的区别
这是哨兵机制中最容易混淆的两个概念,他们作用在不同阶段
概念 | 作用阶段 | 含义 | 是否需要过半? |
| 主观下线 → 客观下线(ODOWN)判断 | 至少需要多少个哨兵同意主节点“已下线”,才能判定为客观下线 | ❌ 不需要过半,只需 ≥ |
(多数派) | 故障转移执行阶段 | 执行 failover 前,需要获得哨兵集群多数派(> N/2)的授权 | ✅ 必须过半 |
2.2.1. 详细解释:
- quorum(配置项):
-
- 在sentinel.conf中设置,例如:
sentinel monitor mymaster 127.0.0.1 6379 2
这里的 2
就是 quorum
值。
- 作用:当有>=quorum个哨兵认为主节点“客观下线”,就将其标记为“客观下线”
- 注意:
quorum
不一定是多数!例如 5 个哨兵,quorum=2
是合法的。
quorum控制的是"是否启动故障转移的门槛"
- majority(隐式多数派):
- 故障转移不能由单独的哨兵擅自执行!
- 被选中的“领哨兵”必须获得超过半数哨兵的投票授权,才能真正执行failover
- 这个多数是动态计算的:
majority = floor(N / 2) + 1
(N 为哨兵总数)
majority控制的是“是否执行故障转移的权限”。
2.2.2. 举例说明:
假设我们部署了三个哨兵,配置:
sentinel monitor mymaster 10.0.0.1 6379 2
- quorum = 2
- majority = 2 (因为3/2 + 1 = 2)
场景:
- 主节点宕机
- 哨兵A和哨兵B都发现了主节点无响应,标记为SDOWN
- 因为 ≥
quorum
(2 ≥ 2),集群将主节点标记为 ODOWN。 - 哨兵A发起leader选举,需要获得>=majority(即2票)支持。
- 哨兵B投票给A -> A获得票(自己+ B),成为leader
- A执行故障转移,选新主
3. 脑裂问题:什么情况下会发生?如何通过配置 min-replicas-to-write
避免?
Redis脑裂问题是指在网络分区场景下,主节点和从节点、哨兵节点之间失去通信,导致多个节点同时任认为自己是主节点,从而同时接受写请求,造成数据不一致乃至丢失的问题
3.1. 什么情况下会发生脑裂?
3.1.1. 网络分区:
假设架构如下:
- 1个主节点(Master)
- 2个从节点(Replica)
- 3个哨兵(Sentinel)
正常情况:主+从+哨兵
3.1.2. 脑裂发生过程:
- 网络故障:主节点与哨兵节点、从节点之间的网络断开,但主节点自身仍可被客户端访问
-
- 主节点被孤立在一个分区A
- 哨兵+从节点在另一个分区B
- 哨兵判定主节点ODOWN:
-
- 哨兵发现主节点失联,且满足quorum和majority条件
- 出发故障转移,将某个节点提升为新主
- 结果:
-
- 旧主仍在A接受客户端写入
- 新主在B也接受写入
- 两个主同时存在->脑裂
- 网络恢复后:
-
- 旧主会以从节点身份重新加入集群
- 但旧主上在脑裂期间写入的数据会被新主的数据覆盖 -> 永久丢失!
这就是脑裂最危险的地方:数据丢失,而非仅仅是不一致
3.2. 如何避免脑裂?
Redis提供了两个关键配置(Redis 5+推荐使用新命名):
旧配置(已弃用) | 新配置(推荐) | 作用 |
|
| 主节点至少要有 N 个从节点在线,才允许写入 |
|
| 从节点的最大允许延迟(秒) |
3.2.1. 配置示例:
# 至少有 1 个从节点与主节点保持连接
min-replicas-to-write 1# 且该从节点的复制延迟不能超过 10 秒
min-replicas-max-lag 10
3.2.2. 作用机制:
- 当主节点发现连接的从节点数量<
min-replicas-to-write
-
- 或从节点延迟>
min-replicas-max-lag
- 或从节点延迟>
- 主节点会拒绝所有写请求,只读!
- 哨兵正常提升新主,新主可写
- 网络恢复后,无数据冲突,旧数据同步新主数据
结果:虽然服务短暂不可写,但是避免了数据丢失和不一致情况!
五、高并发缓存问题
5.1 缓存一致性
1. 先更新 DB 还是先删缓存?为什么?
1.1. 两种方案对比:
1.1.1. 方案A:先删除缓存,再更新数据库(风险高)
del cache[key];
update DB;
问题:并发下可能写入旧数据到缓存
- 线程A:删除缓存
- 线程B:查询缓存未命中->读DB(此时DB未更新->得到旧值->写入缓存
- 线程A:更新DB为新值
- 结果:缓存中是旧值,DB是新值,导致不一致现象
这种方案“缓存污染”在高并发下极易发生,且持续时间长(直到下次更新或过期)!
1.1.2. 方案B:先更新数据库,再删除缓存
update DB;
del cache[key];
优势:不一致窗口极小,且可接受
- 线程A:更新DB为新值
- 线程B:可能在A删除缓存前读到旧缓存(短暂不一致)
- 线程A:删除缓存
- 后续请求:缓存未命中,读DB(新值),更新缓存
此处的不一致时短暂的(毫秒级),且最终一致。相比方案A的“长期脏缓存”,风险小得多
1.2. 为什么“先更新DB再删缓存”更安全?
1.2.1. 失败影响可控
- 如果删除缓存失败,可以通过重试机制(消息队列、异步任务)补偿
- 而方案A中,一旦旧数据被写入缓存,很难自动纠正
1.2.2. 符合“写后失效”原则
- 更新后让缓存失效,由下次读请求“按需加载”最新数据
- 避免了“写缓存”带来的并发覆盖问题
1.2.3. 与旁路缓存模式天然契合
- 旁路缓存的标准实践就是:
-
- 读:先查缓存,未命中查DB,回填缓存
- 写:更新DB,删除缓存(而非更新缓存)
1.3. 极端情况:删除缓存失败怎么办?
即使采用“先更新DB再删除缓存”,也可能因网络抖动导致删缓存失败
1.3.1. 解决方案:
重试机制:
- 删除失败后,将key加入重试队列(RabbitMQ、kafka等)
- 异步消费者不断重试删除,直到成功
设置缓存过期时间(TTL)兜底:
- 即使删除失败,缓存也会在TTL后自动失效
- 虽然不一致窗口变长,但是最终一致!
实际系统中,“更新DB+删除缓存+TTL+重试”是标准组合拳
1.3.2. 生产环境最佳实践:
- 更新DB
- 删除缓存
-
- 删除失败:重试机制+TTL兜底
- 读操作
-
- 先读缓存:未读到,读DB并回填
通过合理设计,可以在高并发下将不一致窗口压缩到毫秒级,在性能与一致性之间取得最佳平衡
2. Cache-Aside Pattern 的标准流程?有没有更好的方案(如双删、延迟双删)?
旁路缓存模式是最常用、最经典的缓存使用模式,尤其适用于高并发读多写少的场景(如商品详情、用户信息等)。下面我们先明确其标准流程,再分析“双删”“延迟双删”等变种方案的适用性与风险
2.1. 标准流程:
2.1.1. 读操作:
String get(String key) {// 1. 先读缓存String value = cache.get(key);if (value != null) {return value; // 缓存命中}// 2. 缓存未命中,查数据库value = db.query(key);if (value != null) {// 3. 回填缓存(可选:加过期时间)cache.set(key, value, TTL);}return value;
}
2.1.2. 写操作:
void update(String key, String newValue) {// 1. 先更新数据库db.update(key, newValue);// 2. 再删除缓存(不是更新!)cache.delete(key);
}
2.2. 为什么“删除缓存”而不是“更新缓存”?
对比项 | 更新缓存 | 删除缓存 |
并发安全 | ❌ 多线程可能覆盖(A 写新值,B 写旧值) | ✅ 无覆盖风险 |
复杂度 | ❌ 需保证 DB 与缓存原子性 | ✅ 简单,最终一致 |
缓存利用率 | ❌ 可能写入不会被读的数据 | ✅ 按需加载,节省内存 |
2.3. “双删”和“延迟双删”是什么?有必要吗?
🌪️ 背景:担心“先更新 DB 再删缓存”仍有短暂不一致
比如:
- 线程 A 更新 DB
- 线程 B 读缓存(旧值)→ 未命中 → 读 DB(新值)→ 回填缓存
- 但如果在 A 删除缓存前,B 已经读了旧缓存 → 短暂不一致(毫秒级)
这个窗口极小,通常可接受。但某些场景(如金融)希望进一步缩小。
2.3.1. 方案1:双删
void update(String key, String newValue) {cache.delete(key); // 第一次删db.update(key, newValue); // 更新 DBcache.delete(key); // 第二次删
}
问题:
- 第一次删是多余的:如果并发读发生在第一次删之后、DB更新之前,仍然会加载旧值到缓存
- 第二次删无法解决“读在删前”的问题
- 增加无谓的开销
- 结论:不推荐双删!!
2.3.2. 延迟双删
void update(String key, String newValue) {cache.delete(key); // 第一次删db.update(key, newValue); // 更新 DBThread.sleep(500); // 等待可能的并发读完成cache.delete(key); // 第二次删(清理可能被回填的旧缓存)
}
理论作用:
- 假设DB更新后、第二次删缓存之前,有并发读加载了旧值到缓存
- 等待一段时间如(500ms)让这些“脏读请求”执行完毕
- 再删一次,清除可能被写入的旧缓存
问题:
- sleep阻塞线程:写接口延迟增加,吞吐量暴跌
- 等待时间难确定:500ms不一定够
- 仍然无法100%保证一致:如果sleep期间又有新读请求?
- 破坏高并发写性能
2.3.3. 更好的解决方案(比双删更可靠)
- 异步监听Binlog(推荐)
-
- 使用Canal/Debezium监听MySQL binglog
- DB变更后,由中间件异步删除缓存
- 业务代码无入侵,删除更可靠
APP -> 更新DB -> MySQL Binlog -> Canal -> 删除Redis
适合中大型系统,最终一致,但架构复杂度比较高
- 写操作后强制读取
-
- 写完DB,立即加载到缓存中可查看
- 缓存加版本号/时间戳
-
- 缓存值中包含DB版本号(如update_time)
- 读取时比较版本,旧版本自动丢弃
- 需要DB支持版本字段
2.3.4. 如何抉择?
场景 | 推荐方案 |
绝大多数业务(电商、社交等) | 标准 Cache-Aside:先更新 DB,再删缓存 + TTL + 删除失败重试 |
强一致性 + 低频写 | 延迟双删(谨慎使用)或写后读主 |
高可靠 + 中大型系统 | Binlog 监听异步删缓存 |
避免 | 双删、先删缓存再更新 DB、更新缓存而非删除 |
黄金法则:
- 不要为了“理论完美”牺牲性能和复杂度
- 毫秒级不一致在多数场景可接受
- 用TTL+本地兜底,比Sleep双删更优雅
Cache-Aside 本身已是经过大规模验证的最佳实践,“先更新 DB,再删除缓存”就是标准答案。双删类方案属于“过度优化”,往往得不偿失。
5.2 缓存穿透
1. 定义?举例(查一个不存在的 user_id)
1.1. 定义:
缓存穿透是指查询一个数据库、缓存中不存在的数据,由于没有命中缓存,系统会去查询数据库表,但是数据库也查不到结构,因此不会写入缓存,下次再有相同请求的时候,依然会直接穿透到数据库中,造成无效查询压力。
1.2. 举例:
假设系统中用户ID从1开始递增,当前最大user_id是10000
- 用户(攻击者)请求user_id = 999999999
- 缓存中没有这个key(缓存未命中)
- 系统去数据库查询,发现user_id不存在
- 因为数据不存在,系统通常不会将缓存空结果缓存
- 下次再有请求user_id = 999999999,又会重复上述流程
如果攻击者用大量不同的、不存在的user_id发起请求(如遍历负数、超大整数等),就会导致数据库承受大量无效查询,这就是缓存穿透的问题
1.3. 常见解决方案;
- 缓存空值:
-
- 对于查询结构为空的情况,也将其缓存(比如缓存一个特殊值或控对象),并设置较短的过期时间(如1~5分种),防止重复穿透。
- 布隆过滤器
-
- 在缓存前加一层布隆过滤器,预先将所有合法的user_id存入。当请求到来时,先通过布隆过滤器判断该user_id是否可能存在。如果布隆过滤器判断“一定不存在”,则直接返回,不再查询缓存和数据库
- 参数校验:
-
- 对请求参数做合法性校验(如user_id必须>0 且 < 最大用户ID),提前拦截非法请求
2. 生产环境最佳实践
目前业界最经典、高效且安全的三层防御体系为“参数校验+布隆过滤器+空值缓存(短TTL)”,下面我将对其进行详细介绍
2.1. 第一层:参数校验
在请求进入缓存/数据库前,快速拦截明显是非法的请求
- 检查user_id是否为正整数,是否在合理的范围内(1 ≤ user_id ≤ max_user_id + 10000)
- 是否符合格式(如非字符串、非特殊字符串)
- 可结合业务规则(如用户ID不能是保留值:0,-1, 999999等)。
优点:
- 零成本拦截大量明显非法请求
- 不消耗缓存或者数据库资源
局限:
无法防御看似合法但是不存在的ID,所以我们需要引入第二层布隆过滤器
2.2. 布隆过滤器
快速判断某个user_id“一定不存在”,从而避免后续查询
原理简述:
- 布隆过滤器是一个空间效率极高的概率型数据结构
- 支持add(key)和might_contain(key)
- 特点:
-
- 如果返回不存在:100%不存在(可以安全拦截)
- 如果返回“可能存在”:可能有误判,需继续查缓存/DB
实现方式:
- 在用户注册/创建时,将user_id加入布隆过滤器
- 查询时先查布隆过滤器:
-
- 若不存在:直接返回用户不存在
- 若可能存在:进入缓存查询
示例流程:
请求 user_id=999999999↓
[参数校验] → 合法(正整数,范围OK)↓
[布隆过滤器] → 返回“不存在”↓
直接返回 {"error": "User not found"},不查缓存、不查DB!
优点:
- 内存占用极小(百万级ID只需要MB)
- 查询速度快O(1)
- 能有效拦截海量伪造但格式各法的ID
注意事项:
- 布隆过滤器不支持删除
- 需在数据写入时同步更新(如注册、导入用户时)
- 误判率可通过参数调整(如1%),但是不影响正确性(只影响少量请求继续往下走)
及时有误判,也只是让少量请求进入第三层,无害!
2.3. 空值缓存
作用:
兜底防护:当请求通过前两层,但DB中确实差不到时,缓存空结果,防止同一ID被反复查询
实现方式:
- 查询缓存:未命中
- 查询DB:未命中
- 写入缓存一个特殊空值(如“NULL”或JSON{}),并设置短TTL(如60~300秒)
- 后续相同请求直接命中空缓存,快速返回
SET user:999999999 "NULL" EX 120 # 缓存空值,2分钟过期
优点:
- 防止同一个不存在ID被高频请求(如前端bug或爬虫反复查)
- 设置TTL,影响可控
风险控制:
- 必须配合前两层!否则攻击者用海量不同ID打进来,仍会打爆内存
- TTL不宜过长(建议1~5分钟)
- 可主动清理:当user_id被创建的时候,主动DEL user:XXX.
2.4. 完整请求处理流程图
+------------------+| 请求 user_id=x |+------------------+↓+---------------------+| 参数校验 || (格式、范围、合法性)|+---------------------+↓ 合法?否 → 返回错误↓ 是+---------------------+| 布隆过滤器 || "一定不存在"? |+---------------------+↓ 是返回 "用户不存在"↓ 否(可能存在)+---------------------+| 查询缓存 |+---------------------+↓ 命中?是 → 返回结果↓ 否+---------------------+| 查询数据库 |+---------------------+↓ 存在?是 → 写入缓存,返回↓ 否+---------------------+| 缓存空值(短TTL) |+---------------------+↓返回 "用户不存在"
2.5. 为什么这个方案行?
层级 | 防御目标 | 性能开销 | 安全性 | 适用场景 |
参数校验 | 拦截明显非法请求 | 极低 | 高 | 所有系统必备 |
布隆过滤器 | 拦截海量伪造合法ID | 低(内存小、O(1)) | 极高(100% 拦截不存在) | ID 集合可预知/可同步 |
空值缓存 | 防同一ID反复穿透 | 中(少量内存) | 中(需控TTL) | 兜底,防突发重复请求 |
2.6. Java项目中生产实例:
2.6.1. 引入依赖:
<dependencies><!-- Spring Boot Web --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><!-- Spring Data Redis --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-redis</artifactId></dependency><!-- Guava (for BloomFilter) --><dependency><groupId>com.google.guava</groupId><artifactId>guava</artifactId><version>33.0.0-jre</version></dependency><!-- Optional: for Redis connection pool --><dependency><groupId>org.apache.commons</groupId><artifactId>commons-pool2</artifactId></dependency>
</dependencies>
2.6.2. 配置类:布隆过滤器+Redis
@Configuration
public class CacheConfig {// 预估用户总量(可动态调整),如果用户量远超此值:误判率会上升,可定期重建或使用动态布隆过滤器private static final long EXPECTED_USER_COUNT = 1_000_000L;// 误判率:1% -- 可以手动配置,结合业务需求,一般来说这个值相对比较合适,但是想要降低误判率,就需要扩大内存占用private static final double FPP = 0.01;@Beanpublic BloomFilter<Long> userBloomFilter() {return BloomFilter.create(Funnels.longFunnel(),EXPECTED_USER_COUNT,FPP);}// 可选:初始化时加载已有用户 ID(从 DB 批量加载)@PostConstructpublic void initBloomFilter(BloomFilter<Long> bloomFilter, UserRepository userRepository) {// 注意:生产环境应分页加载,避免 OOMList<Long> existingUserIds = userRepository.findAllUserIds();existingUserIds.forEach(bloomFilter::put);}
}
2.6.3. 用户服务:三层防御逻辑
@Service
public class UserService {// Redis缓存前缀private static final String USER_CACHE_PREFIX = "user:";// 缓存穿透时存入redis中的空值private static final String NULL_VALUE = "NULL";// 存NULL值的TTLprivate static final int NULL_CACHE_TTL_SECONDS = 120; // 2分钟// 假设最大用户ID(可从DB或缓存动态获取)private static final long MAX_USER_ID = 10_000_000L;@Autowiredprivate RedisTemplate<String, String> redisTemplate;@Autowiredprivate BloomFilter<Long> userBloomFilter;@Autowiredprivate UserRepository userRepository;public User getUserById(Long userId) {// ============ 第一层:参数校验 ============if (userId == null || userId <= 0 || userId > MAX_USER_ID + 10_000) {throw new IllegalArgumentException("Invalid user ID: " + userId);}// ============ 第二层:布隆过滤器 ============if (!userBloomFilter.mightContain(userId)) {// 100% 不存在,直接返回 null 或抛异常return null;}String cacheKey = USER_CACHE_PREFIX + userId;// ============ 查询缓存 ============String cached = redisTemplate.opsForValue().get(cacheKey);if (cached != null) {if (NULL_VALUE.equals(cached)) {return null; // 空值缓存命中}// 反序列化 User(简化为 JSON,实际可用 Jackson)return parseUser(cached);}// ============ 查询数据库 ============User user = userRepository.findById(userId);if (user != null) {// 写入正常缓存(比如 TTL 30 分钟)redisTemplate.opsForValue().set(cacheKey, serializeUser(user), Duration.ofMinutes(30));return user;} else {// ============ 第三层:缓存空值(短 TTL) ============redisTemplate.opsForValue().set(cacheKey,NULL_VALUE,Duration.ofSeconds(NULL_CACHE_TTL_SECONDS));return null;}}// 用户注册时调用:同步布隆过滤器 + 清理空缓存(如有)public void registerUser(User user) {userRepository.save(user);userBloomFilter.put(user.getId());// 清理可能存在的空缓存String cacheKey = USER_CACHE_PREFIX + user.getId();redisTemplate.delete(cacheKey);}// 简化序列化(实际建议用 Jackson)private String serializeUser(User user) {return user.getId() + "," + user.getName(); // 示例}private User parseUser(String data) {String[] parts = data.split(",");return new User(Long.parseLong(parts[0]), parts[1]);}
}
2.6.4. 注意事项:
此处的方案目前只适用于单体结构,我们如果是在分布式环境中,我们需要使用RedisBloom(Redis Module)实现分布式布隆过滤器,避免每个JVM实例重复加载
2.6.5. 位图是否可以替代呢?
特性 | 位图(Bitmap) | 布隆过滤器(Bloom Filter) |
数据结构 | 一个 bit 数组,下标 = ID | 多个 hash 函数 + bit 数组 |
支持操作 |
, |
, |
是否支持任意 key | ❌ 仅支持 非负整数 ID(且最好连续) | ✅ 支持任意类型 key(字符串、ID、UUID 等) |
内存占用 | 极低(1 亿 ID ≈ 12.5 MB) | 低(100 万 ID,1% 误判率 ≈ 1 MB) |
是否存在误判 | ❌ 无误判(精确判断) | ✅ 有误判(false positive),但无漏判 |
是否支持删除 | ✅ 可 | ❌ 标准 BloomFilter 不支持(除非用 Counting BloomFilter) |
ID 不连续的影响 | ⚠️ 内存浪费严重(如 ID=1 和 ID=10亿,需 10亿 bit) | ✅ 无影响 |
5.3 缓存雪崩
1. 定义?大量 key 同时过期 + 高并发查询 DB
1.1. 定义
缓存雪崩是指在某一个时刻,大量缓存key同时失效(过期),而此处又有高并发请求访问这些数据,导致所有请求瞬间穿透缓存,全部打到数据库上,造成数据库压力骤增,甚至宕机。
1.2. 典型场景:
假设你有一个电商系统,商品详情缓存 TTL(过期时间)统一设为 1 小时:
- 系统在 10:00:00 批量加载了 10 万个商品,缓存 key 都在 11:00:00 同时过期;
- 恰好 11:00:00 有大促活动,每秒 10 万请求 查询商品;
- 所有请求发现缓存失效 → 全部去查数据库;
- 数据库连接池耗尽、CPU 打满 → 服务不可用。
这就是典型的缓存雪崩。
注意:和“缓存穿透”不同,雪崩查询的是真实存在的数据,只是缓存集体失效了。
1.3. 危害:
- 数据库QPS瞬间飙升,可能直接被打挂
- 接口响应变慢或超时,引发雪崩式服务故障
- 用户体验极差,甚至导致资产损失
1.4. 与缓存击穿、缓存穿透的区别?
问题 | 原因 | 查询数据 | 特点 |
缓存穿透 | 查询不存在的数据 | DB 中无此数据 | 请求穿透缓存,反复查 DB |
缓存击穿 | 热点 key 过期瞬间,高并发打到 DB | DB 中有,但缓存刚失效 | 单个 key 引发 DB 压力 |
缓存雪崩 | 大量 key 同时过期 | DB 中有,但缓存集体失效 | 多个 key 同时失效,DB 被压垮 |
2. 生产级别解决方案
2.1.1. 设置随机过期时间(TTL)最常用
核心思想:避免所有key同时过期。
// 原本:统一 1 小时
// 改为:基础时间 + 随机偏移
int baseTTL = 3600; // 1小时
int randomOffset = new Random().nextInt(600); // 0~10分钟随机
redisTemplate.opsForValue().set(key, value, Duration.ofSeconds(baseTTL + randomOffset));
效果:key在1h~1h10m内陆续过期,避免集体失效
2.1.2. 永不过期+后台异步更新(适合核心数据)
- 缓存不设置TTL,永远有效
- 启动一个后台线程/定时任务,定期更新缓存(如每30分钟)
2.1.3. 高可用架构
- 使用Redis集群+哨兵/Cluster,避免单点故障
- 及时部分节点宕机,其它节点仍然可提供服务,降低雪崩概率
2.1.4. 服务降级&熔断限流
- 当检测到DB压力过大时:
-
- 限流:拒绝部分请求(如返回“稍后再试”)
- 降级:返回兜底数据(如默认商品信息)
- 熔断:但是精致访问DB,可等缓存恢复
2.1.5. 热点数据永不过期+互斥重建
- 对已知热点key(如首页banner、爆款商品),设置永不过期
- 若因异常失效,使用互斥锁(Redis分布式锁)保证只有一个线程去重建缓存
String lockKey = "lock:" + key;
if (redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(10))) {try {// 重建缓存User user = db.query(...);redisTemplate.opsForValue().set(key, user, Duration.ofHours(1));} finally {redisTemplate.delete(lockKey);}
}
2.1.6. 总结:缓存雪崩防御checklist
措施 | 是否推荐 | 说明 |
随机 TTL | ✅✅✅ | 简单有效,必做 |
异步更新缓存 | ✅✅ | 适合核心数据 |
Redis 高可用 | ✅✅ | 基础保障 |
限流降级 | ✅✅ | 保命兜底 |
热点 key 永不过期 | ✅ | 针对性优化 |
3. Java 实现:如何用 synchronized
或 ReentrantLock
实现缓存重建的互斥?
虽然synchronized
和ReentrantLock
是JVM本地锁(仅对单机有效),但在单体应用或单实例部署场景下,他们是简单有效的互斥手段
注意:分布式环境下必须使用分布式锁(如Redis分布式锁),本地锁无法跨JVM生效
场景说明:
- 缓存key:user:1001
- 缓存失效(首次访问)
- 多个线程并发调用getUser(1001)
- 目标:只允许一个线程查DB并写缓存,其他线程等待或快速失败
3.1. 方案1:使用synchronized
错误做法(锁整个方法):
public synchronized User getUser(Long id) { ... } // 锁粒度太大,所有用户串行!
正确做法:按key加锁
@Service
public class UserService {private final Map<String, Object> lockMap = new ConcurrentHashMap<>();public User getUser(Long userId) {String cacheKey = "user:" + userId;// 1. 先查缓存User user = cache.get(cacheKey);if (user != null) {return user;}// 2. 获取该 key 对应的锁对象(避免锁整个方法)Object lock = lockMap.computeIfAbsent(cacheKey, k -> new Object());synchronized (lock) {try {// 双重检查:可能其他线程已重建缓存user = cache.get(cacheKey);if (user != null) {return user;}// 3. 查数据库user = userRepository.findById(userId);if (user != null) {cache.put(cacheKey, user, Duration.ofMinutes(30));}return user;} finally {// 可选:清理锁对象(避免内存泄漏)lockMap.remove(cacheKey);}}}
}
3.2. 方案二:使用ReentrantLock
(更灵活)
@Service
public class UserService {private final Map<String, ReentrantLock> lockMap = new ConcurrentHashMap<>();public User getUser(Long userId) {String cacheKey = "user:" + userId;User user = cache.get(cacheKey);if (user != null) return user;ReentrantLock lock = lockMap.computeIfAbsent(cacheKey, k -> new ReentrantLock());try {lock.lock();// 双重检查user = cache.get(cacheKey);if (user != null) return user;// 查 DBuser = userRepository.findById(userId);if (user != null) {cache.put(cacheKey, user, Duration.ofMinutes(30));}return user;} finally {lock.unlock();// 可选:lockMap.remove(cacheKey); // 注意:可能被其他线程刚获取,谨慎清理}}
}
3.3. 生产环境最佳实践:
- 优先使用缓存随机缓存过期策略,从源头减少集体失效
- 热点key用不过期+后台刷新,避免频繁重建
- 单机使用本地锁,分布式使用Redis锁
- 永远需要双重检查,避免重复查DB
5.4 缓存击穿
1. 定义?热点 key 过期瞬间大量请求打到 DB
缓存击穿(Cache Breakdown)是指某个热点 key 在缓存中过期失效的瞬间,大量并发请求同时访问该 key,由于缓存未命中,这些请求直接穿透到数据库(DB),造成数据库瞬时压力剧增,严重时可能导致数据库崩溃。
典型场景举例:
- 电商平台秒杀活动中,某张热门优惠券的缓存刚好在高并发访问时过期;
- 热门商品详情页缓存失效,大量用户同时刷新页面。
关键特征:
- 针对的是确实存在但已过期的数据(与“缓存穿透”不同);
- 问题集中在单个热点 key 上(与“缓存雪崩”不同,后者是大量 key 同时失效);
- 发生在缓存失效的瞬间,具有突发性和高并发性。
2. 解决方案:永不过期?逻辑过期 + 后台异步刷新?
2.1. 永不过期(物理不过期)
- 原理:缓存中的热点key不设置过期时间(TTL = 0),避免因过期导致大量请求穿透到DB
- 优点:彻底规避缓存击穿问题
- 缺点:
-
- 数据可能长期不一致(DB更新,缓存还是旧值)
- 需要配合主动监听(如监听DB变更、定时任务等)来刷新缓存
- 不适合频繁变化的数据
注意:“永不过期”通常指物理上不设 TTL,但逻辑上仍需维护数据新鲜度。
2.2. 逻辑过期+后台异步刷新(推荐)
- 原理:
-
- 缓存中存储的 value 不仅包含数据,还包含一个逻辑过期时间(如
{"data": ..., "expire_time": 1700000000}
); - 请求读取缓存时,不依赖 Redis 自身的 TTL,而是检查逻辑过期时间;
- 如果已逻辑过期,则由一个线程(或协程)负责异步重建缓存,其他请求继续使用旧数据(容忍短暂不一致);
- 通常配合互斥锁(如 Redis 的 SETNX) 防止多个线程同时重建。
- 缓存中存储的 value 不仅包含数据,还包含一个逻辑过期时间(如
- 优点:
-
- 避免缓存击穿;
- 保证高可用,请求不会阻塞;
- 数据最终一致。
- 适用场景:对短暂数据不一致可容忍的热点数据(如商品信息、用户资料等)。
2.3. 互斥锁重建缓存
- 原理:
-
- 当缓存失效时,第一个请求获取分布式锁(如 Redis 的
SET key lock EX 5 NX
); - 成功获取锁的线程去 DB 查询并回填缓存;
- 其他请求等待或重试(或直接返回旧数据/默认值)。
- 当缓存失效时,第一个请求获取分布式锁(如 Redis 的
- 优点:简单直接。
- 缺点:
-
- 可能造成请求排队或延迟;
- 锁竞争在极高并发下仍可能成为瓶颈;
- 若重建过程慢,用户体验差。
🔸 适合对数据一致性要求高、并发不是极端高的场景。
2.4. 提前刷新(预热/定时任务)
- 对已知热点key,在其过期前主动刷新缓存
- 适用于可预测的热点数据(如每日排行榜、固定活动页)
2.5. 总结对比
方案 | 是否解决击穿 | 数据一致性 | 延迟 | 实现复杂度 | 适用场景 |
永不过期 | ✅ | 弱 | 低 | 低 | 静态/低频更新数据 |
逻辑过期 + 异步刷新 | ✅✅(推荐) | 最终一致 | 低 | 中 | 高并发热点数据 |
互斥锁重建 | ✅ | 强 | 高 | 中 | 一致性要求高的场景 |
提前刷新 | ✅ | 强 | 低 | 高 | 可预测热点 |
对于大多数高并发系统,采用“逻辑过期+后台异步刷新+互斥锁防并发重建”是最稳健的组合策略。