很早就接触并使用过 Redis,但仅停留在业务应用层面,本次将从底层原理重新系统学习。

Redis(Remote Dictionary Server,远程字典服务),是一款开源的、基于内存的、支持多种数据结构的高性能键值存储系统。

1. 环境搭建和实操

学习一个新技术,最好的方法是先用,对它有个大致了解,然后再一步一步深入。如果从来没有使用过,就去看各种概念,很容易淹没在知识点和各种术语的海洋里。

1.1 使用包管理器安装Redis(Ubuntu系统)

# 更新软件包列表
sudo apt update
# 在线安装 Redis 服务端(Ubuntu)
sudo apt install redis-server -y

# 启动 Redis 服务
sudo systemctl start redis-server

# 测试是否安装成功
redis-cli ping

结果返回 PONG,说明安装成功,且服务器正常运行。

安装后的配置文件位置:/etc/redis/redis.conf(是 Redis 核心配置文件,端口、密码、持久化、内存限制、底层编码阈值等所有运行规则均在此定义。)

1.2 使用 redis-cli进行实操

redis-cli 是 Redis 自带的命令行工具,安装 Redis 时会自动安装。刚刚安装完Redis我们已经用redis-cli测试过了。下面就可以开始练习常用命令了(这些命令最好动手敲两遍,遇到error日志AI搜索一下原因,可以加深对Redis的理解)。

Redis 的键值对中的 key 就是字符串对象,而 value 就是指Redis的数据类型,可以是String,也可以是List、Hash、Set、 Zset、Bitmap、Stream 等数据类型。本文主要介绍五种常见的数据类型:String,List,Hash,Set,Zset。

redis-cli 小贴士

  • 命令不区分大小写,但习惯用大写(行业通用规范:命令大写、key/value 小写,提升可读性)
  • 按 Tab 键可以自动补全命令
  • 上下箭头可以查看历史命令
  • 输入 HELP @<分类> 查看帮助
  • 安全退出redis-cli 客户端:用EXIT或QUIT

一. 基础命令练习(String 类型)

1. 键值对操作

# 设置键值
SET name "张三"
SET age 25
SET email "zhangsan@example.com"

# 获取键值
GET name
GET age
GET email

# 设置带过期时间的键(10秒后自动删除)
SET session "abc123" EX 10

# 查看剩余存活时间
TTL session

# 判断键是否存在
EXISTS name
EXISTS phone

# 删除键
DEL email

# 获取键的类型
TYPE name
TYPE age

2. 数值操作(原子操作)

# 设置数值
SET counter 10

# 自增 1
INCR counter
INCR counter

# 自增指定数值
INCRBY counter 5

# 自减 1
DECR counter

# 自减指定数值
DECRBY counter 3

# 浮点数增加
SET price 19.99
INCRBYFLOAT price 0.01

# 查看当前值
GET counter
GET price

3. 同时操作多个键

# 批量设置
MSET user:1 "Alice" user:2 "Bob" user:3 "Charlie"

# 批量获取
MGET user:1 user:2 user:3

# 如果不存在则设置(用于分布式锁)
SETNX lock "locked"
SETNX lock "again"  # 键已存在,设置失败,返回 0

二. 列表命令(List,有序可重复)

# 从右侧推入(尾部)
RPUSH tasks "任务1"
RPUSH tasks "任务2"
RPUSH tasks "任务3"

# 从左侧推入(头部)
LPUSH tasks "紧急任务"

# 查看列表范围(0 到 -1 表示全部)
LRANGE tasks 0 -1
LRANGE tasks 0 2

# 从左侧弹出(取出并删除)
LPOP tasks

# 从右侧弹出
RPOP tasks

# 获取列表长度
LLEN tasks

# 获取指定索引的值
LINDEX tasks 0

三. 哈希表命令(Hash,类似对象)

# 设置哈希表的字段
HSET user:1000 name "张三"
HSET user:1000 age 30
HSET user:1000 city "北京"

# 批量设置哈希表字段
HSET user:1001 name "李四" age 25 city "上海"

# 获取单个字段
HGET user:1000 name

# 获取所有字段和值
HGETALL user:1000

# 获取所有字段名
HKEYS user:1000

# 获取所有值
HVALS user:1000

# 获取字段数量
HLEN user:1000

# 删除字段
HDEL user:1000 city

# 字段自增
HINCRBY user:1000 age 1

四. 集合命令(Set,无序不重复)

# 添加元素
SADD colors "red"
SADD colors "green"
SADD colors "blue"
SADD colors "red"  # 重复添加无效

# 查看所有元素
SMEMBERS colors

# 判断元素是否存在
SISMEMBER colors "green"  # 返回 1
SISMEMBER colors "yellow" # 返回 0

# 获取集合大小
SCARD colors

# 随机获取元素(不删除)
SRANDMEMBER colors

# 随机弹出元素(删除并返回)
SPOP colors

# 删除元素
SREM colors "blue"

# 集合运算
SADD set1 1 2 3
SADD set2 2 3 4

SINTER set1 set2   # 交集:2, 3
SUNION set1 set2   # 并集:1, 2, 3, 4
SDIFF set1 set2    # 差集:1

五. 有序集合命令(ZSet,也称作Sorted Set,带分数排序)

# 添加元素(分数决定排序)
ZADD leaderboard 100 "Alice"
ZADD leaderboard 85 "Bob"
ZADD leaderboard 95 "Charlie"
ZADD leaderboard 100 "David"

# 查看排名(按分数升序)
ZRANGE leaderboard 0 -1 WITHSCORES

# 查看排名(按分数降序)
ZREVRANGE leaderboard 0 -1 WITHSCORES

# 获取前3名(降序)
ZREVRANGE leaderboard 0 2 WITHSCORES

# 查看指定分数范围的元素
ZRANGEBYSCORE leaderboard 90 100 WITHSCORES

# 获取元素的排名(从0开始)
ZRANK leaderboard "Bob"       # 升序排名
ZREVRANK leaderboard "Alice"  # 降序排名

# 增加分数
ZINCRBY leaderboard 5 "Bob"

# 获取元素个数
ZCARD leaderboard

# 删除元素
ZREM leaderboard "David"

六. 键管理命令

# 查找所有符合模式的键(生产环境慎用!)
KEYS *
KEYS user:*
KEYS *:1000

# 生产环境推荐使用 SCAN(不阻塞)
SCAN 0 MATCH user:* COUNT 10

# 检查键是否存在
EXISTS name

# 删除键
DEL name

# 设置过期时间(秒)
EXPIRE session 60

# 设置过期时间(毫秒)
PEXPIRE session 60000

# 移除过期时间(永久保存)
PERSIST session

# 重命名键
RENAME old_key new_key
RENAMENX old_key new_key  # 仅当新键名不存在时

# 随机返回一个键
RANDOMKEY

    七. 五种数据类型的主要应用场景

    数据类型 典型应用场景
    String (字符串) 缓存对象、计数器、分布式锁
    List (列表) 消息队列、最新消息列表(如评论、通知)
    Hash (哈希) 存储对象(如用户信息、商品详情),可单独操作字段
    Set (集合) 标签系统、共同好友、数据去重
    Sorted Set (有序集合) 排行榜、带权重的任务队列、范围查找

    2. 常用数据类型的底层实现

    下面介绍五种常用数据类型依托的底层数据结构。

    2.1 SDS(Simple Dynamic String - 简单动态字符串)

    SDS是 Redis 自己造的字符串结构,用来完全替代 C 语言字符串。Redis 里所有需要存储字符串数据的地方(包括 Key、String 类型的Value、Hash 的 field/value、List/Set/ZSet 的成员、命令名字等),底层都是用 SDS 来存储的。

    1. 为什么 Redis 不直接用 C 字符串?
    C 语言字符串有 4 个致命缺陷:

    • 获取长度慢:必须遍历到 \0 才能知道长度,O(N)
    • 容易缓冲区溢出:拼接字符串不检查空间
    • 修改时频繁内存重分配:性能差
    • 不能存二进制(图片、视频):遇到 \0 就截断

    Redis 是高性能内存数据库,C 字符串完全扛不住。所以 Redis 自己造了 SDS。

    2. SDS 长什么样?(底层结构)

    SDS 的经典简化模型(C语言)

    struct sdshdr {
        // 已使用长度
        unsigned int len;
    
        // 剩余可用空间
        unsigned int free;
    
        // 真实存字符的数组
        char buf[];
    };

    你存一个字符串:"redis",SDS 在内存里长这样:

    len = 5

    free = 10

    buf = ['r','e','d','i','s','\0']

    • len:已经用了多少字节
    • free:还剩多少空间
    • buf:真实数据

    3. SDS 解决了 C 字符串的所有问题

    ① 获取长度 = O (1)

    • C 语言:遍历到 \0 → O (N)
    • SDS:直接读 len → O(1)

    ② 不会缓冲区溢出
    SDS 拼接前会先检查:free >= 需要的空间吗?
    不够就先扩容,再写入。
    ③ 减少内存重分配(Redis 高性能关键)
    SDS 做了两个优化:

    • 空间预分配:扩容时多分配一点,给未来用
    • 惰性空间释放:字符串缩短时,不立即回收内存,留着备用

    这让 Redis 字符串修改极快。
    ④ 二进制安全
    C 字符串遇到 \0 就停。SDS 靠 len 判断长度,不是靠 \0。所以能存图片、音频、视频、序列化对象。

    2.2 从 linkedlist 到 quicklist

    quicklist 是 Redis 3.2 版本引入的,它结合了旧版本中 ziplist(压缩列表)和 linkedlist(双端链表)的优点,形成了一种双向链表 + 节点内压缩的混合结构,兼顾了性能和内存。

    所以这里我们先讲 linkedlist 和 ziplist,然后再来讲quicklist,最后介绍 listpack (Redis 7新结构)。

    2.2.1 Redis 双向链表 linkedlist

    ① 链表节点 (listNode):这是存储数据的最小单元。

    typedef struct listNode {
        struct listNode *prev;  // 指向前一个节点的指针
        struct listNode *next;  // 指向后一个节点的指针
        void *value;            // 指向节点中具体存储的数据
    } listNode;

    一个节点包含了前驱和后继指针,因此可以双向遍历。void* 指针的设计让这个节点可以持有任何类型的数据,实现了多态性。

    ② 链表管理器 (list):为了方便管理整个链表,Redis 还定义了一个链表结构体。

    typedef struct list {
        listNode *head;  // 指向链表的头节点
        listNode *tail;  // 指向链表的尾节点
        void *(*dup)(void *ptr); // 节点值复制函数
        void (*free)(void *ptr); // 节点值释放函数
        int (*match)(void *ptr, void *key); // 节点值匹配函数
        unsigned long len; // 链表中的节点数量
    } list;

    通过这个结构体,Redis 可以 O(1) 复杂度地获取链表的头节点、尾节点和长度,这是直接用节点拼接无法做到的。

    结构示意图:

    head <-> node1 <-> node2 <-> node3 <-> tail

    len = 3

    Redis双向链表 linkedlist 的优缺点

    优点:

    ✅ 双向:可以双向遍历
    ✅ 无环:头尾指向 NULL
    ✅ 带头尾指针:O(1) 获取头尾
    ✅ 带长度计数器:O(1) 获取长度
    ✅ 多态:void* 可保存任意类型

    缺点:

    • 内存开销大:每个节点需要 16 字节(prev + next)
    • 缓存不友好:节点不连续存储,CPU 缓存命中率低
    • 每个节点独立内存,内存碎片化严重
    • 查找效率低:需要遍历,时间复杂度O(N)

    2.2.2 ziplist - 压缩列表

    ziplist 是一种连续内存的紧凑存储结构,它是由连续内存块组成的顺序型数据结构,类似于数组。它通过特定的编码方式存储多个元素(字符串或整数),整个结构无需指针关联,极大地节省了内存空间。其整体结构及单个元素(entry)的存储格式如下

    整体结构

    一共 5 个部分:

    • zlbytes(4 字节):整个 ziplist 占多少字节
    • zltail(4 字节):最后一个 entry 的偏移量(快速找尾)
    • zllen(2 字节):entry 个数
    • entryN:真正存的数据(核心)
    • zlend(1 字节):结束符 0xFF

    entry 结构

    • prevlen:记录前一个 entry 的长度,目的是为了实现从后向前遍历,长度:1 字节 或 5 字节
    • encoding:记录当前节点的类型(字符串 / 整数)、长度,主要通过它来解析 data
    • data:当前节点的实际数据,类型和长度都由 encoding 决定

    prevlen 属性的空间大小跟前一个节点长度值的关系如下:

    • 如果前一个节点的长度小于 254 字节,那么 prevlen 属性需要用 1 字节的空间来保存这个长度值
    • 如果前一个节点的长度大于等于 254 字节,那么 prevlen 属性需要用 5 字节的空间来保存这个长度值

    在压缩列表中:

    • 查找第一个元素:可以通过表头固定长度直接定位,复杂度 O(1)。
    • 查找最后一个元素:可以通过表头中的 zltail 字段(尾部偏移量)直接定位,复杂度 O(1)。
    • 查找其他元素:只能逐个遍历,复杂度 O(N)。

    因此,压缩列表不适合保存过多的元素。
    往压缩列表中插入数据时,压缩列表会根据前一个节点的总长度决定 prevlen 使用 1 字节还是 5 字节;同时根据当前节点的数据类型(字符串/整数)和数据大小决定 encoding 使用不同长度。这种根据数据情况动态分配空间的设计思想,是 Redis 为了节省内存而采用的。

      连锁更新问题

      场景:多个长度为 253 字节的节点

      初始:
      [e1:253B] [e2:253B] [e3:253B] [e4:253B]
        ↑ prevlen = 253 (1 字节)

      插入 254 字节的 e0:
      [e0:254B] [e1:253B] [e2:253B] [e3:253B] [e4:253B]

      连锁反应:
      1. e1 的 prevlen 需要记录 254
         → 从 1 字节扩展为 5 字节
         → e1 长度变为 257 字节(增加了5-1=4个字节)

      2. e2 的 prevlen 需要记录 257
         → 也要扩展为 5 字节
         → e2 长度变为 257 字节

      3. e3、e4... 依次连锁更新

      最坏情况:O(N²) 时间复杂度

      ziplist 的特性

      特性 说明
      ✅ 内存极致压缩 连续内存,紧凑编码
      ✅ 遍历高效 CPU 缓存友好
      ❌ 插入删除慢 需要重新分配内存
      ❌ 新增或修改有连锁更新风险 最坏 O(n²)

      因此,小数据集、低修改频率的场景,ziplist性能更高效。

      注:Redis 7.0 开始 ziplist 被 listpack 替代(解决连锁更新)

      2.2.3 quicklist - 快速列表

      quicklist 设计初衷

      linkedlist 问题:内存开销大,缓存不友好

      ziplist 问题:插入删除慢,连锁更新可能

      quicklist 方案: 链表 + 压缩列表的混合结构,即把多个元素打包成一个压缩块(ziplist),再用双向链表串联所有块,每个链表节点是一个 ziplist。

      注:quicklist自Redis 3.2 沿用至今,6.0/7.0 版本仅内部包裹的压缩结构不同。

      版本差异:
      Redis 6.0:链表节点内部包裹 ziplist
      Redis 7.0:链表节点内部包裹 listpack
      整体架构完全不变,仅内部压缩单元替换。

      quicklist 结构

      // quicklist
      typedef struct quicklist {
          quicklistNode *head;
          quicklistNode *tail;
          unsigned long count;       // 所有ziplist中元素的总数
          unsigned long len;         // quicklistNode的个数
          //...其他字段省略
      } quicklist;
      
      // quicklist 节点
      typedef struct quicklistNode {
          struct quicklistNode *prev;
          struct quicklistNode *next;
          unsigned char *zl;          // 指向 ziplist 的指针
          unsigned int sz;            // ziplist 字节大小
          unsigned int count : 16;    // ziplist 元素个数
          //...其他字段省略
      } quicklistNode;
      

      示意图如下:

      增删查逻辑(结合结构理解)

      • 头部 / 尾部增删:直接操作 quicklist 的 head/tail 对应的压缩块,O (1) 级别,效率极高。
      • 中间位置读写:需要遍历链表找到对应块,再遍历块内元素,效率下降。
      • 块分裂:单个压缩块元素达到 fill 阈值时,自动拆分出新的 quicklistNode。避免了单个ziplist过大导致的插入/删除性能问题。
      • 块合并:删除元素后,相邻块元素数极少时,会触发合并,减少内存碎片。

      2.2.4 listpack - 紧凑列表

      listpack目的是替代压缩列表,它最大特点是 listpack 中每个节点不再包含前一个节点的长度了,解决压缩列表的连锁更新问题。

      整体内存结构

      各字段说明:

      • total-bytes(4 字节):整个 listpack 占用总字节数
      • num-entries(2 字节):元素总个数,O (1) 获取长度
      • entry:数据项(核心单元)
      • end-bytes(1 字节):结束标记 0xFF,固定占位

      entry结构

      各字段说明:

      • encoding:标识数据类型(整数 / 字符串)+ 数据长度,解析 data
      • data:真实存储的数据内容
      • entry-length:当前整个 entry 的总字节长度,即encoding+data的总长度

      listpack 没有压缩列表中记录前一个节点长度的字段,只记录当前节点的长度,向 listpack 加入一个新元素的时候,不会影响其他节点的长度字段的变化,从而避免了压缩列表的连锁更新问题。

      2.3 dict /hashtable

      dict 是 Redis 核心中的核心:Redis 整个数据库、Hash、Set 类型底层都依赖它。

      dict 本质是哈希表,作用:

      • 存储 Redis 全局所有 Key-Value(整个数据库就是一个大 dict)
      • Hash 数据类型:元素数量 / 大小超阈值后,由 listpack/ziplist 转为 dict/hashtable
      • Set 数据类型:整数集合不满足时,底层切换为 dict/hashtable

      设计目标:查询、增删 O (1),兼顾读写性能与内存利用率。

      完整结构体

      Redis 字典由三层结构组成:dict(总管理器)、dictht(哈希表)、dictEntry(哈希节点)。

      1. 节点实体 dictEntry

      typedef struct dictEntry {
          // 键
          void *key;
          // 值,联合体,可存指针/整数/长整型
          union {
              void *val;
              uint64_t u64;
              int64_t s64;
              double d;
          } v;
          // 哈希冲突:单向链表,指向下一个节点
          struct dictEntry *next;
      } dictEntry;
      • key:字典的键
      • 联合体 v:灵活存储不同类型值,节省内存
      • next:链地址法解决哈希冲突,同一桶位的节点串成单向链表

      2. 哈希表 dictht
      一个字典会维护两张哈希表 ht[0]、ht[1],用于 rehash:

      typedef struct dictht {
          // 哈希表数组,每个元素指向一条链表
          dictEntry **table;
          // 桶的总数量(数组长度)
          unsigned long size;
          // size 掩码,计算索引:hash(key) & sizemask
          unsigned long sizemask;
          // 当前已存储节点数量
          unsigned long used;
      } dictht;
      • table:一维数组,俗称桶数组
      • sizemask = size - 1,用来快速计算 key 所在桶下标
      • used:已使用节点数,用于判断负载因子

      3. 字典总结构 dict

      typedef struct dict {
          // 哈希表数组,默认只用 ht[0],rehash 时启用 ht[1]
          dictht ht[2];
          // rehash 进度标记,-1 表示不在 rehash
          long rehashidx;
          // 迭代器数量,安全迭代时使用
          unsigned long iterators;
      } dict;

      结构示意图

      dict

       ├─ ht[0] (主哈希表) 

       │         ├─ table[0] → entry1 → entry2 → null

       │         ├─ table[1] → null

       │         └─ table[2] → entry3 → null

       ├─ ht[1] (备用表,仅rehash时使用)

       └─ rehashidx = -1 (无rehash)

      渐进式 Rehash
      这是 Redis 的核心优化之一,避免一次性 rehash 导致服务停顿。

      1. 触发条件

      在正常服务请求阶段,插入的数据,都会写入到哈希表 0,此时的哈希表 1 并没有被分配空间。
      随着数据逐步增多(根据负载因子等进行判断),触发 rehash 操作,详细的触发情况如下:

      load_factor = ht[0].used / ht[0].size
      
      // 扩容条件
      if (load_factor >= 1 && 未在执行BGSAVE/BGREWRITEAOF) {
          rehash();  // 扩展为第一个 >= used * 2 的 2^n
      }
      
      if (load_factor >= 5) {
          rehash();  // 强制扩容
      }
      
      // 缩容条件
      if (load_factor < 0.1) {
          rehash();  // 缩小为第一个 >= used 的 2^n
      }
      

      2. Rehash 流程

      初始状态:
      ht[0]: size=4, used=4  (负载因子 1.0)
      ht[1]: 空
      rehashidx = -1

      步骤 1:开始 rehash
      ht[0]: size=4, used=4
      ht[1]: size=8, used=0  ← 分配新空间
      rehashidx = 0          ← 开始迁移

      步骤 2:每次操作时,顺带迁移一个桶
      迁移 ht[0].table[0] → ht[1]
      rehashidx = 1

      步骤 3:继续迁移...
      rehashidx = 2, 3, 4...

      步骤 N:迁移完成
      ht[0] → 释放
      ht[1] → 成为新的 ht[0]
      rehashidx = -1

      3. Rehash 期间的操作

      // 查找:先查 ht[0],再查 ht[1]
      dictEntry *dictFind(dict *d, const void *key) {
          if (d->rehashidx != -1) {
              entry = findInHashTable(&d->ht[0], key);
              if (entry) return entry;
              return findInHashTable(&d->ht[1], key);
          }
          return findInHashTable(&d->ht[0], key);
      }
      
      // 插入:新节点只插入 ht[1]
      // 修改/删除:同时在 ht[0] 和 ht[1] 中修改/删除
      

      dict/hashtable 优缺点

      优点

      • 读写查询平均 O (1),性能极强
      • 自动扩容缩容,自适应数据量
      • 渐进式 rehash,避免阻塞服务

      缺点

      • 哈希冲突严重时,链表变长,性能退化
      • rehash 阶段临时占用双倍内存

      2.4 intset - 整数集合

      当一个 Set 对象只包含整数值元素,并且元素数量不大时,就会使用整数集这个数据结构作为底层实现。

      结构定义

      typedef struct intset {
          uint32_t encoding;  // 编码类型:决定每个元素占几字节
          uint32_t length;    // 元素数量
          int8_t contents[];  // 连续数组:内存完全连续、有序、无重复(实际类型取决于encoding)
      } intset;
      
      // 编码类型
      // -32768 ~ 32767, 每个元素占 2 字节(范围小整数)
      #define INTSET_ENC_INT16 (sizeof(int16_t))  
      // -2^31 ~ 2^31-1, 每个元素占 4 字节
      #define INTSET_ENC_INT32 (sizeof(int32_t)) 
      // -2^63 ~ 2^63-1, 每个元素占 8 字节(大整数)
      #define INTSET_ENC_INT64 (sizeof(int64_t))  
      

      编码升级
      当插入的元素超出当前编码范围时,IntSet 会自动升级:

      存储小整数 [1, 5, 10, 100]:
      ┌──────────────────────────────┐
      │ encoding = INT16                                                │
      │ length = 4                                                             │
      │ contents = [1, 5, 10, 100]                                     │
      │   (每个元素 2 字节)                                              │
      └──────────────────────────────┘

      插入 65535 后升级:
      ┌──────────────────────────────────┐
      │ encoding = INT32                                                          │
      │ length = 5                                                                       │
      │ contents = [1, 5, 10, 100, 65535]                                   │
      │   (每个元素 4 字节)                                                        │
      └──────────────────────────────────┘

      升级流程

      • 1. 根据新编码分配空间
      • 2. 从后往前迁移元素(避免覆盖)
      • 3. 插入新元素(必定在头部或尾部)
      • 4. 更新 encoding 和 length

      intset 优缺点
      优点

      • 内存连续、占用极小,CPU 缓存友好
      • 元素有序,二分查找效率高
      • 天然去重,适配 Set 特性

      缺点

      • 仅支持整数,类型单一
      • 中间插入 / 删除需要移动元素,数据量大后性能变差
      • 编码只升不降,可能存在内存轻微浪费
      • 元素超量 / 出现非整数,强制转为 hashtable

      2.5 skiplist - 跳表

      跳表是 Redis ZSet(有序集合)的核心底层结构之一。ZSet 同时使用跳表 + 哈希表:跳表负责维护元素的有序性(支持范围查询、排名操作),哈希表负责 O(1) 快速查找单个元素的分值。两者通过指针共享同一个元素对象,不重复存储数据。

      如何理解跳表?在了解跳表之前,我们先从普通链表开始讲起。

      首先,对于普通单链表,即使链表是有序的,我们要查找某个元素,也需要从头到尾遍历整个链表。

      如果你想寻找这个链表中有没有节点8,那么你就要从节点1开始,依次向后遍历,直到遍历到一个数值等于8的节点,共需要8步。

      那么如何能让这个遍历的次数减少呢?

      我们可以考虑在原链表之上建立索引层:每个节点可以出现在多个层级中,高层级的节点作为下一层级的“索引”,通过额外的指针跳过中间节点,从而加速查找,如下图:

      在查询的时候,我们可以从索引层去查询,先找到节点1,然后到节点4,然后到节点7,节点7之后没有节点了,那么就去下一层去寻找,下一层的节点7之后就是节点8,这次我们只需要4次就找到了答案。

      那如果我们再多建一级索引呢?如下图所示:在顶层索引中,从 1 开始,下一个是 7,7 小于 8,继续往后;发现 7 后面没有索引节点了,就下沉到下一层,从 7 的底层指针找到 8,共 3 次比较。虽然加了两级索引优化效果不是很明显,这是因为我们的数据量太少。在数据量大时,优化效果比较显著。

      在有序链表的基础上增加多级索引,这就是跳表。传统链表的查找时间复杂度为 O(N),而跳表通过建立多层索引链表,优化了查找性能,平均查找时间复杂度为 O(log N)。

      跳表结构示意图(简化版)

      第3层索引: head -------------------------------------------------> 9

      第2层索引: head -------------> 3 -------------------------------> 9

      第1层索引: head -> 1 -------> 3 -------> 5 -------> 7 -------> 9

      原始链表:   head -> 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9

      • 最底层:完整有序链表,保存所有真实数据
      • 上层:逐层精简的索引层,每层节点是下层节点的 “抽样”
      • 查找逻辑:从最高层开始,能跳就跳,不能跳就下沉到下一层,直到底层

      跳表不是固定间隔建索引,而是通过随机算法决定每个节点的层数(例如 Redis 中最大 32 层,每升一层的概率为 0.25)。这种随机化保证了平均性能,同时简化了插入/删除操作。

      skiplist 结构定义

      // 跳表节点
      typedef struct zskiplistNode {
          // 成员数据(SDS 字符串)
          sds ele;
          // 分值(排序依据,浮点型)
          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;

      关键字段解读

      • ele:ZSet 的成员元素;score:排序用的分值。
      • backward:后退指针,只有底层链表有,支持从尾向前遍历。
      • level[]:柔性数组,代表多层索引;每个层级包含 forward(后继指针)和 span(跨度,记录当前节点到下一节点之间有多少个真实元素,用于排名计算)。

      ZSet(有序集合)底层为什么选择跳表而不选红黑树?

      跳表相比红黑树有如下优点:

      • 内存开销可控:跳表多层索引有少量额外开销,但 Redis 随机层数策略让整体内存占用稳定;红黑树每个节点需维护多个颜色、父子、兄弟指针,开销并不更低
      • 实现简单:跳表代码逻辑远比重平衡二叉树(红黑树)简单,维护成本低,出错概率小。
      • 范围查询优势:ZSet 高频场景是区间遍历、排行榜。跳表天然是链表结构,定位起点后可直接顺序遍历;红黑树是树形结构,范围遍历需要中序遍历,实现更复杂、性能更弱。
      • 并发友好:跳表修改节点时,仅需修改相邻节点指针;红黑树增删会触发多次旋转,锁竞争 / 逻辑复杂度更高。

      2.6 五种数据类型和底层数据结构的关系

      前面我们提到过,Redis 的键值对中的 key 就是字符串对象,它的value可以是不同的数据类型。Redis通过 redisObject 统一管理所有value,redisObject包含三个关键字段:

      • type:数据类型(String/List/Hash/Set/ZSet)
      • encoding:底层数据结构编码
      • ptr:指向实际数据结构的指针(SDS/ziplist/intset/quicklist/hashtable/skiplist)

      这种设计使Redis能根据数据特点动态选择最优编码方式。Redis 会根据 value 的数据类型和数据大小对底层数据结构进行自动转换。如下表(以下转换基于Redis默认配置,实际阈值可通过配置文件调整)

      数据类型 数据量小 数据量大 转换条件
      STRING int / SDS SDS 整数执行字符串操作时转为SDS
      LIST quicklist quicklist 无转换(始终quicklist)
      HASH ziplist hashtable entries>512 或 任一field/value>64字节
      SET             intset hashtable 出现非整数 或 entries>512

      ZSET        

       ziplist skiplist+hashtable  entries>128 或 任一member>64字节

      注:

      一. 实际上 String 有三种编码:

      • 1. int:value 是可以用 long 表示的整数
      • 2. embstr:短字符串(≤44字节),redisObject和SDS连续分配
      • 3. raw:长字符串(>44字节),redisObject和SDS分离分配

      但本质上embstr和raw都是 SDS,它们俩的区别只是内存分配方式不同。

      二. 以上内容基于Redis 6.0。Redis 7.0 开始有重要变化:

      • 用 listpack 替换 ziplist
      • quicklist 节点内部由 ziplist 改成 listpack
      • 避免了 ziplist 的连锁更新问题

      上述转换的核心理念是:在内存效率和访问性能之间取得平衡。当数据量小或元素较小时,使用内存紧凑的编码(如 ziplist, intset);当数据规模增长后,自动转为读写性能更稳定但内存占用稍高的编码(如 hashtable, skiplist)。这也是Redis为什么快的重要原因之一——高效的数据结构。

      2.7 与底层数据结构相关的问题理解

      2.7.1 SDS简单动态字符串相关问题

      问题描述 分析
      为什么len命令是O(1)? SDS 结构体内部维护了 len 字段(当前有效字符长度),读取长度直接取该字段,无需遍历字符数组,时间复杂度 O(1)。
      为什么追加字符串不一定导致内存重分配? 1. 追加后总长度 < 1MB
      新容量 = 实际长度 × 2(双倍预分配)
      一次扩容,后续多次小追加都不用再分配。
      2. 追加后总长度 ≥ 1MB
      新容量 = 实际长度 + 1MB(多预留 1MB 空闲,不再翻倍)
      3. 字符串缩短时,不会立即回收空闲内存,避免频繁分配释放。

      2.7.2 ziplist 相关问题

      问题描述 分析
      为什么小Hash节省内存? 数据量小、内容短时,Redis Hash 底层使用 ziplist 压缩列表。
      ziplist 采用连续内存 + 紧凑编码,无指针、哈希桶等额外冗余开销,内存利用率极高。
      Hash 什么时候会从节省内存模式转为普通模式?

      默认配置的情况下,如果哈希内 field 数量 > 512或者任意一个 field /value 的 字节长度 > 64就会由ziplist转换为hashtable

      2.7.3 skiplist 相关问题

      skiplist是ZSet的底层数据结构之一。

      问题描述 分析
      ZRANK 为什么快?—— 查排名 操作本质:给定一个成员,查找它在有序集合中的排名(分值排第几)。
      底层支持:Redis 的跳表在实现时,每个节点除了存元素和分值,还会维护一个 span 属性,表示当前节点到下一个节点跨越了多少个元素。
      快速计算:在查找某个成员的过程中,Redis 会不断累加沿途经过的 span 值。等找到成员时,累加结果就是它的排名。
      ZRANGE 为什么快?—— 按排名范围取数据 操作本质:给定起始排名和结束排名(例如第 0 名到第 9 名),取出对应成员。
      两步走:
      先用 O(log N) 找到起始排名的节点(利用跳表的索引快速定位)。
      然后沿着底层链表向后遍历,取出后续节点,直到达到结束排名。遍历过程是 O(M),M 是要返回的元素个数。
      ZRANGE 的时间复杂度是 O(log N + M)。相比遍历整个集合,效率极高。

      2.7.4 intset 相关问题

      问题描述 分析
      为什么纯整数Set内存占用极低? 底层是 intset 整数集合,连续内存、自适应整型编码、无指针和哈希冗余,内存占用极低。
      为什么SADD一个字符串后,内存突然变大? intset 仅支持纯整数,插入字符串会不可逆转为 dict 哈希表;dict 存在大量节点、指针、哈希桶等冗余结构,内存开销大幅上升。

      2.7.5 其他问题

      问题描述 分析
      ZSet 为什么天然有序

      核心:底层主力是跳表 (SkipList)
      常规模式下 ZSet 由 跳表 + dict 组成,跳表节点始终按照 score(分值)全局有序排列。
      多层索引结构既保证有序性,又能实现快速查找、范围查询、排名计算。
      即使是小数据量的 ziplist 模式,内部也会按分值顺序存储,同样维持有序特性。

      Set 为什么自动去重

      Set底层无论使用intset还是dict/hashtable,其设计核心都是存储唯一元素。intset通过有序数组+二分查找保证唯一,dict/hashtable则利用键的全局唯一性自然实现去重。

      3. 单线程 + I/O多路复用

      Redis 核心网络模型是单线程处理命令 + I/O 多路复用监听套接字,并非全程只有一个线程,只是核心读写、命令执行串行化。

      3.1 Redis 的「单线程」到底指什么?

      Redis 主线程只做这几件事,并且是串行执行:

      • 监听客户端连接、读写网络数据
      • 解析客户端命令(GET/SET 等)
      • 执行数据读写、内存操作
      • 返回结果给客户端

      这部分全程单线程,不并发。

      注:Redis 不是纯单线程。
      Redis 有多线程 / 后台线程,只是不处理核心命令:

      • 后台线程:持久化(RDB/AOF)、过期键删除、大内存异步释放、文件刷盘
      • 新版本 (6.0+) I/O 多线程:仅处理网络读写、协议解析,命令执行、数据操作依旧单线程

      目的:缓解大网络 I/O 瓶颈,不改变核心串行特性。

      为什么核心要用单线程?

      1. 避免线程竞争、锁开销:内存数据结构不用加锁,不存在多线程并发修改冲突,性能损耗极低。
      2. CPU 不是瓶颈:Redis 是内存数据库,99% 耗时在网络 I/O,而非计算。单线程完全吃不满 CPU。
      3. 实现简单、无线程切换开销:上下文切换、线程调度都会拖慢速度,单线程规避这些问题。

      单线程的短板:单个慢命令会阻塞整个主线程(如 KEYS、FLUSHALL、超大集合遍历),生产严禁使用。

      3.2 I/O 多路复用:单线程如何处理上万客户端?

      I/O 多路复用(I/O Multiplexing)是一种单线程监控多个文件描述符(FD) 的技术,当其中任何一个 FD 准备就绪(可读/可写/异常)时,系统通知应用程序进行相应操作。在 Linux 下,客户端连接、服务端监听端口、文件、网络套接字,都抽象成 FD(数字编号)。Redis 主线程把所有客户端 FD 交给多路复用器监听。

      问题场景
      假设 Redis 要同时处理多个客户端连接:
      传统方式(多进程/多线程):

      客户端1 → 线程1(阻塞等待)
      客户端2 → 线程2(阻塞等待)
      客户端3 → 线程3(阻塞等待)

      • 每个连接需要一个线程/进程
      • 线程切换开销大
      • 连接数越多,性能越差

      I/O 多路复用方式:

      一个线程同时监控多个连接:
                       ┌──→ 客户端1(有数据?)
                       │
      主线程 ──┼──→ 客户端2(有数据?)
                       │

                       │──→  ...(有数据?)

                       │
                       └──→ 客户端n(有数据?)

      • 一个线程监控多个连接
      • 哪个连接有数据,就处理哪个
      • 没有数据时,线程可以休眠(不占 CPU)

      生活类比

      方式 类比 问题
      多线程 餐厅每个客人配一个服务员 服务员太多,成本高
      I/O多路复用 一个服务员同时服务多个客人 客人喊一声,服务员过去处理

      Linux 主流多路复用模型(Redis 选用 epoll)
      从旧到新演进:

      1. select:FD 数量有限,遍历所有 FD,效率低
      2. poll:解除数量限制,依旧全量遍历
      3. epoll(Redis 默认):事件驱动,只返回就绪的 FD,不用遍历全部;高并发下性能碾压前两者,是 Linux 高并发网络标配

      epoll 的工作流程:

      // 1. 创建一个 epoll 实例
      int epfd = epoll_create(1024);
      
      // 2. 将多个客户端 socket 加入监控列表
      epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd1, &ev);
      epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd2, &ev);
      epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd3, &ev);
      
      // 3. 循环:等待任意一个 socket 有数据
      while (1) {
          int nfds = epoll_wait(epfd, events, 1024, -1);  // 阻塞等待
          for (int i = 0; i < nfds; i++) {
              read(events[i].data.fd, buffer);   // 读取数据
              process_command(buffer);            // 执行 Redis 命令
          }
      }

      Redis 单线程 + I/O 多路复用完整流程

      详细步骤

      1. 启动时:
         Redis 创建 epoll 实例,将监听 socket 加入监控

      2. 客户端连接:
         客户端1 → 监听 socket 可读 → epoll 通知 → Redis 调用 accept() 接受连接
         → 将客户端1 的 socket fd 加入 epoll 监控

      3. 客户端发送命令:
         客户端1 发送 "GET key" → socket 可读 → epoll 通知
         → Redis 读取数据 → 解析命令 → 执行命令 → 写回结果

      4. 没有事件时:
         epoll_wait() 阻塞 → Redis 休眠 → CPU 空闲

      为什么这套组合速度极快?

      原因 说明
      内存操作 数据都在内存,没有磁盘 I/O 瓶颈
      I/O 多路复用 单线程高效管理成千上万连接
      避免上下文切换 没有线程竞争、锁等待、切换开销
      数据结构优化 哈希表、跳表、压缩列表等高效结构
      非阻塞 I/O 读取数据时,如果数据还没准备好,立刻返回(不等待),去处理下一个有数据的连接

      单线程的瓶颈
      任何慢命令(如KEYS *、HGETALL大Hash、ZUNIONSTORE)、大网络 I/O、CPU 密集操作都会阻塞之后所有命令。生产禁止使用阻塞命令。

      3.3 与单线程+I/O多路复用相关的问题

      问题描述 分析
      为什么单线程能处理10万+QPS? 核心机制 I/O 多路复用,Linux 使用 epoll,单线程监听海量连接,有请求才处理,规避多线程上下文切换、锁竞争开销。
      Redis 为什么快 数据全存内存、核心命令复杂度低、单线程无额外线程开销。 
      Redis的性能瓶颈 网络 I/O/ 内存。Redis 命令处理逻辑简单、都是内存操作,CPU 很难打满;瓶颈普遍出在网络吞吐、带宽、客户端连接数、内存大小
      慢查询危害 主线程串行执行命令,一条慢命令会阻塞所有后续请求,拖垮整体服务。
      Redis 6.0 多线程

      该多线程指的是I/O多线程,只处理网络读写、协议解析,命令执行、数据操作依旧单线程(Redis 6.0+ 引入的 I/O 多线程默认关闭,需在配置文件中设置)

      4. 数据持久化:如何保证重启不丢数据?

      Redis 数据默认全放内存,断电 / 重启数据丢失,持久化就是把内存数据落地到磁盘,分三大方案:RDB、AOF、混合持久化,核心底层依赖 fork + 写时复制 (COW)。

      4.1 前置基础:fork 与 写时复制 Copy-On-Write

      1. fork 子进程

      Redis使用Linux系统的 fork() 系统调用创建子进程:
      fork 创建子进程后,并不会立刻拷贝物理内存,仅复制页表,父子进程共享内存页
      目的:让子进程负责把数据刷磁盘,父进程继续正常处理客户端命令,不阻塞主进程(父进程)业务。
      2. 写时复制 COW(Copy-On-Write,Linux 内核机制)
      fork 之后并不会立刻复制整份内存(内存太大时瞬间复制会卡死、耗资源),而是采用页只读共享:
      1. 父子进程共用同一块物理内存,所有内存页标记为「只读」。
      2. 父进程收到新写命令(SET/DEL 等):

      • 内核检测到「只读内存被修改」
      • 触发 COW:把被修改的这一页内存单独复制一份给父进程
      • 父进程在新副本上修改,子进程继续读原来的旧内存

      3. 子进程全程只读取 fork 那一刻的内存快照,不受后续写操作影响。


      一句话总结:fork 出子进程 → 内存共享只读 → 有写操作才复制对应内存页 → 子进程安心落地快照。

      4.2 RDB快照(Redis Database)

      RDB是全量二进制快照,把某个时刻的Redis内存数据全量保存到磁盘。

      内存数据:                          RDB 文件:
      ┌─────────┐              ┌─────────────┐
      │ name:tom       │  ──→   │ 二进制数据               │
      │ age:25            │              │ (经过压缩)                │
      │ ...                    │              └─────────────┘
      └─────────┘

      怎么触发?

      方式 命令 特点
      手动 SAVE 阻塞主进程,不推荐
      手动 BGSAVE fork 子进程,不阻塞主进程,推荐
      自动 save 900 1 900秒内至少1次修改则触发

      注:其他隐式触发:1. Redis 正常关机 shutdown;2. 主从复制:从节点初次全量同步,主节点自动生成 RDB。

      完整执行流程(BGSAVE)

      1. 父进程收到 BGSAVE 指令,调用 fork() 创建子进程(COW 机制生效)。
      2. 子进程遍历内存,把fork 瞬间的全量数据写入临时 RDB 文件。
      3. 写入完成,临时文件改名 dump.rdb,覆盖旧文件。
      4. 子进程退出,父进程继续对外服务。

      特点总结
      ✅ 优点
      二进制压缩文件,体积小,适合备份、迁移。
      恢复速度极快(直接加载二进制)。
      子进程后台执行,几乎不影响主线程性能。
      ❌ 缺点
      会丢数据:两次 RDB 之间的新数据,断电全部丢失。
      例:5 分钟生成一次快照,最多丢失 5 分钟数据。
      频繁 fork 会有短暂卡顿;数据量极大时,COW 复制内存页会消耗磁盘 / 内存。

      4.3 AOF日志(Append Only File)

      AOF是增量文本日志:不存数据本身,记录每一条客户端写命令,追加到 appendonly.aof 文件末尾。重启 Redis 时,重新从头到尾回放所有命令,恢复数据。

      AOF的三种刷盘策略

      配置 含义 安全性 性能
      appendfsync always 每次写命令都刷盘 最高(最多丢 1 条数据) 最差(频繁 IO,高并发下性能差)
      appendfsync everysec 每秒刷一次 高(最多丢1秒数据) 好(性能 & 安全性折中,生产最常用)
      appendfsync no 交给操作系统自行刷盘 低(宕机可能丢失大量数据) 最好

      AOF 重写

      问题:长期运行 AOF 文件会无限膨胀(一条 key 反复 SET,会存几十上百条重复命令)。
      AOF 重写:生成一份「等价、精简」的新 AOF 文件,替换旧文件。

      AOF 文件会越来越大,比如:

      同一个 key 执行了 100 次 INCR:
      INCR counter  # 1
      INCR counter  # 2
      ...
      INCR counter  # 100

      重写后变成一条:

      SET counter 100

      重写流程(依旧 fork + COW)

      1. 父进程 fork() 子进程。
      2. 子进程遍历当前内存数据,直接根据现有数据生成最简写命令(不是复制旧日志),写入新 AOF 临时文件。
      3. 重写期间,父进程新命令正常写入旧 AOF + 重写缓冲区。
      4. 子进程写完新文件后,把「重写缓冲区」里的增量命令追加到新文件。
      5. 原子替换新旧 AOF 文件,重写结束。

      特点总结
      ✅ 优点
      数据安全性高,配合 everysec 最多丢 1 秒数据。
      文本格式,可打开查看、手动修复。
      ❌ 缺点
      日志文件体积远大于 RDB。
      数据恢复慢(需要逐条回放命令)。
      持续磁盘 I/O,性能略低于 RDB。

      4.4 混合持久化(Redis 4.0+)

      为什么需要混合?

      单独用 RDB:丢数据;单独用 AOF:文件大、恢复慢。
      混合持久化 = 结合两者优点。

      流程和原理

      开启配置:

      aof-use-rdb-preamble yes  # 开启混合持久化(Redis4.0+ 默认开启)

      1. 执行 AOF 重写 时:

      • 先把当前全量内存数据以 RDB 二进制格式写入新 AOF 文件头部。
      • 再把重写期间增量的 AOF 命令追加在后面。

      2. 最终 AOF 文件结构:

      【RDB 二进制全量数据】 + 【后续增量 AOF 命令日志】

      重启时的恢复流程

      服务器重启
          │
          ↓
      读取 AOF 文件
          │
          ├── 先加载 RDB 部分(快速恢复全量数据)
          │
          └── 再重放 AOF 部分的增量命令(补上后续修改)
          │
          ↓
      恢复完成

      优缺点
      ✅ 优点
      兼具 RDB 恢复快、文件小 + AOF 数据安全。
      目前生产环境标准最优选择。
      ❌ 缺点
      文件前半段是二进制,后半段是文本,无法手动完整编辑。

      4.5 三种持久化方式汇总对比

      特性 RDB AOF 混合持久化 (4.0+)
      存储形式 二进制快照 (全量) 文本命令 (增量) RDB 头 + AOF 增量
      数据丢失风险 丢一段区间数据 最多丢 1 秒 (默认) 几乎不丢
      文件体积 中等偏小
      恢复速度 极快
      磁盘 I/O 压力 低(低频) 高(持续写入) 中等
      适用场景 冷备、迁移、容忍丢数 高数据安全场景 生产首选

      4.6 数据持久化相关的问题

      问题描述 分析
      为什么BGSAVE和BGREWRITEAOF都能做到非阻塞? BGSAVE 和 BGREWRITEAOF 都靠 fork 子进程 + 写时复制(COW)把 “繁重的磁盘 I/O” 放到后台,主线程只短暂阻塞在 fork 那一瞬间,之后全程非阻塞、继续处理命令。
      为什么在RDB期间,Redis内存占用可能突然变高(几乎是原来的两倍)?

      如果写操作非常频繁,涉及大量不同的内存页,操作系统会不断拷贝被修改的页。极端情况下,几乎所有数据页都被修改过一次,那么内存占用就接近原内存 × 2(一份给子进程做快照,一份给主进程写新数据)。

      为什么需要合理设置maxmemory,给fork()留出内存空间。

      maxmemory 不应该设置为物理内存的 100%,必须预留内存给操作系统和 fork()。
      不预留内存的风险:
      fork() 失败:fork() 需要分配内核内部结构(如页表)。即使父子进程共享物理内存,fork() 本身也需要少量空闲内存。如果内存已满,fork() 会失败,导致 bgsave/bgrewriteaof 无法执行,持久化失效。
      OOM Killer 杀进程:当内存耗尽时,Linux 会启动 OOM Killer(内存溢出杀手),随机选择一个进程杀掉。Redis 很可能被选中,导致服务中断。
      写时复制无空间:即使 fork() 成功,当主进程需要修改某个内存页而触发 COW 时,如果没有空闲内存来放置拷贝的新页,系统会进入死锁或直接崩溃。

      Logo

      免费领 150 小时云算力,进群参与显卡、AI PC 幸运抽奖

      更多推荐