学习目标
学完本章,你应该能够:
- 说清 Redis 为什么能扛住高并发:用「全内存 + 单线程无锁 + IO 多路复用 + 高效数据结构」四句话讲透性能来源。
- 对照 SDS、跳表、quicklist 等底层结构,解释 String/List/Hash/Set/ZSet 各自为什么这样设计,面试时能讲成工程故事。
- 画出 Cache-Aside、Read/Write-Through 的读写流程,并针对缓存穿透 / 雪崩 / 击穿给出缓存空值、布隆过滤器、随机 TTL、互斥锁等应对方案。
- 讲清 RDB 与 AOF 的取舍、惰性 / 定期删除与八种淘汰策略,以及 Redis Cluster 哈希槽分片的路由与故障转移。
- 在并发场景下落地数据一致性(先更新 DB 再删缓存 + MQ 重试)和分布式锁(SET NX EX + UUID + Lua 解锁 + 看门狗续期)。
前置知识:
- 基本的键值数据库与 SQL 概念
- Go 语言基础(文中示例多为 Go,能读懂即可)
- 单机层面理解过进程 / 线程、网络 IO 的阻塞与多路复用
- 了解「主从复制」「哈希」等分布式基础概念(后文会展开)
本章你会动手做的事:
- 用 Go 跑通一个 Cache-Aside 读取流程,观察命中 / 未命中分支。
- 写一个小布隆过滤器,验证「说不存在一定不存在、说存在可能误判」的特性。
- 给热点 key 加随机 TTL,观察它如何分散过期压力、避免雪崩。
前言:为什么 Redis 能撑起高并发缓存
想象一家奶茶店:
- 没有缓存:每个顾客下单后,店员都要重新煮茶、调糖、加冰——慢得要命
- 有缓存:店员提前备好几桶常用底茶(红茶、绿茶),顾客来了直接调配——快很多
- Redis:就像一个超级智能的备料柜,不仅备料快,还能自动淘汰过期的、统计哪些最热门、多台机器协同备料
Redis 能做到单机 10 万+ QPS,靠的不是蛮力,而是一套精密的内部机制。本教程将从底层原理到工程实践,带你完整理解 Redis 高可用高性能缓存的方方面面。
graph TD
A[Redis 高可用高性能缓存] --> B[核心机制]
A --> C[分布式架构]
A --> D[缓存问题]
A --> E[高并发治理]
B --> B1[缓存原理与设计]
B --> B2[数据类型与底层结构]
B --> B3[事务与IO多路复用]
B --> B4[持久化与淘汰策略]
C --> C1[Cluster集群与哈希槽]
D --> D1[穿透/雪崩/击穿]
D --> D2[布隆过滤器]
E --> E1[高并发数据一致性]
E --> E2[hotkey/bigkey]
E --> E3[分布式锁]一、Redis 缓存原理与设计
1.1 Redis 是什么
Redis(Remote Dictionary Server)是一个基于内存的键值对数据库,它把所有数据存在内存里,所以读写速度极快。
┌──────────────────────────┐
客户端 ────────→ │ Redis Server │
(GET/SET) │ ┌────────────────────┐ │
←──────────── │ │ 内存数据区 │ │
│ │ key1 → value1 │ │
│ │ key2 → value2 │ │
│ │ key3 → value3 │ │
│ └────────────────────┘ │
│ ┌────────────────────┐ │
│ │ 持久化(可选) │ │
│ │ RDB / AOF │ │
│ └────────────────────┘ │
└──────────────────────────┘
类比:把 Redis 想象成办公桌上随手可及的「便签本」(内存),而 MySQL 是文件柜里锁着的「档案盒」(磁盘)。查便签本秒回,翻档案盒得走流程。Redis 就是把热点数据摊在桌面上、随取随用的那个角色。
这张图说明 Redis 在应用与数据库之间的位置:
graph LR
App[应用请求] --> Redis[(Redis 内存缓存)]
App --> DB[(MySQL 磁盘数据库)]
Redis -->|未命中时回源| DB
Redis -->|命中时直接返回| App1.2 为什么 Redis 这么快
| 原因 | 说明 |
|---|---|
| 全内存操作 | 数据在内存中,读写都是纳秒级,远快于磁盘的毫秒级 |
| 单线程模型 | 避免了多线程的上下文切换和锁竞争,核心命令单线程执行 |
| IO 多路复用 | 一个线程同时处理大量客户端连接,不阻塞 |
| 高效数据结构 | 底层用 SDS、跳表、压缩列表等精心设计的数据结构 |
| C 语言实现 | 贴近操作系统,执行效率高 |
1.3 单线程为什么不慢
很多人疑惑:单线程怎么会快?关键在于 Redis 的瓶颈不是 CPU,而是内存和网络。
graph LR
subgraph 传统多线程数据库
T1[线程1] -->|加锁| DB1[(数据库)]
T2[线程2] -->|等锁| DB1
T3[线程3] -->|等锁| DB1
T4[线程4] -->|等锁| DB1
end
subgraph Redis单线程
R1[IO多路复用] -->|事件循环| R2[命令执行]
R2 -->|无锁竞争| R3[(内存)]
end类比:一条高速公路只有一条车道,但每辆车都是跑车且不堵车,比有十条车道但天天堵的路还快。
1.4 缓存设计模式
在引入 Redis 作为缓存时,通常有以下几种设计模式:
Cache-Aside(旁路缓存)— 最常用
// Cache-Aside 模式:应用先查缓存,缓存没有再查数据库,然后写入缓存
// GetUserByID 根据用户ID获取用户信息(Cache-Aside 模式)
// 参数:ctx 上下文,userID 用户ID
// 返回:用户信息或错误
func GetUserByID(ctx context.Context, userID int64) (*User, error) {
// 第一步:构造缓存 key
cacheKey := fmt.Sprintf("user:%d", userID)
// 第二步:先查 Redis 缓存
cached, err := redisClient.Get(ctx, cacheKey).Result()
if err == nil {
// 缓存命中!直接返回(不需要查数据库)
// Redis 命中率越高,数据库压力越小
var user User
json.Unmarshal([]byte(cached), &user)
return &user, nil
}
// 第三步:缓存未命中,查询数据库
user, err := db.QueryUser(ctx, userID)
if err != nil {
return nil, err
}
// 第四步:将数据库结果写入缓存(设置过期时间,防止数据一直不一致)
// 这里设置 30 分钟过期,是 "最终一致性" 的体现
userJSON, _ := json.Marshal(user)
redisClient.Set(ctx, cacheKey, userJSON, 30*time.Minute)
return user, nil
}
graph TD
A[应用请求数据] --> B{查询Redis缓存}
B -->|命中| C[直接返回缓存数据]
B -->|未命中| D[查询数据库]
D --> E[返回数据]
E --> F[写入Redis缓存]
F --> G[设置过期时间]Read-Through / Write-Through
// Read-Through:应用只和缓存交互,缓存自己去查数据库
// Write-Through:写入时同时写缓存和数据库
// CacheStore 封装了缓存和数据库的协同操作
type CacheStore struct {
redis *redis.Client
db *sql.DB
}
// Get Read-Through 模式
func (s *CacheStore) Get(ctx context.Context, key string) (string, error) {
// 先查缓存
val, err := s.redis.Get(ctx, key).Result()
if err == nil {
return val, nil // 命中缓存
}
// 缓存未命中,由 CacheStore(而不是应用层)去查数据库
val, err = s.db.QueryRow(ctx, "SELECT value FROM kv WHERE key = $1", key).String()
if err != nil {
return "", err
}
// 回填缓存
s.redis.Set(ctx, key, val, 30*time.Minute)
return val, nil
}
// Set Write-Through 模式
func (s *CacheStore) Set(ctx context.Context, key, value string) error {
// 先写缓存
err := s.redis.Set(ctx, key, value, 30*time.Minute).Err()
if err != nil {
return err
}
// 再写数据库
_, err = s.db.Exec(ctx, "UPDATE kv SET value = $1 WHERE key = $2", value, key)
return err
}
二、Redis 数据类型以及底层结构和原理
Redis 提供了 5 种核心数据类型 + 3 种高级数据类型。每种类型背后都有精心设计的底层数据结构。
2.1 全景图
graph TD
A[Redis 数据类型] --> B[5种核心类型]
A --> C[3种高级类型]
B --> B1[String 字符串]
B --> B2[List 列表]
B --> B3[Hash 哈希表]
B --> B4[Set 集合]
B --> B5[ZSet 有序集合]
C --> C1[Bitmap 位图]
C --> C2[HyperLogLog 基数统计]
C --> C3[Stream 消息流]
B1 --> B1S["底层:SDS / int / embstr"]
B2 --> B2S["底层:quicklist"]
B3 --> B3S["底层:ziplist / hashtable"]
B4 --> B4S["底层:intset / hashtable"]
B5 --> B5S["底层:ziplist / skiplist跳表"]2.2 String(字符串)
String 是 Redis 最基础的类型,一个 key 对应一个 value。虽然叫"字符串",但它可以存数字、文本、甚至二进制数据(图片、视频)。
底层结构:SDS(Simple Dynamic String)
Redis 没有直接用 C 语言的 char[],而是自己实现了 SDS:
// Redis SDS 结构体(简化版)
struct sdshdr {
int len; // 已使用长度(记录字符串实际有多长)
int free; // 剩余可用空间(预分配的空间,减少内存重分配次数)
char buf[]; // 实际存储数据的字符数组
};
// 为什么不用 C 的 char[]?
// 1. C 字符串获取长度要遍历 O(n),SDS 直接读 len 字段 O(1)
// 2. C 字符串修改时可能溢出(没有边界检查),SDS 有空间预分配
// 3. C 字符串不能存二进制(遇到 \0 就截断),SDS 用 len 判断结束
// 4. SDS 修改字符串时,如果空间够就不需要重新分配内存(空间预分配策略)
三种内部编码
| 编码 | 条件 | 说明 |
|---|---|---|
| int | 值是整数且 ≤ long 范围 | 直接存为 long 类型,节省空间 |
| embstr | 字符串长度 ≤ 44 字节 | RedisObject 和 SDS 连续分配在一块内存,一次 malloc |
| raw | 字符串长度 > 44 字节 | RedisObject 和 SDS 分开分配,两次 malloc |
// Go 中操作 Redis String 的示例
// 基本操作
val, err := rdb.Set(ctx, "name", "张三", 0).Result() // 设置,0表示永不过期
val, err = rdb.Get(ctx, "name").Result() // 获取
// 计数器操作(原子性,适合做点赞数、库存等)
rdb.Incr(ctx, "counter") // +1
rdb.IncrBy(ctx, "counter", 10) // +10
rdb.Decr(ctx, "counter") // -1
rdb.DecrBy(ctx, "counter", 5) // -5
// 设置过期时间
rdb.SetEX(ctx, "token", "abc123", 3600) // 1小时后过期
// 不存在才设置(分布式锁的基础)
ok, err := rdb.SetNX(ctx, "lock:order:1", "owner1", 10*time.Second).Result()
// ok=true 表示设置成功(获取到锁)
// ok=false 表示 key 已存在(没抢到锁)
2.3 List(列表)
List 是一个有序的字符串列表,支持从两端插入和弹出。
底层结构:quicklist
graph LR
subgraph "quicklist = 双向链表 + ziplist"
A["node1
ziplist
[a,b,c]"] <--> B["node2
ziplist
[d,e,f]"] <--> C["node3
ziplist
[g,h]"]
endquicklist 是"双向链表 + 压缩列表"的组合体:每个链表节点内部是一个 ziplist(压缩列表),兼顾了链表的灵活性和 ziplist 的小内存占用。
应用场景
// 场景1:消息队列(生产者-消费者模式)
// 生产者:从左端推入消息
rdb.LPush(ctx, "task_queue", "task1")
rdb.LPush(ctx, "task_queue", "task2")
// 消费者:从右端弹出消息(阻塞式,队列空时等待)
// BRPop 会阻塞直到有消息或超时
result, err := rdb.BRPop(ctx, 30*time.Second, "task_queue").Result()
// result = ["task_queue", "task1"](最先进去的先出来)
// 场景2:最新文章列表
// 每发一篇文章,用 LPUSH 加入列表,获取最新N篇用 LRANGE
rdb.LPush(ctx, "latest_posts", "article_id_100")
rdb.LPush(ctx, "latest_posts", "article_id_101")
// 获取最新的10篇文章
posts, _ := rdb.LRange(ctx, "latest_posts", 0, 9).Result()
2.4 Hash(哈希表)
Hash 是一个 key 下面存多个 field-value 对,类似 Go 的 map[string]string。
底层结构:ziplist / hashtable
| 编码 | 条件 | 说明 |
|---|---|---|
| ziplist | 元素少且值短 | 用压缩列表,省内存,遍历查找 |
| hashtable | 元素多或值长 | 用真正的哈希表,O(1) 查找 |
// Hash 操作示例:存储用户信息
// 设置用户的不同字段
rdb.HSet(ctx, "user:1", "name", "张三")
rdb.HSet(ctx, "user:1", "age", "25")
rdb.HSet(ctx, "user:1", "city", "北京")
// 也可以批量设置
rdb.HMSet(ctx, "user:2", map[string]interface{}{
"name": "李四",
"age": "30",
"city": "上海",
})
// 获取单个字段
name, _ := rdb.HGet(ctx, "user:1", "name").Result() // "张三"
// 获取所有字段
allFields, _ := rdb.HGetAll(ctx, "user:1").Result()
// map[string]string{"name":"张三", "age":"25", "city":"北京"}
// 场景:购物车
// key = cart:用户ID, field = 商品ID, value = 数量
rdb.HSet(ctx, "cart:1001", "product_001", "2")
rdb.HIncrBy(ctx, "cart:1001", "product_001", 1) // 数量+1
2.5 Set(集合)
Set 是一个无序的、不重复的字符串集合。
底层结构:intset / hashtable
| 编码 | 条件 | 说明 |
|---|---|---|
| intset | 全是整数且数量少 | 用紧凑的整数数组,省内存 |
| hashtable | 有非整数或数量多 | 用哈希表 |
// Set 操作示例
// 添加元素
rdb.SAdd(ctx, "tags:article1", "go", "redis", "cache")
rdb.SAdd(ctx, "tags:article2", "go", "database", "mysql")
// 求交集(共同标签)
common, _ := rdb.SInter(ctx, "tags:article1", "tags:article2").Result()
// ["go"]
// 求并集
all, _ := rdb.SUnion(ctx, "tags:article1", "tags:article2").Result()
// ["go", "redis", "cache", "database", "mysql"]
// 求差集(article1有但article2没有的)
diff, _ := rdb.SDiff(ctx, "tags:article1", "tags:article2").Result()
// ["redis", "cache"]
// 场景1:共同好友
rdb.SAdd(ctx, "friends:user1", "alice", "bob", "charlie")
rdb.SAdd(ctx, "friends:user2", "bob", "charlie", "david")
commonFriends, _ := rdb.SInter(ctx, "friends:user1", "friends:user2").Result()
// ["bob", "charlie"]
// 场景2:抽奖(随机弹出)
winner, _ := rdb.SPop(ctx, "lottery_participants").Result()
2.6 ZSet(有序集合)
ZSet 在 Set 的基础上,每个元素关联一个 score(分数),按分数自动排序。这是 Redis 最强大的数据类型之一。
底层结构:ziplist / skiplist(跳表)
graph TD
subgraph 跳表结构
L3["Level 3: ──────→ [30] ──────────────────→"]
L2["Level 2: ──→ [10] ──→ [30] ──────→ [50] →"]
L1["Level 1: → [5] → [10] → [20] → [30] → [40] → [50] →"]
L0["Level 0: → [5] → [10] → [20] → [30] → [40] → [50] →"]
L3 -.-> L2
L2 -.-> L1
L1 -.-> L0
end跳表的核心思想:在有序链表上加多级索引,让查找从 O(n) 降到 O(log n)。就像地铁的快线/慢线——快线只停大站,慢线每站都停,换乘几次就能快速到达。
// ZSet 操作示例:排行榜
// 添加玩家分数
rdb.ZAdd(ctx, "game_rank", redis.Z{Score: 3500, Member: "player1"})
rdb.ZAdd(ctx, "game_rank", redis.Z{Score: 2800, Member: "player2"})
rdb.ZAdd(ctx, "game_rank", redis.Z{Score: 4200, Member: "player3"})
rdb.ZAdd(ctx, "game_rank", redis.Z{Score: 1500, Member: "player4"})
// 增加分数(原子操作)
rdb.ZIncrBy(ctx, "game_rank", 500, "player4") // player4 从1500变成2000
// 获取排行榜 Top 3(按分数从高到低)
top3, _ := rdb.ZRevRangeWithScores(ctx, "game_rank", 0, 2).Result()
// [{player3 4200}, {player1 3500}, {player2 2800}]
// 获取某玩家的排名(从0开始)
rank, _ := rdb.ZRevRank(ctx, "game_rank", "player3").Result()
// 0(第一名)
// 获取某玩家的分数
score, _ := rdb.ZScore(ctx, "game_rank", "player3").Result()
// 4200
2.7 高级数据类型速览
| 类型 | 用途 | 典型场景 |
|---|---|---|
| Bitmap | 位操作 | 用户签到、活跃统计、布隆过滤器 |
| HyperLogLog | 基数估算(有误差) | UV 统计(不精确但极省内存) |
| Stream | 消息流 | 消息队列、事件溯源 |
| Geo | 地理位置计算 | 附近的人、距离计算 |
// Bitmap 示例:用户签到
// key = sign:用户ID:年月, offset = 日期-1, value = 1表示签到
// 用户1001在2024年1月5日签到
rdb.SetBit(ctx, "sign:1001:202401", 4, 1) // offset从0开始,5日对应offset=4
// 检查是否签到
signed, _ := rdb.GetBit(ctx, "sign:1001:202401", 4).Result()
// signed = 1 表示已签到
// 统计本月签到次数
count, _ := rdb.BitCount(ctx, "sign:1001:202401", 0, -1).Result()
// count = 签到天数
// HyperLogLog 示例:统计UV
// 每个独立访客访问时
rdb.PFAdd(ctx, "uv:page:home", "user1", "user2", "user3")
// 获取独立访客数(有约0.81%误差,但只需12KB内存)
uv, _ := rdb.PFCount(ctx, "uv:page:home").Result()
三、Redis 事务机制和 IO 多路复用
3.1 Redis 事务
Redis 的事务和 MySQL 的事务完全不同。Redis 事务的本质是:一组命令按顺序执行,中间不会被其他命令插入。
graph TD
A[Redis事务流程] --> B["1. MULTI 开启事务"]
B --> C["2. 命令入队(不执行)"]
C --> D["3. EXEC 执行所有命令"]
D --> E["4. 返回所有命令的结果"]
F[DISCARD] --> G["取消事务,放弃所有命令"]Redis 事务 vs MySQL 事务
| 对比项 | Redis 事务 | MySQL 事务 |
|---|---|---|
| 原子性 | 不支持(部分失败不会回滚) | 支持(全部成功或全部回滚) |
| 隔离性 | 支持(命令顺序执行) | 支持(多种隔离级别) |
| 持久性 | 不保证(取决于持久化配置) | 支持 |
| 回滚 | 不支持 | 支持 |
| 并发控制 | 单线程天然隔离 | 锁 + MVCC |
事务示例
// Go 中使用 Redis 事务(通过 Transaction 闭包)
// 场景:转账,从账户A扣钱,给账户B加钱
err := rdb.Watch(ctx, func(tx *redis.Tx) error {
// 1. 读取账户A的余额
balanceA, err := tx.Get(ctx, "account:A").Int()
if err != nil {
return err
}
// 2. 检查余额是否足够
if balanceA < 100 {
return errors.New("余额不足")
}
// 3. 执行事务:扣钱 + 加钱
_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
pipe.DecrBy(ctx, "account:A", 100) // A 扣100
pipe.IncrBy(ctx, "account:B", 100) // B 加100
return nil
})
return err
}, "account:A") // Watch account:A,如果事务期间被修改则自动重试
WATCH 实现乐观锁
// WATCH 实现乐观锁的原理
// 1. WATCH key → 监视某个 key
// 2. MULTI → 开启事务
// 3. 命令入队
// 4. EXEC → 执行时检查 key 是否被修改过
// - 如果没被修改 → 正常执行
// - 如果被修改了 → 事务取消,返回 nil
// 伪代码说明 WATCH 的乐观锁效果:
// 时间线 事务A 事务B
// T1 WATCH balance
// T2 MULTI
// T3 SET balance 200 SET balance 500
// T4 EXEC
// → 检测到 balance 被事务B修改了
// → 事务取消,返回 nil
// → 应用层需要重试
3.2 IO 多路复用
IO 多路复用是 Redis 单线程也能处理大量连接的核心秘密。
什么是 IO 多路复用
graph TD
subgraph 没有多路复用
A1[线程1] -->|阻塞等待| C1[客户端1]
A2[线程2] -->|阻塞等待| C2[客户端2]
A3[线程3] -->|阻塞等待| C3[客户端3]
A4["问题:1000个连接需要1000个线程"]
end
subgraph IO多路复用
B1[单线程] -->|epoll/select| M[多路复用器]
M -->|监听所有连接| C1
M -->|监听所有连接| C2
M -->|监听所有连接| C3
B1 -->|只处理有数据的连接| B2["1个线程管理1000+连接"]
end类比:餐厅服务员——没有多路复用时,一个服务员盯一桌客人(阻塞等待);有多路复用时,一个服务员轮流看所有桌子,哪桌按铃就服务哪桌。
epoll 工作原理
graph LR
subgraph epoll 三步走
S1["1. epoll_create()
创建epoll实例"] --> S2["2. epoll_ctl()
注册感兴趣的fd(文件描述符)"]
S2 --> S3["3. epoll_wait()
等待事件(有数据来了才返回)"]
end// epoll 核心API(Linux 系统调用)
// 1. 创建 epoll 实例
int epfd = epoll_create(1);
// 2. 注册要监听的文件描述符(比如客户端连接)
struct epoll_event event;
event.events = EPOLLIN; // 监听可读事件(有数据到达)
event.data.fd = client_fd; // 记录是哪个客户端
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &event);
// 3. 等待事件发生(阻塞,直到有事件)
struct epoll_event events[100];
int n = epoll_wait(epfd, events, 100, -1); // -1表示无限等待
// n = 有多少个事件发生
// 4. 遍历发生事件的fd,逐一处理
for (int i = 0; i < n; i++) {
int fd = events[i].data.fd;
// 读取该客户端的数据并处理
read(fd, buffer, sizeof(buffer));
// 执行Redis命令
executeCommand(buffer);
}
Redis 事件循环
graph TD
A[Redis 事件循环 Event Loop] --> B["epoll_wait 等待事件"]
B --> C{事件类型?}
C -->|新连接| D[accept 新客户端]
C -->|可读| E["读取客户端命令"]
C -->|可写| F["发送响应给客户端"]
C -->|定时| G[执行定时任务]
E --> H["解析命令"]
H --> I["执行命令(单线程)"]
I --> J["将结果写入输出缓冲区"]
J --> F
D --> B
F --> B
G --> BRedis 把所有操作都放到一个事件循环里:有新连接就 accept,有数据就读、解析、执行,有输出就写回。整个过程单线程,不需要加锁。
四、Redis 持久化机制以及缓存过期和淘汰策略
4.1 RDB(Redis Database)持久化
RDB 是把 Redis 内存中的数据全量快照写入磁盘。
graph TD
A[RDB 触发方式] --> B[手动: SAVE/BGSAVE]
A --> C[自动: 定时触发]
A --> D[ shutdown 关闭时触发]
B --> B1["SAVE: 阻塞主线程
(期间无法处理命令)"]
B --> B2["BGSAVE: fork子进程
(不阻塞主线程)"]
C --> C1["save 900 1: 900秒内1次修改"]
C --> C2["save 300 10: 300秒内10次修改"]
C --> C3["save 60 10000: 60秒内10000次修改"]BGSAVE 的 fork + COW 机制
graph TD
A["Redis 主进程"] -->|fork| B["子进程"]
B --> C["遍历所有数据
写入 RDB 文件"]
A --> D["继续处理客户端命令
(不阻塞)"]
E["Copy-On-Write 写时复制"] --> F["子进程和父进程共享物理内存"]
F --> G["父进程收到写命令时"]
G --> H["操作系统复制对应内存页
(只复制被修改的页)"]# redis.conf 中的 RDB 配置
# 自动触发条件(满足任一条件就触发 BGSAVE)
save 900 1 # 900秒(15分钟)内至少1次修改
save 300 10 # 300秒(5分钟)内至少10次修改
save 60 10000 # 60秒内至少10000次修改
# 如果上面三条都不想要,可以写 save "" 完全关闭 RDB
# RDB 文件名
dbfilename dump.rdb
# 存储目录
dir /var/lib/redis/
# 压缩(用 LZF 压缩,减少文件大小,但增加 CPU 开销)
rdbcompression yes
# 校验和(写入 CRC64 校验,防止文件损坏)
rdbchecksum yes
4.2 AOF(Append Only File)持久化
AOF 是把每一条写命令追加到日志文件中,类似 MySQL 的 Binlog。
graph LR
A[客户端执行写命令] --> B[Redis执行命令]
B --> C[将命令写入AOF缓冲区]
C --> D[根据策略刷入AOF文件]
D --> E[追加写入磁盘]AOF 三种刷盘策略
| 策略 | 配置值 | 说明 | 性能 | 数据安全 |
|---|---|---|---|---|
| always | appendfsync always | 每条命令都刷盘 | 最慢 | 最安全(最多丢1条命令) |
| everysec | appendfsync everysec | 每秒刷一次(默认) | 较好 | 较安全(最多丢1秒数据) |
| no | appendfsync no | 让操作系统决定何时刷 | 最好 | 不安全(可能丢失较多) |
# redis.conf 中的 AOF 配置
# 开启 AOF
appendonly yes
# AOF 文件名
appendfilename "appendonly.aof"
# 刷盘策略(推荐 everysec)
appendfsync everysec
# AOF 重写触发条件
auto-aof-rewrite-percentage 100 # 文件比上次重写后大了100%就重写
auto-aof-rewrite-min-size 64mb # 文件至少64MB才触发重写
AOF 重写
AOF 文件会越来越大(因为记录了所有写命令),需要定期重写压缩。
graph TD
subgraph 重写前 AOF文件很大
A1["SET name 张三"]
A2["SET name 李四"]
A3["SET name 王五"]
A4["DEL name"]
A5["SET name 赵六"]
end
subgraph 重写后 AOF文件很小
B1["SET name 赵六"]
end
D[丢弃 不产生最终 AOF]
A1 --> |重写| B1
A2 --> |丢弃| D
A3 --> |丢弃| D
A4 --> |丢弃| D
A5 --> |合并| B1重写的原理:fork 子进程,遍历当前内存数据,生成最精简的命令。中间5条历史命令只需要最终结果1条。
4.3 RDB vs AOF 对比
| 对比项 | RDB | AOF |
|---|---|---|
| 持久化方式 | 全量快照 | 增量命令日志 |
| 文件大小 | 小(二进制压缩) | 大(文本命令) |
| 恢复速度 | 快(直接加载) | 慢(需要重放命令) |
| 数据安全 | 可能丢失两次快照之间的数据 | 最多丢1秒(everysec) |
| CPU 开销 | BGSAVE 时 fork 有开销 | 持续写入有开销 |
| 推荐场景 | 备份、灾难恢复 | 数据安全要求高 |
生产环境推荐 RDB + AOF 同时开启,兼顾恢复速度和数据安全。Redis 4.0+ 支持混合持久化(AOF 文件前半段是 RDB,后半段是增量命令)。
4.4 缓存过期策略
Redis 可以为每个 key 设置过期时间(TTL),过期后 key 会被自动删除。
两种删除策略
graph TD
A[Redis 过期删除] --> B[惰性删除 Lazy Deletion]
A --> C[定期删除 Periodic Deletion]
B --> B1["访问key时检查是否过期"]
B1 --> B2["过期就删除,返回nil"]
B2 --> B3["优点:不额外消耗CPU"]
B3 --> B4["缺点:过期key不被访问就一直占内存"]
C --> C1["每隔一段时间随机抽样检查"]
C1 --> C2["删除过期的key"]
C2 --> C3["优点:主动清理"]
C3 --> C4["缺点:需要平衡频率和CPU消耗"]Redis 同时使用这两种策略:惰性删除兜底 + 定期删除主动清理。
Redis 的过期字典
// Redis 内部维护一个过期字典(expires dict)
// key → 过期时间戳(毫秒)
// 设置过期时间的命令
rdb.SetEX(ctx, "key1", "value", 60) // 60秒后过期
rdb.Expire(ctx, "key2", 120) // 已存在的key设置120秒过期
rdb.ExpireAt(ctx, "key3", time.Now().Add(1*time.Hour)) // 在指定时间过期
// 查看剩余TTL
ttl, _ := rdb.TTL(ctx, "key1").Result()
// ttl = 剩余秒数(-1表示永不过期,-2表示key不存在)
// 取消过期时间(变成永久key)
rdb.Persist(ctx, "key1")
4.5 缓存淘汰策略
当 Redis 内存满了(达到 maxmemory 限制),需要淘汰一些 key 来腾空间。这就是淘汰策略。
graph TD
A["内存使用 > maxmemory"] --> B[触发淘汰策略]
B --> C{选择的策略}
C -->|noeviction| D["不淘汰,写入直接报错"]
C -->|allkeys-lru| E["从所有key中淘汰最久未使用的"]
C -->|allkeys-lfu| F["从所有key中淘汰使用频率最低的"]
C -->|allkeys-random| G["从所有key中随机淘汰"]
C -->|volatile-lru| H["从设了过期的key中淘汰最久未使用的"]
C -->|volatile-lfu| I["从设了过期的key中淘汰频率最低的"]
C -->|volatile-random| J["从设了过期的key中随机淘汰"]
C -->|volatile-ttl| K["从设了过期的key中淘汰TTL最短的"]八种淘汰策略详解
| 策略 | 全称 | 淘汰范围 | 算法 | 推荐场景 |
|---|---|---|---|---|
| noeviction | no eviction | 不淘汰 | — | 不允许丢失数据(写报错) |
| allkeys-lru | all keys LRU | 所有 key | 最近最少使用 | 缓存最常用(推荐) |
| allkeys-lfu | all keys LFU | 所有 key | 最少使用频率 | 有明显冷热区分的数据 |
| allkeys-random | all keys random | 所有 key | 随机 | 无访问模式 |
| volatile-lru | volatile LRU | 设了过期的 key | LRU | 部分数据可淘汰 |
| volatile-lfu | volatile LFU | 设了过期的 key | LFU | 部分数据可淘汰 |
| volatile-random | volatile random | 设了过期的 key | 随机 | 部分数据可淘汰 |
| volatile-ttl | volatile TTL | 设了过期的 key | TTL 最短 | 优先淘汰快过期的 |
LRU 和 LFU 的区别
graph TD
subgraph LRU 最近最少使用
L1["关注:多久没被访问"]
L2["淘汰:最久没被访问的key"]
L3["问题:偶尔被访问的冷数据会被保留"]
end
subgraph LFU 最少使用频率
F1["关注:被访问了多少次"]
F2["淘汰:访问频率最低的key"]
F3["优势:能识别真正热门和冷门数据"]
end# redis.conf 中的内存淘汰配置
# 最大内存限制(建议设置为物理内存的 50%~70%)
maxmemory 4gb
# 淘汰策略(缓存场景推荐 allkeys-lru)
maxmemory-policy allkeys-lru
# LRU 采样数量(越大越精确但越慢,默认5)
maxmemory-samples 5
# LFU 相关配置
lfu-log-factor 10 # 计数器增长因子,越大增长越慢
lfu-decay-time 1 # LFU 计数衰减周期(分钟)
五、Redis Cluster 模式与哈希槽算法
5.1 为什么需要集群
单机 Redis 有以下瓶颈:
graph TD
A[单机Redis瓶颈] --> B[内存上限:单机通常不超过256GB]
A --> C[吞吐量上限:单机约10万QPS]
A --> D[单点故障:宕机全部不可用]
A --> E[网络带宽:万兆网卡也是上限]Redis Cluster 通过数据分片解决这些问题:把数据分散到多个节点,每个节点负责一部分数据。
5.2 哈希槽算法
Redis Cluster 把所有数据划分为 16384 个哈希槽(slot),每个节点负责一部分槽。
graph TD
A["key → CRC16(key) % 16384"] --> B["计算结果 = 槽位编号"]
B --> C{槽位属于哪个节点?}
C -->|0~5460| D["Node A"]
C -->|5461~10922| E["Node B"]
C -->|10923~16383| F["Node C"]// Go 中使用 Redis Cluster
// 创建 Cluster 客户端
clusterClient := redis.NewClusterClient(&redis.ClusterOptions{
Addrs: []string{
":7000", // 节点A
":7001", // 节点B
":7002", // 节点C
":7003", // 节点A的从节点
":7004", // 节点B的从节点
":7005", // 节点C的从节点
},
// 读写分离配置
RouteRandomly: true, // 读请求可以打到从节点
})
// 使用方式和单机完全一样!Cluster 客户端会自动路由
clusterClient.Set(ctx, "user:1", "张三", 0)
val, _ := clusterClient.Get(ctx, "user:1").Result()
// 如果 key 的槽位在 Node B 上,客户端会自动路由到 Node B
// 这个过程对应用透明(不需要手动指定节点)
为什么是 16384 个槽
- 心跳消息小:集群节点间通过 Gossip 协议通信,需要传输每个节点负责的槽位信息。16384 位 = 2KB,网络开销可控
- 节点数量合理:Redis 官方建议集群不超过 1000 个节点,16384 个槽足够分配
- CRC16 足够分散:CRC16 算法对 16384 取模能保证数据均匀分布
5.3 集群架构
graph TD
subgraph Redis Cluster
subgraph 分片1
A1["Master A
槽位 0-5460"]
A2["Slave A
(复制Master A)"]
end
subgraph 分片2
B1["Master B
槽位 5461-10922"]
B2["Slave B
(复制Master B)"]
end
subgraph 分片3
C1["Master C
槽位 10923-16383"]
C2["Slave C
(复制Master C)"]
end
end
A1 -.->|主从复制| A2
B1 -.->|主从复制| B2
C1 -.->|主从复制| C2故障转移
graph TD
A["Master A 宕机"] --> B["集群检测到心跳超时"]
B --> C["标记 Master A 为下线(PFAIL→FAIL)"]
C --> D["Slave A 发起选举"]
D --> E["超过半数Master同意 → Slave A 当选"]
E --> F["Slave A 升级为 Master"]
F --> G["接管槽位 0-5460"]
G --> H["集群恢复正常"]5.4 其他 Redis 高可用方案对比
| 方案 | 架构 | 特点 | 适用场景 |
|---|---|---|---|
| 主从复制 | 1主N从 | 读写分离,手动故障转移 | 小规模,读多写少 |
| Sentinel 哨兵 | 主从 + 哨兵集群 | 自动故障转移,但单主写入 | 中规模 |
| Cluster | 多主多从分片 | 数据分片 + 自动故障转移 | 大规模,高吞吐 |
graph TD
A[Redis高可用演进] --> B[主从复制]
B -->|加自动故障转移| C[Sentinel 哨兵模式]
C -->|加分片能力| D[Cluster 集群模式]
B --> B1["1主N从,读写分离
缺点:故障需手动切换"]
C --> C1["哨兵监控主从
主挂了自动选从升级
缺点:单主写入瓶颈"]
D --> D1["多主多从分片
写入水平扩展
缺点:不支持跨槽事务"]5.5 Cluster 的限制
| 限制 | 说明 | 解决方案 |
|---|---|---|
| 不支持跨槽操作 | MSET k1 v1 k2 v2 如果 k1 和 k2 在不同槽会报错 | 使用 {tag} 让相关 key 在同一槽 |
| 不支持多数据库 | 只能使用 db0 | — |
| 不支持 SELECT | — | — |
| 事务有限制 | 只能在同一槽内执行 | {tag} |
// Hash Tag 示例:让相关 key 落到同一个槽
// 不加 tag:user:1 和 user:1:profile 可能在不同节点
// 加 tag {user1}:CRC16 只对大括号内的 user1 计算,保证同槽
clusterClient.Set(ctx, "user:{user1}", "基本信息", 0)
clusterClient.Set(ctx, "user:{user1}:profile", "详细资料", 0)
clusterClient.Set(ctx, "user:{user1}:orders", "订单列表", 0)
// 这三个 key 的 CRC16 都是 CRC16("user1") % 16384
// 所以一定在同一个节点上,可以做事务操作
六、缓存穿透、雪崩、击穿
这是缓存系统最经典的三大问题,面试必考。
6.1 三大问题全景对比
graph TD
A[缓存三大问题] --> B[缓存穿透]
A --> C[缓存雪崩]
A --> D[缓存击穿]
B --> B1["查一个根本不存在的数据
缓存和DB都没有
每次请求都打到DB"]
C --> C1["大量key同时过期
或Redis宕机
请求全部涌向DB"]
D --> D1["一个热点key突然过期
大量请求同时查DB
DB瞬间压力暴增"]6.2 缓存穿透(Penetration)
问题:查询一个数据库中根本不存在的数据,缓存自然也没有,每次请求都穿透到数据库。
graph LR
A[请求 userID=999999
(不存在)] --> B{查Redis}
B -->|未命中| C{查MySQL}
C -->|不存在| D[返回空]
D --> E["不写入缓存(因为数据不存在)"]
E --> F["下次请求又穿透到DB"]
F -->|攻击者重复请求| G["DB被打垮"]解决方案
// 方案1:缓存空值(最简单)
func GetUserByID(ctx context.Context, userID int64) (*User, error) {
cacheKey := fmt.Sprintf("user:%d", userID)
// 查缓存
cached, err := redisClient.Get(ctx, cacheKey).Result()
if err == nil {
if cached == "NULL" {
// 缓存中存的是空值标记,说明数据库中不存在
// 直接返回,不再查数据库
return nil, ErrNotFound
}
var user User
json.Unmarshal([]byte(cached), &user)
return &user, nil
}
// 查数据库
user, err := db.QueryUser(ctx, userID)
if err == sql.ErrNoRows {
// 数据库中不存在,缓存一个空值标记(短过期时间)
// 过期时间设短一些(比如5分钟),防止之后新增了数据
redisClient.Set(ctx, cacheKey, "NULL", 5*time.Minute)
return nil, ErrNotFound
}
if err != nil {
return nil, err
}
// 存在,写入缓存
userJSON, _ := json.Marshal(user)
redisClient.Set(ctx, cacheKey, userJSON, 30*time.Minute)
return user, nil
}
// 方案2:布隆过滤器(更优雅,下一节详解)
func GetUserWithBloom(ctx context.Context, userID int64) (*User, error) {
cacheKey := fmt.Sprintf("user:%d", userID)
// 第一步:先查布隆过滤器,判断 key 是否可能存在
exists, err := bloomFilter.TestString(cacheKey)
if !exists {
// 布隆过滤器说不存在 → 一定不存在,直接返回
// 这一层拦截了绝大多数无效请求
return nil, ErrNotFound
}
// 布隆过滤器说可能存在 → 继续查缓存和数据库
// ...(后续逻辑同上)
}
6.3 缓存雪崩(Avalanche)
问题:大量 key 在同一时间过期,或者 Redis 宕机,导致大量请求同时涌入数据库。
graph TD
A["批量预热缓存时
所有key设置相同过期时间"] --> B["时间到了,全部同时过期"]
B --> C["所有请求穿透到数据库"]
C --> D["数据库瞬间压力暴增"]
D --> E["数据库可能被压垮"]
E --> F["整个系统雪崩"]解决方案
// 方案1:过期时间加随机值,避免同时过期
func CacheUser(ctx context.Context, user *User) error {
cacheKey := fmt.Sprintf("user:%d", user.ID)
userJSON, _ := json.Marshal(user)
// 基础过期时间 30 分钟 + 随机 0~5 分钟
// 这样不同 key 的过期时间分散开,不会同时失效
baseTTL := 30 * time.Minute
randomTTL := time.Duration(rand.Intn(300)) * time.Second // 0~300秒随机
redisClient.Set(ctx, cacheKey, userJSON, baseTTL+randomTTL)
return nil
}
// 方案2:多级缓存(本地缓存 + Redis)
type MultiLevelCache struct {
localCache *lru.Cache // 本地缓存(进程内,极快)
redis *redis.Client // 分布式缓存
}
func (c *MultiLevelCache) Get(ctx context.Context, key string) (string, error) {
// 第一级:查本地缓存(纳秒级)
if val, ok := c.localCache.Get(key); ok {
return val.(string), nil
}
// 第二级:查 Redis(毫秒级)
val, err := c.redis.Get(ctx, key).Result()
if err == nil {
// 回填本地缓存(设置较短的本地TTL)
c.localCache.Add(key, val)
return val, nil
}
// 两级缓存都没有,查数据库
// ...
return "", ErrNotFound
}
// 方案3:熔断降级(Redis 宕机时保护数据库)
func GetWithCircuitBreaker(ctx context.Context, key string) (string, error) {
// 如果 Redis 不可用,启用熔断器
// 直接返回降级数据(默认值、空数据等),不查数据库
if circuitBreaker.IsOpen() {
return getDefaultValue(key), nil
}
val, err := redisClient.Get(ctx, key).Result()
if err != nil {
// Redis 异常,记录失败次数
circuitBreaker.RecordFailure()
return getDefaultValue(key), nil
}
circuitBreaker.RecordSuccess()
return val, nil
}
6.4 缓存击穿(Breakdown)
问题:一个热点 key 突然过期,大量并发请求同时查数据库。
graph TD
A["热点key(如秒杀商品)"] --> B["过期了!"]
B --> C["1000个请求同时发现缓存没了"]
C --> D["1000个请求同时查数据库"]
D --> E["数据库被一个热点key打垮"]解决方案
// 方案1:互斥锁(只让一个请求查数据库,其他等待)
var (
mu sync.Mutex // 互斥锁
locks sync.Map // 每个key一把锁(避免全局锁影响其他key)
)
func GetHotKey(ctx context.Context, key string) (string, error) {
// 先查缓存
val, err := redisClient.Get(ctx, key).Result()
if err == nil {
return val, nil // 缓存命中
}
// 缓存未命中,获取该key的互斥锁
// 使用 sync.Map 为每个 key 创建独立的锁
lockIface, _ := locks.LoadOrStore(key, &sync.Mutex{})
lock := lockIface.(*sync.Mutex)
lock.Lock()
defer lock.Unlock()
// 双重检查:拿到锁后再查一次缓存(可能其他请求已经重建了缓存)
val, err = redisClient.Get(ctx, key).Result()
if err == nil {
return val, nil // 别人已经重建好了
}
// 确实没有,查数据库
val = queryFromDatabase(key)
// 写回缓存(设置较长的过期时间)
redisClient.Set(ctx, key, val, 1*time.Hour)
// 清理锁
locks.Delete(key)
return val, nil
}
// 方案2:热点 key 永不过期(逻辑过期)
// 不给热点key设置TTL,而是在value中存一个逻辑过期时间
type CacheItem struct {
Value string `json:"value"`
ExpireTime time.Time `json:"expire_time"` // 逻辑过期时间
}
func GetWithLogicalExpire(ctx context.Context, key string) (string, error) {
cached, err := redisClient.Get(ctx, key).Result()
if err != nil {
return "", ErrNotFound
}
var item CacheItem
json.Unmarshal([]byte(cached), &item)
// 检查逻辑过期时间
if time.Now().Before(item.ExpireTime) {
// 未过期,直接返回
return item.Value, nil
}
// 逻辑已过期,但缓存还在(物理未过期)
// 异步重建缓存,当前请求返回旧数据(可接受的短暂不一致)
go func() {
// 加分布式锁,防止多个线程同时重建
ok, _ := redisClient.SetNX(ctx, "lock:"+key, "1", 30*time.Second).Result()
if ok {
// 拿到锁,重建缓存
newVal := queryFromDatabase(key)
newItem := CacheItem{
Value: newVal,
ExpireTime: time.Now().Add(1 * time.Hour),
}
newJSON, _ := json.Marshal(newItem)
redisClient.Set(ctx, key, newJSON, 0) // 0 = 永不物理过期
redisClient.Del(ctx, "lock:"+key)
}
}()
// 返回旧数据(虽然过期了,但还能用)
return item.Value, nil
}
6.5 三大问题对比总结
| 问题 | 本质 | 触发条件 | 核心方案 |
|---|---|---|---|
| 穿透 | 查不存在的数据 | 恶意攻击、爬虫 | 缓存空值 + 布隆过滤器 |
| 雪崩 | 大量key同时失效 | 批量预 heatsame TTL | 随机TTL + 多级缓存 + 熔断 |
| 击穿 | 热点key失效 | 热点数据过期 | 互斥锁 + 逻辑过期 |
七、布隆过滤器(Bloom Filter)
7.1 什么是布隆过滤器
布隆过滤器是一种概率型数据结构,特点:
- 说"不存在" → 一定不存在(100%准确)
- 说"存在" → 可能存在(有误判率)
类比:一个不太靠谱的门卫。你说你不认识某人,门卫信了(一定不认识);但你说你认识某人,门卫可能认错(把相似的人当成认识的人)。
graph LR
A[输入元素] --> B["多个Hash函数
h1, h2, h3..."]
B --> C["计算多个位图位置"]
C --> D{所有位置都是1?}
D -->|是| E["可能存在(有误判率)"]
D -->|否| F["一定不存在"]7.2 原理图解
graph TD
subgraph 初始化 空的位数组
A0["bit[0]=0 bit[1]=0 bit[2]=0 ... bit[19]=0"]
end
subgraph 插入元素 hello
B1["h1(hello)=3 → bit[3]=1"]
B2["h2(hello)=7 → bit[7]=1"]
B3["h3(hello)=15 → bit[15]=1"]
end
subgraph 插入元素 world
C1["h1(world)=7 → bit[7]=1(已经是1)"]
C2["h2(world)=12 → bit[12]=1"]
C3["h3(world)=15 → bit[15]=1(已经是1)"]
end
subgraph 查询元素 test
D1["h1(test)=3 → bit[3]=1 ✓"]
D2["h2(test)=12 → bit[12]=1 ✓"]
D3["h3(test)=18 → bit[18]=0 ✗"]
D4["结论:一定不存在(因为bit[18]=0)"]
end7.3 代码实现
// 使用 Go 实现 Bloom Filter
import (
"hash/fnv"
"math"
)
// BloomFilter 布隆过滤器结构体
type BloomFilter struct {
bitSet []bool // 位数组(实际应用中用 bitmap 更省内存)
bitSize uint // 位数组大小
hashCount uint // Hash函数个数
}
// NewBloomFilter 根据预期元素数量和误判率创建布隆过滤器
// 参数:expectedItems 预期元素数量,falsePositiveRate 期望误判率(如0.01表示1%)
func NewBloomFilter(expectedItems uint, falsePositiveRate float64) *BloomFilter {
// 计算需要的位数组大小
// 公式:m = -n * ln(p) / (ln(2)²)
// n=元素数量,p=误判率
m := uint(math.Ceil(float64(expectedItems) * math.Abs(math.Log(falsePositiveRate)) / (math.Log(2) * math.Log(2))))
// 计算需要的Hash函数个数
// 公式:k = (m/n) * ln(2)
k := uint(math.Ceil(float64(m) / float64(expectedItems) * math.Log(2)))
return &BloomFilter{
bitSet: make([]bool, m),
bitSize: m,
hashCount: k,
}
}
// hashN 使用双Hash法生成第 i 个Hash值
// 原理:h_i(x) = (h1(x) + i * h2(x)) % m
// 这样只需要两个基础Hash函数,就能生成 k 个Hash值
func (bf *BloomFilter) hashN(data []byte, i uint) uint {
// 第一个Hash函数:FNV-1a
h1 := fnv.New32a()
h1.Write(data)
hash1 := h1.Sum32()
// 第二个Hash函数:FNV-1
h2 := fnv.New32()
h2.Write(data)
hash2 := h2.Sum32()
// 双Hash法生成第 i 个Hash值
return uint((uint(hash1) + uint(i)*uint(hash2)) % bf.bitSize)
}
// Add 向布隆过滤器中添加元素
func (bf *BloomFilter) Add(data []byte) {
for i := uint(0); i < bf.hashCount; i++ {
pos := bf.hashN(data, i) // 计算第 i 个Hash位置
bf.bitSet[pos] = true // 将对应位置设为1
}
}
// Test 检查元素是否可能存在
// 返回false → 一定不存在
// 返回true → 可能存在(有误判概率)
func (bf *BloomFilter) Test(data []byte) bool {
for i := uint(0); i < bf.hashCount; i++ {
pos := bf.hashN(data, i)
if !bf.bitSet[pos] {
// 只要有一个位置是0,就一定不存在
return false
}
}
// 所有位置都是1,可能存在(也可能是误判)
return true
}
// 实际应用:防止缓存穿透
// 启动时预热:把数据库中所有用户ID加入布隆过滤器
func InitBloomFilter(ctx context.Context) {
// 创建布隆过滤器:预期100万用户,误判率1%
bf = NewBloomFilter(1000000, 0.01)
// 从数据库加载所有用户ID
userIDs, _ := db.GetAllUserIDs(ctx)
for _, id := range userIDs {
bf.Add([]byte(fmt.Sprintf("user:%d", id)))
}
}
// 查询时先用布隆过滤器过滤
func GetUser(ctx context.Context, userID int64) (*User, error) {
cacheKey := fmt.Sprintf("user:%d", userID)
// 布隆过滤器检查
if !bf.Test([]byte(cacheKey)) {
// 一定不存在,直接返回,不查缓存和数据库
// 这一层拦截了99%以上的无效请求
return nil, ErrNotFound
}
// 可能存在,继续查缓存
cached, err := redisClient.Get(ctx, cacheKey).Result()
if err == nil {
// ... 返回缓存数据
}
// 查数据库
// ...
}
7.4 布隆过滤器的优缺点
| 优点 | 缺点 |
|---|---|
| 空间效率极高(1亿数据只需约120MB) | 有误判率(说存在但实际不存在) |
| 查询和插入都是 O(k)(k=Hash函数个数) | 不能删除元素(位数组无法撤销) |
| 实现简单 | 需要预先知道数据量(动态扩容复杂) |
不能删除的原因:多个元素可能共享同一个 bit 位。删除元素 A 时如果把 A 对应的 bit 清零,可能影响元素 B。
八、高并发场景下的数据一致性
8.1 问题背景
当 Redis 作为缓存时,数据同时存在 Redis 和 MySQL 两处。更新数据时,先更新哪个?先删缓存还是后删缓存?
graph TD
A[数据一致性问题的根源] --> B["数据在两处:
Redis(缓存)+ MySQL(数据库)"]
B --> C["更新时无法保证两处同时成功"]
C --> D["并发读写下可能出现不一致"]8.2 常见更新策略对比
| 策略 | 流程 | 问题 |
|---|---|---|
| 先更新数据库,再更新缓存 | DB→Redis | 并发下缓存可能被旧值覆盖 |
| 先更新缓存,再更新数据库 | Redis→DB | DB失败导致数据丢失 |
| 先删缓存,再更新数据库 | Del Redis→DB | 并发下可能读旧值回填缓存 |
| 先更新数据库,再删缓存(推荐) | DB→Del Redis | 删除失败导致短暂不一致 |
8.3 各策略的问题分析
策略1:先更新缓存,再更新数据库
sequenceDiagram
participant A as 线程A
participant B as 线程B
participant R as Redis
participant D as MySQL
Note over A,D: 并发更新同一数据
A->>D: UPDATE balance=200
B->>D: UPDATE balance=300
B->>D: COMMIT
B->>R: SET balance=300
A->>D: COMMIT
A->>R: SET balance=200(旧值覆盖了新值!)
Note over R: Redis=200 但 DB=300 → 不一致!策略2:先删缓存,再更新数据库
sequenceDiagram
participant T as 线程A(更新)
participant R as 读线程B
participant Redis as Redis
participant DB as MySQL
T->>Redis: DEL balance(删除缓存)
R->>Redis: GET balance(未命中)
R->>DB: SELECT balance(读到旧值100)
T->>DB: UPDATE balance=200
T->>DB: COMMIT
R->>Redis: SET balance=100(旧值回填!)
Note over Redis: Redis=100 但 DB=200 → 不一致!策略3:先更新数据库,再删缓存(推荐)
graph TD
A["1. 更新数据库 MySQL"] --> B["2. 删除缓存 Redis"]
B --> C["下次读请求来时"]
C --> D["缓存未命中"]
D --> E["查数据库拿到最新值"]
E --> F["回填缓存"]这是 Cache-Aside 模式的标准做法。虽然也有极端并发情况可能导致不一致,但概率极低。
8.4 延迟双删策略
针对"先删缓存再更新数据库"的并发问题,可以使用延迟双删:
// 延迟双删策略
func UpdateUserWithDoubleDelete(ctx context.Context, userID int64, newData *User) error {
cacheKey := fmt.Sprintf("user:%d", userID)
// 第一次删除缓存
redisClient.Del(ctx, cacheKey)
// 更新数据库
err := db.UpdateUser(ctx, userID, newData)
if err != nil {
return err
}
// 延迟一段时间后再次删除缓存
// 目的:等读线程把旧值回填后,再删一次
go func() {
time.Sleep(1 * time.Second) // 延迟时间需要大于"一次读请求耗时"
redisClient.Del(ctx, cacheKey)
}()
return nil
}
graph TD
A["1. 删除缓存"] --> B["2. 更新数据库"]
B --> C["3. 延迟等待(如1秒)"]
C --> D["4. 再次删除缓存"]
D --> E["确保旧值被清除"]
style A fill:#f9f,stroke:#333
style D fill:#f9f,stroke:#3338.5 最终一致性保障:消息队列 + 重试
如果"删除缓存"这一步失败了怎么办?用消息队列确保最终删除成功。
// 删除缓存失败时,通过消息队列异步重试
func UpdateUserWithMQ(ctx context.Context, userID int64, newData *User) error {
// 更新数据库
err := db.UpdateUser(ctx, userID, newData)
if err != nil {
return err
}
// 删除缓存
err = redisClient.Del(ctx, fmt.Sprintf("user:%d", userID)).Err()
if err != nil {
// 删除失败,发送消息到MQ,稍后重试
// 消息内容包含:要删除的key
mq.Publish("cache_delete_retry", map[string]interface{}{
"key": fmt.Sprintf("user:%d", userID),
"retry": 0, // 重试次数
})
}
return nil
}
// MQ 消费者:处理删除重试
func ConsumeCacheDelete() {
msgs := mq.Consume("cache_delete_retry")
for msg := range msgs {
key := msg["key"].(string)
retry := msg["retry"].(int)
err := redisClient.Del(ctx, key).Err()
if err != nil {
if retry < 3 {
// 重试次数未超限,再次投递(带延迟)
time.Sleep(time.Duration(retry+1) * time.Second) // 指数退避
mq.Publish("cache_delete_retry", map[string]interface{}{
"key": key,
"retry": retry + 1,
})
}
// 超过3次放弃(记录日志人工处理)
}
}
}
九、Hot Key 和 Big Key 的发现与解决
9.1 Hot Key(热 Key)
问题:某个 key 被大量并发访问,导致单个 Redis 节点 CPU 打满,成为瓶颈。
graph TD
A["热点key: product:秒杀商品100"] --> B["10万QPS全部打到同一个节点"]
B --> C["该节点CPU 100%"]
C --> D["其他节点空闲"]
D --> E["集群整体吞吐受限"]如何发现 Hot Key
| 方法 | 说明 | 优缺点 |
|---|---|---|
| redis-cli –hotkeys | Redis 4.0+ 内置命令 | 简单,但会扫描全量数据 |
| MONITOR 命令 | 捕获所有命令 | 精确,但影响性能(不推荐生产用) |
| 代理层统计 | 在 Twemproxy/Proxy 层统计 | 无侵入,但需要部署代理 |
| 业务预估 | 运营提前告知大促商品 | 最主动,但依赖业务配合 |
解决方案
// 方案1:本地缓存(多级缓存)
type HotKeyCache struct {
localCache sync.Map // 进程内本地缓存
redis *redis.Client
lock sync.Mutex
}
func (c *HotKeyCache) Get(ctx context.Context, key string) (string, error) {
// 第一级:查本地缓存(纳秒级,不走网络)
if val, ok := c.localCache.Load(key); ok {
return val.(string), nil
}
// 本地缓存没有,查 Redis
val, err := c.redis.Get(ctx, key).Result()
if err != nil {
return "", err
}
// 回填本地缓存(短TTL,如5秒,保证数据新鲜度)
c.localCache.Store(key, val)
time.AfterFunc(5*time.Second, func() {
c.localCache.Delete(key) // 5秒后自动过期
})
return val, nil
}
// 方案2:拆分热点 key
// 把一个热点 key 拆成多个副本,分散到不同节点
func SetHotKey(ctx context.Context, key, value string) error {
// 拆分成3个副本,分散到不同槽位
for i := 0; i < 3; i++ {
subKey := fmt.Sprintf("%s:%d", key, i) // 如 product💯0
redisClient.Set(ctx, subKey, value, 1*time.Hour)
}
return nil
}
func GetHotKey(ctx context.Context, key string) (string, error) {
// 随机选择一个副本读取,分散压力
i := rand.Intn(3)
subKey := fmt.Sprintf("%s:%d", key, i)
return redisClient.Get(ctx, subKey).Result()
}
9.2 Big Key(大 Key)
问题:某个 key 的 value 过大,导致内存不均、操作阻塞、网络拥塞。
graph TD
A[Big Key 问题] --> B["内存不均:单key占几GB
导致节点内存倾斜"]
A --> C["操作阻塞:DEL大key耗时
阻塞主线程"]
A --> D["网络拥塞:一次传输几MB
占用大量带宽"]
A --> E["过期风暴:大key过期时
一次性删除大量数据"]Big Key 的标准
| 类型 | Big Key 阈值 |
|---|---|
| String | 单个 value > 10KB |
| Hash/List/Set/ZSet | 元素数量 > 5000 或 总大小 > 10MB |
| Stream | 消息数量 > 10000 |
如何发现 Big Key
# 方法1:redis-cli --bigkeys 命令(扫描采样)
redis-cli --bigkeys
# 方法2:redis-cli --memkeys 命令(Redis 7.0+,按内存排序)
redis-cli --memkeys
# 方法3:MEMORY USAGE 命令查看单个key的内存占用
redis-cli MEMORY USAGE mykey
解决方案
// 方案1:拆分大 key
// 把一个大的 Hash 拆成多个小 Hash
// 拆分前:一个Hash存10万用户信息
// HSET users:user_info field1 value1 field2 value2 ...
// 拆分后:按用户ID取模拆成100个小Hash
func GetUserFromSplitHash(ctx context.Context, userID int64) (string, error) {
// 根据 userID 取模决定存到哪个子 Hash
shard := userID % 100
hashKey := fmt.Sprintf("users:user_info:shard:%d", shard)
field := fmt.Sprintf("user:%d", userID)
return redisClient.HGet(ctx, hashKey, field).Result()
}
// 方案2:压缩大 value
// 把大的 JSON 用 gzip 压缩后再存
import "compress/gzip"
func SetCompressed(ctx context.Context, key, value string) error {
// 压缩
var buf bytes.Buffer
gz := gzip.NewWriter(&buf)
gz.Write([]byte(value))
gz.Close()
// 存压缩后的数据
return redisClient.Set(ctx, key, buf.Bytes(), 0).Err()
}
// 方案3:异步删除大 key(Redis 4.0+)
// 不要用 DEL(阻塞),用 UNLINK(异步删除)
// 错误做法:DEL 大 key 会阻塞主线程
// redisClient.Del(ctx, "bigkey") // 可能阻塞几秒!
// 正确做法:UNLINK 异步删除
redisClient.Do(ctx, "UNLINK", "bigkey") // 后台异步删除,不阻塞
// 方案4:分批删除大 Hash 中的元素
func DeleteBigHash(ctx context.Context, hashKey string) error {
// 每次删除100个field
batchSize := 100
for {
// 使用 HSCAN 安全遍历(不会阻塞)
fields, cursor, _ := redisClient.HScan(ctx, hashKey, 0, "", batchSize).Result()
if len(fields) == 0 {
break // 没有元素了
}
// 批量删除这些 field
if len(fields) > 0 {
redisClient.HDel(ctx, hashKey, fields...)
}
if cursor == 0 {
break // 遍历完毕
}
}
// 最后删除空 Hash 本身
redisClient.Del(ctx, hashKey)
return nil
}
十、Redis 并发竞争问题与分布式锁
10.1 并发竞争问题
在分布式系统中,多个服务实例同时操作 Redis 同一个 key 时,会产生竞争条件。
sequenceDiagram
participant A as 实例A
participant B as 实例B
participant R as Redis
Note over A,R: 场景:扣减库存
A->>R: GET stock = 10
B->>R: GET stock = 10
A->>R: SET stock = 9(10-1)
B->>R: SET stock = 9(10-1)
Note over R: 最终 stock=9,但应该是8!
少扣了一次10.2 分布式锁的核心要求
| 要求 | 说明 |
|---|---|
| 互斥性 | 同一时刻只有一个客户端能持有锁 |
| 可重入性 | 同一客户端可以多次获取同一把锁 |
| 防死锁 | 锁必须有过期时间,防止持有者崩溃导致永久锁死 |
| 高可用 | 锁服务本身要高可用 |
| 锁归属 | 释放锁时必须验证是不是自己的锁(防止误解锁) |
10.3 Redis 分布式锁实现
版本1:基础版(SETNX + EXPIRE)
// 基础版:使用 SETNX + 过期时间
func TryLockV1(ctx context.Context, key string, ttl time.Duration) (bool, error) {
// SETNX:不存在才设置(原子操作)
// 同时设置过期时间(防止死锁)
ok, err := redisClient.SetNX(ctx, key, "locked", ttl).Result()
if err != nil {
return false, err
}
return ok, nil
}
func UnlockV1(ctx context.Context, key string) error {
// 直接删除
return redisClient.Del(ctx, key).Err()
}
// 问题:
// 1. 任何客户端都能解锁(不安全,A的锁可能被B解了)
// 2. 不支持可重入
// 3. 锁过期后其他客户端获取锁,原客户端还以为是自己持有
版本2:安全版(UUID 标识 + Lua 原子解锁)
import "github.com/google/uuid"
// 安全版:用 UUID 标识锁的持有者
// TryLock 获取分布式锁
// 参数:key 锁名称,ttl 过期时间
// 返回:lockValue(解锁时用),是否获取成功
func TryLockV2(ctx context.Context, key string, ttl time.Duration) (string, bool, error) {
// 生成唯一标识,证明"这把锁是我的"
lockValue := uuid.New().String()
// SET key value NX EX seconds
// NX:不存在才设置(保证互斥)
// EX:设置过期时间(防止死锁)
ok, err := redisClient.SetNX(ctx, key, lockValue, ttl).Result()
if err != nil {
return "", false, err
}
if !ok {
return "", false, nil // 获取失败(锁被其他人持有)
}
return lockValue, true, nil
}
// Unlock 释放分布式锁
// 关键:必须用 Lua 脚本保证"判断+删除"是原子操作
func UnlockV2(ctx context.Context, key, lockValue string) error {
// Lua 脚本:先判断锁是不是自己的,是才删除
// 如果不用 Lua,GET+DEL 两步之间可能被其他人修改
luaScript := `
if redis.call("get", KEYS[1]) == ARGV[1] then
-- 锁的值和传入的lockValue一致,说明是自己的锁
return redis.call("del", KEYS[1])
else
-- 不是自己的锁,不能删
return 0
end
`
// 执行 Lua 脚本(Redis 保证 Lua 脚本的原子性)
result, err := redisClient.Eval(ctx, luaScript, []string{key}, lockValue).Result()
if err != nil {
return err
}
if result.(int64) == 0 {
return errors.New("解锁失败:锁已过期或不是你的锁")
}
return nil
}
graph TD
A["加锁:SET key UUID NX EX 30"] --> B{"返回 OK?"}
B -->|是| C["获取锁成功
lockValue=UUID"]
B -->|否| D["获取锁失败
锁被其他人持有"]
C --> E["执行业务逻辑"]
E --> F["解锁:Lua脚本
if GET key == UUID then DEL key"]
F --> G{"解锁成功?"}
G -->|是| H["完成"]
G -->|否| I["锁已过期被别人获取
记录日志告警"]版本3:锁续期(Watchdog 看门狗)
问题:如果业务执行时间超过锁的过期时间,锁会自动释放,其他客户端就能获取锁,导致并发问题。
// 锁续期:后台 goroutine 定期延长锁的过期时间
func TryLockWithWatchdog(ctx context.Context, key string, ttl time.Duration) (string, bool, error) {
lockValue := uuid.New().String()
ok, err := redisClient.SetNX(ctx, key, lockValue, ttl).Result()
if err != nil || !ok {
return "", false, err
}
// 启动看门狗 goroutine,定期续期
go func() {
ticker := time.NewTicker(ttl / 3) // 每隔 TTL/3 续期一次
defer ticker.Stop()
for {
select {
case <-ctx.Done():
// 上下文取消,停止续期
return
case <-ticker.C:
// 续期:用 Lua 脚本保证原子性
renewScript := `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("pexpire", KEYS[1], ARGV[2])
else
return 0
end
`
result, err := redisClient.Eval(ctx, renewScript,
[]string{key},
lockValue,
int64(ttl/time.Millisecond),
).Result()
if err != nil || result.(int64) == 0 {
// 续期失败(锁已过期或被删除)
return
}
// 续期成功,继续等待下一次续期
}
}
}()
return lockValue, true, nil
}
graph TD
A["获取锁 TTL=30秒"] --> B["启动看门狗"]
B --> C["每10秒检查一次"]
C --> D{"锁还在?"}
D -->|是| E["续期到30秒"]
E --> C
D -->|否| F["停止续期"]
C --> G{"业务完成?"}
G -->|是| H["取消看门狗 + 解锁"]
G -->|否| C10.4 Redlock 算法
单机 Redis 分布式锁在主从切换时可能丢失锁。Redlock 由 Redis 作者提出,使用多个独立 Redis 实例来提高可靠性。
graph TD
A["Redlock 流程"] --> B["向N个(通常5个)独立Redis实例
同时发送 SETNX"]
B --> C["记录每个实例的响应时间"]
C --> D{"超过半数(N/2+1)
成功获取锁?"}
D -->|是| E["获取锁成功"]
D -->|否| F["获取锁失败"]
E --> G["向所有实例发送解锁请求"]// Redlock 简化实现
type RedLock struct {
clients []*redis.Client // 多个独立的 Redis 实例
quorum int // 法定数量(半数以上)
}
func NewRedLock(addrs []string) *RedLock {
clients := make([]*redis.Client, len(addrs))
for i, addr := range addrs {
clients[i] = redis.NewClient(&redis.Options{Addr: addr})
}
return &RedLock{
clients: clients,
quorum: len(clients)/2 + 1, // 半数以上
}
}
func (rl *RedLock) TryLock(ctx context.Context, key string, ttl time.Duration) (string, bool, error) {
lockValue := uuid.New().String()
successCount := 0
start := time.Now()
// 向所有 Redis 实例发送加锁请求
for _, client := range rl.clients {
ok, err := client.SetNX(ctx, key, lockValue, ttl).Result()
if err == nil && ok {
successCount++
}
}
// 计算实际加锁消耗的时间
elapsed := time.Since(start)
// 判断是否获取成功:
// 1. 成功数超过半数
// 2. 加锁耗时小于TTL(否则锁可能已过期)
if successCount >= rl.quorum && elapsed < ttl {
return lockValue, true, nil
}
// 获取失败,向所有实例发送解锁请求(清理)
rl.Unlock(ctx, key, lockValue)
return "", false, nil
}
func (rl *RedLock) Unlock(ctx context.Context, key, lockValue string) {
// 向所有实例发送解锁请求(不管之前是否成功)
luaScript := `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`
for _, client := range rl.clients {
client.Eval(ctx, luaScript, []string{key}, lockValue)
}
}
10.5 分布式锁注意事项
| 注意点 | 说明 |
|---|---|
| 锁要有过期时间 | 防止持有者崩溃导致永久死锁 |
| 解锁要验证归属 | 用 UUID + Lua 脚本,防止误解锁 |
| 锁续期要可靠 | 业务超时时需要看门狗续期 |
| 不要锁太久 | TTL 尽量短,减少锁持有期间的故障风险 |
| Redlock 有争议 | Martin Kleppmann 认为 Redlock 在时钟漂移下不安全,生产中可用 ZooKeeper/etcd 替代 |
十一、面试高频考点速记
| 考点 | 一句话总结 |
|---|---|
| Redis 为什么快 | 全内存 + 单线程无锁 + IO 多路复用 + 高效数据结构 |
| SDS vs C 字符串 | SDS 有 len 字段 O(1) 获取长度、二进制安全、空间预分配 |
| 跳表原理 | 有序链表 + 多级索引,查找 O(log n),ZSet 底层结构 |
| RDB vs AOF | RDB 全量快照恢复快但可能丢数据,AOF 增量日志数据安全但恢复慢 |
| 惰性删除 + 定期删除 | Redis 同时用两种过期策略:访问时检查 + 定时抽样清理 |
| LRU vs LFU | LRU 看最后访问时间,LFU 看访问频率 |
| 哈希槽 | CRC16(key) % 16384,16384 个槽分配到多个节点 |
| 缓存穿透 | 查不存在的数据,方案:缓存空值 + 布隆过滤器 |
| 缓存雪崩 | 大量 key 同时过期,方案:随机 TTL + 多级缓存 + 熔断 |
| 缓存击穿 | 热点 key 过期,方案:互斥锁 + 逻辑过期 |
| 布隆过滤器 | 说不存在一定不存在,说存在可能误判;不能删除 |
| 数据一致性 | 推荐:先更新 DB 再删缓存,失败用 MQ 重试 |
| Hot Key | 本地缓存 + 拆分副本 |
| Big Key | 拆分 + 压缩 + UNLINK 异步删除 |
| 分布式锁 | SET NX EX + UUID + Lua 原子解锁 + 看门狗续期 |
| Redlock | 多实例半数以上成功才算获取锁,提高容错性 |
自测题与动手练习
自测题(合上书能答出来,才算懂):
- 有人说「Redis 是单线程,所以并发能力弱」。请用 Redis 瓶颈(CPU vs 内存 / 网络)和 IO 多路复用解释为什么这个判断是错的。
- ZSet 的底层是跳表。请说明「在有序链表上加多级索引」如何把查找从 O(n) 降到 O(log n),以及为什么不用平衡树。
- 缓存穿透、雪崩、击穿三者的「触发条件」和「核心解法」分别是什么?请各用一句话区分,不要混为一谈。
- 数据一致性为什么不推荐「先更新缓存,再更新数据库」或「先删缓存,再更新数据库」?为什么「先更新 DB 再删缓存」仍是推荐做法?
- 分布式锁只用
SETNX加过期时间有什么隐患?为什么解锁必须用 UUID + Lua 脚本做原子判断?
动手练习(建议真做一遍):
- 用 Go 实现一个
GetUserByID的 Cache-Aside 流程,故意把数据库查不到的 key 缓存为"NULL"(短 TTL),观察穿透防护效果。 - 实现文中
NewBloomFilter,插入 100 万个元素后用误判率 1% 验证「不存在一定不存在」,并测出实际误判次数。 - 用
redis-cli --hotkeys和--bigkeys扫描你本地的一个实例,按文中阈值找出 hot key / big key,并实践用UNLINK异步删除一个大 key。
本章小结
- Redis 的高性能来自「全内存 + 单线程无锁竞争 + epoll 多路复用 + SDS / 跳表等精妙结构」,瓶颈在内存与网络而非 CPU。
- 数据类型选型要看底层:String 用 SDS,ZSet 用跳表保证有序,List 用 quicklist 兼顾灵活与小内存。
- 缓存三大问题各有专属解法:穿透靠空值 / 布隆过滤器,雪崩靠随机 TTL + 多级缓存 + 熔断,击穿靠互斥锁 + 逻辑过期。
- 持久化用 RDB(快恢复)+ AOF(少丢数据)组合;内存打满时按 allkeys-lru 等策略淘汰最不热的数据。
- 集群用 16384 哈希槽分片,数据一致性推荐「先更新 DB 再删缓存 + MQ 重试」,并发安全靠分布式锁(SET NX EX + UUID + Lua + 看门狗)。
下一章可顺着「数据分片」的思路,继续深入 Redis 的运维与调优,把本篇的工程结论落到真实压测与监控上。