vault backup: 2026-08-08 19:01:04
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
---
|
||||
tags: [redis, rdb-aof, persistence, fork, snapshot]
|
||||
create time: 2026-08-08 18:43
|
||||
update time: 2026-08-08 18:43
|
||||
---
|
||||
|
||||
# RDB 与 AOF 持久化
|
||||
|
||||
## 概述
|
||||
|
||||
Redis 作为内存数据库,崩溃重启后需要可靠的数据恢复机制。RDB(快照)和 AOF(追加日志)是 Redis 提供的两种持久化方案,各有优劣。Redis 4.0 之后又引入了混合持久化(Hybrid Persistence),结合了两者之长。理解它们的机制和取舍,是在生产环境选型的基础。
|
||||
|
||||
## 核心原理
|
||||
|
||||
### RDB — 快照机制
|
||||
|
||||
RDB(Redis Database)在指定时间间隔内将内存中的数据集写入磁盘,生成一个压缩的二进制文件(dump.rdb)。
|
||||
|
||||
**fork 子进程快照流程:**
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client as 客户端
|
||||
participant Master as Redis主进程
|
||||
participant Child as fork子进程
|
||||
participant Disk as 磁盘
|
||||
|
||||
Client->>Master: SAVE / BGSAVE
|
||||
Master->>Master: 执行 bgsave
|
||||
Master->>Master: fork 创建子进程
|
||||
Note over Master: 父进程继续处理请求
|
||||
Master->>Child: 子进程独占写内存
|
||||
Child->>Child: 遍历内存数据结构
|
||||
Child->>Child: 写入临时 RDB 文件
|
||||
Child->>Disk: mv 替换 dump.rdb
|
||||
Child-->>Master: SIGCHLD 通知完成
|
||||
```
|
||||
|
||||
关键细节:
|
||||
- **COW(Copy-On-Write)**:fork 时父子进程共享内存页,子进程读数据,父进程写数据。当某页被修改时操作系统复制该页给子进程使用。这意味着 RDB 过程中内存峰值 = 当前内存 + fork 瞬间的增量变化量。
|
||||
- **save vs bgsave**:`SAVE` 阻塞所有客户端等待完成(生产环境禁用);`BGSAVE` 后台异步执行。
|
||||
- **自动触发**:通过 `save <seconds> <changes>` 配置自动触发(如 `save 900 1` 表示 900 秒内有至少 1 个 key 被修改则触发)。
|
||||
- **RDB 压缩**:RDB 文件本身是无损压缩的二进制格式,不同版本间不兼容(不能跨版本还原)。
|
||||
|
||||
> [!WARNING]
|
||||
> RDB 每次全量快照,两次快照之间的数据在崩溃时会丢失。不适合对数据完整性要求极高的场景。
|
||||
|
||||
### AOF — 追加日志
|
||||
|
||||
AOF(Append Only File)以日志形式记录每个写操作,重启时重放日志恢复数据。
|
||||
|
||||
**appendfsync 三种策略:**
|
||||
|
||||
| 策略 | fsync 频率 | 性能 | 数据安全性 |
|
||||
|------|-----------|------|----------|
|
||||
| always | 每条命令一次 | 最差(约等于MySQL innodb_flush_log_at_trx_commit=1) | 最高,零丢失 |
|
||||
| everysec(默认) | 每秒一次 | 良好(1ms 系统调用) | 丢 1 秒数据 |
|
||||
| no | OS 决定 | 最佳 | 完全取决于 OS 刷新节奏 |
|
||||
|
||||
**AOF 重写(Rewrite)原理:**
|
||||
|
||||
AOF 文件会随时间越来越大。重写不是简单地删除旧文件再写新内容,而是:
|
||||
|
||||
1. 父进程 fork 子进程
|
||||
2. 子进程遍历内存数据,将每个 key 用 `SET key value` 命令序列化(而非读取旧 AOF)
|
||||
3. 父进程同时将新写命令写入 `aof_rewrite_buffer`
|
||||
4. 子进程完成后将新数据和缓冲区合并写入临时文件
|
||||
5. 原子替换原 AOF 文件
|
||||
|
||||
> [!NOTE]
|
||||
> AOF 重写期间,旧 AOF 保持不变直到新文件准备好,保证不会丢失任何已确认的数据。重写后的 AOF 可能比原来的小很多(去除了已过期 key 的旧记录)。
|
||||
|
||||
### 混合持久化
|
||||
|
||||
Redis 4.0+ 引入的混合持久化 = RDB 全量快照 + AOF 增量日志:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[BGSTART] --> B[fork 子进程]
|
||||
B --> C["写 RDB 部分<br/>(全部内存快照)"]
|
||||
B --> D["父进程收集<br/>AOF 增量命令"]
|
||||
C --> E["合并为临时文件"]
|
||||
D --> E
|
||||
E --> F["原子替换 AOF 文件"]
|
||||
```
|
||||
|
||||
优势:
|
||||
- 启动速度远快于纯 AOF(RDB 部分只需加载一次全量快照)
|
||||
- 数据安全性接近 pure AOF everysec(RDB 部分保证了到 fork 时刻的完整状态)
|
||||
|
||||
### 选型对比
|
||||
|
||||
| 维度 | RDB | AOF(everysec) | 混合持久化 |
|
||||
|------|-----|----------------|----------|
|
||||
| 数据丢失风险 | 高(丢最近快照期间的数据) | 低(最多丢 1s) | 极低 |
|
||||
| 启动恢复速度 | 快(单个文件) | 慢(逐条重放) | 较快 |
|
||||
| 磁盘占用 | 小(压缩二进制) | 大(文本指令) | 中等 |
|
||||
| CPU 开销 | 高(定期全量拷贝) | 低(持续追加) | 中 |
|
||||
| 适用场景 | 容灾备份、可接受短暂丢数据 | 对数据一致性要求高 | 兼顾速度与安全的推荐方案 |
|
||||
|
||||
## 代码示例
|
||||
|
||||
Go 中使用 Redigo 触发和管理持久化:
|
||||
|
||||
```go
|
||||
conn, _ := redis.Dial("tcp", "localhost:6379")
|
||||
defer conn.Close()
|
||||
|
||||
// 手动触发 BGSAVE(生产环境通常交给监控定时触发)
|
||||
res, _ := redis.String(conn.Do("BGSAVE"))
|
||||
// res == "Background saving started"
|
||||
|
||||
// 查看上次 BGSAVE 的状态
|
||||
info, _ := redis.StringMap(conn.Do("INFO", "persistent"))
|
||||
fmt.Println(info["last_bgsave_status"]) // "ok" or "err"
|
||||
```
|
||||
|
||||
```bash
|
||||
# Redis CLI 检查 AOF 状态
|
||||
127.0.0.1:6379> CONFIG GET appendonly
|
||||
1) "appendonly"
|
||||
2) "yes" # yes=开启, no=关闭
|
||||
|
||||
# 动态开启 AOF 重写
|
||||
127.0.0.1:6379> CONFIG SET auto-aof-rewrite-percentage 100
|
||||
OK
|
||||
```
|
||||
|
||||
## 实践场景
|
||||
|
||||
1. **默认推荐**:开启混合持久化(`aof-use-rdb-preamble yes`),既保证数据安全又控制启动时间。大多数互联网公司的标准配置。
|
||||
|
||||
2. **冷备策略**:每天凌晨做一次 `SAVE`(或 `BGSAVE`),将 dump.rdb 上传到 OSS/S3。注意 `SAVE` 是阻塞命令,不要在高峰期执行。
|
||||
|
||||
3. **迁移场景**:从一个 Redis 集群迁到另一个,用 `BGSAVE` 拿到最新快照后复制文件到新实例,恢复速度远超 AOF 重放。
|
||||
|
||||
4. **大数据量陷阱**:当内存超过 10GB 时,fork 导致 COW 成本剧增。考虑增大 AOF 重写阈值或将业务拆分到多实例,避免单次 fork 过大的内存开销。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[03.Redis/core/Redis 五大核心数据结构]]
|
||||
- [[03.Redis/core/集群与哨兵机制]]
|
||||
- [[03.Redis/strategies/多级缓存架构设计]]
|
||||
@@ -0,0 +1,148 @@
|
||||
---
|
||||
tags: [redis, data-structures, sds, quicklist, skiplist]
|
||||
create time: 2026-08-08 18:43
|
||||
update time: 2026-08-08 18:43
|
||||
---
|
||||
|
||||
# Redis 五大核心数据结构
|
||||
|
||||
## 概述
|
||||
|
||||
Redis 的性能基石在于其精心设计的底层数据结构。Redis 的每个值都有多种编码(encoding),会根据数据规模和内容自动切换,在内存占用和操作效率之间寻找最优解。理解这些编码的切换策略,是面试中的高频考点,也是性能调优的关键前提。
|
||||
|
||||
## 核心原理
|
||||
|
||||
### String — SDS(Simple Dynamic String)
|
||||
|
||||
Redis 的 String 类型不是直接用 C 字符串,而是用 SDS(Simple Dynamic String)结构。SDS 的定义如下:
|
||||
|
||||
```c
|
||||
struct sdshdr {
|
||||
int len; // buf 中已使用的字节数
|
||||
int free; // buf 中未使用的字节数
|
||||
unsigned char flags; // 低3位标记类型:SDS_TYPE_5/8/10/16/32
|
||||
char buf[]; // 可变长度字符数组,以 \0 结尾
|
||||
};
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> SDS 有 5 种格式(SDS_TYPE_5 到 SDS_TYPE_32),通过 `flags` 字段的低 3 位区分。SDS_TYPE_5 用于短字符串(len < 32),直接内嵌在 dictEntry 中而不分配独立对象。
|
||||
|
||||
**为什么不用 C 字符串?**
|
||||
|
||||
| 问题 | C 字符串 | SDS |
|
||||
|------|---------|-----|
|
||||
| O(N) 获取长度 | strlen 遍历计数 | len 字段 O(1) |
|
||||
| 缓冲区溢出风险 | strcpy/strcat 不安全 | 分配时检查空间 |
|
||||
| 修改需重新分配 | 每次可能 realloc | 惰性空间释放(free 保留) |
|
||||
| 二进制安全 | 遇 \0 截断 | 记录 len,可存任意二进制数据 |
|
||||
|
||||
**innative 优化**:从 Redis 7.0 开始,小字符串使用 SDS_TYPE_5 直接存储在 dictEntry 的键槽中,完全避免额外的堆分配,减少内存碎片和 malloc 开销。
|
||||
|
||||
### List — quicklist
|
||||
|
||||
Redis 3.2 之前用 ziplist + linkedlist 实现 List,3.2 之后统一为 **quicklist**。
|
||||
|
||||
quicklist 是一个双向链表,每个节点(quicklistNode)是一个 ziplist 或 listpack(Redis 7.0+)。通过 `list-max-ziplist-size` 控制每个节点的压缩列表大小:
|
||||
|
||||
| list-max-ziplist-size 值 | 含义 |
|
||||
|--------------------------|------|
|
||||
| 正数 N | 该节点最多 N 个元素 |
|
||||
| -1 | 每节点不限(仅受 memory 约束) |
|
||||
| -2 | 每节点 <= 8KB |
|
||||
| -3 | 每节点 <= 4KB |
|
||||
| -4 | 每节点 <= 2KB |
|
||||
| -5 | 每节点 <= 1KB(默认) |
|
||||
|
||||
```go
|
||||
// Go 伪代码示意 quicklist 的结构
|
||||
type quicklist struct {
|
||||
head *quicklistNode
|
||||
tail *quicklistNode
|
||||
count int64 // 总元素数
|
||||
zipsize int // list-max-ziplist-size 全局配置
|
||||
}
|
||||
|
||||
type quicklistNode struct {
|
||||
prev *quicklistNode
|
||||
next *quicklistNode
|
||||
ptr unsafe.Pointer // 指向 ziplist/listpack
|
||||
sz uint32 // 字节大小
|
||||
}
|
||||
```
|
||||
|
||||
### Hash — zipmap / hashtable 切换
|
||||
|
||||
Hash 有两种编码,根据数据和元素数量自动切换:
|
||||
|
||||
- **zipmap**(Redis 4.0 之前)→ 当元素数量 >= 512 且最大 value 长度 < 64 字节时使用 hashtable
|
||||
- **ziplist**(Redis 4.0 ~ 5.x)→ 元素数量 <= 512 且所有 value 长度 < 64 字节
|
||||
- **hashtable**(Redis 4.0+ 默认)→ 只要有一个 value > 64 字节 或 元素数 > 512 就切换到 hashtables
|
||||
|
||||
> [!WARNING]
|
||||
> Redis 4.0+ 废弃了 ziplist 作为 Hash 编码,默认始终使用 hashtable。这是因为 ziplist 插入删除的均摊代价在大数据量下反而更高。ziplist 编码目前仅在 ZSet 场景中保留。
|
||||
|
||||
### Set — intset → hashtable 切换
|
||||
|
||||
Set 编码切换逻辑非常直观:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A["Set 创建"] --> B{"所有元素都是整数?"}
|
||||
B -->|是| C["intset 编码"]
|
||||
B -->|否| D["hashtable 编码"]
|
||||
C --> E{"新加入的元素仍是整数?"}
|
||||
E -->|是| F["继续 intset"]
|
||||
E -->|否| G["转换为 hashtable"]
|
||||
D --> H["维持 hashtable"]
|
||||
```
|
||||
|
||||
- **intset**:有序整数集合,紧凑排列在连续内存中。支持 int16_t、int32_t、int64_t 三种格式,升级时 realloc 整个结构(不可降级)。
|
||||
- **hashtable**:通用哈希表,任何类型的成员都能存储。
|
||||
|
||||
### ZSet — skiplist + ziplist / quicklist 切换
|
||||
|
||||
ZSet 是最复杂的结构,组合了两层编码:
|
||||
|
||||
- **ziplist**(Redis 5.0 之前)/ **quicklist**(Redis 5.0+):当元素少且值小时用压缩列表存储
|
||||
- **skiplist + hashtable**:标准模式,跳表按 score 排序,hashtable 按 member 做 O(1) 查找
|
||||
|
||||
> [!TIP]
|
||||
> 面试常考:跳表的平均查找复杂度 O(log N),最坏 O(N);Redis 跳表通过限制随机层级上限(maxlevel=32)和控制概率 p=0.25,保证最坏情况也在可接受范围。跳表节点包含:score(分数)、member(成员)、back(后退指针)、forward(多层前进指针数组)。
|
||||
|
||||
## 代码示例
|
||||
|
||||
以下展示如何查看 Redis 对象的内部编码:
|
||||
|
||||
```bash
|
||||
# Redis CLI 中查看键的内部编码
|
||||
127.0.0.1:6379> OBJECT ENCODED mykey
|
||||
"ziplist" # 或 "skiplist", "hashtable", "intset"
|
||||
```
|
||||
|
||||
```go
|
||||
// Go 中使用 Redigo 库观察 Redis 行为
|
||||
import "github.com/gomodule/redigo/redis"
|
||||
|
||||
conn, _ := redis.Dial("tcp", "localhost:6379")
|
||||
defer conn.Close()
|
||||
|
||||
// HSET 少量数据 → ziplist/hashmap
|
||||
conn.Do("HSET", "user:1", "name", "alice", "age", "25")
|
||||
// 数据量大后自动切 hashtable —— 用户无感知
|
||||
```
|
||||
|
||||
## 实践场景
|
||||
|
||||
1. **Hash 内存优化**:对于一个用户有几十个字段的小 Hash,手动合并成单个 Key(如 `user:1:name`)不如用一个 Hash 结构 `user:1`,因为 ziplist 能省大量内存。
|
||||
|
||||
2. **ZSet 排行榜实现**:电商商品销量排名、社交媒体点赞排行等场景天然适合 ZSet。`ZREVRANGEBYSCORE` 可以高效取 Top-N。
|
||||
|
||||
3. **List 替代方案**:如果 List 只用两端操作(LPUSH + RPOP),本质上是个队列;但 quicklist 的中间节点插入在大数据量时会有 O(N) 成本,考虑用 Stream 替代。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[03.Redis/core/RDB 与 AOF 持久化]]
|
||||
- [[03.Redis/core/集群与哨兵机制]]
|
||||
- [[03.Redis/strategies/多级缓存架构设计]]
|
||||
- [[03.Redis/strategies/缓存穿透击穿雪崩解决方案]]
|
||||
@@ -0,0 +1,207 @@
|
||||
---
|
||||
tags: [redis, cluster-sentinel, sharding, gossip, failover]
|
||||
create time: 2026-08-08 18:43
|
||||
update time: 2026-08-08 18:43
|
||||
---
|
||||
|
||||
# 集群与哨兵机制
|
||||
|
||||
## 概述
|
||||
|
||||
Redis 单实例架构天然面临内存上限和单点故障问题。Redis Cluster(原生分片集群)解决了水平扩展,Sentinel(哨兵)解决了高可用自动故障转移。理解它们的架构、通信协议和选举机制,是构建生产级 Redis 基础设施的前提。
|
||||
|
||||
## 核心原理
|
||||
|
||||
### Sentinel — 哨兵架构
|
||||
|
||||
哨兵不是对数据进行分片的,而是在主从复制的基础上增加自动化故障检测和恢复能力。
|
||||
|
||||
**哨兵节点组成:**
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph RedisCluster["Redis 主从集群"]
|
||||
M["Master<br/>192.168.1.10:6379"]
|
||||
S1["Slave1<br/>192.168.1.11:6379"]
|
||||
S2["Slave2<br/>192.168.1.12:6379"]
|
||||
end
|
||||
|
||||
subgraph Sentinels["哨兵集群"]
|
||||
SEN1["Sentinel-1<br/>192.168.1.10:26379"]
|
||||
SEN2["Sentinel-2<br/>192.168.1.11:26379"]
|
||||
SEN3["Sentinel-3<br/>192.168.1.12:26379"]
|
||||
end
|
||||
|
||||
subgraph Clients["客户端"]
|
||||
APP1["应用服务A"]
|
||||
APP2["应用服务B"]
|
||||
end
|
||||
|
||||
M -->|replication| S1
|
||||
M -->|replication| S2
|
||||
SEN1 -->|监控| M
|
||||
SEN2 -->|监控| M
|
||||
SEN3 -->|监控| M
|
||||
SEN1 <-->|Gossip通信| SEN2
|
||||
SEN2 <-->|Gossip通信| SEN3
|
||||
SEN3 <-->|Gossip通信| SEN1
|
||||
APP1 -->|连接| M
|
||||
APP2 -->|连接| M
|
||||
```
|
||||
|
||||
**四大职责:**
|
||||
|
||||
1. **监控(Monitoring)**:定期检查 Master、Slave 和其他 Sentinel 是否可达(通过 PING)。
|
||||
2. **提醒(Notification)**:当某个节点发现异常时,通知其他 Sentinel。
|
||||
3. **自动故障转移(Failover)**:当 master 被标记为客观下线(ODOWN)时,选择一个 slave 升为主节点。
|
||||
4. **配置提供者(Configuration Provider)**:客户端连接 sentinel 来获取当前 master 地址。
|
||||
|
||||
**故障转移步骤:**
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant S1 as Sentinel-1
|
||||
participant Quorum as Quorum(n/2+1)
|
||||
participant Master as 原Master
|
||||
participant Slave as Slave节点
|
||||
|
||||
S1->>Master: PING (超时判定主观下线)
|
||||
S1->>S1: 标记为 Subjectively Down (SDOWN)
|
||||
S1->>Quorum: 询问是否也认为 Master SDOWN
|
||||
Quorum-->>S1: n/2+1 确认
|
||||
S1->>S1: 标记为 Objectively Down (ODOWN)
|
||||
S1->>S1: 选举 leader Sentinel
|
||||
Note over S1: RAFT-like 选举<br/>先到先得
|
||||
S1->>Quorum: 投票选出 Failover Owner
|
||||
S1->>Slave: 选出最合适的 slave (复制偏移量最大)
|
||||
Slave->>Slave: SLAVEOF NO ONE
|
||||
Slave->>Slave: 提升为 Master
|
||||
S1->>其他 Slave: SLAVEOF new-master IP PORT
|
||||
S1->>Clients: 更新 master 地址
|
||||
```
|
||||
|
||||
**关键参数:**
|
||||
|
||||
| 参数 | 默认值 | 含义 |
|
||||
|------|-------|------|
|
||||
| down-after-milliseconds | 30000 | 主观下线的判断时间(ms) |
|
||||
| failover-timeout | 180000 | 故障转移超时(ms) |
|
||||
| parallel-syncs | 1 | 故障转移后同时同步的新 master 数量 |
|
||||
|
||||
> [!NOTE]
|
||||
> quorum = n/2 + 1 中的 n 是配置的 Sentinel 节点总数,不是存活节点数。如果配置了 3 个 Sentinel,需要至少 2 个认为 master SDOWN 才会触发 ODOWN。这意味着少数派宕机不影响多数派的故障检测。
|
||||
|
||||
### Redis Cluster — 槽位分配
|
||||
|
||||
Redis Cluster 是无中心架构,每个节点都知道完整的拓扑结构。数据分片基于 **哈希槽(hash slot)**。
|
||||
|
||||
**16384 个槽位:**
|
||||
|
||||
- Redis Cluster 将 16384 个 hash slot 分布在多个节点上
|
||||
- Key 的 slot 计算:`CRC16(key) % 16384`
|
||||
- 客户端可以通过 `ASK` 和 `MOVED` 重定向消息定位到正确节点
|
||||
|
||||
**槽位迁移流程(在线迁移):**
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Admin as 管理员
|
||||
participant NodeA as Source Node
|
||||
participant NodeB as Target Node
|
||||
participant Client as 客户端
|
||||
|
||||
Admin->>NodeA: CLUSTER ADDSLOTS 0-5460
|
||||
NodeA->>NodeB: 开始迁移 slots
|
||||
Note over NodeA,NodeB: 阶段1: IMPORTING
|
||||
NodeA->>NodeA: setslot IMPORTING <slot> <target-id>
|
||||
NodeB->>NodeB: setslot MIGRATING <slot> <source-id>
|
||||
loop 逐个 key 迁移
|
||||
NodeA->>NodeB: MIGRATE host port key timeout
|
||||
Note over NodeA: client 查 key → ASK redirect →<br/>再查到目标 node
|
||||
end
|
||||
NodeA->>NodeA: setslot NODE <my-id> (迁移完成)
|
||||
Client->>NodeB: 直接查找 (no redirect needed)
|
||||
```
|
||||
|
||||
### Gossip 协议
|
||||
|
||||
哨兵之间使用简单的主子 Gossip 协议进行信息交换:
|
||||
|
||||
- **PUBLISH/SUBSCRIBE**:哨兵可以发布订阅频道获取全局事件
|
||||
- **HEARTBEAT**:每秒钟互相发送 PING,包含自身的版本号和当前 master 状态
|
||||
- **INFO 传播**:每个哨兵维护整个集群的视图,定期与其他哨兵同步
|
||||
- **领导选举**:基于 Raft 思想的简化版——先到先得,获得 quorum 票数成为 owner
|
||||
|
||||
> [!WARNING]
|
||||
> Gossip 协议存在最终一致性延迟。在哨兵刚刚选出新 leader 的瞬间,未收到通知的哨兵可能仍认为旧状态是正确的,此时如果再次发生故障转移请求可能出现混乱。实际生产中要合理设置 timeouts。
|
||||
|
||||
### CP vs AP 权衡
|
||||
|
||||
Redis Cluster 在设计上做出了明确的取舍:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A["CAP 定理"] --> B{"一致性 or 可用性?"}
|
||||
B -->|Redis Cluster 选择| C["AP 倾向<br/>(可用性优先)"]
|
||||
B -->|对比方案| D["Redis Sentinel CP 倾向"]
|
||||
|
||||
C --> E["分片节点不可用时<br/>部分操作返回 -CLUSTERDOWN"]
|
||||
D --> F["单主从切换期间<br/>短暂不可用但最终一致"]
|
||||
|
||||
E --> G["适合:缓存/排行榜等可接受短暂不一致场景"]
|
||||
F --> H["适合:会话存储/限流计数等要求强一致场景"]
|
||||
```
|
||||
|
||||
| 维度 | Redis Sentinel | Redis Cluster |
|
||||
|------|---------------|---------------|
|
||||
| 一致性模型 | CP(主从切换期间短暂不可用但保证一致) | AP(分片后允许不同分区有不同视角) |
|
||||
| 数据分片 | 否(全量复制到每个从节点) | 是(16384 槽位分散) |
|
||||
| 多 DB 支持 | 支持(DB0 ~ DB15) | 不支持(仅 DB0) |
|
||||
| 扩容方式 | 手动迁移或重新搭建 | 在线增量迁移 |
|
||||
| 适用场景 | 中小规模、强一致性要求 | 大规模、水平扩展需求 |
|
||||
|
||||
## 代码示例
|
||||
|
||||
Go 中使用 Redigo 连接 Sentinel:
|
||||
|
||||
```go
|
||||
import "github.com/gomodule/redigo/redis"
|
||||
|
||||
sentinelAddrs := []string{"192.168.1.10:26379", "192.168.1.11:26379"}
|
||||
pool := &redis.Pool{
|
||||
MaxIdle: 10,
|
||||
DialContext: func(ctx context.Context) (conn redis.Conn, err error) {
|
||||
// 自动发现 master,无需硬编码地址
|
||||
conn, err = redis.DialSentinel(
|
||||
"mymaster", // sentinel 网络名
|
||||
sentinelAddrs, // sentinel 地址列表
|
||||
"", "", // username/password
|
||||
)
|
||||
return
|
||||
},
|
||||
}
|
||||
defer pool.Close()
|
||||
```
|
||||
|
||||
```bash
|
||||
# Sentinel CLI 查看当前 master 信息
|
||||
127.0.0.1:26379> SENTINEL get-master-addr-by-name mymaster
|
||||
1) "192.168.1.10"
|
||||
2) "6379" # 如果返回空数组,说明正在故障转移中
|
||||
```
|
||||
|
||||
## 实践场景
|
||||
|
||||
1. **Sentinel 部署最佳实践**:至少 3 个(奇数),跨机房部署避免单机房断电导致全部失联。quorum 设为 n/2 + 1。
|
||||
|
||||
2. **客户端感知的 Sentinel 连接**:Java 的 Jedis / Lettuce 和 Go 的 redigo 都内置了 Sentinel 发现逻辑,配置好 master name 即可自动路由。不要把 master IP 写死在配置里。
|
||||
|
||||
3. **Cluster 扩缩容**:新增节点时先 `CLUSTER MEET` 加入集群,再通过 `CLUSTER ADDSLOT` 分配槽位,最后用 `redis-cli --cluster rebalance` 自动均衡。整个过程业务零停机。
|
||||
|
||||
4. **脑裂风险**:当网络和物理隔离导致两个"主"共存时,会产生数据分裂。可通过 `min-replicas-to-write` 和 `min-replicas-max-lag` 降低风险。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[03.Redis/core/Redis 五大核心数据结构]]
|
||||
- [[03.Redis/core/RDB 与 AOF 持久化]]
|
||||
- [[03.Redis/strategies/多级缓存架构设计]]
|
||||
@@ -0,0 +1,179 @@
|
||||
---
|
||||
tags: [redis, heavykeeper, hot-key-detection, top-k, count-min-sketch]
|
||||
create time: 2026-08-08 18:43
|
||||
update time: 2026-08-08 18:43
|
||||
---
|
||||
|
||||
# HeavyKeeper 热点探测算法
|
||||
|
||||
## 概述
|
||||
|
||||
在分布式缓存系统中,热点 Key(Hot Key)问题是一种特殊的缓存击穿现象:单个或少数几个 key 的访问频率远高于平均水平,超过底层 Redis 实例的处理能力。HeavyKeeper 是美团开源的一种在线 Top-K 热点检测算法,能够在 O(K) 空间复杂度的前提下,以极低的计算开销实时维护访问量最高的 K 个 key,同时具备抗误报、自适应衰减的能力。
|
||||
|
||||
## 核心原理
|
||||
|
||||
### Top-K 维护问题的背景
|
||||
|
||||
在海量流量中找出出现频率最高的 K 个元素,这是一个经典的流式计数问题。传统方案如排序法需要 O(N) 空间存储所有数据,不适合高并发场景。HeavyKeeper 的核心创新在于概率衰减加双计数器机制。
|
||||
|
||||
### Fading Count(概率衰减因子 alpha)
|
||||
|
||||
HeavyKeeper 的核心思想是每个计数器的值都会随着时间自然衰减。具体来说,每次增加计数时,实际增加值不是固定值 1,而是以概率 alpha(0 < alpha < 1)进行增加:
|
||||
|
||||
```
|
||||
new_count = old_count * (1 - alpha) + alpha
|
||||
```
|
||||
|
||||
这个设计的精妙之处在于:
|
||||
- 自动淘汰旧热点:长期不更新的 key 其计数器会逐渐衰减到接近 0
|
||||
- 无需显式 TTL:不像传统方案需要手动设置过期时间
|
||||
- 响应速度快:alpha 越大衰减越快,对突发热点更敏感
|
||||
|
||||
### 双计数器结构(Error Counter + Main Counter)
|
||||
|
||||
HeavyKeeper 采用类似 Count-Min Sketch 的多哈希结构,但加入了关键改进每个 entry 使用两个计数器协同工作。
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Input["输入流"]
|
||||
STREAM["key 访问序列 k1, k3, k1, k5"]
|
||||
end
|
||||
|
||||
subgraph HK["HeavyKeeper 内部结构"]
|
||||
subgraph MT["主计数表 m 行"]
|
||||
T1["Table[0]: h0 -> MainC"]
|
||||
T2["Table[1]: h1 -> MainC"]
|
||||
Tm["Table[m-1]: hm -> MainC"]
|
||||
end
|
||||
|
||||
subgraph ET["误差计数表 m 行"]
|
||||
E1["Table[0]: h0 -> ErrC"]
|
||||
E2["Table[1]: h1 -> ErrC"]
|
||||
Em["Table[m-1]: hm -> ErrC"]
|
||||
end
|
||||
end
|
||||
|
||||
subgraph Output["Top-K 输出"]
|
||||
HEAP["最小堆保留最大值"]
|
||||
end
|
||||
|
||||
STREAM --> T1
|
||||
STREAM --> T2
|
||||
STREAM --> Tm
|
||||
T1 -.-> E1
|
||||
T2 -.-> E2
|
||||
Tm -.-> Em
|
||||
T1 --> HEAP
|
||||
T2 --> HEAP
|
||||
Tm --> HEAP
|
||||
```
|
||||
|
||||
**Main Counter(主计数器)**:记录 key 的实际访问次数(经过衰减)。当查询某个 key 的频率时取 m 个 hash 表中该 key 对应位置的最小值(与 Count-Min Sketch 一致)。
|
||||
|
||||
**Error Counter(误差计数器)**:专门用来估计其他冲突 key 可能造成的虚假抬高值。用同样的哈希方法但只在不冲突时递增。查询时,true_count 约等于 Main Counter 减去 Error Counter。
|
||||
|
||||
这一设计比 Count-Min Sketch 的关键优势是 CMS 没有误差补偿机制所有的 collision 都被计入;HeavyKeeper 通过 Error Counter 减去估计的碰撞干扰,大幅降低误报率。
|
||||
|
||||
### 最小堆淘汰机制
|
||||
|
||||
为了维护 Top-K,HeavyKeeper 内部维护一个大小为 K 的最小堆(min-heap):
|
||||
|
||||
| 操作 | 行为 |
|
||||
|------|------|
|
||||
| Insert(key) | 更新所有 m 个表的 main/error counter,将当前估算频率插入堆 |
|
||||
| Heap Full and New greater than Min | 弹出堆顶(最小的),插入新元素 |
|
||||
| Heap Full and New less than or equal Min | 忽略新元素(已不在 Top-K 范围内) |
|
||||
|
||||
由于是最小堆,堆顶始终是当前 Top-K 中的最小值。只有新元素的估计频率大于堆顶时才有机会进入 Top-K。
|
||||
|
||||
### 空间复杂度分析
|
||||
|
||||
HeavyKeeper 的空间复杂度为 O(m x K),其中:
|
||||
- m 是计数表的行数(hash 函数数),通常取 4~8
|
||||
- K 是要维护的 Top-K 大小
|
||||
- 每个计数器为 uint32(4 bytes)
|
||||
|
||||
对比典型值:K = 1000, m = 4 -> 4000 个计数器 x 4 bytes = 16KB,极其紧凑。
|
||||
|
||||
### 与其他方案的对比
|
||||
|
||||
| 维度 | HeavyKeeper | Count-Min Sketch | Misra-Gries | Lossy Counting |
|
||||
|------|-------------|------------------|-------------|---------------|
|
||||
| 空间复杂度 | O(m x K) | O(m / epsilon) | O(1/epsilon x log N) | O(log(1/delta) / epsilon) |
|
||||
| 支持 Top-K | 原生支持(最小堆) | 需外部维护 | 不支持直接 Top-K | 需额外数据结构 |
|
||||
| 时间复杂度 | O(m) 每次 | O(m) 每次 | O(1) 每次 | O(1) 每次 |
|
||||
| 误差控制 | Main 减 Error 双重抵消 | 仅有上界无下界 | bounded by epsi*N | bounded by delta*N |
|
||||
| 自适应衰减 | 有(alpha 参数) | 无(需手动重置) | 有(阈值 cutoff) | 有(threshold decay) |
|
||||
| 适用场景 | 实时热点检测加 Top-K | 频率近似估计 | 频繁项发现 | 概念漂移场景 |
|
||||
| 工程落地难度 | 中 | 低 | 高 | 高 |
|
||||
|
||||
## 代码示例
|
||||
|
||||
Go 简化版 HeavyKeeper 核心逻辑:
|
||||
|
||||
```go
|
||||
type HeavyKeeper struct {
|
||||
tables [][]uint32
|
||||
errTables [][]uint32
|
||||
hashFns []hash.Hash64
|
||||
m int
|
||||
k int
|
||||
alpha float64
|
||||
minHeap *MinHeap
|
||||
}
|
||||
|
||||
func (h *HeavyKeeper) Update(key string) {
|
||||
for i := 0; i < h.m; i++ {
|
||||
idx := h.hashFns[i].Hash([]byte(key)) % uint64(len(h.tables[i]))
|
||||
|
||||
// Main Counter: 带衰减的增加
|
||||
val := uint32(float64(h.tables[i][idx])*(1-h.alpha) + h.alpha)
|
||||
h.tables[i][idx] = val
|
||||
|
||||
// Error Counter: 只在非冲突时增长
|
||||
if h.errTables[i][idx] < h.tables[i][idx] {
|
||||
h.errTables[i][idx]++
|
||||
}
|
||||
}
|
||||
|
||||
estFreq := h.estimateFrequency(key)
|
||||
h.minHeap.Insert(estFreq, key)
|
||||
}
|
||||
|
||||
func (h *HeavyKeeper) estimateFrequency(key string) uint32 {
|
||||
minMain := ^uint32(0)
|
||||
maxErr := uint32(0)
|
||||
for i := 0; i < h.m; i++ {
|
||||
idx := h.hashFns[i].Hash([]byte(key)) % uint64(len(h.tables[i]))
|
||||
if h.tables[i][idx] < minMain {
|
||||
minMain = h.tables[i][idx]
|
||||
}
|
||||
if h.errTables[i][idx] > maxErr {
|
||||
maxErr = h.errTables[i][idx]
|
||||
}
|
||||
}
|
||||
if minMain > maxErr {
|
||||
return minMain - maxErr
|
||||
}
|
||||
return 0
|
||||
}
|
||||
```
|
||||
|
||||
## 实践场景
|
||||
|
||||
1. **Redis 热点探测**:在应用服务端部署 HeavyKeeper 实例,统计各 key 的 QPS,超过阈值的自动触发本地缓存预热或多实例分片隔离。这也是 ThumbUP 项目的核心技术之一。
|
||||
|
||||
2. **CDN 热点调度**:视频网站统计各资源的访问热度,动态调整 CDN 边缘节点的内容分发策略,把 Top-K 内容提前推送到最近节点。
|
||||
|
||||
3. **数据库慢查询告警**:不仅监测慢 SQL 的数量,用 HeavyKeeper 找出执行最频繁的 SQL 模式(通过 SQL fingerprint 作为 key),配合索引优化方案治理。
|
||||
|
||||
4. **反爬虫策略**:识别异常高频的请求来源 IP 和 URL 组合(将 ip 加 path 拼接作为 key),一旦进入 Top-K 立即触发验证码或限流。
|
||||
|
||||
> [!TIP]
|
||||
> 面试加分点:可以讨论 alpha 参数的调优经验——alpha 大则衰减快、响应突发热点但不稳定;alpha 小则衰减慢、能记住长期热点但对突发反应迟钝。生产环境中 alpha 一般取 0.01 ~ 0.05,根据业务流量特征实验确定。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[03.Redis/strategies/多级缓存架构设计]]
|
||||
- [[03.Redis/strategies/缓存穿透击穿雪崩解决方案]]
|
||||
- [[03.Redis/core/集群与哨兵机制]]
|
||||
@@ -0,0 +1,169 @@
|
||||
---
|
||||
tags: [redis, multi-level-cache, caffeine, cache-consistency, avalanche]
|
||||
create time: 2026-08-08 18:43
|
||||
update time: 2026-08-08 18:43
|
||||
---
|
||||
|
||||
# 多级缓存架构设计
|
||||
|
||||
## 概述
|
||||
|
||||
在大型分布式系统中,仅靠 Redis 单级缓存已不足以支撑超高并发场景。多级缓存通过在客户端本地(L1)和远程服务(L2)之间分层存储热点数据,大幅降低远程调用延迟和网络带宽消耗。本文介绍 Caffeine 到 Redis 到 MySQL 的三级架构设计及一致性、雪崩等核心挑战的解决方案。
|
||||
|
||||
## 核心原理
|
||||
|
||||
### 三级缓存层次
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A["应用服务 App Service"] -->|"L1: Caffeine<br/>内存缓存 (微秒)"| B["应用服务 App Service"]
|
||||
B -->|"L2: Redis<br/>远程缓存 (毫秒)"| C["MySQL<br/>持久化存储"]
|
||||
A -->|"miss"| B
|
||||
B -->|"miss"| C
|
||||
```
|
||||
|
||||
**L1 — Caffeine(本地缓存)**:
|
||||
- 基于 JVM 堆内内存,读写延迟小于 1 微秒
|
||||
- 支持 LRU、LFU、Window LFU 多种淘汰算法
|
||||
- 通过 maximumSize 限制内存占用,自动驱逐过期条目
|
||||
- 局限:多实例间数据无法同步,每个实例有自己的缓存视图
|
||||
|
||||
**L2 — Redis(远程缓存)**:
|
||||
- 集中式共享缓存,所有实例共享同一份数据
|
||||
- 网络 RTT 通常在 1~5ms(同城)到 50ms+(跨城)
|
||||
- 支持 TTL、持久化、复杂数据结构
|
||||
|
||||
**L3 — MySQL(持久层)**:
|
||||
- 最终数据来源,保证数据的持久性和强一致性
|
||||
- 查询延迟通常 10ms~数百 ms
|
||||
|
||||
### 缓存一致性挑战
|
||||
|
||||
多级缓存最大的痛点是数据更新时如何让所有层的缓存保持一致。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Writer as 写请求
|
||||
participant DB as MySQL
|
||||
participant Redis as L2: Redis
|
||||
participant App1 as 实例A (L1)
|
||||
participant App2 as 实例B (L1)
|
||||
|
||||
Writer->>DB: UPDATE key = val
|
||||
DB-->>Writer: OK
|
||||
Writer->>Redis: DEL key
|
||||
Redis-->>Writer: OK
|
||||
|
||||
Note over App1,App2: 注意:此处只删除了 Redis<br/>L1 缓存需要下次访问时刷新
|
||||
|
||||
App1->>Redis: GET key (miss)
|
||||
Redis->>DB: SELECT * FROM ...
|
||||
DB-->>Redis: result
|
||||
Redis->>App1: result
|
||||
App1->>App1: 写入 L1 Caffeine
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> 最实用的策略是先删缓存再更新 DB(或先更新 DB 再删缓存)。推荐先更新 DB 再删缓存因为后一种情况下极端竞态(读请求在新旧值切换期间读到旧值并回写到缓存)的概率更低。绝不使用先删缓存再写 DB——那会导致写操作完成后、DB 写入前的窗口期内读请求拿到陈旧缓存。
|
||||
|
||||
### 二级缓存失效传播方案
|
||||
|
||||
当多个应用实例同时修改同一个 key 时,可能出现两个问题:
|
||||
1. **重复重建**:多个实例同时发现缓存缺失,各自去查 DB 并重写缓存
|
||||
2. **脏数据残留**:L1 缓存不知道 L2 已经被删除
|
||||
|
||||
解决策略:
|
||||
|
||||
| 方案 | 做法 | 优缺点 |
|
||||
|------|-----|--------|
|
||||
| **短 TTL + 异步刷新** | 给缓存设置随机短 TTL(如 5~15min),后台线程提前 2min 刷新 | 简单有效,容忍短暂不一致 |
|
||||
| **消息队列广播** | DB 更新后发 MQ,各实例监听后清除自己的 L1 | 实时性好,但增加系统复杂度 |
|
||||
| **Canal + binlog 监听** | 通过 Canal 解析 MySQL binlog,自动推送 invalidate 事件 | 解耦彻底,适合大规模部署 |
|
||||
|
||||
> [!WARNING]
|
||||
> 不要依赖定时扫描比对来做一致性校验——延迟太高且成本高。生产环境首选方案:MQ 或 binlog 监听加短 TTL 兜底。
|
||||
|
||||
### 过期时间随机化防雪崩
|
||||
|
||||
大量缓存同时过期会导致请求瞬间穿透到数据库,引发雪崩。解决方法是在 TTL 基础上加上随机偏移量:
|
||||
|
||||
```go
|
||||
import "math/rand"
|
||||
|
||||
func generateTTL(baseMinutes int, jitterPercent int) time.Duration {
|
||||
jitter := baseMinutes * jitterPercent / 100
|
||||
actualMinutes := baseMinutes - jitter + rand.Intn(2*jitter)
|
||||
return time.Duration(actualMinutes) * time.Minute
|
||||
}
|
||||
|
||||
// 例如 baseMinutes=10, jitterPercent=30
|
||||
// 实际 TTL 落在 [7, 13] 分钟之间均匀分布
|
||||
```
|
||||
|
||||
### 缓存穿透防护
|
||||
|
||||
场景:恶意用户或异常流量反复查询不存在的 key,绕过缓存直接打到 DB。
|
||||
|
||||
防护策略:
|
||||
|
||||
| 策略 | 做法 | 适用场景 |
|
||||
|------|-----|---------|
|
||||
| **空值缓存** | 查询结果为空时也缓存一个特殊标记(如 nil),设极短 TTL(30s~2min) | 适用于不存在的数据比例较低的场景 |
|
||||
| **布隆过滤器** | 在缓存前先用 Bloom Filter 判断 key 是否存在 | 适用于 key 集合相对稳定、允许误判的场景 |
|
||||
| **接口层鉴权限流** | 对高频无效查询做 IP 或 token 级别的限流 | 作为辅助防线 |
|
||||
|
||||
## 代码示例
|
||||
|
||||
Go 中用 singleflight 防止缓存击穿:
|
||||
|
||||
```go
|
||||
import "golang.org/x/sync/singleflight"
|
||||
|
||||
type Cache struct {
|
||||
group singleflight.Group
|
||||
mu sync.RWMutex
|
||||
local map[string]cache.Entry
|
||||
}
|
||||
|
||||
func (c *Cache) Get(ctx context.Context, key string,
|
||||
fn func() (interface{}, error)) (interface{}, error) {
|
||||
|
||||
// 1. L1 命中直接返回
|
||||
c.mu.RLock()
|
||||
if entry, ok := c.local[key]; ok && !entry.IsExpired() {
|
||||
c.mu.RUnlock()
|
||||
return entry.Value, nil
|
||||
}
|
||||
c.mu.RUnlock()
|
||||
|
||||
// 2. L1 miss + L2 miss → singleflight 阻止并发重复查 DB
|
||||
val, err, _ := c.group.Do(key, func() (interface{}, error) {
|
||||
val, err := redisGet(ctx, key)
|
||||
if err != nil {
|
||||
val, err = fn() // 查 DB
|
||||
if err == nil {
|
||||
redisSetWithTTL(ctx, key, val, generateTTL(10, 30))
|
||||
c.setLocal(key, val, 15*time.Minute)
|
||||
}
|
||||
}
|
||||
return val, err
|
||||
})
|
||||
return val, err
|
||||
}
|
||||
```
|
||||
|
||||
## 实践场景
|
||||
|
||||
1. **商品详情页缓存**:电商商品 SKU 信息变更频率低(小时级)、读请求极高(万 QPS),非常适合多级缓存。L1 存热点 SKU,L2 存全量活跃 SKU,DB 做兜底。
|
||||
|
||||
2. **配置中心类数据**:开关配置、规则引擎参数等全局配置,更新时通过 MQ 广播失效,L1 TTL 设 1 小时加后台预刷。
|
||||
|
||||
3. **限流计数**:高频调用的限流 counter 不适合放多级缓存(每次都涉及网络开销),直接用 Redis INCR 或本地原子计数器即可。
|
||||
|
||||
4. **容量规划参考**:假设单机 QPS 10000,L1 hit rate 90%,L2 hit rate 80%(相对 L1 miss),则对外部 Redis 的请求约 1000 × (1 - 0.8) = 200 QPS。这是评估集群规模的核心指标。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[03.Redis/core/Redis 五大核心数据结构]]
|
||||
- [[03.Redis/strategies/缓存穿透击穿雪崩解决方案]]
|
||||
- [[03.Redis/strategies/HeavyKeeper 热点探测算法]]
|
||||
@@ -0,0 +1,225 @@
|
||||
---
|
||||
tags: [redis, cache-strategy, cache-aside, read-through, write-through]
|
||||
create time: 2026-08-08 18:43
|
||||
update time: 2026-08-08 18:43
|
||||
---
|
||||
|
||||
# 旁路缓存与读写策略
|
||||
|
||||
## 概述
|
||||
|
||||
在引入 Redis 作为数据加速层之后,应用与 Redis + MySQL 之间的读写交互模式就成了一个核心设计决策。业界主要有四种经典策略:Cache Aside、Read Through、Write Through 和 Write Behind。它们的核心差异在于"谁负责将数据写入或读取到缓存",以及在一致性、延迟和复杂度之间的不同取舍。
|
||||
|
||||
## 各方案详解
|
||||
|
||||
### 1. Cache Aside(旁路缓存)—— 最常用
|
||||
|
||||
**核心思路**:应用同时管理缓存和数据库,读操作先看缓存、miss 再查 DB 并回填;写操作先更新 DB、再删除缓存。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant App as Application
|
||||
participant Redis as L2 Cache
|
||||
participant DB as Database
|
||||
|
||||
alt 读请求
|
||||
C->>App: GET key
|
||||
App->>Redis: GET key
|
||||
alt hit
|
||||
Redis-->>App: value
|
||||
else miss
|
||||
Redis-->>App: nil
|
||||
App->>DB: SELECT * FROM ... WHERE id = ?
|
||||
DB-->>App: result
|
||||
App->>Redis: SET key value EX ttl
|
||||
end
|
||||
App-->>C: result
|
||||
end
|
||||
|
||||
alt 写请求
|
||||
C->>App: UPDATE key = new_val
|
||||
App->>DB: UPDATE ... SET val = new_val
|
||||
DB-->>App: OK
|
||||
App->>Redis: DEL key
|
||||
end
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- 实现简单,解耦彻底,Redis 只是可选优化
|
||||
- DB 是权威数据源,缓存永远从 DB 重建
|
||||
|
||||
**缺点**:
|
||||
- 写操作多一次 DEL(虽然 DEL 比 SET 便宜很多)
|
||||
- 存在"竞态窗口":写完后 DEL 之前,并发读可能把旧值回填到缓存(极端情况)
|
||||
|
||||
### 2. Read Through(通明读取)
|
||||
|
||||
**核心思路**:应用只和缓存层交互,不直接接触 DB。缓存层封装了从 DB 加载数据的逻辑。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant App as Application
|
||||
participant CacheLayer as Cache (Read Through)
|
||||
participant DB as Database
|
||||
|
||||
C->>App: GET key
|
||||
App->>CacheLayer: GET key
|
||||
alt hit
|
||||
CacheLayer-->>App: value
|
||||
else miss
|
||||
CacheLayer->>DB: SELECT * FROM ... WHERE id = ?
|
||||
DB-->>CacheLayer: result
|
||||
CacheLayer->>CacheLayer: SET key value EX ttl
|
||||
CacheLayer-->>App: result
|
||||
end
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- 应用层代码更简洁,无需关心 DB 逻辑
|
||||
- 缓存加载逻辑集中在缓存层,便于统一调优
|
||||
|
||||
**缺点**:
|
||||
- 缓存层需要知道 DB schema,耦合增加
|
||||
- 通常配合 Write Behind 使用(纯 Read Through + Cache Aside 混合较少)
|
||||
|
||||
### 3. Write Through(通明写入)
|
||||
|
||||
**核心思路**:写操作时,应用先写缓存,由缓存层负责同步写入 DB。对应用而言写操作只返回"缓存已接受"即可认为成功。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant App as Application
|
||||
participant CacheLayer as Cache (Write Through)
|
||||
participant DB as Database
|
||||
|
||||
C->>App: UPDATE key = new_val
|
||||
App->>CacheLayer: SET key new_val
|
||||
CacheLayer-->>App: OK // 缓存写入成功即返回
|
||||
CacheLayer->>DB: UPDATE ... SET val = new_val
|
||||
DB-->>CacheLayer: OK
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- 读操作可以直接命中缓存,不会读到过期或不一致的数据
|
||||
- 缓存和 DB 之间天然的一致性保障
|
||||
|
||||
**缺点**:
|
||||
- 写延迟增加(必须等 DB 确认后才返回)
|
||||
- 如果 DB 不可用但缓存可用,整个写操作会失败
|
||||
|
||||
### 4. Write Behind(异步回写)
|
||||
|
||||
**核心思路**:写操作只写缓存,由缓存层的后台线程异步批量刷盘到 DB。这是最快但风险最高的策略。
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as Client
|
||||
participant App as Application
|
||||
participant CacheLayer as Cache (Write Behind)
|
||||
participant DB as Database
|
||||
participant BGThread as Background Writer
|
||||
|
||||
C->>App: UPDATE key = new_val
|
||||
App->>CacheLayer: SET key new_val
|
||||
CacheLayer-->>App: OK // 极快返回
|
||||
CacheLayer->>BGThread: 加入批量队列
|
||||
|
||||
loop 每秒或每 N 条
|
||||
BGThread->>DB: 批量执行多条 UPDATE
|
||||
BGThread->>CacheLayer: 标记批次成功/失败
|
||||
end
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- 极致性能,写操作几乎无额外开销
|
||||
- 批量刷盘减少 DB IO 次数
|
||||
|
||||
**缺点**:
|
||||
- **崩溃丢数据风险**:缓存未刷盘前宕机,该批次数据丢失
|
||||
- 需要复杂的故障恢复机制
|
||||
|
||||
## 对比总结
|
||||
|
||||
| 维度 | Cache Aside | Read Through | Write Through | Write Behind |
|
||||
|------|-------------|--------------|---------------|-------------|
|
||||
| **缓存由谁维护** | 应用层 | 缓存层 | 缓存层 | 缓存层 |
|
||||
| **读的延迟** | 中(miss 时有 DB RTT) | 中(miss 时有 DB RTT) | 低(始终从缓存读) | 低(始终从缓存读) |
|
||||
| **写的延迟** | 中(DB + DEL) | 低(仅写缓存) | 中(缓存 + 同步 DB) | 极低(仅写缓存) |
|
||||
| **一致性保证** | 最终一致(有竞态窗口) | 强一致(缓存层代理) | 强一致 | 弱一致(异步延迟) |
|
||||
| **系统复杂度** | 低 | 中 | 高 | 最高 |
|
||||
| **容错性** | 好(DB 独立于缓存) | 中(缓存层挂了需降级) | 中 | 差(缓存单点故障影响大) |
|
||||
| **适用场景** | 通用推荐方案 | 缓存即主要数据源 | 强一致+可容忍写延迟 | 日志/指标等非关键数据 |
|
||||
|
||||
## 选型建议
|
||||
|
||||
> [!TIP]
|
||||
> **面试标准答案**:绝大多数场景首选 Cache Aside。它简单、解耦、容错好。只有当你的架构本身就是"缓存为主存储"(如会话存储、配置中心)时,才考虑 Read Through + Write Through。Write Behind 只用于能接受短暂数据丢失的非关键场景。
|
||||
|
||||
具体决策树:
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A["是否需要强一致?"] -->|"否"| B["是否能接受写延迟?" ]
|
||||
A -->|"是"| C["Cache Aside / Write Through"]
|
||||
B -->|"是 (要极致速度)"| D["Write Behind"]
|
||||
B -->|"否"| E["Can 承受 DB RTT?"]
|
||||
E -->|"否"| F["Read Through + Write Through"]
|
||||
E -->|"是"| C
|
||||
```
|
||||
|
||||
实际工程中还有一个变体叫做 **"Cache Aside with Delayed Delete"**:写操作后延时几百毫秒再删缓存,可以解决部分竞态问题,但不适合高一致性要求场景。
|
||||
|
||||
## 代码示例
|
||||
|
||||
Go 中 Cache Aside 的常见实现:
|
||||
|
||||
```go
|
||||
func GetProduct(ctx context.Context, id string) (*Product, error) {
|
||||
// 1. 读:查缓存
|
||||
val, err := redis.Get(ctx, "product:"+id).Bytes()
|
||||
if err == nil {
|
||||
var p Product
|
||||
json.Unmarshal(val, &p)
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// 2. 缓存 miss:查 DB
|
||||
p, err := db.GetProduct(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3. 回填缓存(带随机 TTL 防雪崩)
|
||||
ttl := randomTTL(10*time.Minute, 30)
|
||||
redis.SetEX(ctx, "product:"+id, p, ttl)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func UpdateProduct(ctx context.Context, id string, data Product) error {
|
||||
if err := db.UpdateProduct(ctx, id, data); err != nil {
|
||||
return err
|
||||
}
|
||||
// 写:先写 DB 再删缓存
|
||||
redis.Del(ctx, "product:"+id)
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
## 实践场景
|
||||
|
||||
1. **电商商品查询**:典型 Cache Aside 场景。商品更新频率低(运营后台发布),读 QPS 远高于写。先更新 DB 再 DEL 缓存,配合短 TTL 兜底。
|
||||
|
||||
2. **用户 Session 存储**:可以考虑 Read Through + Write Through。Session 是主存储(非持久化),缓存层挂掉需要降级方案(如临时回退到 Cookie 存储)。
|
||||
|
||||
3. **统计数据实时看板**:指标聚合数据可以用 Write Behind。写入 Redis 后立即返回,后台批量 flush 到 ClickHouse/TimescaleDB。偶尔丢失几条指标完全可接受。
|
||||
|
||||
4. **微博关注关系**:Follower/Following 列表频繁变更,Cache Aside 中的 DEL 频率过高反而成为瓶颈。此时应直接用 ZSet 在 Redis 中维护完整数据结构,绕过缓存刷新策略的困扰。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[03.Redis/strategies/多级缓存架构设计]]
|
||||
- [[03.Redis/strategies/缓存穿透击穿雪崩解决方案]]
|
||||
- [[03.Redis/core/RDB 与 AOF 持久化]]
|
||||
@@ -0,0 +1,201 @@
|
||||
---
|
||||
tags: [redis, cache-penetration, cache-breakdown, cache-avalanche, bloom-filter]
|
||||
create time: 2026-08-08 18:43
|
||||
update time: 2026-08-08 18:43
|
||||
---
|
||||
|
||||
# 缓存穿透、击穿、雪崩解决方案
|
||||
|
||||
## 概述
|
||||
|
||||
缓存三大问题——穿透(Penetration)、击穿(Breakdown)和雪崩(Avalanche)是面试中必问的经典题目。它们看似相似,但本质完全不同:穿透是不存在的数据反复请求,击穿是热点 key 过期时并发重建缓存,雪崩是大量 key 同时过期导致流量全部涌向 DB。理解各自的成因并对症下药,才能构建健壮的缓存系统。
|
||||
|
||||
## 详解
|
||||
|
||||
### 穿透 — 布隆过滤器与空值缓存
|
||||
|
||||
**成因**:恶意用户或异常查询不断访问数据库中不存在的 key(如 id = -1 或随机 ID),每次缓存都 miss 导致请求直接打到数据库,可能压垮 DB。
|
||||
|
||||
#### 方案一:布隆过滤器(Bloom Filter)
|
||||
|
||||
布隆过滤器的核心是用一个极小的位数组来近似判断元素是否属于某个集合。它的优势是空间效率极高用几个 MB 的内存就能判断数十亿级别的数据是否存在。
|
||||
|
||||
**工作原理**:
|
||||
1. 初始化一个 m 位的位数组(全 0)和 k 个独立哈希函数
|
||||
2. 存入数据时,用 k 个哈希函数算出 k 个位置,置为 1
|
||||
3. 查询时,同样计算 k 个位置,如果任一位置为 0,则必定不存在;全部为 1 则可能存在
|
||||
|
||||
**误判率计算**:
|
||||
```
|
||||
p ≈ (1 - e^(-kn/m))^k
|
||||
```
|
||||
其中 m 是位数组大小,n 是元素数量,k 是哈希函数个数。最优哈希函数数:`k = (m/n) * ln(2)`,此时误判率最低。
|
||||
|
||||
> [!TIP]
|
||||
> 面试常考:当 m/n = 10、k = 7 时,误判率约 0.8%。实际工程中可以通过增大 m/n 进一步降低误判率(如 m/n = 20 时误判率降至 0.02%)。
|
||||
|
||||
Go 中使用典型 Bloom Filter 结构:
|
||||
|
||||
```go
|
||||
type BloomFilter struct {
|
||||
bits []bool
|
||||
hash func([]byte) uint64
|
||||
}
|
||||
|
||||
func (b *BloomFilter) Add(key string) {
|
||||
for i := 0; i < k; i++ {
|
||||
pos := b.hash([]byte(key+strconv.Itoa(i))) % uint64(len(b.bits))
|
||||
b.bits[pos] = true
|
||||
}
|
||||
}
|
||||
|
||||
func (b *BloomFilter) MightContain(key string) bool {
|
||||
for i := 0; i < k; i++ {
|
||||
pos := b.hash([]byte(key+strconv.Itoa(i))) % uint64(len(b.bits))
|
||||
if !b.bits[pos] {
|
||||
return false // 一定不在
|
||||
}
|
||||
}
|
||||
return true // 可能在
|
||||
}
|
||||
```
|
||||
|
||||
**缺点**:无法删除(除非用 Counting Bloom Filter,每个 bit 换成 counter)、不支持跨节点共享(需要 Redisson 分布式布隆过滤器)。
|
||||
|
||||
#### 方案二:空值缓存
|
||||
|
||||
对查询结果为空的 key,仍然写入缓存并设置短 TTL(30s~2min):
|
||||
|
||||
| 策略 | 优点 | 缺点 |
|
||||
|------|-----|------|
|
||||
| 布隆过滤器 | 空间利用率极高,拦截效果好 | 有 False Positive,不可删减元素 |
|
||||
| 空值缓存 | 实现简单,精确匹配 | 占用额外存储空间,大 key 集合时浪费内存 |
|
||||
| 组合使用 | 布隆做粗筛 + 空值做兜底 | 复杂度略增 |
|
||||
|
||||
### 击穿 — 分布式锁保证单点重建
|
||||
|
||||
**成因**:一个超级热点 key(如首页配置、爆款商品详情)刚好过期,此时大量并发请求同时发现缓存 miss,纷纷去查 DB 并重写缓存,瞬间将 DB 流量放大数倍甚至数十倍。
|
||||
|
||||
**方案一:互斥锁(Mutex Lock)**
|
||||
|
||||
只有一个线程去查 DB 并回填缓存,其余线程等待(重试读缓存)。
|
||||
|
||||
```go
|
||||
import "github.com/go-redis/redismutex"
|
||||
|
||||
func GetHotKey(ctx context.Context, key string) ([]byte, error) {
|
||||
val, err := redis.Get(ctx, key).Bytes()
|
||||
if err == nil {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
mutex := redismutex.New(key, redisClient)
|
||||
locked, err := mutex.Lock(5*time.Second, 100*time.Millisecond)
|
||||
if err != nil || !locked {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
return redis.Get(ctx, key).Bytes() // 等别人重建好再读
|
||||
}
|
||||
defer mutex.Unlock()
|
||||
|
||||
val, err = redis.Get(ctx, key).Bytes() // 双重检查
|
||||
if err == nil {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
val = queryDBAndSerialize(key) // 真正查 DB
|
||||
redis.SetEX(ctx, key, val, randomTTL(10*time.Minute, 30))
|
||||
return val, nil
|
||||
}
|
||||
```
|
||||
|
||||
**方案二:永不过期(逻辑 TTL)**
|
||||
|
||||
物理上不设置过期时间,而是在内存中维护一个逻辑过期时间。发现逻辑过期后,异步触发重建,读取到的仍是旧值(无脑 hit),直到新缓存写入成功。
|
||||
|
||||
> [!WARNING]
|
||||
> 互斥锁方案的死锁风险:如果重建过程抛出异常没有释放锁,其他线程永远阻塞。务必使用 defer unlock 或超时机制。
|
||||
|
||||
| 维度 | 互斥锁方案 | 逻辑 TTL 方案 |
|
||||
|------|----------|-------------|
|
||||
| 一致性 | 强(新值写入后才可读到) | 最终一致(有短暂旧值窗口) |
|
||||
| 性能开销 | 锁等待增加延迟 | 无锁开销 |
|
||||
| 实现复杂度 | 中等 | 较低 |
|
||||
| 适用场景 | 强一致性要求 | 高可用优先 |
|
||||
|
||||
### 雪崩 — 过期时间加随机偏移量
|
||||
|
||||
**成因**:大批量 key 设置相同的过期时间,到期时集中失效,请求洪水般涌向下流,超出系统承载能力。
|
||||
|
||||
**方案一:过期时间随机化**(最推荐)
|
||||
|
||||
在基础 TTL 上加加减号 30% 的随机波动:
|
||||
|
||||
```go
|
||||
func RandomJitter(base time.Duration, jitterRatio int) time.Duration {
|
||||
range_ := int(base) * jitterRatio / 100
|
||||
delta := rand.Intn(2*range_) - range_
|
||||
return base + time.Duration(delta)
|
||||
}
|
||||
|
||||
// base=10min, ratio=30 -> [7min, 13min] 均匀分布
|
||||
```
|
||||
|
||||
**方案二:多实例部署 + 读写隔离**
|
||||
|
||||
将缓存拆分为多个独立实例(按业务线分或按 hash slot 分),单个实例的雪崩不会波及全局。配合读写分离,读操作走从库减轻主库压力。
|
||||
|
||||
**方案三:服务降级 + 限流熔断**
|
||||
|
||||
当缓存大面积失效时,通过 Sentinel/Hystrix 快速降级,返回默认值或走本地缓存,避免级联故障。
|
||||
|
||||
| 策略 | 优点 | 缺点 |
|
||||
|------|-----|------|
|
||||
| TTL 随机化 | 零成本,效果明显 | 不能解决极端热点同时过期的问题 |
|
||||
| 多实例部署 | 故障域隔离 | 增加运维成本 |
|
||||
| 熔断降级 | 保护系统不被打垮 | 用户体验下降(返回降级数据) |
|
||||
|
||||
## 代码示例
|
||||
|
||||
综合防御的完整缓存获取方法:
|
||||
|
||||
```go
|
||||
func SafeGet(ctx context.Context, key string, fn QueryFn) ([]byte, error) {
|
||||
// 第1道防线:布隆过滤器拦截不存在的 key
|
||||
if !bloom.MightContain(key) {
|
||||
return nil, ErrNotExist
|
||||
}
|
||||
|
||||
// 第2道防线:读缓存
|
||||
val, err := redis.Get(ctx, key).Bytes()
|
||||
if err == nil {
|
||||
return val, nil
|
||||
}
|
||||
|
||||
// 第3道防线:分布式锁防止击穿
|
||||
mutex := lock.GetOrNew(key, 5*time.Second)
|
||||
if locked, _ := mutex.TryLock(); locked {
|
||||
defer mutex.Unlock()
|
||||
val = doQuery(ctx, key, fn)
|
||||
return val, nil
|
||||
}
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
return redis.Get(ctx, key).Bytes()
|
||||
}
|
||||
```
|
||||
|
||||
## 实践场景
|
||||
|
||||
1. **电商 SKU 查询**:透穿防护(布隆过滤器预加载所有有效 SKU ID)+ 击穿防护(互斥锁)+ 雪崩防护(TTL 随机化 10加减号 3 min)。三层防御覆盖所有攻击面。
|
||||
|
||||
2. **社交媒体点赞数**:几乎不可能透穿(key 都是有效的),重点防击穿。可以用永不过期加后台异步刷新的逻辑 TTL 方案,保证 QPS 峰值时 DB 不受影响。
|
||||
|
||||
3. **活动秒杀页面**:超热点 key,建议预热到 L1 缓存(应用本地),L2 用互斥锁保护,L3 用 CDN 静态化。不要把所有压力都放在 Redis 上。
|
||||
|
||||
4. **日志聚合统计**:适合放宽一致性要求,逻辑 TTL 方案更合适用户看到的统计数据延迟几分钟完全可以接受,但系统稳定性远比数据实时性重要。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[03.Redis/strategies/多级缓存架构设计]]
|
||||
- [[03.Redis/strategies/旁路缓存与读写策略]]
|
||||
- [[03.Redis/strategies/HeavyKeeper 热点探测算法]]
|
||||
Reference in New Issue
Block a user