高可用高性能缓存应用

2023-11-22T14:21:02+08:00 | 38分钟阅读 | 更新于 2023-11-22T14:21:02+08:00

@

学习目标

学完本章,你应该能够:

  1. 说清 Redis 为什么能扛住高并发:用「全内存 + 单线程无锁 + IO 多路复用 + 高效数据结构」四句话讲透性能来源。
  2. 对照 SDS、跳表、quicklist 等底层结构,解释 String/List/Hash/Set/ZSet 各自为什么这样设计,面试时能讲成工程故事。
  3. 画出 Cache-Aside、Read/Write-Through 的读写流程,并针对缓存穿透 / 雪崩 / 击穿给出缓存空值、布隆过滤器、随机 TTL、互斥锁等应对方案。
  4. 讲清 RDB 与 AOF 的取舍、惰性 / 定期删除与八种淘汰策略,以及 Redis Cluster 哈希槽分片的路由与故障转移。
  5. 在并发场景下落地数据一致性(先更新 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 -->|命中时直接返回| App

1.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]"] end

quicklist 是"双向链表 + 压缩列表"的组合体:每个链表节点内部是一个 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 --> B

Redis 把所有操作都放到一个事件循环里:有新连接就 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 三种刷盘策略

策略配置值说明性能数据安全
alwaysappendfsync always每条命令都刷盘最慢最安全(最多丢1条命令)
everysecappendfsync everysec每秒刷一次(默认较好较安全(最多丢1秒数据)
noappendfsync 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 对比

对比项RDBAOF
持久化方式全量快照增量命令日志
文件大小小(二进制压缩)大(文本命令)
恢复速度快(直接加载)慢(需要重放命令)
数据安全可能丢失两次快照之间的数据最多丢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最短的"]

八种淘汰策略详解

策略全称淘汰范围算法推荐场景
noevictionno eviction不淘汰不允许丢失数据(写报错)
allkeys-lruall keys LRU所有 key最近最少使用缓存最常用(推荐)
allkeys-lfuall keys LFU所有 key最少使用频率有明显冷热区分的数据
allkeys-randomall keys random所有 key随机无访问模式
volatile-lruvolatile LRU设了过期的 keyLRU部分数据可淘汰
volatile-lfuvolatile LFU设了过期的 keyLFU部分数据可淘汰
volatile-randomvolatile random设了过期的 key随机部分数据可淘汰
volatile-ttlvolatile TTL设了过期的 keyTTL 最短优先淘汰快过期的

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)"]
    end

7.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→DBDB失败导致数据丢失
先删缓存,再更新数据库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:#333

8.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 –hotkeysRedis 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 -->|否| C

10.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 AOFRDB 全量快照恢复快但可能丢数据,AOF 增量日志数据安全但恢复慢
惰性删除 + 定期删除Redis 同时用两种过期策略:访问时检查 + 定时抽样清理
LRU vs LFULRU 看最后访问时间,LFU 看访问频率
哈希槽CRC16(key) % 16384,16384 个槽分配到多个节点
缓存穿透查不存在的数据,方案:缓存空值 + 布隆过滤器
缓存雪崩大量 key 同时过期,方案:随机 TTL + 多级缓存 + 熔断
缓存击穿热点 key 过期,方案:互斥锁 + 逻辑过期
布隆过滤器说不存在一定不存在,说存在可能误判;不能删除
数据一致性推荐:先更新 DB 再删缓存,失败用 MQ 重试
Hot Key本地缓存 + 拆分副本
Big Key拆分 + 压缩 + UNLINK 异步删除
分布式锁SET NX EX + UUID + Lua 原子解锁 + 看门狗续期
Redlock多实例半数以上成功才算获取锁,提高容错性

自测题与动手练习

自测题(合上书能答出来,才算懂)

  1. 有人说「Redis 是单线程,所以并发能力弱」。请用 Redis 瓶颈(CPU vs 内存 / 网络)和 IO 多路复用解释为什么这个判断是错的。
  2. ZSet 的底层是跳表。请说明「在有序链表上加多级索引」如何把查找从 O(n) 降到 O(log n),以及为什么不用平衡树。
  3. 缓存穿透、雪崩、击穿三者的「触发条件」和「核心解法」分别是什么?请各用一句话区分,不要混为一谈。
  4. 数据一致性为什么不推荐「先更新缓存,再更新数据库」或「先删缓存,再更新数据库」?为什么「先更新 DB 再删缓存」仍是推荐做法?
  5. 分布式锁只用 SETNX 加过期时间有什么隐患?为什么解锁必须用 UUID + Lua 脚本做原子判断?

动手练习(建议真做一遍)

  1. 用 Go 实现一个 GetUserByID 的 Cache-Aside 流程,故意把数据库查不到的 key 缓存为 "NULL"(短 TTL),观察穿透防护效果。
  2. 实现文中 NewBloomFilter,插入 100 万个元素后用误判率 1% 验证「不存在一定不存在」,并测出实际误判次数。
  3. 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 的运维与调优,把本篇的工程结论落到真实压测与监控上。

About Me

没什么想介绍的,一个很大众的码农…

喜欢代码,车,马,真的是 🐎

讨厌别人让我给自己的代码写注释 最厌烦别人的程序没有写注释

目标

学AI,加油!加油!