当前位置: 首页 > news >正文

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字段直接记录,无需遍历

杜绝缓冲区溢出

修改前会检查 alloc,自动扩容

二进制安全

可以存储任意字节(包括 \0),因为长度由 len决定,不依赖 \0

空间预分配 & 惰性释放

• 扩容时:若 len < 1MB,分配 2×len;否则+1MB
• 缩容时:不立即释放内存,而是标记为 free(可通过 MEMORY PURGE主动释放)

举例:执行 SET name "Alice",Redis 会创建一个 SDS,len=5alloc≥5buf = "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. 基本思路:
  1. 核心思想:利用Redis的 INCR key原子命令的原子性,让多个服务实例并发请求时,都能获取到全剧唯一、单调递增的ID
  2. 实现步骤:
    1. 预先设置一个 key(如 global:id:user),初始值可为 0 或某个起始值;
    2. 每次需要生成 ID 时,调用 INCR global:id:user
    3. Redis 返回递增后的值,即为新 ID。
  1. 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. 读取当前值;
    2. +1;
    3. 写回新值;
    4. 返回结果;
  • 整个过程不可中断,因此天然线程安全 & 分布式安全

所以,不需要额外加锁INCR 本身就能保证分布式环境下的 ID 唯一性和递增性。

3.3. 与Java的AtomicInteger 对比

维度

Redis INCR

Java AtomicInteger

作用范围

分布式(跨 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 字段标识):

编码类型

适用场景

底层结构

ziplist(redis7.0以前)

小 Hash(字段少、值小)

压缩列表(连续内存)

hashtable

大 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 的最大字节数

转换条件(任一满足即转换):

  1. Hash 中的 field-value 对数量 > hash-max-ziplist-entries(默认 512);
  2. 任意一个 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 名)+ 元数据,包括:

开销项

说明

redisObject

16 字节(包含 type、encoding、refcount、lru等)

key 的 SDS 字符串

"user:1001:name",长度 + 结构体开销

哈希表 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) vs DEL 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

字段访问模式差异极大

name高频访问,bio极少访问 → 拆分

需要对单个字段加锁

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 内存溢出。
  • 阻塞操作:虽然 BLPOPBRPOP 提供了阻塞式的读取方式来等待新消息的到来,但如果生产者速率远高于消费者的处理能力,则可能造成消息堆积,影响系统性能。
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 命令

说明

添加好友(双向)

SADD user:friends:1001 2002
SADD user:friends:2002 1001

好友关系需双向维护

查询共同好友

SINTER user:friends:1001 user:friends:1002

返回两个 Set 的交集

判断是否为好友

SISMEMBER user:friends:1001 2002

O(1) 高效判断

获取所有好友

SMEMBERS user:friends:1001

注意:大数据量慎用,可改用 SSCAN分页

注意:踩坑点:

  • 混合类型:不要把整数和字符串混在一个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. 查找过程示例:

最高层最左(-∞)开始:

  1. Level 2: -∞ → 12,但 12 > 6,不能跳,下到 Level 1
  2. Level 1: -∞ → 4,4 < 6,继续;4 → 8,8 > 6,下到 Level 0
  3. Level 0: 4 → 6,找到!

总共比较了:12(×)、4(√)、8(×)、6(√) → 仅 4 次,而普通链表要 4 次(1→3→4→6),但数据量越大优势越明显。

平均查找复杂度:O(log n)

1.4. 插入操作(以插入5为例)
  1. 查找插入位置(类似查找过程,记录每层“最后一个小于5的节点”)。
  2. 随机决定层数(比如抛硬币,直到出现反面,正面次数 = 新层数)。
    • 假设 5 随机到 Level 1
  1. 在 Level 0 和 Level 1 插入节点,并更新前后指针。
1.5. 为什么跳表适合Redis?

特性

说明

✅ 支持有序

天然按 score 排序

✅ 范围查询快

从起点线性遍历底层链表即可(如 ZRANGE

✅ 插入/删除 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)自动触发,也可手动执行 BGSAVESAVE 命令。
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,几乎不丢失数据(取决于 appendfsync策略)

文件更小

相比纯 AOF,避免了大量冗余命令(如多次 SET 同一个 key)

兼容性好

对客户端透明,无需修改应用代码

2.5. 注意事项:
  1. 仅在 AOF 重写时生效
    混合格式只在执行 BGREWRITEAOF(或自动触发重写)时生成。初始 AOF 文件仍是纯文本。
  2. 旧版本 Redis 无法识别
    若用 Redis < 4.0 打开混合 AOF 文件,会报错(因无法解析开头的 RDB 数据)。
  3. 文件仍叫 appendonly.aof
    虽然包含 RDB 内容,但文件名不变,管理方式仍按 AOF 处理。
  4. 推荐生产环境开启
    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 控制,有三种选项:

配置项

含义

数据丢失风险

性能影响

适用场景

always

每次写操作都同步刷盘

几乎为 0(除非磁盘故障)

⚠️ 极高(每秒几百~几千 QPS)

对数据一致性要求极高的金融、支付系统

everysec

(默认)

每秒同步一次

最多丢失 1 秒数据

✅ 平衡(推荐)

绝大多数生产环境

no

由操作系统决定何时刷盘

可能丢失数秒甚至更多

⚡ 最高

不关心数据丢失的缓存场景

最大限度减少丢失 → 使用 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-writemin-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. quorummajority 的区别

这是哨兵机制中最容易混淆的两个概念,他们作用在不同阶段

概念

作用阶段

含义

是否需要过半?

quorum

主观下线 → 客观下线(ODOWN)判断

至少需要多少个哨兵同意主节点“已下线”,才能判定为客观下线

❌ 不需要过半,只需 ≥ quorum

majority

(多数派)

故障转移执行阶段

执行 failover 前,需要获得哨兵集群多数派(> N/2)的授权

✅ 必须过半

2.2.1. 详细解释:
  1. quorum(配置项):
    1. 在sentinel.conf中设置,例如:
sentinel monitor mymaster 127.0.0.1 6379 2

这里的 2 就是 quorum 值。

  • 作用:当有>=quorum个哨兵认为主节点“客观下线”,就将其标记为“客观下线”
  • 注意:quorum 不一定是多数!例如 5 个哨兵,quorum=2 是合法的。

quorum控制的是"是否启动故障转移的门槛"

  1. 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+推荐使用新命名):

旧配置(已弃用)

新配置(推荐)

作用

min-slaves-to-write

min-replicas-to-write

主节点至少要有 N 个从节点在线,才允许写入

min-slaves-max-lag

min-replicas-max-lag

从节点的最大允许延迟(秒)

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. 生产环境最佳实践:
  1. 更新DB
  2. 删除缓存
    1. 删除失败:重试机制+TTL兜底
  1. 读操作
    1. 先读缓存:未读到,读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 再删缓存”仍有短暂不一致

比如:

  1. 线程 A 更新 DB
  2. 线程 B 读缓存(旧值)→ 未命中 → 读 DB(新值)→ 回填缓存
  3. 如果在 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. 更好的解决方案(比双删更可靠)
  1. 异步监听Binlog(推荐)
    1. 使用Canal/Debezium监听MySQL binglog
    2. DB变更后,由中间件异步删除缓存
    3. 业务代码无入侵,删除更可靠
APP -> 更新DB -> MySQL Binlog -> Canal -> 删除Redis

适合中大型系统,最终一致,但架构复杂度比较高

  1. 写操作后强制读取
    • 写完DB,立即加载到缓存中可查看
  1. 缓存加版本号/时间戳
    • 缓存值中包含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 数组

支持操作

SETBIT(id, 1)

, GETBIT(id)

ADD(key)

, MIGHT_CONTAIN(key)

是否支持任意 key

❌ 仅支持 非负整数 ID(且最好连续)

✅ 支持任意类型 key(字符串、ID、UUID 等)

内存占用

极低(1 亿 ID ≈ 12.5 MB)

低(100 万 ID,1% 误判率 ≈ 1 MB)

是否存在误判

无误判(精确判断)

有误判(false positive),但无漏判

是否支持删除

✅ 可 SETBIT(id, 0)

❌ 标准 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 实现:如何用 synchronizedReentrantLock 实现缓存重建的互斥?

虽然synchronizedReentrantLock是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) 防止多个线程同时重建。
  • 优点
    • 避免缓存击穿;
    • 保证高可用,请求不会阻塞;
    • 数据最终一致。
  • 适用场景:对短暂数据不一致可容忍的热点数据(如商品信息、用户资料等)。
2.3. 互斥锁重建缓存
  • 原理
    • 当缓存失效时,第一个请求获取分布式锁(如 Redis 的 SET key lock EX 5 NX);
    • 成功获取锁的线程去 DB 查询并回填缓存;
    • 其他请求等待或重试(或直接返回旧数据/默认值)。
  • 优点:简单直接。
  • 缺点
    • 可能造成请求排队或延迟
    • 锁竞争在极高并发下仍可能成为瓶颈;
    • 若重建过程慢,用户体验差。

🔸 适合对数据一致性要求高、并发不是极端高的场景。

2.4. 提前刷新(预热/定时任务)
  • 对已知热点key,在其过期前主动刷新缓存
  • 适用于可预测的热点数据(如每日排行榜、固定活动页)
2.5. 总结对比

方案

是否解决击穿

数据一致性

延迟

实现复杂度

适用场景

永不过期

静态/低频更新数据

逻辑过期 + 异步刷新

✅✅(推荐)

最终一致

高并发热点数据

互斥锁重建

一致性要求高的场景

提前刷新

可预测热点

对于大多数高并发系统,采用“逻辑过期+后台异步刷新+互斥锁防并发重建”是最稳健的组合策略。

http://www.dtcms.com/a/516061.html

相关文章:

  • 做金融的看哪些网站店铺设计分析
  • 【机器学习07】 激活函数精讲、Softmax多分类与优化器进阶
  • 香水推广软文seo入门教学
  • AI一周事件(2025年10月15日-10月21日)
  • 从零搭建 RAG 智能问答系统 5:多模态文件解析与前端交互实战
  • H618-实现基于RTMP推流的视频监控
  • vue 项目中 components、views、layout 各个目录规划,组件、页面、布局如何实现合理搭配,实现嵌套及跳转合理,使用完整说明
  • 网站建设彩铃短信营销
  • 公司网站建设管理办法汉中网络推广
  • 深度学习(14)-Pytorch torch 手册
  • 喜讯|中国质量认证中心(CQC)通过个人信息保护合规审计服务认证
  • iOS原生与Flutter的交互编程
  • 【研究生随笔】Pytorch中的线性回归
  • OCR 识别:电子保单的数字化助力
  • 好看的网站哪里找网站免费软件
  • Jmeter接口常用组织形式及PICT使用指南
  • iOS 混淆实战,多工具组合完成 IPA 混淆、加固与发布治理(iOS混淆|IPA加固|无源码混淆|App 防反编译)
  • 飞牛fnNAS搭建Web网页版OFFICE(WPS)软件
  • Mysql杂志(三十四)——MVCC、日志分类
  • Qwen3ForCausalLM 源码解析
  • 用多工具组合把 iOS 混淆做成可复用的工程能力(iOS混淆 IPA加固 无源码混淆 Ipa Guard)
  • 扎根乡土,科技赋能:中和农信的综合助农之路
  • SignalR 协议深度分析
  • 在 Linux 系统上安装 Miniconda、安装 Xinference,并设置 Xinference 开机自启动
  • 第一篇:把任意 HTTP API 一键变成 Agent 工具
  • 使用PCIE B210烧写SIM卡
  • 大模型太贵太慢?豆包1.6想打破这个“行业幻觉”
  • 卖酒网站排名阳江 网站建设
  • 唐宇迪2025最新机器学习课件——学习心得(1)
  • python基于卷积神经网络的桥梁裂缝检测系统(django),附可视化界面,源码