Redis深度探索

目录

[一、Redis 核心数据结构(结合 Java 使用场景)](#一、Redis 核心数据结构(结合 Java 使用场景))

[1.1 String](#1.1 String)

[1. 基础:底层实现(SDS)、最大容量?](#1. 基础:底层实现(SDS)、最大容量?)

[1.1. 为什么不使用C语言原生字符串?](#1.1. 为什么不使用C语言原生字符串?)

[1.2. SDS结构(以Redis 3.2+优化版本为例)](#1.2. SDS结构(以Redis 3.2+优化版本为例))

[1.3. SDS的优势](#1.3. SDS的优势)

[1.4. String的最大容量是多少?](#1.4. String的最大容量是多少?)

[1.5. 为什么限制512MB?](#1.5. 为什么限制512MB?)

[1.6. Java开发中实战注意事项:](#1.6. Java开发中实战注意事项:)

[1.7. 总结:](#1.7. 总结:)

[2. 场景:String一般用来做什么?比如 token、计数器?](#2. 场景:String一般用来做什么?比如 token、计数器?)

[2.1. 分布式会话/Token存储](#2.1. 分布式会话/Token存储)

[2.2. 计数器:](#2.2. 计数器:)

[2.3. 分布式锁(简易版):](#2.3. 分布式锁(简易版):)

[2.4. 缓存简单对象(序列化后)](#2.4. 缓存简单对象(序列化后))

[2.5. 为什么String不是万能的?](#2.5. 为什么String不是万能的?)

[3. 扩展:INCR 实现分布式 ID?原子性如何保证?和 Java 的 AtomicInteger 有何异同?](#3. 扩展:INCR 实现分布式 ID?原子性如何保证?和 Java 的 AtomicInteger 有何异同?)

[3.1. 基本思路:](#3.1. 基本思路:)

[3.2. 原子性如何保证:](#3.2. 原子性如何保证:)

[3.3. 与Java的AtomicInteger 对比](#3.3. 与Java的AtomicInteger 对比)

[1.2 Hash](#1.2 Hash)

[1. 底层结构:ziplist vs hashtable?什么时候转换?](#1. 底层结构:ziplist vs hashtable?什么时候转换?)

[1.1. Hash的两种底层编码](#1.1. Hash的两种底层编码)

[2. ziplist(压缩列表)详解](#2. ziplist(压缩列表)详解)

[2.1. 设计目标:](#2.1. 设计目标:)

[2.2. 内存结构(简化):](#2.2. 内存结构(简化):)

[2.3. 特点:](#2.3. 特点:)

[3. hashtable(哈希表)详解](#3. hashtable(哈希表)详解)

[3.1. 底层结构:](#3.1. 底层结构:)

[3.2. 内存结构:](#3.2. 内存结构:)

[3.3. 特点:](#3.3. 特点:)

[3.4. 什么时候从ziplist转换为hashtable?](#3.4. 什么时候从ziplist转换为hashtable?)

[4. Redis7.0+的变化:ziplist->listpack](#4. Redis7.0+的变化:ziplist->listpack)

[5. 性能与内存权衡总结:](#5. 性能与内存权衡总结:)

[6. Java开发实战推荐:](#6. Java开发实战推荐:)

[7. 底层数据类型总结:](#7. 底层数据类型总结:)

[8. Java 对应:为什么不用多个 String 而用 Hash?节省内存的原理?](#8. Java 对应:为什么不用多个 String 而用 Hash?节省内存的原理?)

[8.1. 场景对比:String vs Hash](#8.1. 场景对比:String vs Hash)

[8.1.1. 方案1:多个String (key-value)拆分](#8.1.1. 方案1:多个String (key-value)拆分)

[8.1.2. 方案2: 用一个Hash](#8.1.2. 方案2: 用一个Hash)

[8.2. 为什么Hash更节省内存?--核心原理](#8.2. 为什么Hash更节省内存?--核心原理)

[8.3. 其他优势(不止内存):](#8.3. 其他优势(不止内存):)

[8.3.1. 网络开销更小](#8.3.1. 网络开销更小)

[8.3.2. 原子性操作](#8.3.2. 原子性操作)

[8.3.3. 管理更简单](#8.3.3. 管理更简单)

[8.3.4. 底层编码优化:](#8.3.4. 底层编码优化:)

[8.4. Java代码对比](#8.4. Java代码对比)

[8.4.1. 多个String](#8.4.1. 多个String)

[8.4.2. Hash](#8.4.2. Hash)

[8.5. 什么情况下应该使用多个String?](#8.5. 什么情况下应该使用多个String?)

[9. 场景:用户信息缓存用 Hash 合适吗?如果字段非常多(比如上百个)还合适吗?](#9. 场景:用户信息缓存用 Hash 合适吗?如果字段非常多(比如上百个)还合适吗?)

[1.3 List](#1.3 List)

[1. 底层:quicklist 是什么?为什么不用单纯的 linkedlist 或 ziplist?](#1. 底层:quicklist 是什么?为什么不用单纯的 linkedlist 或 ziplist?)

[1.1. quicklist是什么?](#1.1. quicklist是什么?)

[1.1.1. 为什么不使用单纯的linkedlist?](#1.1.1. 为什么不使用单纯的linkedlist?)

[1.1.2. 为什么不使用单纯的ziplist?](#1.1.2. 为什么不使用单纯的ziplist?)

[1.1.3. quicklist如何权衡二者?](#1.1.3. quicklist如何权衡二者?)

[1.1.4. Redis 7.0+:ziplist → listpack](#1.1.4. Redis 7.0+:ziplist → listpack)

[2. 场景:消息队列用 List 实现?有什么问题?(无 ACK、无持久化保障等)](#2. 场景:消息队列用 List 实现?有什么问题?(无 ACK、无持久化保障等))

[2.1. 缺乏确认机制(ACK)](#2.1. 缺乏确认机制(ACK))

[2.2. 持久化保障不足](#2.2. 持久化保障不足)

[2.3. 队列容量限制](#2.3. 队列容量限制)

[2.4. 不适合复杂的消息路由与过滤](#2.4. 不适合复杂的消息路由与过滤)

[2.5. 扩展性和高可用性挑战](#2.5. 扩展性和高可用性挑战)

[1.4 Set](#1.4 Set)

[1. 底层:intset vs hashtable?](#1. 底层:intset vs hashtable?)

[1.1. 原理:](#1.1. 原理:)

[2. 场景:标签系统、共同好友?](#2. 场景:标签系统、共同好友?)

[2.1. 标签系统:](#2.1. 标签系统:)

[2.2. 共同好友:](#2.2. 共同好友:)

[1.5 ZSet(Sorted Set)](#1.5 ZSet(Sorted Set))

[1. 底层:跳表 + 哈希表?](#1. 底层:跳表 + 哈希表?)

[1.1. 什么是跳表?](#1.1. 什么是跳表?)

[1.2. 跳表的结构:](#1.2. 跳表的结构:)

[1.3. 查找过程示例:](#1.3. 查找过程示例:)

[1.4. 插入操作(以插入5为例)](#1.4. 插入操作(以插入5为例))

[1.5. 为什么跳表适合Redis?](#1.5. 为什么跳表适合Redis?)

[1.6. 为什么要有哈希表?](#1.6. 为什么要有哈希表?)

[1.7. 跳表和哈希表如何配合?](#1.7. 跳表和哈希表如何配合?)

[二、持久化机制(RDB / AOF)](#二、持久化机制(RDB / AOF))

[1. RDB 和 AOF 的区别?各自优缺点?](#1. RDB 和 AOF 的区别?各自优缺点?)

[1.1. RDB](#1.1. RDB)

[1.1.1. 工作原理:](#1.1.1. 工作原理:)

[1.1.2. 优点:](#1.1.2. 优点:)

[1.1.3. 缺点:](#1.1.3. 缺点:)

[1.2. AOF](#1.2. AOF)

[1.2.1. 工作原理:](#1.2.1. 工作原理:)

[1.2.2. 优点:](#1.2.2. 优点:)

[1.2.3. 缺点:](#1.2.3. 缺点:)

[1.3. 对比总结:](#1.3. 对比总结:)

[1.4. 生产环境最佳实践](#1.4. 生产环境最佳实践)

[2. 混合持久化(Redis 4.0+)是什么?开启后文件结构?](#2. 混合持久化(Redis 4.0+)是什么?开启后文件结构?)

[2.1. 什么是混合持久化?](#2.1. 什么是混合持久化?)

[2.2. 如何开启:](#2.2. 如何开启:)

[2.3. 开启后的AOF文件结构:](#2.3. 开启后的AOF文件结构:)

[2.4. 混合持久化的优势:](#2.4. 混合持久化的优势:)

[2.5. 注意事项:](#2.5. 注意事项:)

[2.6. 验证是否启用成功:](#2.6. 验证是否启用成功:)

[2.7. 总结:](#2.7. 总结:)

[3. 如果 Redis 宕机,如何最大限度减少数据丢失?(结合 AOF fsync 策略)](#3. 如果 Redis 宕机,如何最大限度减少数据丢失?(结合 AOF fsync 策略))

[3.1. 启用AOF持久化(基础前提)](#3.1. 启用AOF持久化(基础前提))

[3.2. 选择安全的appendfsync策略](#3.2. 选择安全的appendfsync策略)

[3.3. 启用混合持久化](#3.3. 启用混合持久化)

[3.4. 配合AOF重写](#3.4. 配合AOF重写)

[3.5. 架构层面:主从+哨兵/Redis Cluster](#3.5. 架构层面:主从+哨兵/Redis Cluster)

[3.6. 总结:](#3.6. 总结:)

[三、IO 模型与单线程事件循环](#三、IO 模型与单线程事件循环)

[1. Redis 为什么是单线程?单线程如何处理高并发?](#1. Redis 为什么是单线程?单线程如何处理高并发?)

[1.1. 为什么Redis采用单线程模型?](#1.1. 为什么Redis采用单线程模型?)

[1.2. 单线程为什么支撑高并发?](#1.2. 单线程为什么支撑高并发?)

[1.2.1. 基于I/O多路复用的(epoll/kqueue)的时间驱动模型](#1.2.1. 基于I/O多路复用的(epoll/kqueue)的时间驱动模型)

[1.2.2. 非阻塞I/O](#1.2.2. 非阻塞I/O)

[1.2.3. 高效的内存数据结构](#1.2.3. 高效的内存数据结构)

[1.2.4. 纯内存操作](#1.2.4. 纯内存操作)

[1.2.5. Pipeline和批量操作](#1.2.5. Pipeline和批量操作)

四、主从复制与哨兵机制

[1. 主从复制流程(全量 + 增量)?](#1. 主从复制流程(全量 + 增量)?)

[1.1. 全量复制:](#1.1. 全量复制:)

[1.2. 增量复制:](#1.2. 增量复制:)

[2. 哨兵机制:如何选主?quorum 和 majority 的区别?](#2. 哨兵机制:如何选主?quorum 和 majority 的区别?)

[2.1. 哨兵如何选主?](#2.1. 哨兵如何选主?)

[2.1.1. 筛选候选从节点](#2.1.1. 筛选候选从节点)

[2.1.2. 排序候选从节点(按优先级打分)](#2.1.2. 排序候选从节点(按优先级打分))

[2.1.3. 执行故障转移](#2.1.3. 执行故障转移)

[2.2. quorum 和 majority 的区别](#2.2. quorum 和 majority 的区别)

[2.2.1. 详细解释:](#2.2.1. 详细解释:)

[2.2.2. 举例说明:](#2.2.2. 举例说明:)

[3. 脑裂问题:什么情况下会发生?如何通过配置 min-replicas-to-write 避免?](#3. 脑裂问题:什么情况下会发生?如何通过配置 min-replicas-to-write 避免?)

[3.1. 什么情况下会发生脑裂?](#3.1. 什么情况下会发生脑裂?)

[3.1.1. 网络分区:](#3.1.1. 网络分区:)

[3.1.2. 脑裂发生过程:](#3.1.2. 脑裂发生过程:)

[3.2. 如何避免脑裂?](#3.2. 如何避免脑裂?)

[3.2.1. 配置示例:](#3.2.1. 配置示例:)

[3.2.2. 作用机制:](#3.2.2. 作用机制:)

五、高并发缓存问题

[5.1 缓存一致性](#5.1 缓存一致性)

[1. 先更新 DB 还是先删缓存?为什么?](#1. 先更新 DB 还是先删缓存?为什么?)

[1.1. 两种方案对比:](#1.1. 两种方案对比:)

[1.1.1. 方案A:先删除缓存,再更新数据库(风险高)](#1.1.1. 方案A:先删除缓存,再更新数据库(风险高))

[1.1.2. 方案B:先更新数据库,再删除缓存](#1.1.2. 方案B:先更新数据库,再删除缓存)

[1.2. 为什么"先更新DB再删缓存"更安全?](#1.2. 为什么“先更新DB再删缓存”更安全?)

[1.2.1. 失败影响可控](#1.2.1. 失败影响可控)

[1.2.2. 符合"写后失效"原则](#1.2.2. 符合“写后失效”原则)

[1.2.3. 与旁路缓存模式天然契合](#1.2.3. 与旁路缓存模式天然契合)

[1.3. 极端情况:删除缓存失败怎么办?](#1.3. 极端情况:删除缓存失败怎么办?)

[1.3.1. 解决方案:](#1.3.1. 解决方案:)

[1.3.2. 生产环境最佳实践:](#1.3.2. 生产环境最佳实践:)

[2. Cache-Aside Pattern 的标准流程?有没有更好的方案(如双删、延迟双删)?](#2. Cache-Aside Pattern 的标准流程?有没有更好的方案(如双删、延迟双删)?)

[2.1. 标准流程:](#2.1. 标准流程:)

[2.1.1. 读操作:](#2.1.1. 读操作:)

[2.1.2. 写操作:](#2.1.2. 写操作:)

[2.2. 为什么"删除缓存"而不是"更新缓存"?](#2.2. 为什么“删除缓存”而不是“更新缓存”?)

[2.3. "双删"和"延迟双删"是什么?有必要吗?](#2.3. “双删”和“延迟双删”是什么?有必要吗?)

[2.3.1. 方案1:双删](#2.3.1. 方案1:双删)

[2.3.2. 延迟双删](#2.3.2. 延迟双删)

[2.3.3. 更好的解决方案(比双删更可靠)](#2.3.3. 更好的解决方案(比双删更可靠))

[2.3.4. 如何抉择?](#2.3.4. 如何抉择?)

[5.2 缓存穿透](#5.2 缓存穿透)

[1. 定义?举例(查一个不存在的 user_id)](#1. 定义?举例(查一个不存在的 user_id))

[1.1. 定义:](#1.1. 定义:)

[1.2. 举例:](#1.2. 举例:)

[1.3. 常见解决方案;](#1.3. 常见解决方案;)

[2. 生产环境最佳实践](#2. 生产环境最佳实践)

[2.1. 第一层:参数校验](#2.1. 第一层:参数校验)

[2.2. 布隆过滤器](#2.2. 布隆过滤器)

[2.3. 空值缓存](#2.3. 空值缓存)

[2.4. 完整请求处理流程图](#2.4. 完整请求处理流程图)

[2.5. 为什么这个方案行?](#2.5. 为什么这个方案行?)

[2.6. Java项目中生产实例:](#2.6. Java项目中生产实例:)

[2.6.1. 引入依赖:](#2.6.1. 引入依赖:)

[2.6.2. 配置类:布隆过滤器+Redis](#2.6.2. 配置类:布隆过滤器+Redis)

[2.6.3. 用户服务:三层防御逻辑](#2.6.3. 用户服务:三层防御逻辑)

[2.6.4. 注意事项:](#2.6.4. 注意事项:)

[2.6.5. 位图是否可以替代呢?](#2.6.5. 位图是否可以替代呢?)

[5.3 缓存雪崩](#5.3 缓存雪崩)

[1. 定义?大量 key 同时过期 + 高并发查询 DB](#1. 定义?大量 key 同时过期 + 高并发查询 DB)

[1.1. 定义](#1.1. 定义)

[1.2. 典型场景:](#1.2. 典型场景:)

[1.3. 危害:](#1.3. 危害:)

[1.4. 与缓存击穿、缓存穿透的区别?](#1.4. 与缓存击穿、缓存穿透的区别?)

[2. 生产级别解决方案](#2. 生产级别解决方案)

[2.1.1. 设置随机过期时间(TTL)最常用](#2.1.1. 设置随机过期时间(TTL)最常用)

[2.1.2. 永不过期+后台异步更新(适合核心数据)](#2.1.2. 永不过期+后台异步更新(适合核心数据))

[2.1.3. 高可用架构](#2.1.3. 高可用架构)

[2.1.4. 服务降级&熔断限流](#2.1.4. 服务降级&熔断限流)

[2.1.5. 热点数据永不过期+互斥重建](#2.1.5. 热点数据永不过期+互斥重建)

[2.1.6. 总结:缓存雪崩防御checklist](#2.1.6. 总结:缓存雪崩防御checklist)

[3. Java 实现:如何用 synchronized 或 ReentrantLock 实现缓存重建的互斥?](#3. Java 实现:如何用 synchronized 或 ReentrantLock 实现缓存重建的互斥?)

[3.1. 方案1:使用synchronized](#3.1. 方案1:使用synchronized)

[3.2. 方案二:使用ReentrantLock(更灵活)](#3.2. 方案二:使用ReentrantLock(更灵活))

[3.3. 生产环境最佳实践:](#3.3. 生产环境最佳实践:)

[5.4 缓存击穿](#5.4 缓存击穿)

[1. 定义?热点 key 过期瞬间大量请求打到 DB](#1. 定义?热点 key 过期瞬间大量请求打到 DB)

[2. 解决方案:永不过期?逻辑过期 + 后台异步刷新?](#2. 解决方案:永不过期?逻辑过期 + 后台异步刷新?)

[2.1. 永不过期(物理不过期)](#2.1. 永不过期(物理不过期))

[2.2. 逻辑过期+后台异步刷新(推荐)](#2.2. 逻辑过期+后台异步刷新(推荐))

[2.3. 互斥锁重建缓存](#2.3. 互斥锁重建缓存)

[2.4. 提前刷新(预热/定时任务)](#2.4. 提前刷新(预热/定时任务))

[2.5. 总结对比](#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 做了多种优化,根据字符串长度使用不同结构体:

cpp 复制代码
// 对于长度 < 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示例:
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示例(限流):
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示例:
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
java 复制代码
// 写入
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
java 复制代码
// 写入
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)**的混合体。

结构定义(简化):

cs 复制代码
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 或 listpack
    size_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. 读操作:
java 复制代码
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. 写操作:
java 复制代码
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:双删
java 复制代码
void update(String key, String newValue) {
    cache.delete(key);          // 第一次删
    db.update(key, newValue);   // 更新 DB
    cache.delete(key);          // 第二次删
}

问题:

  • 第一次删是多余的:如果并发读发生在第一次删之后、DB更新之前,仍然会加载旧值到缓存
  • 第二次删无法解决"读在删前"的问题
  • 增加无谓的开销
  • 结论:不推荐双删!!
2.3.2. 延迟双删
java 复制代码
void update(String key, String newValue) {
    cache.delete(key);               // 第一次删
    db.update(key, newValue);        // 更新 DB
    Thread.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. 完整请求处理流程图
java 复制代码
                +------------------+
                |  请求 user_id=x  |
                +------------------+
                          ↓
               +---------------------+
               |   参数校验          |
               | (格式、范围、合法性)|
               +---------------------+
                          ↓ 合法?
                         否 → 返回错误
                          ↓ 是
               +---------------------+
               |   布隆过滤器        |
               |  "一定不存在"?      |
               +---------------------+
                          ↓ 是
                    返回 "用户不存在"
                          ↓ 否(可能存在)
               +---------------------+
               |     查询缓存        |
               +---------------------+
                          ↓ 命中?
                         是 → 返回结果
                          ↓ 否
               +---------------------+
               |     查询数据库      |
               +---------------------+
                          ↓ 存在?
                         是 → 写入缓存,返回
                          ↓ 否
               +---------------------+
               |  缓存空值(短TTL)  |
               +---------------------+
                          ↓
                    返回 "用户不存在"
2.5. 为什么这个方案行?

|--------|------------|-------------|----------------|--------------|
| 层级 | 防御目标 | 性能开销 | 安全性 | 适用场景 |
| 参数校验 | 拦截明显非法请求 | 极低 | 高 | 所有系统必备 |
| 布隆过滤器 | 拦截海量伪造合法ID | 低(内存小、O(1)) | 极高(100% 拦截不存在) | ID 集合可预知/可同步 |
| 空值缓存 | 防同一ID反复穿透 | 中(少量内存) | 中(需控TTL) | 兜底,防突发重复请求 |

2.6. Java项目中生产实例:
2.6.1. 引入依赖:
XML 复制代码
<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
java 复制代码
@Configuration
public class CacheConfig {
    // 预估用户总量(可动态调整),如果用户量远超此值:误判率会上升,可定期重建或使用动态布隆过滤器
    private static final long EXPECTED_USER_COUNT = 1_000_000L;
    // 误判率:1% -- 可以手动配置,结合业务需求,一般来说这个值相对比较合适,但是想要降低误判率,就需要扩大内存占用
    private static final double FPP = 0.01;

    @Bean
    public BloomFilter<Long> userBloomFilter() {
        return BloomFilter.create(
            Funnels.longFunnel(),
            EXPECTED_USER_COUNT,
            FPP
        );
    }

    // 可选:初始化时加载已有用户 ID(从 DB 批量加载)
    @PostConstruct
    public void initBloomFilter(BloomFilter<Long> bloomFilter, UserRepository userRepository) {
        // 注意:生产环境应分页加载,避免 OOM
        List<Long> existingUserIds = userRepository.findAllUserIds();
        existingUserIds.forEach(bloomFilter::put);
    }
}
2.6.3. 用户服务:三层防御逻辑
java 复制代码
@Service
public class UserService {

    // Redis缓存前缀
    private static final String USER_CACHE_PREFIX = "user:";
    // 缓存穿透时存入redis中的空值
    private static final String NULL_VALUE = "NULL";
    // 存NULL值的TTL
    private static final int NULL_CACHE_TTL_SECONDS = 120; // 2分钟

    // 假设最大用户ID(可从DB或缓存动态获取)
    private static final long MAX_USER_ID = 10_000_000L;

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @Autowired
    private BloomFilter<Long> userBloomFilter;

    @Autowired
    private 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同时过期。

java 复制代码
// 原本:统一 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分布式锁)保证只有一个线程去重建缓存
java 复制代码
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

错误做法(锁整个方法):

java 复制代码
public synchronized User getUser(Long id) { ... } // 锁粒度太大,所有用户串行!

正确做法:按key加锁

java 复制代码
@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(更灵活)
java 复制代码
@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;

            // 查 DB
            user = 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. 总结对比

|-------------|------------|-----------|--------|-----------|-----------|
| 方案 | 是否解决击穿 | 数据一致性 | 延迟 | 实现复杂度 | 适用场景 |
| 永不过期 | ✅ | 弱 | 低 | 低 | 静态/低频更新数据 |
| 逻辑过期 + 异步刷新 | ✅✅(推荐) | 最终一致 | 低 | 中 | 高并发热点数据 |
| 互斥锁重建 | ✅ | 强 | 高 | 中 | 一致性要求高的场景 |
| 提前刷新 | ✅ | 强 | 低 | 高 | 可预测热点 |

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

相关推荐
努力也学不会java4 小时前
【Spring】Spring事务和事务传播机制
java·开发语言·人工智能·spring boot·后端·spring
hookserver5 小时前
企业微信聚合应用系统,ipad协议接口
java·http·微信·企业微信·ipad
小姐姐味道5 小时前
Claude Skills:被过度吹嘘的的概念翻新!
后端·github·claude
新青年5795 小时前
Go语言项目打包上线流程
开发语言·后端·golang
学习编程的Kitty5 小时前
JavaEE初阶——多线程(2)线程的使用
java·开发语言·java-ee
counting money5 小时前
JAVAEE阶段学习指南
java·开发语言
陈随易5 小时前
PaddleOCR-VL可太强了,图片识别转文字的巅峰之作
前端·后端·程序员
Ray665 小时前
Delete vs Truncate vs Drop
后端