Skip to content

Redis 深度解析 ​

#组件 · #Redis · #缓存 · #数据结构 · #分布式 · #哨兵 · #集群 · #RDB · #AOF · #jemalloc

从进程模型到数据结构底层实现,覆盖 Redis 核心设计思想与工程实践。

一、进程/线程模型 ​

1.1 单线程事件循环 ​

Redis 6.0 之前是纯粹的单线程模型,所有命令执行都在一个线程中串行完成:

mermaid
flowchart LR
    A["客户端连接"] --> B["epoll_wait<br/>I/O多路复用就绪"]
    B --> C["命令解析<br/>RESP 协议解码"]
    C --> D["命令执行<br/>操作内存数据结构"]
    D --> E["返回结果给客户端"]
    E --> B

核心机制:aeEventLoop 事件驱动框架,底层基于 epoll (Linux) / kqueue (FreeBSD) / select。单线程循环:等待事件 → 读取命令 → 执行命令 → 写回响应,周而复始。

原因说明
纯内存操作绝大部份命令 O(1),无磁盘 I/O 阻塞
非阻塞 I/O 多路复用epoll_wait 同时监听数千连接,只处理就绪 fd
避免上下文切换无线程切换开销,无锁竞争
精心设计的数据结构针对内存和缓存优化(SDS/ziplist/skiplist 等)

1.2 6.0+ 多线程 I/O ​

Redis 6.0 引入 I/O 线程,但命令执行仍是单线程:

mermaid
flowchart TB
    subgraph IOThreads["I/O 线程池 (读/写 socket)"]
        T0["IO Thread 0"]
        T1["IO Thread 1"]
        T2["IO Thread 2"]
    end

    IOThreads -->|"解析后的命令"| Main["主线程<br/>串行执行所有命令<br/>操作内存数据结构"]
    Main -->|"响应数据"| IOThreads

关键:I/O 线程只负责读 socket 字节流和写回响应,不执行命令。所有对内存数据结构的操作仍在主线程中串行进行。

配置:io-threads 4 io-threads-do-reads yes

1.3 后台线程 ​

Redis 一直有后台线程处理阻塞操作:

后台线程作用
bio_close_file异步关闭大文件
bio_aof_fsync异步 AOF fsync 刷盘
bio_lazy_free异步惰性删除大 key(UNLINK、FLUSHDB ASYNC)

1.4 通信协议 — RESP ​

Redis 使用 RESP(REdis Serialization Protocol)作为客户端-服务端之间的通信协议:

  • 基于 TCP 的文本协议,人类可读,易于实现和调试
  • 5 种数据类型标识:
类型前缀示例
Simple String++OK\r\n
Error--ERR unknown command\r\n
Integer::1000\r\n
Bulk String$$5\r\nhello\r\n (长度 + 数据)
Array**2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n

协议示例:

text
// 客户端发送: SET name redis
*3\r\n$3\r\nSET\r\n$4\r\nname\r\n$5\r\nredis\r\n

// 服务端回复: +OK\r\n

RESP2 vs RESP3(Redis 6.0+ 开始支持 RESP3):

特性RESP2RESP3
复杂度简单,所有值扁平化引入 Map/Set/Boolean 等新类型
推送消息不支持原生支持 Pub/Sub 推送
浮点数Bulk String 封装原生 Double 类型
兼容性所有客户端支持需客户端适配

为什么用文本协议而非二进制协议(如 Protobuf)?Redis 定位是高性能但简单的 KV 存储,文本协议易于理解、调试和实现多语言客户端,解析开销相比纯内存操作可忽略。

连接方式:

Client ──TCP── Redis Server (:6379)
              │
              ├── 明文传输(默认,内网环境)
              └── TLS 加密(6.0+, 公网/跨机房)

Pipeline:客户端可连续发送多条命令不等待回复,服务端批量返回,大幅减少 RTT:

text
// 无 Pipeline: Ping-Pong 模式
C: SET a 1 → S: +OK → C: SET b 2 → S: +OK → C: GET a → S: $1\n1

// Pipeline: 批量模式
C: SET a 1\nSET b 2\nGET a\nGET b  →  S: +OK\n+OK\n$1\n1\n$1\n2

Pipeline 不是原子操作,中间可能插入其他客户端的命令。需要原子性请用 MULTI/EXEC 事务或 Lua 脚本。


二、核心数据结构与底层实现 ​

2.1 SDS(Simple Dynamic String) ​

Redis 的 String 类型本质是 Key-String 的映射。每个 String Value 对应的内存结构是 redisObject + SDS。

SDS 结构(Redis 3.2+ 根据长度细分,此处以通用结构示意):

c
// Redis 3.2+ 实际有 sdshdr5/8/16/32/64 五种,按长度自动选择
struct sdshdr {
    int len;      // 已使用长度
    int free;     // 剩余空间 (buf 总长 - len - 1)
    char buf[];   // 柔性数组,末尾自动补 '\0' 兼容 C 函数
};

内存布局示意(embstr 编码):

┌──────────────┬──────┬──────┬──────────────────────────┐
│ redisObject  │ len  │ free │ buf[...] '\0'            │
│ (16B)        │ (4B) │ (4B) │ (len + 1 字节)           │
└──────────────┴──────┴──────┴──────────────────────────┘
←────────────── 一次 malloc ──────────────────────────────→

2.1.1 SDS vs 主流语言 String 实现深度对比 ​

维度C char*C++ std::stringGo stringRedis SDS
获取长度O(n) strlen 遍历O(1) .size()O(1) len()O(1) 读 len 字段
二进制安全❌ \0 截断✅✅✅
可变性可变可变不可变可变
内存布局仅数据本体SSO: ≤15B 栈上 + heap 长串16B 头(ptr+len) + 堆数据头(len/free) + 堆(buf)
扩容策略无 (手动 realloc)2× (MSVC 1.5×)不可扩容(需 strings.Builder)<1MB 预分配等长; ≥1MB 预分配 1MB
缓冲区溢出高风险安全不可变无此问题安全
C 函数兼容原生.c_str()需 []byte 转换原生(buf 末尾 \0)
内存碎片N/A频繁 append 可能每次"修改" = 新分配预分配减少碎片
典型适用系统/内核通用应用只读/并发安全高性能 KV 存储

逐语言分析:

C char* ​
┌───┬───┬───┬───┬───┬────┐
│ h │ e │ l │ l │ o │ \0 │   ← 仅数据,无元信息
└───┴───┴───┴───┴───┴────┘
  • 不以长度描述,以 \0 终结 → strlen 是 O(n)
  • strcat 无越界检查,缓冲区溢出是经典安全漏洞
  • 优点:零开销,适合内核和极简嵌入式场景
C++ std::string ​
// SSO: ≤15 字符时栈上存储,无需堆分配
struct basic_string {          // libstdc++ (GCC) 实现
    char*  ptr;                // 指向数据
    size_t size;               // 长度
    union {
        char   local_buf[16];  // SSO 栈缓冲区
        size_t capacity;       // 堆分配时的容量
    };
};
// 总大小: 32 字节 (x64)
  • SSO(Short String Optimization):短串直接存在对象内部栈空间,零堆分配,极快
  • 扩容因子通常 2×(MSVC 用 1.5× 减少内存碎片)
  • 缺点:每次扩容需复制全量数据,大串频繁 append 性能下降
Go string ​
go
type stringStruct struct {
    str unsafe.Pointer  // 指向底层字节数组
    len int             // 字节长度
}
// 总大小: 16 字节 (x64)
  • 不可变(Immutable):任何"修改"都会创建新 string,旧 string 可能被 GC
  • 优点:并发安全(只读),子串切片 O(1)(共享底层数组)
  • 缺点:频繁拼接用 + → O(n²) 拷贝,必须用 strings.Builder 优化
  • 与 SDS 的根本差异:Go 面向并发只读场景,SDS 面向高频可变场景
Redis SDS — 集各家之长 ​

Redis 3.2+ 按长度使用 5 种 SDS 头,极致节省内存:

c
struct __attribute__ ((__packed__)) sdshdr5  { uint8_t  flags; char buf[]; };  // <32B
struct __attribute__ ((__packed__)) sdshdr8  { uint8_t  len, alloc, flags; char buf[]; };  // <256B
struct __attribute__ ((__packed__)) sdshdr16 { uint16_t len, alloc; uint8_t flags; char buf[]; };  // <64KB
struct __attribute__ ((__packed__)) sdshdr32 { uint32_t len, alloc; uint8_t flags; char buf[]; };  // <4GB
struct __attribute__ ((__packed__)) sdshdr64 { uint64_t len, alloc; uint8_t flags; char buf[]; };  // ≥4GB

设计精要:

设计点说明
__packed__取消结构体对齐填充,节省内存
5 种头短串用 uint8_t,而非一律 4 字节 len,减少元数据开销
buf 末端 \0可直接传给 printf、strcmp 等 C 函数
惰性释放sdstrim() 后不立即 realloc,free 空间留给下次使用
预分配APPEND 高频场景下减少 realloc 次数,实际是空间换时间

编码与 redisObject 的关系:

redisObject 是 Redis 所有 key/value 的容器(16 字节):

redisObject {
    type:      4 bit   (string/list/hash/set/zset)
    encoding:  4 bit   (int/embstr/raw/ziplist/...)
    lru:       24 bit  (LRU 时钟 / LFU 访问计数)
    refcount:  32 bit  (引用计数)
    ptr:       64 bit  (指向实际数据)
}

三种 String 编码:

int:     redisObject.ptr 直接存整数值 (无额外分配)
          ┌─────────────┐
          │ redisObject  │ ptr = 42
          └─────────────┘

embstr:  redisObject + SDS 连续分配 (一次 malloc, ≤44B)
          ┌──────────────┬──────────────┐
          │ redisObject  │ SDS(含buf)   │
          └──────────────┴──────────────┘

raw:     redisObject + SDS 分开分配 (两次 malloc, >44B)
          ┌──────────────┐    ┌──────────────┐
          │ redisObject  │───→│ SDS(含buf)   │
          └──────────────┘    └──────────────┘

embstr 阈值为什么是 44?

64 字节是 jemalloc 的一个 bin 大小,redisObject 16B + sdshdr8 3B + buf 44B + \0 1B = 64B,刚好一个 cache line。超过则变为两次分配。

完整的内存布局推导:

jemalloc 的内存分配策略:
  jemalloc 将内存按 size class 分配:
    8, 16, 32, 48, 64, 80, 96, 112, 128, ...

  申请 ≤64B 的内存 → 分配 64B 的 bin(无浪费)
  申请 65B → 分配 80B 的 bin(浪费 15B)

Redis 的 embstr 编码目标: 让 redisObject + SDS 一次 malloc 放进 64B

内存布局:
  ┌──────────────────────────────────────────────────────────────────┐
  │                        64 字节 (1 个 cache line)                   │
  ├────────────────┬───────────────────────────────────────────────────┤
  │  redisObject   │              SDS (sdshdr8)                        │
  │    16 字节      │                                                   │
  ├────────────────┼──────┬──────┬──────────────────────────────┬─────┤
  │ type(4b)       │ len  │alloc │         buf (字符串内容)       │ \0  │
  │ encoding(4b)   │ (1B) │(1B)  │         最多 44 字节          │(1B) │
  │ lru(24b)       │      │      │                              │     │
  │ refcount(4B)   │      │      │                              │     │
  │ *ptr(8B)       │      │      │                              │     │
  ├────────────────┼──────┼──────┼──────────────────────────────┼─────┤
  │     16B        │  1B  │  1B  │           44B                │ 1B  │
  └────────────────┴──────┴──────┴──────────────────────────────┴─────┘
                    ↑ sdshdr8 的 flags 字段 1B 在哪?
                    实际: flags(1B) + len(1B) + alloc(1B) = 3B

  16B + 3B + 44B + 1B = 64B ✅

为什么这个设计好?

  1. 一次 malloc: embstr 只需一次内存分配(raw 需要两次: redisObject + SDS 分开分配)
     → 减少内存碎片 + 减少 malloc 系统调用

  2. 一个 cache line: 64B 恰好是 x86 CPU 的一个 cache line
     → 读取整个字符串只需一次内存访问(L1 cache hit 后全部可用)
     → raw 编码需要两次内存访问(先读 redisObject,再通过 ptr 跳转读 SDS)

  3. 只读优化: embstr 是只读的(修改时会转为 raw)
     → 因为修改可能导致长度变化 → 需要 realloc → 但 redisObject 和 SDS 是连续的
     → realloc 后 redisObject 的地址可能变了 → 所有引用都要更新 → 太复杂
     → 所以 embstr 一旦修改就转为 raw(两次分配,各自独立 realloc)

2.2 Dict(字典/Hash 底层) ​

Redis 的 Hash、Set(部分情况)、全局键空间都基于字典。

版本标注:以下代码基于 Redis ≤7.2 的实现。Redis 7.4(2024年7月发布)将 dict 内部实现重构为新的 hashtable 模块,核心变化:拉链法 → 开放寻址。

旧实现(≤7.2):拉链法

bucket 数组:
┌─────┬─────┬─────┬─────┬─────┐
│  0  │  1  │  2  │  3  │ ... │
└──┬──┴──┬──┴─────┴─────┴─────┘
   │     │
   ▼     ▼
 dictEntry → dictEntry → NULL   dictEntry → NULL
  {key,val}  {key,val}          {key,val}

冲突时用链表串联,弊端:

  • 每个 dictEntry 是独立 malloc 的,内存不连续 → 大量 cache miss
  • 每个 entry 多一个 next 指针(8 字节开销/key)
  • 查找时必须跟随链表指针跳转

新实现(7.4+):开放寻址

开放寻址的思路:不用链表,所有 entry 直接内联存储在 bucket 数组中。冲突时,按探测序列在数组内找下一个空闲 slot。

Redis 7.4 使用的具体探测策略是 Robin Hood 哈希(一种带位移平衡的线性探测变体):

线性探测(朴素版):
  hash("foo") → slot 2 被占 → 试 slot 3 → slot 4 → slot 5 → 找到空位
  问题:形成"聚集"(cluster),长链查找越来越慢

Robin Hood 哈希:
  每个 entry 额外存储一个 "PSL"(Probe Sequence Length,探测距离)
  = 当前所在位置 - hash 原始位置

  插入时:如果新 key 的 PSL > 当前位置的 PSL
    → "劫富济贫":踢走当前位置的 entry(它 PSL 小,离"家"近)
    → 被踢走的 entry 继续向前找位置
    → 最终效果:所有 entry 的 PSL 方差极小 → 查找距离稳定

  Robin Hood 哈希的核心性质:
    最大 PSL ≈ O(log n),远优于朴素线性探测的 O(√n)
    这意味着最坏查找也是对数级别的,而非根号级别
bucket 数组(连续内存):
┌─────────────┬─────────────┬─────────────┬─────────────┬─────────────┐
│ slot[0]     │ slot[1]     │ slot[2]     │ slot[3]     │ slot[4] ...  │
│ key,val     │ key,val     │ (empty)     │ key,val     │ (empty)      │
└─────────────┴─────────────┴─────────────┴─────────────┴─────────────┘

查找 key="foo" → hash("foo") = 2:
  检查 slot[2] → 不是 → 按探测序列找 slot[3] → 是

开放寻址 vs 拉链法对比:

维度拉链法(≤7.2)开放寻址(7.4+)
内存布局entry 散落各处(每次 malloc)单块连续数组(一次 malloc)
Cache 友好度差,链表跳转 ≈ cache miss好,顺序访问 slots
空间开销每 key 多 8B(next 指针)无额外指针开销
查找速度跟随链表,不可预测线性探测,CPU 预取友好
删除简单,从链表摘除需用 tombstone 标记(逻辑删除)

tombstone 删除的完整机制:

开放寻址中不能直接物理删除 entry——因为删除一个 slot 会打断探测链,导致后面的 entry 找不到。

示例:
  hash("A")=0, hash("B")=1, hash("C")=1
  slot[0]=A, slot[1]=B, slot[2]=C (B被hash冲突推到slot[1], C推到slot[2])

  物理删除 slot[1]=B:
    slot[0]=A, slot[1]=(空), slot[2]=C
    查找 C: hash("C")=1 → slot[1] 为空 → 停止 → 找不到 C!❌

  Tombstone 删除:
    slot[0]=A, slot[1]=(TOMBSTONE), slot[2]=C
    查找 C: hash("C")=1 → slot[1] 是 tombstone → 继续探测 → slot[2]=C ✅
    插入 D: hash("D")=1 → slot[1] 是 tombstone → 直接复用这个 slot

Tombstone 的清理:
  1. 新插入可以覆盖 tombstone(释放这个 slot)
  2. 达到一定比例(如 25%)后触发 rehash → 重建数组 → 彻底清除
  3. 渐进式 rehash 迁移时自然跳过 tombstone

为什么 Redis 现在才改? 不是 Redis 不想用开放寻址,而是渐进式 rehash 对开放寻址的设计要求更高——两张表同时存在时,开放寻址的"从 table[0] 迁移到 table[1]"需要处理更复杂的边界条件。7.4 的 hashtable 模块专门为渐进式 rehash 设计了迁移协议。

c
// Redis ≤7.2 的 dict 实现
typedef struct dict {
    dictType *type;      // 类型特定函数
    dictEntry **table[2]; // 两张哈希表 (用于渐进式 rehash)
    long size;            // 哈希表大小
    long used;            // 已用 entry 数
    int rehashidx;        // rehash 进度,-1 表示未进行
} dict;

typedef struct dictEntry {
    void *key;
    union { void *val; uint64_t u64; int64_t s64; } v;
    struct dictEntry *next;  // 拉链法解决冲突(7.4 改为开放寻址)
} dictEntry;

渐进式 Rehash:避免大字典 rehash 时 STW:

rehashidx = -1   →   正常状态
rehashidx = 0    →   开始 rehash, 数据在 table[0] 和 table[1]
rehashidx = N    →   第 N 个 slot 已迁移
rehashidx = -1   →   迁移完成,table[1] → table[0]

每次增删改查操作顺带迁移 1 个 slot,分摊开销。

渐进式 Rehash 的分摊分析——为什么不会影响响应时间?

问题: 如果一次性 rehash 100 万个 key → 需要遍历所有 slot → 阻塞 ~100ms
     Redis 单线程 → 这 100ms 内所有请求都被阻塞 → 不可接受

渐进式方案:
  1. 触发 rehash 时,分配新 table[1](大小 = table[0] × 2)
  2. 设置 rehashidx = 0
  3. 每次 CRUD 操作时,额外迁移 table[0][rehashidx] 这个 slot 的所有 entry
  4. rehashidx++
  5. 直到 rehashidx == table[0].size → 迁移完成 → 释放 table[0]

分摊代价分析:
  假设 table[0] 有 N 个 slot,总共 M 个 key
  每个 slot 平均有 M/N 个 entry(负载因子 = M/N ≈ 1)

  每次 CRUD 操作额外迁移 1 个 slot:
    迁移代价 = 遍历该 slot 的链表 ≈ O(M/N) ≈ O(1)(负载因子为 1 时)

  总迁移代价 = N 个 slot × O(1) = O(N)
  分摊到 N 次操作 → 每次操作额外 O(1) 代价 ✅

  最坏情况: 某个 slot 有很多 entry(哈希冲突严重)
    → 迁移该 slot 时代价 > O(1)
    → 但 Redis 限制了单次迁移的最大步数(dictRehash 的 n 参数)
    → 超过限制就停下,下次继续

rehash 期间的查找:
  先查 table[0] → 没找到 → 再查 table[1]
  → 最多两次查找 → 代价 O(1) + O(1) = O(1)

rehash 期间的插入:
  新 key 只插入 table[1](不插入 table[0])
  → 保证 table[0] 只减不增 → 最终一定能迁移完

定时器辅助:
  如果 Redis 空闲(没有客户端请求),serverCron 定时器也会主动迁移
  → 避免"如果没有请求,rehash 永远完不成"的问题
  → 每次定时器迁移 100 个 slot(dictRehashMilliseconds)

2.2.1 Hash 跨语言对比 ​

不同语言/框架的 HashMap/Dict 实现有根本性的设计差异:

维度Redis DictJava HashMapGo mapPython dict (3.6+)
冲突解决拉链法(单链表)拉链法 → 树化 (≥8)开放寻址开放寻址
扩容策略渐进式 (分摊)一次性全量渐进式 (evacuation)一次性全量
负载因子触发 rehash=1, 收缩=0.10.756.5 (扩容)2/3 (~0.67)
有序性❌❌❌ (迭代随机)✅ 插入序 (3.7 保证)
线程安全❌❌❌ (并发写 panic)❌ (GIL 保护)
缩减机制手动/惰性❌ 不缩减可能缩减可能缩减
内存结构两个 table 数组单数组 + 链表/树桶数组(bucket)索引数组 + entries 数组

逐语言分析:

Java HashMap (JDK 8+) ​
java
// 核心思想: 链长≥8 && 总容量≥64 → 链表转红黑树
class Node<K,V> {
    int hash;
    K key;
    V value;
    Node<K,V> next;   // 链表
}
// treeify: Node → TreeNode (红黑树)
  • 拉链转红黑树:极端哈希冲突下,查找从 O(n) 退化为 O(log n)
  • 一次性全量扩容:resize() 时可能引起 GC 停顿
  • Redis 为何不学?内存库数据量远小于 JVM 堆,O(n) 链遍历通常 OK;且渐进式 rehash 已解决停顿问题
Go map ​
go
// runtime/map.go — 开放寻址,非拉链法
type hmap struct {
    count     int      // 元素数量
    B         uint8    // 2^B = 桶数量
    buckets   unsafe.Pointer  // 桶数组
    oldbuckets unsafe.Pointer // 扩容时的旧桶
}
// 每个桶存 8 个 kv 对 + tophash
  • 开放寻址而非拉链:Go 认为缓存局部性更重要(遍历数组比遍历链表快)
  • 扩容分批进行(evacuation):类似 Redis 渐进式,但触发时机不同(赋值/删除时迁移 1-2 个桶)
  • tophash:用 key hash 的高 8 位做快速比较,避免每次都比较完整 key
  • 与 Redis 根本差异:Go 面向通用编程做了缓存友好优化,Redis 面向内存紧凑和渐进式做了设计
Python dict (3.6+ Compact Dict) ​
python
# 3.6+: 紧凑字典 (Compact Dict) — 两个数组分离
indices = [idx1, idx2, ...]   # 哈希索引表 (稀疏)
entries = [(hash,key,val), ...]  # 紧密 entry 数组 (按插入序)
  • 分离索引和条目,entries 按插入顺序紧密排列 → 自动保序
  • 比旧版节省内存且迭代更快
  • Redis 对比:应用场景不同,Redis 不需要迭代序保证,节省内存优先级更高
总结:Redis Dict 的独特设计 ​
设计原因
双 table支持渐进式 rehash,避免单次大迁移
拉链而非开放寻址实现简单,rehash 时节点拷贝成本低
不树化内存库数据规模可控,O(n) 链遍历可接受
不自动缩减惰性删除:避免频繁 alloc/free;通过 HT_RESIZE 手动触发
union val值可以是 ptr/u64/s64,减少小整数的额外内存分配

2.3 Ziplist(压缩列表) ​

Hash 和 ZSet 在小数据量时的底层编码,连续内存块:

┌────────┬────────┬───────┬───────┬─────┬───────┬────────┐
│zlbytes │zltail  │zllen  │entry1 │ ... │entryN │zlend   │
│(总字节) │(尾偏移) │(节点数)│       │     │       │(0xFF)  │
└────────┴────────┴───────┴───────┴─────┴───────┴────────┘

每个 entry 结构:

┌─────────────┬──────────────┬──────────┐
│ prevlen      │ encoding     │ data     │
│ (前节点长度)  │ (类型+长度)   │ (实际值)  │
└─────────────┴──────────────┴──────────┘
优点缺点
内存紧凑,无指针开销插入/删除可能连锁更新(prevlen 回溯)
CPU cache 友好不能太大(连锁更新代价 O(n²))

连锁更新的数学分析:

连锁更新的根因在于 prevlen 字段的动态长度:

prevlen 编码规则:
  前一个 entry 长度 < 254 字节 → prevlen 占 1 字节
  前一个 entry 长度 ≥ 254 字节 → prevlen 占 5 字节 (0xFE + 4字节长度)

触发条件:
  1. entry_i 原来是 <254 字节,它的 prevlen 占 1 字节
  2. entry_i 之前插入了一个 ≥254 字节的新 entry
  3. entry_i 的 prevlen 需要从 1 字节膨胀到 5 字节 → entry_i 自身变长 4 字节
  4. 如果 entry_i 变长后恰好也从 <254 变成了 ≥254
     → entry_{i+1} 的 prevlen 也要膨胀
     → 级联下去!

最坏情况分析:
  假设有一串连续的 entry,每个都是 253 字节 (prevlen=1B + encoding=1B + data=251B)
  在头部修改一个 entry 使其变为 257 字节:
    entry₁ 膨胀 4 字节 → 253+4=257 ≥ 254
    → entry₂ 的 prevlen 需要 5 字节 → entry₂ 也膨胀 → 257 ≥ 254
    → entry₃ 的 prevlen 需要 5 字节 → entry₃ 也膨胀 → ...
    → 整个链表 N 个 entry 都需要重新分配内存!

  每次 realloc 都是 O(N_entry) 的内存拷贝(因为 ziplist 是连续内存)
  → 总代价 O(N × N_entry) = O(n²)

这就是为什么 ziplist 只能用于小数据(≤6.2 时默认 `zset-max-ziplist-entries 128`,7.0+ 改用 listpack 后参数名为 `zset-max-listpack-entries`):
链表越长,连锁更新的风险越大,代价也越大。

2.4 Listpack(紧凑列表,替代 Ziplist) ​

Redis 7.0 起全面替代 ziplist,解决连锁更新问题:

┌─────────────┬───────┬───────┬─────┬───────┬────────┐
│ total_bytes │ num   │entry1 │ ... │entryN │ 0xFF   │
└─────────────┴───────┴───────┴─────┴───────┴────────┘

每个 entry 的 encoding 里包含自身总长度,不再依赖 prevlen,彻底消除连锁更新。

Listpack 如何消除连锁更新:

Ziplist entry:
  ┌─────────────┬──────────────┬──────────┐
  │ prevlen      │ encoding     │ data     │  ← prevlen 存的是前一个 entry 的长度!
  │ (1 或 5 字节) │              │          │     前一个变 → 我也得变 → 级联
  └─────────────┴──────────────┴──────────┘

Listpack entry:
  ┌──────────────┬──────────────┬──────────┬─────────────┐
  │ encoding      │ data         │ backlen  │             │
  │ (含自身总长度) │              │(自身长度) │             │
  └──────────────┴──────────────┴──────────┴─────────────┘

关键改变: 每个 entry 存的是**自己的**总长度(backlen),不是前一个 entry 的长度。

向前遍历: 读取当前 entry 末尾的 backlen → 知道当前 entry 有多长 → 指针前移 backlen 字节 → 到达前一个 entry
向后遍历: 读取当前 entry 的 encoding → 知道总长度 → 指针后移 total_len 字节 → 到达下一个 entry

为什么不会连锁更新?
  entry₁ 被修改变长 → entry₁ 的 encoding 和 backlen 更新(仅影响 entry₁ 自身)
  entry₂ 的 prevlen? → Listpack 根本没有 prevlen!
  → entry₂ 完全不受影响,后面的 entry 也都无关
  → 修改成本 O(1),与链表长度无关 ✅

Ziplist 和 Listpack 的对比:

维度ZiplistListpack
向前遍历读 prevlen读 backlen
向后遍历读 encoding读 encoding
修改后的影响范围可能连锁 O(n²)仅自身 O(1)
内存开销prevlen 1-5Bbacklen 1-5B(基本相同)
首次引入Redis 2.xRedis 5.0 (Stream), 7.0 (全面)

2.5 Skiplist(跳表,ZSet 底层) ​

2.5.1 为什么 ZSet 用跳表 + 字典双结构? ​

有序集合(ZSet)在数据量大时使用跳表 + 字典组合,各司其职:

ZSet:
┌─────────────┐     ┌─────────────────┐
│    dict     │     │   zskiplist     │
│ member→score│     │ score→member    │
│ (O(1) 查找) │     │ (O(log n) 范围) │
└─────────────┘     └─────────────────┘
       ↕ (member 和 score 在跳表节点中共享, 不重复存储)
c
typedef struct zskiplistNode {
    sds ele;          // member 值
    double score;     // 分值
    struct zskiplistNode *backward;     // 后退指针
    struct zskiplistLevel {
        struct zskiplistNode *forward;  // 前进指针
        unsigned long span;             // 跨度 (本层跨过节点数)
    } level[];        // 层级数组
} zskiplistNode;

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length;  // 节点数
    int level;             // 最大层数(不含表头)
} zskiplist;

2.5.2 层级生成与概率分析 ​

层级生成:随机投掷硬币法,每次有 P = 1/4 概率加一层,最大 64 层:

c
int zslRandomLevel(void) {
    int level = 1;
    while ((random() & 0xFFFF) < (ZSKIPLIST_P * 0xFFFF))  // P = 0.25
        level += 1;
    return (level < ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;  // max 64
}

为什么 P = 0.25 而非 0.5?

P 值每节点期望层数搜索复杂度内存开销
0.52.0O(log₂ n)每节点 ~2 层指针
0.25~1.33O(log₄ n) ≈ O(0.5×log₂ n)每节点 ~1.33 层指针
  • P=0.5:每节点平均 2 层,查找更快,但内存开销更大(每个 forward 指针 8B + span 8B = 16B)
  • P=0.25:每节点平均 1.33 层,内存节省 ~33%,查找只慢常数倍(仍为 O(log n))
  • Redis 作为内存库,选择内存优先,P=0.25 已足够

span 字段——Redis 跳表的独门武器:

Level 3:  head ──────────── span=2 ──────────→ 50 ──→ NULL
Level 2:  head ── span=1 ──→ 10 ── span=2 ──→ 50 ──→ NULL
Level 1:  head → 10 → 20 → 30 → 50 → NULL
          span: 1    1    1    1

ZRANK "30" 计算: 从 head 开始, 累计沿途 span = 1+1+1 = 3 (第3个元素, 从0开始)

span 让 ZRANK(查排名)和 ZRANGE(按排名查)都能在 O(log n) 完成,这是普通跳表做不到的。

2.5.3 跳表 vs 其他有序结构 ​

结构插入查找范围查询排名查询实现复杂度内存
跳表 (Redis)O(log n)O(log n)O(log n + M)O(log n)⭐⭐ 简单中
红黑树O(log n)O(log n)O(log n + M)O(n)*⭐⭐⭐⭐ 复杂低
B+ 树O(log n)O(log n)O(log n + M)O(log n)⭐⭐⭐⭐⭐低(磁盘友好)
有序数组O(n)O(log n)O(log n + M)O(1)⭐ 极简极低
二叉堆O(log n)O(n)❌ 不支持❌⭐⭐低

*红黑树排名查询需维护子树大小,标准实现不提供

ZSet 为什么用跳表而非红黑树/AVL?

  1. 实现简单:跳表插入只需改前后指针,红黑树需要旋转和变色,边界情况多
  2. 范围查询自然:跳表 Level 1 是完整有序链表,定位起点后直接遍历;红黑树需要中序遍历
  3. 支持排名:span 字段天然支持 rank,红黑树需要额外维护 size 字段
  4. 调试友好:跳表可视化简单,红黑树旋转难以直观理解

完整的量化对比——空间、时间、并发三个维度:

一、时间复杂度对比(N 个元素)

操作          跳表                红黑树              差异原因
查找          O(log N)            O(log N)            理论相同
插入          O(log N)            O(log N)            跳表常数更小(无旋转)
删除          O(log N)            O(log N)            跳表常数更小(无旋转)
范围查询      O(log N + M)        O(log N + M)        跳表 cache 更友好 ↓
排名查询      O(log N)            O(log N)*           红黑树需额外维护 size

*红黑树排名需要每个节点存子树大小,标准库不提供

范围查询的 cache 行为差异:
  跳表: 定位起点后,Level 1 是连续链表 → 顺序遍历 → prefetch 有效
  红黑树: 中序遍历需要"左-根-右"递归 → 指针跳转不连续 → cache miss 多

  实测: 范围查询 1000 个元素
    跳表: ~15μs (链表顺序遍历)
    红黑树: ~25μs (中序遍历,频繁跳转)
    差距: 跳表快 40-60%
二、空间开销对比(N=100万, 64位系统)

跳表节点:
  struct zskiplistNode {
      sds ele;           // 8B (指针)
      double score;      // 8B
      struct backward;   // 8B (指针)
      struct level[] {   // 每层: forward(8B) + span(8B) = 16B
          forward, span
      };
  };

  平均层数 = 1/(1-p) = 1/(1-0.25) = 1.33 层
  平均每节点 = 8+8+8 + 1.33×16 ≈ 45.3 字节

红黑树节点:
  struct rbtree_node {
      void *key;         // 8B
      void *value;       // 8B
      struct *left;      // 8B
      struct *right;     // 8B
      struct *parent;    // 8B
      int color;         // 4B (通常对齐到 8B)
  };

  每节点 = 8+8+8+8+8+8 = 48 字节

对比: 跳表 45.3B vs 红黑树 48B → 空间基本持平!
     (p=0.25 时跳表甚至更省内存)

如果 p=0.5 (Java ConcurrentSkipListMap):
  平均层数 = 1/(1-0.5) = 2 层
  平均每节点 = 8+8+8 + 2×16 = 56 字节 → 比红黑树多 17%
三、并发友好性对比

跳表的并发优势:
  插入/删除只需修改前后指针 → 可以用 CAS 原子操作实现无锁
  不同层的指针修改相互独立 → 可以分层加锁或无锁

  Java ConcurrentSkipListMap 就是无锁跳表的典型实现:
    - 标记删除 (mark) + CAS 更新 next 指针
    - 无需全局锁,高并发下性能远超 synchronized TreeMap

红黑树的并发困难:
  旋转操作涉及 3-4 个节点的指针同时修改 → 必须原子完成
  旋转可能传播到根节点 → 影响范围不可预测
  → 实际只能用粗粒度锁(如 Java TreeMap 不支持并发)

Redis 是单线程,不需要并发 → 这个优势对 Redis 不重要
但这解释了为什么 Java/LevelDB 等多线程场景也选择跳表

2.5.4 跳表跨语言对比 ​

实现语言并发P 值最大层特点
Redis zskiplistC❌ 单线程0.2564span 支持 rank、backward 双向遍历
Java ConcurrentSkipListMapJava✅ 无锁 CAS0.531线程安全、NavigableMap 接口
LevelDB SkipListC++❌ 单线程*0.2512Arena 分配器、极简实现
Go (无标准库)Go———需第三方如 golang-skiplist

*LevelDB 的跳表虽然单线程,但 memtable 写是单线程的

Java ConcurrentSkipListMap 与 Redis 跳表的关键差异:

java
// Java: 并发安全的跳表, 天然支持 NavigableMap (lowerKey, ceilingKey 等)
ConcurrentSkipListMap<String, Integer> map = new ConcurrentSkipListMap<>();
map.put("a", 1);
map.ceilingKey("b");  // 找 ≥ "b" 的最小 key

// 无锁实现: CAS 原子更新指针
// P=0.5: 牺牲内存换查找速度
// 无 span: 不支持直接查 rank

LevelDB 跳表(RocksDB 同源):

  • Arena 内存分配器:批量分配节点,减少 malloc 开销
  • 12 层上限:因为内存数据量远小于磁盘,无需太高
  • 无后退指针:不需要反向遍历(memtable 只用于写入和点查)
  • 无 span:不需要排名查询

总结:Redis 跳表的唯一性

Redis zskiplist = 跳表基础结构 + span(排名) + backward(反向遍历) + dict 辅助(member→score O(1))。这种设计在满足 ZSet 全部需求(范围、排名、反向、点查)的同时,实现了代码简洁和内存高效的最优平衡。

2.6 Quicklist(List 底层,3.2+ / 7.0+) ​

2.6.1 演进历史 ​

Redis List 的底层实现经历了三次重大变革:

Redis 2.x         Redis 3.0.x        Redis 3.2+ / 7.0+
┌──────────┐      ┌──────────┐       ┌──────────────────┐
│linkedlist│ 或   │  ziplist  │  →    │    quicklist      │
│          │      │          │       │  (ziplist 节点)    │
│  O(1)    │      │ 连续内存   │       │         ↓          │
│push/pop  │      │ cache友好  │       │  (listpack 节点)   │
│          │      │          │       │   (7.0+ 全面替代)   │
│ 指针开销大│      │ 连锁更新   │       └──────────────────┘
└──────────┘      └──────────┘

各方案的权衡:

方案内存头部 push/pop中间插入范围查询Cache 友好
纯双向链表❌ 差 (每节点 2 指针)✅ O(1)✅ O(1)❌ 离散遍历❌ 差
纯 Ziplist✅ 好 (无指针)❌ O(n) 迁移❌ 连锁更新✅ 连续遍历✅ 好
Quicklist⚠️ 折中✅ O(1)✅ O(log n)*✅ 节点内连续⚠️ 折中

*中间插入:先定位节点 O(n/ziplist_size),再节点内插入 O(ziplist_size),近似 O(n/k)

2.6.2 Quicklist 结构详解 ​

quicklist:
┌──────────┐    ┌──────────┐    ┌──────────┐
│ quicklist │    │ quicklist │    │ quicklist │
│   node   │◄──►│   node   │◄──►│   node   │
│ ┌──────┐ │    │ ┌──────┐ │    │ ┌──────┐ │
│ │ list │ │    │ │ list │ │    │ │ list │ │
│ │ pack │ │    │ │ pack │ │    │ │ pack │ │
│ │(8KB) │ │    │ │(8KB) │ │    │ │(8KB) │ │
│ └──────┘ │    │ └──────┘ │    │ └──────┘ │
└──────────┘    └──────────┘    └──────────┘
  • list-max-listpack-size(7.0+,旧名 list-max-ziplist-size):控制每个节点最大字节数/元素数(默认 -2 = 8KB)
  • list-compress-depth:LZF 压缩中间节点(两端不压,因为 LPUSH/RPOP 高频)
c
typedef struct quicklist {
    quicklistNode *head, *tail;
    unsigned long count;      // 总元素数
    unsigned long len;        // 节点数
    int fill : 16;            // 节点填充因子
    unsigned int compress : 16; // 压缩深度
} quicklist;

节点分裂与合并:

  • 插入时若节点满(超过 fill),在中间分裂
  • 删除后节点太空(< fill/2),尝试与相邻节点合并
  • 保持节点在合理大小范围,平衡链表灵活性与连续内存效率

2.6.3 List 跨语言对比 ​

维度Redis QuicklistC++ std::vectorGo sliceC++ std::listJava LinkedList
底层链表 + 连续块连续数组连续数组双向链表双向链表
随机访问O(n/k)O(1)O(1)O(n)O(n)
头部插入O(1)O(n)O(n)O(1)O(1)
尾部插入O(1)O(1)*O(1)*O(1)O(1)
中间插入O(n/k)O(n)O(n)O(1)O(1)
内存开销/元素~1 字节0 (紧邻)0 (紧邻)16B (2 指针)~24B (Node对象)
Cache 友好节点内好极好极好差差
批量操作节点内连续连续连续离散离散

为什么 Redis 不直接用连续数组(像 vector/slice)?

LPUSH (头部插入):
vector/slice: [   ][   ][   ]  →  所有元素右移一位 → O(n)
Redis:        直接在第一个节点头部写入 → O(1)

为什么 Redis 不直接用双向链表?

  • 64位系统每个链表节点至少需要 prev/next 两个指针(16 字节)
  • 存一个小整数如 42,数据 8 字节,指针开销 16 字节 → 200% 额外开销
  • Quicklist 每个节点可存数百元素,指针开销被均摊

总结:Quicklist 的设计哲学

在「纯连续」的 vector/slice 和「纯离散」的 linked list 之间取折中:节点间用链表(灵活插入),节点内用连续块(内存紧凑 + cache 友好)。这是 Redis 对 "既能头部 O(1) push/pop,又要内存高效" 需求的精确回应。

2.7 Intset(整数集合,Set 小量时) ​

Set 在元素全为整数且数量少时的底层编码:

c
typedef struct intset {
    uint32_t encoding;  // INTSET_ENC_INT16 / INT32 / INT64
    uint32_t length;    // 元素个数
    int8_t contents[];  // 有序整数数组
} intset;

有序存储,二分查找 O(log n)。插入时若类型不匹配则自动升级(如 int16 → int32)。

升级而非降级:如 int16→int32 后,即使删除导致所有值都能回到 int16 范围,也不会降级回 int16。原因是降级判断需要遍历全部元素,代价高且场景罕见。

为什么只升级不降级?——空间vs时间的权衡分析:

intset 的三种编码:
  INTSET_ENC_INT16: 每个元素 2 字节,存储范围 [-32768, 32767]
  INTSET_ENC_INT32: 每个元素 4 字节,存储范围 [-2³¹, 2³¹-1]
  INTSET_ENC_INT64: 每个元素 8 字节,存储范围 [-2⁶³, 2⁶³-1]

升级场景:
  Set 中全是 int16 → encoding=INT16
  插入 40000 (超出 int16 范围) → 升级: int16→int32
  所有已有元素 ×2 的内存拷贝 (2B → 4B)
  ← 这个升级过程是 O(n) 的,但只发生一次

降级场景:
  Set 经历过升级 int16→int32
  删除 40000 → Set 中只剩下 int16 范围的值
  如果降级: 遍历所有元素检查是否都在 int16 范围内 → O(n)
  然后重新分配内存 → O(n)
  ← 降级也需要 O(n) 代价!

权衡:
  升级: 用户插入一个大值 → 升级是必须的(否则存不下)→ O(n) 可以接受
  降级: 用户删除了大值 → 降级是"optional"的 → 多占用一倍内存 (4B vs 2B)
       假设 500 个元素 × 额外 2B = 1KB → 这点空间不值得 O(n) 的遍历代价

  只有当 intset 中的元素数量非常大(数千以上),降级才有空间收益
  但此时 intset 可能已被编码切换为 hashtable(set-max-intset-entries 默认 512)
  → 降级场景实际很少出现 → 主动降级的 CPU 代价 > 额外内存代价

结论: 不降级策略在绝大多数场景下是最优的(省 CPU,多占的那点内存可忽略)

2.7.1 Set 跨语言对比 ​

Redis Set 的两层编码(intset / hashtable)与其他语言的集合实现有显著差异:

维度Redis SetC++ std::setC++ std::unordered_setJava HashSetGo (无原生Set)
底层intset(有序数组) / hashtable红黑树哈希表HashMap 封装map[T]struct{}
有序性intset 有序; hashtable 无序✅ 有序❌❌❌
查/插/删O(log n) / O(1)O(log n)O(1) 平均O(1)O(1)
内存效率intset 极好; hashtable 一般每个节点额外指针+颜色一般Node 对象开销大struct{} 值零开销
集合运算原生 SINTER/SUNION/SDIFF需 set_intersection 等算法需手动实现需手动实现需手动实现
编码切换intset→hashtable 自动N/AN/AN/AN/A

关键差异分析:

C++ std::set — 红黑树,追求有序 ​
cpp
std::set<int> s = {3, 1, 2};
// 内部: 平衡二叉搜索树, 迭代有序: 1, 2, 3
// 内存: 每个节点 ≈ 3ptr (left/right/parent) + color = ~40B 额外开销
  • 优点:始终有序,范围查询高效
  • 缺点:内存开销大,适用于需要排序的场景
  • Redis 选择:有序需求用 ZSet(跳表),Set 专注去重和集合运算,无需排序开销
Java HashSet — HashMap 的壳 ​
java
// HashSet 内部就是一个 HashMap, value 是哑对象
public class HashSet<E> {
    private transient HashMap<E, Object> map;
    private static final Object PRESENT = new Object();
}
  • 相比 Redis Set,多一层 HashMap.Node 包装,每个元素有 hash/key/value/next 四字段
Go — 没有原生 Set,用 map 模拟 ​
go
// Go 风格: 利用 map 的 key 唯一性
set := make(map[string]struct{})
set["a"] = struct{}{}  // struct{} 占 0 字节

// 缺点: 无原生集合运算, 需手写循环
intersection := make(map[string]struct{})
for k := range set1 {
    if _, ok := set2[k]; ok {
        intersection[k] = struct{}{}
    }
}
Redis Set 的独特优势 ​
特性说明
原生集合运算SINTER(交)、SUNION(并)、SDIFF(差) 在服务端高效执行
intset 编码纯整数场景,连续内存 + 二分查找,极致紧凑
自动编码切换小数据用 intset 省内存,大数据转 hashtable 保性能
SINTERSTORE 等结果可直接存为新 key,避免数据传输开销

三、五种数据类型及其编码选择 ​

类型小数据编码大数据编码切换阈值配置
Stringint / embstrraw长度 > 44
Hashlistpackhashtablehash-max-listpack-entries 512(7.0+)
Listquicklist(quicklist 统一)list-max-listpack-size -2(7.0+)
Setintsethashtableset-max-intset-entries 512
Sorted Setlistpackskiplist + dictzset-max-listpack-entries 128(7.0+)

版本注意:Redis 7.0 将 ziplist 全面替换为 listpack,配置参数名也随之变更。旧参数名 hash-max-ziplist-entries、zset-max-ziplist-entries、list-max-ziplist-size 在 7.0+ 中已失效(以别名形式兼容,但推荐用新名)。

各类型核心命令复杂度速查 ​

命令复杂度说明
GET/SETO(1)
HGET/HSETO(1)
LPUSH/RPOPO(1)
LRANGE 0 NO(N)越大的 N 越慢
SADD/SREMO(1)
ZADD/ZREMO(log N)跳表插入/删除
ZRANGE/ZRANKO(log N + M)M 为返回元素数
SORTO(N + M*logM)避免对大集合使用
KEYS patternO(N)生产禁用,用 SCAN

四、持久化 ​

4.1 RDB(快照) ​

SAVE   (主线程阻塞)     → 生产禁用
BGSAVE (fork 子进程)    → 使用 COW,子进程写盘

触发条件:save 900 1 (900秒内1次修改) / save 300 10 / save 60 10000

RDB 优缺点:

  • ✅ 文件紧凑,适合备份/灾备;恢复快
  • ❌ 非实时,最多丢配置间隔内的数据

4.2 AOF(追加日志) ​

┌──────────┐     write     ┌──────────┐    fsync    ┌──────┐
│ 命令执行  │ ────────────→ │ AOF 缓冲区 │ ──────────→ │ 磁盘  │
└──────────┘               └──────────┘             └──────┘

appendfsync 策略:

值行为性能安全性
always每条命令 fsync最低最高
everysec每秒 fsync 一次较好丢 1 秒
no交给 OS 决定最高最不安全

AOF 重写(BGREWRITEAOF):fork 子进程,根据当前数据库状态生成最小命令集,压缩 AOF 体积。

4.3 混合持久化(4.0+) ​

aof-use-rdb-preamble yes:AOF 文件前半段是 RDB 格式的快照,后半段是增量命令。兼顾恢复速度和数据完整性。

4.4 Multi-part AOF(7.0+) ​

Redis 7.0 重构了 AOF 存储机制,解决了单文件 AOF 的三大痛点。

旧机制(≤6.2)的问题:

问题说明
重写需要临时文件BGREWRITEAOF fork 子进程 → 写临时文件 → 完成后 rename 替换。大量写入时临时文件可能和原文件一样大,磁盘空间翻倍
重写期间写入堆积子进程重写期间,主进程的增量命令存在 AOF 缓冲区中。重写完成后一次性写入——如果增量太多,导致短暂的 I/O 尖峰
文件不可预测只有一个 appendonly.aof 文件,损坏时需要全量修复

新机制(7.0+):

text
data-dir/
├── appendonly.aof.1.base.rdb       ← Base 文件(RDB 格式快照)
├── appendonly.aof.1.incr.aof       ← 增量文件(写命令)
└── appendonly.aof.manifest         ← Manifest 文件(元数据)
  • Base 文件:RDB 格式的快照,体积小、恢复快
  • Incr 文件:自上次快照以来的所有写命令(AOF 格式)
  • Manifest 文件:记录当前生效的 base + incr 文件列表

Multi-part AOF 的优势:

优势说明
重写无需临时文件直接生成新的 base 文件 + 新的 incr 文件,旧文件继续服务读
增量文件可轮转incr 文件到达阈值(aof-file-rewrite-percentage)后自动创建新 incr 文件,避免单文件膨胀
恢复更快base 是 RDB 格式,直接加载;incr 只有少量增量命令
可预测磁盘使用重写完成后旧文件才删除,不会出现临时文件导致磁盘空间翻倍

配置:

conf
# 7.0+ Multi-part AOF(默认开启)
aof-use-rdb-preamble yes
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb

五、过期与淘汰 ​

5.1 过期删除策略(惰性 + 定期) ​

策略时机说明
惰性删除访问 key 时expireIfNeeded() 检查是否过期,过期则删
定期删除每 100msactiveExpireCycle() 随机抽 20 个 key,过期比例 >25% 则继续

activeExpireCycle 的完整算法 ​

每 100ms 触发一次(server.hz 默认 10,即每秒 10 次):

1. 遍历所有 DB(默认 16 个)
2. 对每个 DB:
   a. 从 expires 字典中随机取 20 个 key
   b. 删除其中已过期的 key
   c. 如果过期比例 > 25%(即 20 个中有 >5 个过期)
      → 重复步骤 a(说明过期 key 很多,需要继续清理)
   d. 如果过期比例 ≤ 25% → 跳到下一个 DB
3. 每轮有时间限制(默认 25ms),超时则退出,下次继续

为什么是 25%?
  - 太低(如 10%):清理太频繁,浪费 CPU
  - 太高(如 50%):大量过期 key 堆积,浪费内存
  - 25% 是经验值:保证过期 key 占比不会太高,同时不过度消耗 CPU

为什么用随机抽样而不是遍历?
  - expires 字典可能有数百万个 key
  - 全量遍历一次可能需要数秒,会阻塞主线程
  - 随机抽样 + 概率保证:只要过期 key 占比 >25%,就会持续清理
  - 数学上可以证明:这种策略能保证过期 key 占比稳定在 25% 以下

5.2 内存淘汰策略(maxmemory) ​

策略行为
noeviction不淘汰,写返回错误
allkeys-lru所有 key 中淘汰最近最少使用的
volatile-lru设置了过期时间的 key 中 LRU
allkeys-lfu所有 key 中淘汰最不频繁使用的 (4.0+)
volatile-lfu设置了过期时间的 key 中 LFU
allkeys-random随机淘汰
volatile-random在设置了过期时间的 key 中随机淘汰
volatile-ttl淘汰 TTL 最短的 key

LRU 实现(近似):每个 redisObject 存 24bit 时钟,抽样 maxmemory-samples 个 key 淘汰。

为什么 Redis 不用真正的 LRU 链表? ​

真正的 LRU 需要一个双向链表 + 哈希表(如 Linux 页缓存的实现)。但 Redis 有数百万甚至上亿个 key,维护一个全局 LRU 链表的开销太大:

  • 每次访问都要移动链表节点 → O(1) 但常数大(指针操作 + cache miss)
  • 额外内存:每个 key 多两个指针(prev/next)= 16 字节/key
  • 1 亿 key × 16B = 1.6GB 额外内存,不可接受

Redis 的近似 LRU:

每个 redisObject 有一个 24bit 的 lru 字段,记录最后访问时间(秒级精度)。

淘汰时:
1. 随机抽样 maxmemory-samples 个 key(默认 5)
2. 比较它们的 lru 时间戳
3. 淘汰最旧的那个
4. 重复直到内存降到 maxmemory 以下

为什么 5 个样本就够了?
  - Redis 官方测试:5 个样本的近似 LRU 命中率已接近真实 LRU
  - 10 个样本几乎等同于真实 LRU
  - 样本数越多越精确,但每次淘汰的 CPU 开销也越大

LFU 实现(4.0+):24bit 拆分为高 16bit ldt(上次访问时间) + 低 8bit logc(对数计数器)。

Morris Counter(对数计数器)是怎么工作的? ​

LFU 需要统计每个 key 的访问频率,但用普通计数器有两个问题:

  1. 8bit 最多只能计到 255,远远不够
  2. 热点 key 的计数器很快溢出

Morris Counter 的核心思想:不精确计数,而是用概率递增。

counter 的含义不是"访问了 N 次",而是"访问了大约 2^N 次"。

递增规则(每次访问时):
  1. 生成随机数 r ∈ [0, 1)
  2. 计算递增概率 p = 1 / (counter × lfu_log_factor + 1)
     (lfu_log_factor 默认 = 10)
  3. 如果 r < p → counter++
  4. 否则不变

效果:
  counter=0 时: p = 1/(0×10+1) = 1.0    → 100% 递增(第一次访问必增)
  counter=1 时: p = 1/(1×10+1) = 0.09   → 约 11 次访问才递增一次
  counter=5 时: p = 1/(5×10+1) = 0.02   → 约 50 次访问才递增一次
  counter=10时: p = 1/(10×10+1) = 0.01  → 约 100 次访问才递增一次
  counter=255时: p ≈ 0.0004             → 约 2550 次访问才递增一次

这样 8bit 的 counter 就能表示从 1 到数百万次的访问频率范围!

LFU 的衰减机制 ​

如果只增不减,曾经的热点 key 永远不会被淘汰。Redis 用 ldt(上次衰减时间)实现衰减:

每次访问时检查:
  elapsed_minutes = (当前时间 - ldt) / lfu_decay_time
  counter = max(0, counter - elapsed_minutes)

lfu_decay_time 默认 = 1(每分钟衰减 1)

效果:一个 key 如果 10 分钟没被访问,counter 减少 10。
      曾经的热点 key 会逐渐"冷却",最终可被淘汰。

六、主从复制与集群 ​

6.1 主从复制流程 ​

1. 从: SLAVEOF host port  (或 REPLICAOF)
2. 从: 建立 TCP 连接
3. 从: 发送 PSYNC replid offset
4. 主: 判断是部分同步 还是 完全同步
   ├─ 部分同步: 发送 replication backlog 中增量数据
   └─ 完全同步: BGSAVE → 发送 RDB → 发送缓冲区命令

PSYNC 机制(部分重同步)依赖:

  • replication backlog:固定大小环形缓冲区(默认 1MB),存最近命令
  • replid:主节点 ID(重启后变化,无法部分同步)

6.2 Sentinel(哨兵) ​

┌─────────┐  ┌─────────┐  ┌─────────┐
│ Sentinel│  │ Sentinel│  │ Sentinel│   (≥3 个, 奇数)
└────┬────┘  └────┬────┘  └────┬────┘
     │            │            │
     └────────────┼────────────┘
                  ▼
          ┌─────────────┐
          │ Redis 集群   │
          │  M ─ S ─ S  │
          └─────────────┘

核心功能:监控 → 通知 → 自动故障转移 → 配置提供者(客户端发现)。

主观下线 (SDOWN) 和 客观下线 (ODOWN):

  • SDOWN:单个 Sentinel 认为节点不可达
  • ODOWN:≥ quorum 个 Sentinel 都认为不可达,触发故障转移

6.3 Redis Cluster ​

数据分片使用 16384 个哈希槽(hash slot):

CRC16(key) & 16383 → 槽编号

为什么是 16384 而不是 65536 或其他数字? ​

这个数字是 Redis 作者 antirez 在设计时权衡的结果:

1. 心跳包大小限制:
   Cluster 节点间通过 Gossip 协议交换信息,每个心跳包包含一个 bitmap
   标记"我负责哪些槽"。

   16384 个槽 → bitmap = 16384 / 8 = 2KB
   65536 个槽 → bitmap = 65536 / 8 = 8KB

   每秒每个节点发送多个心跳包,2KB vs 8KB 的差距在大集群中很显著。

2. 集群规模上限:
   Redis 官方建议集群最多 ~1000 个主节点。
   16384 / 1000 ≈ 16 个槽/节点(最少情况)。
   如果只有 1000 个槽,每个节点只分到 1 个槽,迁移粒度太粗。
   16384 在"心跳包不太大"和"迁移粒度够细"之间取得平衡。

3. CRC16 的值域:
   CRC16 输出 16bit = 65536 种值。
   16384 = 2^14,用 & 16383 取低 14 位,计算简单高效。
┌──────────────┐  ┌──────────────┐  ┌──────────────┐
│  Master A    │  │  Master B    │  │  Master C    │
│  slots 0-5460│  │ slots 5461-  │  │ slots 10923- │
│              │  │     10922    │  │     16383    │
└──────────────┘  └──────────────┘  └──────────────┘

MOVED vs ASK:

  • MOVED:槽已迁移到永久 owner,客户端更新 slot 映射表
  • ASK:迁移中,只本次重定向到目标节点

七、缓存问题与应对 ​

7.1 缓存穿透 ​

请求不存在的数据,绕过缓存直达 DB。

方案实现
布隆过滤器BF.ADD / BF.EXISTS,拦截不存在 key
缓存空值设短过期时间(30-60s)
参数校验过滤不合理 ID(如负数、超长)

7.2 缓存击穿 ​

热点 key 过期瞬间大量请求打到 DB。

方案实现
逻辑过期value 存过期时间,异步更新
互斥锁SETNX lock 抢锁,抢到的查 DB 回写
永不过期热点 key 不设 TTL,手动更新

7.3 缓存雪崩 ​

大量 key 同时过期或 Redis 宕机。

方案实现
TTL 加随机过期时间加 random(1, 300) 秒
多级缓存本地 Caffeine + Redis
主从 + 哨兵/集群高可用,降级熔断

7.4 大 Key / 热 Key ​

问题危害排查处理
大 Key阻塞、带宽redis-cli --bigkeys拆分、压缩、删
热 KeyCPU 不均redis-cli --hotkeys本地缓存、读写分离

7.5 Client-side Caching(6.0+)— 服务端辅助的客户端缓存 ​

Redis 6.0 引入的 tracking 模式,让客户端缓存与服务器数据保持强一致,无需应用层维护过期和失效逻辑。

传统客户端缓存的问题:

App 本地缓存 key="user:123"
  ↓
其他服务修改了 user:123 → App 本地缓存是脏数据
  ↓
需要设置 TTL,但 TTL 短了缓存命中低,长了数据不一致

Redis Client-side Caching 的两种模式:

模式原理适用场景
默认模式(追踪)客户端执行 CLIENT TRACKING on → Redis 记住该连接读取了哪些 key → 当 key 被修改时推送失效消息(invalidate)需要强一致性
广播模式Redis 不追踪每个连接读了什么,而是向所有订阅的客户端广播被修改的 key 前缀大量客户端共享相同缓存
bash
# === 默认模式(服务端追踪每个客户端读过的 key) ===

# 客户端 A 开启追踪(RESP3 协议,自动推送失效消息)
CLIENT TRACKING on REDIRECT <redirect-client-id>
GET user:123             # 服务端记住:Client A 读过 user:123

# 客户端 B 修改
SET user:123 "newvalue"  # Redis 自动推送 → Client A: invalidate user:123

# 客户端 A 收到失效通知后,下次请求会重新从 Redis 读取
GET user:123             # 从服务器获取最新值


# === 广播模式(前缀匹配,简化服务端追踪) ===
CLIENT TRACKING on BCAST PREFIX user: PREFIX order:
# 任何使用 user: 或 order: 前缀的 key 被修改时,所有订阅客户端都收到失效消息

RESP2 兼容写法(需要单独的失效通知连接):

bash
# 连接1: 主连接,开启追踪并指定通知到连接2
CLIENT TRACKING on REDIRECT <conn2-id>

# 连接2: 独立的失效通知连接,专门接收 invalidate 消息
# 此连接除了接收失效消息外不做任何操作

为什么重要:Client-side Caching 是 Redis 6.0+ 最重要的企业级特性之一。它让 Redis 从"集中式缓存"进化到"服务端 + 本地"两级缓存架构,且数据一致性由 Redis 保证。在高 QPS 场景下(如社交 feeds、排行榜),本地缓存命中可将 Redis 访问量降低 50-90%。


八、Pipeline 与事务 ​

Pipeline ​

客户端批量发送命令不等待回复,服务端批量返回:

Client:  SET a 1 \n SET b 2 \n GET a \n GET b
Server:  +OK \n +OK \n $1 \n 1 \n $1 \n 2
  • 不是原子操作(中间可能插入其他客户端命令)
  • 节省 RTT,比逐条发送快 10-100 倍

事务(MULTI/EXEC) ​

MULTI          → 开启事务
SET a 1        → 入队
INCR b         → 入队
EXEC           → 原子执行
  • 不支持回滚(命令错误会跳过继续执行)
  • WATCH key 实现乐观锁:被 watch 的 key 在 EXEC 前被修改,EXEC 返回 nil

Lua 脚本 ​

EVAL "return redis.call('GET', KEYS[1])" 1 mykey
  • 脚本执行期间阻塞其他命令(原子性保证)
  • 用 SCRIPT KILL 杀死长时间运行的只读脚本
  • 最佳实践:脚本短小、逻辑简单

Lua 脚本的工程痛点:

痛点说明
脚本无版本管理EVAL 传源码,多次调用传重复代码,浪费带宽
无持久化重启后脚本丢失,需重新 SCRIPT LOAD
无命名空间所有脚本共享全局函数空间,命名冲突风险
调试困难出错只知道行号不知道是哪个脚本

Redis Functions(7.0+)— 替代 EVAL ​

Redis 7.0 引入 Functions API,彻底解决 Lua 脚本的工程化问题。Functions 以库的形式持久化管理脚本:

lua
-- mylib.lua
#!lua name=mylib

-- 注册一个函数(持久化到 AOF/RDB,重启后仍可用)
redis.register_function(
    'get_or_set',           -- 函数名
    function(key, default_val)
        local val = redis.call('GET', key)
        if val then return val end
        redis.call('SET', key, default_val)
        return default_val
    end
)

-- 注册一个只读函数
redis.register_function(
    'stats',
    function()
        return redis.call('INFO', 'memory')
    end,
    { flags = { 'no-writes' } }  -- 标记为只读
)
bash
# 加载函数库(持久化!重启后仍在)
cat mylib.lua | redis-cli -x FUNCTION LOAD REPLACE

# 调用
redis-cli FCALL get_or_set 1 mykey "default_value"
redis-cli FCALL_RO stats 0          # 只读,可被 SCRIPT KILL 中断

# 管理
redis-cli FUNCTION LIST
redis-cli FUNCTION DELETE mylib
EVALREDIS FUNCTIONS
脚本存在客户端,每次发送源码函数存在服务端,持久化到 RDB/AOF
无版本管理FUNCTION LOAD REPLACE 覆盖更新
重启丢失重启后自动恢复
无法被监控每个函数有独立的调用统计
共享全局命名空间每个库独立命名空间(mylib.get_or_set)

九、分布式锁 ​

单节点方式 ​

SET lock_key unique_value NX PX 30000

解锁用 Lua 脚本保证原子性:

lua
if redis.call("GET", KEYS[1]) == ARGV[1] then
    return redis.call("DEL", KEYS[1])
else
    return 0
end

Redlock(多节点) ​

在 N 个独立 Redis 实例(N 为奇数,推荐 5)上分别获取锁,过半成功 + 总耗时 < 锁有效期 才算获取成功。

争议:Martin Kleppmann 指出时钟跳跃等边缘场景下 Redlock 不安全。


十、性能优化清单 ​

方面建议
避免 KEYS用 SCAN 游标迭代
避免大 keystring < 10KB,集合元素 < 5000
连接池复用连接,减少 TCP 握手
Pipeline批量命令减少 RTT
慢查询日志slowlog-log-slower-than 10000 (10ms)
关闭 THPecho never > /sys/kernel/mm/transparent_hugepage/enabled
CPU 绑定绑定到固定核心 + 关闭 NUMA 自动平衡
内存碎片activedefrag yes (4.0+) 自动整理

十一、客户端工具与诊断 ​

11.1 redis-cli 常用操作 ​

bash
# === 连接 ===
redis-cli -h 127.0.0.1 -p 6379 -a password -n 0
redis-cli -h 127.0.0.1 -p 6379 --tls --cacert ca.crt   # TLS 连接
redis-cli -u redis://user:pass@host:6379/0               # URI 方式

# === 批量操作 ===
redis-cli --pipe < commands.txt    # 管道批量导入(极快, 比逐条快 100×)
cat data.txt | redis-cli --pipe    # 从文件批量灌入

# === 数据迁移 ===
redis-cli --cluster reshard <host>:<port>          # 集群 rebalance
redis-cli --cluster check <host>:<port>            # 集群健康检查

# === 监控模式 ===
redis-cli --stat            # 实时 QPS/内存统计 (每秒刷新)
redis-cli --latency         # 实时延迟采样
redis-cli --latency-history # 延迟历史趋势 (15s 间隔, 持续采样)
redis-cli --latency-dist    # 延迟分布直方图 (CDF, 需服务端 7.0+)
redis-cli --intrinsic-latency <sec>  # 测试系统内核延迟 (非 Redis)

# === 扫描与诊断 ===
redis-cli --bigkeys         # 扫描大 key (按类型统计最大 key)
redis-cli --memkeys         # 按内存占用排序 (需采样)
redis-cli --hotkeys         # 扫描热 key (基于 LFU, 需 maxmemory-policy 为 LFU)

# === 集群批量操作 ===
redis-cli --cluster call <host>:<port> <command>  # 在所有节点执行命令
redis-cli -c                  # 集群模式 (自动 FOLLOW MOVED/ASK)

11.2 诊断命令 ​

INFO — 系统全景 ​

bash
redis-cli INFO [section]

# 关键 section:
INFO server       # 版本、运行时间、模式
INFO clients      # 连接数、阻塞客户端数
INFO memory       # used_memory、碎片率、maxmemory
INFO stats        # QPS、命中率、key 数量、网络流量
INFO replication  # 主从状态、offset 差
INFO cpu          # CPU 使用情况
INFO commandstats # 每个命令的调用次数/耗时
INFO keyspace     # 每个 DB 的 key 数量/过期数

关键指标速查:

指标含义告警阈值
instantaneous_ops_per_sec实时 QPS参考日常基线
used_memory_rss / used_memory内存碎片率> 1.5 严重碎片
keyspace_hits / (hits + misses)缓存命中率< 90% 缓存效用低
rejected_connections被拒连接> 0 需关注
blocked_clients阻塞中的客户端> 0 可能有慢命令
rdb_last_bgsave_status上次 RDB 状态err 需立即排查
master_repl_offset - slave_repl_offset主从延迟> 1MB 需关注

MONITOR — 实时命令审计 ​

bash
redis-cli MONITOR
# 输出所有客户端执行的每条命令 (生产慎用, 影响性能 30-50%)
# 输出: 时间戳 [db 客户端] "命令" "参数"...
# 1765432100.123456 [0 10.0.0.1:52341] "GET" "user:1001"

MONITOR 是调试利器,但生产环境建议用 --bigkeys / --hotkeys / SLOWLOG 替代。

SLOWLOG — 慢命令追踪 ​

bash
redis-cli SLOWLOG GET 10           # 最近 10 条慢命令
redis-cli SLOWLOG LEN              # 慢日志条数
redis-cli SLOWLOG RESET            # 清空

# 输出字段:
# 1) 自增 ID
# 2) Unix 时间戳
# 3) 执行耗时 (微秒)
# 4) 命令与参数列表
bash
# 配置 (redis.conf)
slowlog-log-slower-than 10000   # 超过 10ms 记录 (微秒)
slowlog-max-len 128             # 最多保存 128 条

LATENCY — 延迟诊断 ​

bash
redis-cli LATENCY LATEST      # 最新延迟事件
redis-cli LATENCY HISTORY cmd # 某类延迟事件的历史
redis-cli LATENCY DOCTOR      # 自动诊断建议
redis-cli LATENCY RESET       # 重置统计

# LATENCY DOCTOR 典型输出:
# "I have detected a major background save in this instance"
# "I have detected a major AOF rewrite in this instance"

11.3 常见错误排查 ​

症状排查命令常见原因
响应变慢SLOWLOG GET 10 + LATENCY DOCTORKEYS/slow command/大 key/BGSAVE
OOMINFO memorymaxmemory 不足/碎片
连接拒绝INFO stats → rejected_connectionsmaxclients 达上限
主从延迟大INFO replication → offset 差网络带宽/从库负载
缓存命中率低INFO stats → keyspace_hits/misseskey 过期快/淘汰策略
CPU 100%INFO commandstats + SLOWLOG热 key/复杂命令(如 SORT)
内存碎片高INFO memory → mem_fragmentation_ratio大量短命 key/jemalloc 问题

快速诊断三板斧:

bash
# 1. 看全局
redis-cli INFO stats memory replication

# 2. 看慢命令
redis-cli SLOWLOG GET 20

# 3. 看大 key / 热 key
redis-cli --bigkeys
redis-cli --hotkeys

十二、命令全览 ​

12.1 通用命令 ​

命令复杂度说明
KEYS patternO(N)匹配所有 key,生产禁用
SCAN cursor [MATCH] [COUNT]O(1)/次游标迭代,替代 KEYS
EXISTS key [key...]O(N)判断 key 是否存在
DEL key [key...]O(N)删除 key
UNLINK key [key...]O(1)异步删除(大 key 推荐)
TYPE keyO(1)key 的类型
TTL / PTTL keyO(1)剩余过期时间 (秒/毫秒)
EXPIRE key secondsO(1)设置过期时间
EXPIREAT key timestampO(1)设置在指定时间戳过期
PERSIST keyO(1)移除过期时间
RENAME key newkeyO(1)重命名(覆盖 newkey)
RENAMENX key newkeyO(1)重命名(newkey 存在则失败)
OBJECT ENCODING keyO(1)查看底层编码
OBJECT REFCOUNT keyO(1)引用计数
OBJECT IDLETIME keyO(1)空闲时间 (秒)
SORT key [BY] [LIMIT] [GET] [ASC&#124;DESC]O(N+M*logM)排序,大集合慎用

12.2 String 命令 ​

命令复杂度说明
GET keyO(1)获取值
SET key value [EX&#124;PX] [NX&#124;XX]O(1)设置值(支持过期+条件)
GETSET key valueO(1)设新值返旧值(6.2 起建议用 SET GET)
SETNX / SETEX / PSETEXO(1)条件设置/含过期设置
MGET key [key...]O(N)批量获取,减少 RTT
MSET key value [key value...]O(N)批量设置,原子操作
GETRANGE key start endO(N)子串(支持负索引)
SETRANGE key offset valueO(N)覆盖子串
STRLEN keyO(1)值长度
APPEND key valueO(1)追加
INCR / DECR / INCRBY / DECRBYO(1)整数加减(原子)
INCRBYFLOAT key incrementO(1)浮点数加减
GETDEL keyO(1)获取并删除 (6.2+)
GETEX key [EX&#124;PX&#124;PERSIST]O(1)获取并设置/清除过期 (6.2+)

典型用法:

bash
SET lock_key unique_value NX EX 30    # 分布式锁
SET cache_key data EX 3600            # 带过期缓存
MSET user:1:name "Alice" user:1:age 25  # 批量原子写入
INCR page:home:views                  # 原子计数器

12.3 Hash 命令 ​

命令复杂度说明
HSET key field value [field value...]O(N)设置字段
HGET key fieldO(1)获取字段值
HMSET / HMGETO(N)批量设/取
HGETALL keyO(N)获取全部字段,大 key 慎用
HDEL key field [field...]O(N)删除字段
HEXISTS key fieldO(1)判断字段存在
HLEN keyO(1)字段数量
HKEYS / HVALS keyO(N)所有字段名/值,大 key 慎用
HINCRBY / HINCRBYFLOATO(1)字段数值运算
HSCAN key cursor [MATCH] [COUNT]O(1)/次迭代字段
HSTRLEN key fieldO(1)字段值长度 (3.2+)

12.4 List 命令 ​

命令复杂度说明
LPUSH / RPUSH key elem [elem...]O(N)左/右推入
LPOP / RPOP key [count]O(N)左/右弹出 (6.2+ 支持 count)
LLEN keyO(1)长度
LRANGE key start stopO(S+N)范围获取,大 offset 慎用
LINDEX key indexO(N)按下标获取
LSET key index valueO(N)按下标设置
LREM key count valueO(N+M)删除指定值
LTRIM key start stopO(N)裁剪范围
LINSERT key BEFORE|AFTER pivot elemO(N)在 pivot 前/后插入
LPOS key elem [RANK] [COUNT] [MAXLEN]O(N)查找元素位置 (6.0.6+)
BLPOP / BRPOP key [key...] timeoutO(N)阻塞弹出(超时秒)
LMOVE / BLMOVEO(1)原子移动元素 (6.2+)

List 经典模式:

bash
# 消息队列 (FIFO): LPUSH + RPOP
# 栈 (LIFO):       LPUSH + LPOP
# 阻塞队列:         LPUSH + BRPOP 5
# 有限队列:         LPUSH + LTRIM 0 99 (保留最新 100 条)

12.5 Set 命令 ​

命令复杂度说明
SADD key member [member...]O(N)添加成员
SREM key member [member...]O(N)移除成员
SCARD keyO(1)成员数
SISMEMBER key memberO(1)判断是否存在
SMEMBERS keyO(N)获取所有成员,大 key 禁用
SSCAN key cursor [MATCH] [COUNT]O(1)/次迭代成员
SRANDMEMBER key [count]O(N)随机获取(count 不重复,负值可重复)
SPOP key [count]O(N)随机弹出并删除
SMOVE source dest memberO(1)原子移动
集合运算
SINTER key [key...]O(N*M)交集
SUNION key [key...]O(N)并集
SDIFF key [key...]O(N)差集
SINTERSTORE / SUNIONSTORE / SDIFFSTOREO(N*M)运算结果存为新 key

12.6 Sorted Set 命令 ​

命令复杂度说明
ZADD key [NX&#124;XX] [GT&#124;LT] [CH] [INCR] score memberO(log N)添加/更新
ZREM key member [member...]O(M*log N)移除
ZCARD keyO(1)成员数
ZSCORE key memberO(1)获取分数
ZRANK / ZREVRANK key memberO(log N)排名(升序/降序)
ZRANGE key min max [BYSCORE&#124;BYLEX] [REV] [LIMIT] [WITHSCORES]O(log N+M)范围查询
ZRANGEBYSCORE / ZRANGEBYLEXO(log N+M)按分数/字典序范围
ZREM key member [member...]O(log N)移除成员
ZREMRANGEBYRANK key start stopO(log N+M)按排名删除
ZREMRANGEBYSCORE key min maxO(log N+M)按分数删除
ZCOUNT key min maxO(log N)分数范围内计数
ZINCRBY key increment memberO(log N)分数增减
ZPOPMIN / ZPOPMAX key [count]O(log N*M)弹出最小/最大
BZPOPMIN / BZPOPMAX key [key...] timeoutO(log N)阻塞弹出
集合运算
ZINTER / ZUNION numkeys key [key...] [WEIGHTS] [AGGREGATE]O(NK)+O(Mlog M)交集/并集
ZINTERSTORE / ZUNIONSTOREO(NK)+O(Mlog M)运算结果存为新 key

典型用法:

bash
ZADD leaderboard 1000 alice 950 bob 880 charlie   # 排行榜
ZRANGE leaderboard 0 9 WITHSCORES                 # Top 10
ZREVRANK leaderboard alice                        # alice 排名
ZINCRBY leaderboard 10 alice                      # 加分
ZREMRANGEBYRANK leaderboard 0 -11                 # 只保留 Top 10

12.7 Stream 命令(5.0+) ​

命令说明
XADD key [MAXLEN] * field value ...追加消息(* = 自动 ID)
XREAD [COUNT] [BLOCK ms] STREAMS key ID读取(阻塞读用 $)
XREADGROUP GROUP group consumer STREAMS key ID消费者组读取
XGROUP CREATE/DESTROY/SETID/DELCONSUMER管理消费者组
XACK key group ID [ID...]确认消息
XCLAIM / XPENDING / XAUTOCLAIM消息认领与 pending 管理
XRANGE / XREVRANGE key start end [COUNT]范围查询
XLEN key消息数量
XDEL key ID [ID...]删除消息
XTRIM key MAXLEN [~] count裁剪长度(~ 近似裁剪)

12.8 其他类型命令 ​

Bitmap(本质是 String 的位操作):

命令说明
SETBIT key offset value设置位
GETBIT key offset获取位
BITCOUNT key [start end]统计 1 的个数
BITOP AND/OR/XOR/NOT dest key [key...]位运算
BITPOS key bit [start] [end]查找第一个 0/1

HyperLogLog(基数估算,标准误差 0.81%):

命令说明
PFADD key element [element...]添加元素
PFCOUNT key [key...]估算基数
PFMERGE dest key [key...]合并多个 HLL

Geospatial(地理位置):

命令说明
GEOADD key longitude latitude member添加坐标
GEODIST key m1 m2 [m&#124;km&#124;ft&#124;mi]距离
GEORADIUS / GEORADIUSBYMEMBER半径搜索
GEOSEARCH / GEOSEARCHSTORE区域搜索 (6.2+)
GEOHASH key member获取 GeoHash

12.5 内存碎片与 jemalloc 深度分析 ​

12.5.1 为什么 Redis 选择 jemalloc? ​

Redis 默认使用 jemalloc 作为内存分配器(编译时可选 libc/tcmalloc/jemalloc):

分配器Redis 适配度原因
jemalloc⭐⭐⭐⭐⭐size class 精细、碎片低、arena 隔离、统计完善
tcmalloc⭐⭐⭐快速路径极快,但碎片控制不如 jemalloc
glibc ptmalloc⭐⭐碎片严重,多 arena 锁竞争

jemalloc 对 Redis 的关键优势:

  • 精细的 size class:Redis 对象大小分布广(SDS 从几字节到几 MB),jemalloc 200+ 个 size class 减少内部碎片
  • arena 隔离:后台线程(bio)和主线程使用不同 arena,减少锁竞争
  • decay 机制:空闲内存不立即归还 OS,短期内可复用,减少 mmap/munmap 开销
  • 统计接口:jemalloc.stats.* 提供精确的内存使用分析

12.5.2 mem_fragmentation_ratio 的真正含义 ​

bash
redis-cli INFO memory
# used_memory:1073741824        ← Redis 认为自己用了多少(逻辑)
# used_memory_rss:1610612736    ← OS 看到的 RSS(物理)
# mem_fragmentation_ratio:1.50  ← RSS / used_memory
mermaid
flowchart LR
    subgraph "used_memory (Redis 视角)"
        A["实际存储的数据"]
        B["内部数据结构开销"]
        C["Lua 脚本内存"]
    end
    subgraph "used_memory_rss (OS 视角)"
        D["used_memory"]
        E["jemalloc 内部碎片<br/>(size class 对齐浪费)"]
        F["jemalloc 外部碎片<br/>(空闲但未归还的页)"]
        G["jemalloc 元数据"]
        H["页表、内核开销"]
    end

碎片率判断标准:

碎片率含义行动
< 1.0RSS < used_memory,通常有 swap⚠️ 检查是否在用 swap
1.0 - 1.1几乎无碎片✅ 理想状态
1.1 - 1.5轻度碎片✅ 正常范围
1.5 - 2.0中度碎片⚠️ 考虑开启 activedefrag
> 2.0严重碎片🚨 必须处理

12.5.3 碎片产生的根因 ​

mermaid
flowchart TD
    A["碎片产生"] --> B["内部碎片"]
    A --> C["外部碎片"]
    B --> B1["请求 40B → jemalloc 分配 48B<br/>浪费 8B (size class 对齐)"]
    B --> B2["SDS 预分配空间<br/>实际用 100B,alloc 200B"]
    C --> C1["大量 key 过期/删除<br/>留下不连续的空洞"]
    C --> C2["value 大小频繁变化<br/>旧空间释放,新空间另分配"]
    C --> C3["不同 size class 的对象交错分配<br/>页内混合导致无法整页归还"]

最容易产生碎片的使用模式:

模式碎片原因典型场景
大量短命 key频繁分配/释放,空洞累积会话缓存、限流计数器
value 大小频繁变化APPEND/SET 导致 SDS 重新分配计数器字符串、动态列表
大量不同大小的对象混合不同 size class 交错,页无法整体回收混合业务
大 key 删除后大块空间被释放但无法被小对象复用大 Hash/Set 过期

12.5.4 activedefrag — Redis 4.0+ 在线碎片整理 ​

bash
# 开启主动碎片整理
CONFIG SET activedefrag yes

# 相关配置
active-defrag-enabled yes
active-defrag-ignore-bytes 100mb       # 碎片绝对量 < 100MB 不整理
active-defrag-threshold-lower 10       # 碎片率 < 10% 不整理
active-defrag-threshold-upper 100      # 碎片率 > 100% 全力整理
active-defrag-cycle-min 1              # 最小 CPU 占比 (%)
active-defrag-cycle-max 25             # 最大 CPU 占比 (%)
active-defrag-max-scan-fields 1000     # 每次扫描的最大字段数

activedefrag 工作原理:

mermaid
flowchart LR
    A["扫描所有 key"] --> B["检查对象是否在碎片页上"]
    B --> C{"对象所在页的<br/>空闲率高?"}
    C -->|是| D["分配新空间<br/>拷贝数据<br/>释放旧空间"]
    C -->|否| E["跳过"]
    D --> F["旧页空闲率进一步提高<br/>最终可整页归还 OS"]

本质:通过"搬家"(重新分配 + 拷贝 + 释放旧地址)让对象聚集到少数页上,使空闲页可以整体归还。

注意事项:

  • activedefrag 在主线程中执行(和命令交替),会占用 CPU
  • 大 key 的整理可能导致延迟抖动
  • 建议在低峰期开启,或设置合理的 cycle-max

12.5.5 jemalloc 的 dirty page decay 对 Redis RSS 的影响 ​

jemalloc 释放的内存不会立即归还 OS,而是保留为 "dirty pages":

text
jemalloc 内存状态:
  allocated → 正在使用
  active    → 已分配给 arena 但可能部分空闲
  dirty     → 已释放但未归还 OS(保留一段时间供复用)
  muzzy     → 已 madvise(MADV_FREE) 但 OS 还没回收
  retained  → 已 munmap 归还 OS

decay 机制:

text
dirty_decay_ms = 10000 (默认 10 秒)
  → dirty page 在 10 秒后开始归还 OS
  → 10 秒内如果有新分配,直接复用 dirty page(无系统调用)

muzzy_decay_ms = 10000 (默认 10 秒)
  → muzzy page 在 10 秒后彻底释放

对 Redis 的影响:

现象原因是否需要处理
删除大量 key 后 RSS 不立即降dirty pages 保留中等 10-20 秒后观察
RSS 持续高于 used_memorydecay 保留 + 碎片看碎片率是否在合理范围
突发流量后 RSS 居高不下峰值分配的 arena 空间被保留可调低 decay_ms

调优 jemalloc decay(Redis 6.0+ 支持):

bash
# 查看 jemalloc 统计
redis-cli MEMORY MALLOC-STATS

# 手动触发内存归还(Redis 7.0+)
redis-cli MEMORY PURGE

# 编译时调整 jemalloc 配置(需重新编译 Redis)
# 或通过环境变量:
MALLOC_CONF="dirty_decay_ms:5000,muzzy_decay_ms:5000" redis-server

12.5.6 BGSAVE/AOF rewrite 时的 COW 与碎片 ​

mermaid
flowchart TD
    A["BGSAVE 触发 fork()"] --> B["父子进程共享物理页 (COW)"]
    B --> C["父进程继续写入"]
    C --> D["写入触发 COW: 复制整个 4KB 页"]
    D --> E["即使只改 1 字节<br/>也要复制整页"]
    E --> F["内存使用量临时翻倍<br/>(最坏情况)"]

COW 放大碎片的机制:

text
正常情况:
  一个 4KB 页上有 100 个小对象
  修改其中 1 个 → COW 复制整页 → 额外 4KB

碎片严重时:
  一个 4KB 页上只有 10 个对象(其余是碎片空洞)
  修改其中 1 个 → COW 复制整页 → 额外 4KB
  但这 4KB 中有效数据只有 10%!

结论: 碎片越严重,COW 的"有效载荷比"越低,内存放大越严重

实战建议:

场景建议
BGSAVE 期间内存翻倍预留 2× 内存,或错峰执行
碎片率高 + 频繁 BGSAVE先整理碎片,再做持久化
容器 memory limit 紧张考虑关闭 RDB,只用 AOF

12.5.7 碎片排障流程 ​

mermaid
flowchart TD
    A["INFO memory 看碎片率"] --> B{"ratio > 1.5?"}
    B -->|否| C["正常,无需处理"]
    B -->|是| D["MEMORY MALLOC-STATS<br/>看 jemalloc 详细统计"]
    D --> E{"dirty pages 占比高?"}
    E -->|是| F["等待 decay 或 MEMORY PURGE"]
    E -->|否| G{"外部碎片为主?"}
    G -->|是| H["开启 activedefrag"]
    G -->|否| I["内部碎片为主<br/>检查 value 大小分布"]
    H --> J["监控 CPU 和延迟"]
    I --> K["优化数据模型<br/>减少 size class 跨度"]

十三、工程实践:Redis 真正难的是延迟抖动和热点传播 ​

13.0 一眼看懂:Redis 为什么会从热点抖成系统抖 ​

mermaid
flowchart LR
    A["热 key / 大 key / 慢命令"] --> B["主线程停留变长"]
    B --> C["请求排队"]
    C --> D["应用超时与重试"]
    D --> E["DB 回源与系统放大"]
现象优先看什么常见误判
RT 抖动SLOWLOG、LATENCY DOCTOR、fork/fsync只怪网络不好
CPU 高热 key、命令模型、慢脚本只怪单线程架构
命中率下降淘汰策略、访问模式变化只怪容量不够
内存突然涨COW、碎片、大 key、惰性删除只怪泄漏

13.1 Redis 的核心瓶颈不是“单线程太弱”,而是主线程承载了太多关键路径 ​

很多人一提 Redis,第一反应就是“单线程会不会成为瓶颈”。更准确的说法其实是:

  • 命令执行主线程是串行的
  • 主线程一旦被某个慢操作占住,其他请求就要排队
  • 所以 Redis 更怕的是尾延迟放大,而不是平均吞吐不够

也就是说,Redis 线上最危险的时刻通常不是“整体都很慢”,而是:

  • 某个命令偶发很慢
  • 主线程被占住几十毫秒到几百毫秒
  • 所有后续请求一起排队
  • 应用体感突然变差

13.2 真正拖慢 Redis 的,往往不是普通 GET/SET,而是少量“破坏事件循环节奏”的操作 ​

典型风险包括:

  • 大 key 删除
  • 大集合遍历
  • KEYS / 大范围 SMEMBERS / HGETALL
  • 长 Lua 脚本
  • 复杂聚合命令
  • 大量过期集中触发
  • RDB/AOF 背景任务带来的 COW 放大

这些问题的共同点是:它们不一定很多,但只要一出现,就会拉长主线程停留时间。

13.3 持久化带来的抖动,很多时候比命令本身更隐蔽 ​

Redis 是内存数据库,但线上延迟经常不是纯 CPU 问题,而是持久化副作用:

  • BGSAVE / BGREWRITEAOF 触发 fork
  • fork 期间页表复制带来卡顿
  • 后续写流量高时,COW 导致内存额外膨胀
  • AOF fsync 或磁盘抖动又会进一步放大延迟
mermaid
flowchart LR
    A["BGSAVE / AOF rewrite"] --> B["fork 成本上升"]
    B --> C["写流量期间 COW 增大"]
    C --> D["内存压力 / 延迟抖动"]
    D --> E["应用侧超时和重试增多"]

所以 Redis 的持久化配置,本质上不是“开不开”,而是:你愿意拿多少延迟稳定性去换数据安全和恢复能力。

13.4 热 key 和大 key,危害完全不同 ​

  • 热 key:请求过于集中,单线程局部热点过高,CPU、网络、队列被一个 key 放大
  • 大 key:一次操作就可能搬大量数据,导致主线程执行时间拉长、网络包过大、复制和持久化成本升高

两者都能把 Redis 拖慢,但处理方式不同:

  • 热 key 更多是分散访问、加本地缓存、拆热点
  • 大 key 更多是重构数据模型、拆分、异步删除、避免全量返回

13.5 淘汰策略选错,问题会从缓存层一路传到数据库 ​

mermaid
flowchart LR
    A["淘汰策略和流量模式不匹配"] --> B["命中率下降"]
    B --> C["应用回源增多"]
    C --> D["MySQL/下游负载升高"]
    D --> E["整体RT抖动 / 超时"]

所以 Redis 的内存淘汰不是“缓存内部问题”,而是整个系统容量设计的一部分。

13.6 Pipeline、Lua、事务都不是免费的 ​

  • Pipeline 减少 RTT,但单次打包太大也会放大主线程连续处理时间
  • Lua 脚本提供原子性,但执行期间 Redis 不能切换去处理其他命令
  • MULTI/EXEC 可以保证一批命令连续执行,但并不会让复杂逻辑更便宜

它们都适合减少往返和保证一致性,但不适合承载过重业务逻辑。

13.7 一个典型故障链 ​

mermaid
flowchart LR
    A["热key/大key/慢命令出现"] --> B["Redis 主线程停留时间上升"]
    B --> C["请求排队"]
    C --> D["应用侧超时和重试"]
    D --> E["Redis QPS 更抖,DB 回源增加"]
    E --> F["故障从缓存层扩散到整个系统"]

13.8 常见误判 ​

现象容易误判为实际可能是
Redis RT 抖动网络不好fork / fsync / 大key / 慢命令
CPU 高单线程架构不行热key 或命令模型不合理
命中率下降Redis 容量不够淘汰策略失配 / 访问模式变化
从库延迟大复制机制差主库大 key、网络、磁盘或从库负载
内存突然上升泄漏COW、碎片、惰性删除堆积

13.9 排障顺序 ​

现象优先看什么
延迟抖动LATENCY DOCTOR, SLOWLOG, INFO commandstats
内存上涨INFO memory, 碎片率、COW、bigkeys
CPU 高热 key、慢命令、Lua、集合操作
命中率下降INFO stats, 淘汰策略、TTL 分布
主从延迟INFO replication, 网络、从库回放压力

13.10 一个实战原则 ​

text
把 Redis 当作“单线程事件循环 + 内存数据结构 + 持久化副作用”的组合体,
不要只把它当作一个很快的 KV。

这样你更容易判断:问题到底是命令、数据模型、持久化,还是整个缓存策略。

参考 ​

批注模式

💬 文章评论

暂无评论,来说点什么吧 👇

编程学习笔记