Redis 基础

99 阅读19分钟

Redis为什么快

  1. Redis是基于内存,内存本身要比常规磁盘访问快。
  2. 数据结构简单。
  3. 采用单线程,省去了上下文切换,不存在竞争,不用考虑各种锁问题。
  4. 基于IO多路由复用机制机制,同时用来监听多个socket连接,处理高并发网络请求。

Redis如何使用IO多路由复用

Redis 使用一个事件驱动的架构来处理所有客户端连接,基于 epoll实现了自己的事件分发器

  1. 事件注册:当客户端连接到 Redis,服务器会把这个 socket 注册到 epoll,监听其读写事件。
  2. 事件监听:Redis 的主线程进入事件循环,不断调用 epoll_wait 等函数阻塞等待事件发生。
  3. 事件触发:某个 socket 有可读/可写事件,epoll 通知 Redis。
  4. 事件处理:Redis 根据事件类型(读、写)调用相应的回调函数,例如读取客户端命令、写入响应等。

IO 多路复用是一种通过一个线程同时监听多个文件描述符(socket 连接)是否可读/可写的机制。它本质上是事 件驱动:当某个描述符上有事件发生(如有数据可读),通知主程序处理。

epoll(Linux 特有):Redis 默认使用这个IO多路复用技术,效率高,支持事件通知机制,不会随着连接数增多而性能下降。

Redis的单线程

为什么使用单线程?

官方答案是:因为CPU不是Redis的瓶颈,Redis的瓶颈最有可能是机器内存或者网络带宽。既然单线程容易实现,而且CPU不会成为瓶颈,那就顺理成章地采用单线程的方案了。

我们使用单线程的方式是无法发挥多核CPU 性能,不过我们可以通过在单机开多个Redis 实例来解决这个问题。

Redis 6.0的多线程

客户端发送请求 --> 多线程读取请求(读 I/O) --> 主线程执行命令 --> 多线程发送响应(写 I/O)
  1. 为什么要引入多线程

    Redis的瓶颈主要是在网络I/O模块带来的CPU耗时,多线程用来处理网络I/O这部分,充分利用CPU资源

  2. 如何开启多线程

    默认时关闭,可以在conf文件中配置开启

    io-threads 4             # 启用 4 个 I/O 线程(不包括主线程)
    io-threads-do-reads yes  # 表示开启读阶段的多线程处理(默认仅写是多线程)
    

Redis数据库设计

Redis使用HashTable管理数据

typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2]; // 两个哈希表,ht[0] 主表,ht[1] 为扩容时新表
    long rehashidx; // 当前rehash的索引,如果为-1表示没有在rehash
    int iterators;
} dict;

typedef struct dictht {
    dictEntry **table; // 哈希表数组
    unsigned long size; // 哈希表大小
    unsigned long sizemask;
    unsigned long used; // 当前拥有的键值对总数
} dictht;

通过 hash(key) % hashtable.size的方式,获取当前key在HashTable的位置。

HashTable实际存储的是当前数据指针。

指针指向一个列表,列表中每一个结构为 Entry(k, v, next)。当遇到hash冲突时,Redis通过头插法,将元素添加到这个列表当中。

扩容

  • size——桶(bucket)的数量,用于内部数组空间大小;

  • used——实际存储的键值对数量,是 dictEntry 总和;

当 (DICT_RESIZE_ENABLE && dict.used >= dict.size) || (!DICT_RESIZE_FORBID && dict.used / dict.size > 安全阈值) 时,
就会触发扩容。扩容会直接创建一个原来长度2倍的HashTable

扩容时采用渐进式rehash

  • 每次访问HashTable时,从前向后,迁移HashTable有值的第一个
  • 后台轮询迁移,每次默认迁移100个

当扩容时,get操作会先去旧HashTable访问,如果没有,则去访问新的HashTable;set操作会直接讲数据添加到新的HashTable。

问题:如果 rehash 还没完成,ht[1] 已满了怎么办?

  1. 假设 rehash 正在进行中,ht[1] 已经存在。
  2. 如果 ht[1] 由于大量新写入(插入),导致负载因子过高,也到达扩容阈值。
  3. 扩容限制:在rehash完成前,不会触发二次扩容(见_dictExpandIfNeeded中的dictIsRehashing判断)。

Redis key

Redis的key类型均为String。

在set时,Redis的key可以为任意类型,在Redis service都会将其转为String类型

redisObject

typedef struct redisObject {
    unsigned type:4;
    unsigned encoding:4;
    unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
                            * LFU data (least significant 8 bits frequency
                            * and most significant 16 bits access time). */
    int refcount;
    void *ptr;
} robj;
  • type:表示 Redis 对象类型,由调用的API决定。
  • encoding:根据value决定不同的存储类型,当数据发生变化时,Redis会将其变为合适的编码结构。
  • lru: 存储与 Redis 的 缓存淘汰策略 (LRU 或 LFU) 相关的信息。
    • LRU:存储的是一个 相对时间戳,淘汰时,Redis 比较所有对象的 lru 值,淘汰距离当前 lru_clock 最远的 (即最久未被访问的) 对象。
    • LFU:
      • 低 8 位 (Least Significant Bits - LSB): 存储一个 访问频率计数器。这是一个基于概率的计数器,值越大表示访问越频繁。它不是精确计数,并且会随着时间衰减。
      • 高 16 位 (Most Significant Bits - MSB): 存储一个 最近一次访问的时间戳 (单位是分钟,相对于某个起点)。这个时间戳用于辅助频率计数器的衰减。如果一个对象很久没被访问,即使它曾经访问频繁,其频率计数也会逐渐降低。
  • refcount:引用计数,Redis 自动内存回收 (垃圾收集) 的基础。它确保不再被使用的对象能够被及时释放。
  • *ptr:指向实际数据的指针
    • 当value小于等于20时,会尝试将其转为long,若转化成功,则将value直接存放到这个位置。
      • 为什么是long?因为64位操作系统中,long和指针所用内存都为8byte,可以减少内存IO。
      • 为什么小于等于20?因为64位操作系统中,最大整数位19位,加上符号位为20位。

embstr 优秀的内存利用

在 Redis 中,当我们存储的值为字符串类型(string)时,其编码方式依赖于字符串的长度:当长度小于等于 44 字节时,采用 embstr 编码;超过 44 字节,则使用 raw 编码。

这是基于对 CPU 缓存机制的优化考虑。CPU 读取内存时,通常以缓存行为单位进行操作,最小读取单位为 64 字节。而一个 redisObject 结构本身仅占用 16 字节,剩余的 48 字节在传统实现中将被浪费。为提高内存利用率,Redis 设计了 embstr 编码策略,巧妙地利用这 48 字节空间。

这 48 字节空间用于存放字符串的实际内容。在 embstr 编码中,Redis 使用 sdshdr8 结构来保存字符串信息,其中 sdshdr8 本身占用 4 字节,剩余的 44 字节则可用于存放字符串数据。

基本数据类型 - String

为什么不使用c语言当中的String?

  • C字符串因为是以\0空字符作为字符串结束标志,所以只能用来保存文本数据,而不能保存图片、音频、视频这样的二进制数据。
  • SDS是通过len属性的值来判断字符串结束,所以SDS是二进制安全的,可以用来保存任意格式的二进制数据。

SDS内部结构

Redis自定义了一种String类型:SDS「simple dynamic string」

  • 内存布局

    在64位系统下,字段len和字段free各占4个字节,紧接着存放字符串。

    +-----------------+
    | len 4b          |
    +-----------------+
    | free 4b         |
    +-----------------+
    | buf content...  |
    | '\0' terminator |
    +-----------------+
    
  • 结构

    // 3.2之前的结构
    struct sdshdr {
        // buf数组中已使用字节数量,即SDS所保存字符串的长度
        unsigned int len;
        // buf数组中未使用的字节数量
        unsigned int free;
        // 字节数组,真正保存字符串的数据空间
        char buf[];
    };
    

    free属性:减少修改字符串时带来的内存重新分配

多种SDS类型(Redis 3.2+)

SDS的总体结构包括头部head和存储用户数据的buf,其中用户数据后总跟着一个\0。为了节省内存,Redis 引入不同大小的 header 类型:sdshdr5/8/16/32/64

详情如:

  • sdshdr5仅 1 char flags描述长度,低3位为类型,高5位存长度(≤31),不存 free,适合极短字符串

    • 内存布局

      +-------------------------------+
      | flags (1B: 3位类型+5len)     |
      +-------------------------------+
      | buf content...                |
      | '\0' terminator               |
      +-------------------------------+
      
    • 结构

    struct __attribute__ ((__packed__)) sdshdr5 {
        unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
        char buf[];
    };
    
  • sdshdr8sdshdr16sdshdr32sdshdr64的结构相同,只不过flags的高5位将不会填充数据。

    sdshdr16为例

    • 内存布局

      +-----------------+
      | len: uint16 2B  |
      | alloc: uint16 2B|
      | flags: char 1B  | 
      +-----------------+
      | buf content...  |
      | '\0' terminator |
      +-----------------+
      
    • 结构

    struct __attribute__ ((__packed__)) sdshdr8 {
        uint8_t len; /* 已经使用的长度 */
        uint8_t alloc; /* 总长度 */
        unsigned char flags; /* 低3位存储类型,高5位预留 */
        char buf[];
    };
    struct __attribute__ ((__packed__)) sdshdr16 {
        uint16_t len; /* used */
        uint16_t alloc; /* excluding the header and null terminator */
        unsigned char flags; /* 3 lsb of type, 5 unused bits */
        char buf[];
    };
    struct __attribute__ ((__packed__)) sdshdr32 {
        uint32_t len; /* used */
        uint32_t alloc; /* excluding the header and null terminator */
        unsigned char flags; /* 3 lsb of type, 5 unused bits */
        char buf[];
    };
    struct __attribute__ ((__packed__)) sdshdr64 {
        uint64_t len; /* used */
        uint64_t alloc; /* excluding the header and null terminator */
        unsigned char flags; /* 3 lsb of type, 5 unused bits */
        char buf[];
    };
    

字节对齐

sdshdr使用了__attribute__ ((__packed__))做修饰,修饰后按1字节对齐,注意buf是个char类型的柔性数组,地址连续,始终在flags之后。

基本数据类型 - List

在 Redis 3.2 之前,list 的底层实现是 linkedlistziplist,优先使用 ziplist,当元素数量「默认 512」或者单个元素大小「默认 64 字节」超过阈值,转为 linkedlist

  • linkedlist:插入/删除快,但内存开销大(每个节点要存指针,64 位系统上一个节点元数据就几十字节)。
  • ziplist:连续内存存储,节省空间,但过大时会导致一次性 realloc(内存拷贝),插入/删除性能差。

自Redis 3.2起,List的底层核心是quicklist,它结合了ziplist和传统双向双向链表的优势

quicklist

typedef struct quicklist {
    quicklistNode *head;
    quicklistNode *tail;
    unsigned long count;        /* total count of all entries in all ziplists */
    unsigned long len;          /* number of quicklistNodes */
    int fill : QL_FILL_BITS;              /* fill factor for individual nodes */
    unsigned int compress : QL_COMP_BITS; /* depth of end nodes not to compress;0=off */
    unsigned int bookmark_count: QL_BM_BITS;
    quicklistBookmark bookmarks[];
} quicklist;
  • head/tail: 指向双向链表首尾节点的指针。
  • count: 整个 List 包含的元素总数 (所有 ziplist entry 之和),LLEN 命令直接返回此值,复杂度 O(1)
  • len: quicklistNode 节点的数量。
  • fill: 控制单个 ziplist 的容量上限。值来自配置 list-max-ziplist-size
  • compress: 控制压缩深度。值来自配置 list-compress-depth
    • 0: 不压缩 (默认)。
    • n: 压缩列表 头尾各 n 个节点 以外的所有节点。因为头尾节点访问频繁,压缩它们收益低且增加 CPU 开销。
  • bookmarks: 用于大型列表遍历的辅助结构 (较少使用)。

quicklistNode

typedef struct quicklistNode {
    struct quicklistNode *prev;
    struct quicklistNode *next;
    unsigned char *zl;
    unsigned int sz;             /* ziplist size in bytes */
    unsigned int count : 16;     /* count of items in ziplist */
    unsigned int encoding : 2;   /* RAW==1 or LZF==2 */
    unsigned int container : 2;  /* NONE==1 or ZIPLIST==2 */
    unsigned int recompress : 1; /* was this node previous compressed? */
    unsigned int attempted_compress : 1; /* node can't compress; too small */
    unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;
  • prev/next: 构成双向链表。
  • zl: 核心数据指针
    • 如果 encoding = RAW (1): 指向一个 ziplist
    • 如果 encoding = LZF (2): 指向一个 quicklistLZF 结构 (内含压缩后的数据)。
  • sz: 当前节点 zl 指向的数据结构占用的总字节数 (无论压缩与否)。
  • count: 该节点内 ziplist 包含的 entry 个数 (O(1) 获取节点内元素数)。
  • encoding: 标识该节点的数据是原始 ziplist 还是压缩后的 LZF 数据。
  • container: 固定为 ZIPLIST (2),表示 zl 指向的是 ziplist (或其压缩形式)。预留了 NONE 类型供未来扩展。
  • recompress: 如果节点是压缩的 (LZF),当需要访问其内容时,Redis 会临时解压它。设置此标志表示该节点是临时解压的,后续需要重新压缩。
  • attempted_compress: 表示该节点曾尝试压缩但太小不值得压缩。

ziplist

ziplistquicklistNode当中真正存储元素的结构

其内存结构如下

<zlbytes> <zltail> <zllen> <entry> <entry> ... <entry> <zlend>
  • <uint32_t zlbytes> 是一个无符号整数,用于保存 ziplist 占用的字节数,包括 zlbytes 字段本身的 4个字节。需要存储此值以便能够在不先遍历整个结构的情况下调整其大小。
  • <uint32_t zltail> 是列表中最后一个条目的偏移量。这使得在列表的远端执行弹出操作时无需进行完整遍历。
  • <uint16_t zllen> 表示条目数量。当条目数量超过 2^16 - 2 时,此值将被设置为 2^16 - 1,此时我们需要遍历整个列表才能知道它包含多少项。
  • <uint8_t zlend> 是一个特殊的条目,表示 ziplist 的结束。它被编码为一个值为 255 的单字节。没有其他正常条目以值为 255 的字节开头。

ziplist entries内存结构如下

<prevlen from 1 to 5 bytes> <encoding> <entry-data>
  • prevlen: 存储 前一个 entry 的总字节长度。这是一个变长字段:
    • 如果前一个 entry 长度 < 254 字节: 用 1 字节存储。
    • 如果前一个 entry 长度 >= 254 字节: 用 5 字节存储 (首字节固定 254 (FE) + 实际长度 4 字节)。
  • encoding: 编码 当前 entry 的数据类型 (整数或字符串) 和长度。
    • 如果 entry 是字符串,编码的第一个字节的前 2 位表示用于存储字符串长度的编码类型,然后是字符串的实际长度。
    • 如果 entry 是整数,编码的第一个字节的前 2 位都设为 1。接下来的 2 位用于指定在这个头部之后将存储哪种类型的整数。
|00pppppp| - 1 字节
字符串值,长度小于或等于 63 字节(6 位)。"pppppp" 表示无符号 6 位长度。

|01pppppp|qqqqqqqq| - 2 字节
字符串值,长度小于或等于 16383 字节(14 位)。14 位数字采用大端序存储。

|10000000|qqqqqqqq|rrrrrrrr|ssssssss|tttttttt| - 5 字节
字符串值,长度大于或等于 16384 字节。仅后 4 字节表示长度,最大为 2^32-1。首字节低 6 位未使用,置 032 位数字采用大端序存储。

|11000000| - 3 字节
整数,编码为 int16\_t(2 字节)。

|11010000| - 5 字节
整数,编码为 int32\_t(4 字节)。

|11100000| - 9 字节
整数,编码为 int64\_t(8 字节)。

|11110000| - 4 字节
整数,编码为 24 位有符号整数(3 字节)。

|11111110| - 2 字节
整数,编码为 8 位有符号整数(1 字节)。

|1111xxxx|(xxxx 为 00011101)
立即数 4 位无符号整数,范围 012。实际编码值为 113,减 1 得到真实值。

|11111111|
ziplist 结束标志。

listpack

listpack 是 Redis 从 5.x 引入、在 6.0/7.0 广泛替换 ziplist 的一种紧凑线性容器格式,用于在一段连续内存中高效存放一串元素(字符串或整数),支持正反向遍历。

相比 ziplistlistpack 的核心改进是消除了级联更新的最坏情况

级联更新:假设一个 ziplist 中有多个连续的、长度刚好在 250-253 字节之间的条目,它们的 prevlen 都是 1 字节。此时,如果在这个序列的头部插入一个长度大于等于 254 的新条目,那么紧跟着它的下一个条目的 prevlen 就需要从 1 字节扩展为 5 字节。这个扩展操作本身可能导致该条目自身的总长度也超过 254 字节,进而引发再下一个条目的 prevlen 也需要扩展……这种连锁反应可能会一直传播下去,导致一次插入操作的平均复杂度从 O(1) 恶化到 O(N)。在最坏情况下,这可能成为一个性能隐患。

其内存结构如下

<lpbytes> <lplen> <entry> <entry> ... <entry> <lpeof>
  • <uint32_t lpbytes>listpack 占用的总字节数(含头与结尾)。

  • <uint16_t lplen>:元素个数。超过可表示范围时饱和为最大值,需要遍历计算真实长度。

  • <uint8_t lpeof>:结尾标记,固定单字节 0xFF。

ziplist 不同点:

  • 没有 zltail(最后一个元素偏移)。listpack 通过每个 entry 尾部的 backlen 反向跳转,配合末尾 0xFF 可 O(1) 找到最后一个 entry。

  • 不存 prevlen(前一元素长度),而是把“本 entry 总长度”的变长值放在“本 entry 的尾部”,避免 ziplist 的级联更新。

listpack entries内存结构如下

<encoding header> <payload> <backlen>
  • encoding header(1~5 字节):描述当前 entry 是字符串还是整数、以及长度/位宽。

    • 对于小整数,可能直接用 1 字节的 encoding 同时表示类型和数值。
    • 对于短字符串,会用 encoding 的前几位表示类型是字符串,后几位表示字符串的长度。
    • 对于大整数或长字符串,encoding 本身可能占用更多字节,并指示需要从后续的多少个字节中读取实际的数据长度。
  • payload:元素实际数据字节(字符串的字节序列,或整数的定长字节)。

  • backlen(1~5 字节):变长整型,表示“当前 entry 的总字节长度”(含 header+payload+backlen 自身)。

    • 编码为 7-bit 组的变长数。每个字节低 7 位是数据位,高 1 位为续位标志。

    • 最后一个字节的最高位为 0;其左边(更高有效)的字节最高位为 1。这样可以从“后往前”解码,单步得到整条 entry 的起始位置。

基本数据类型 - Set

Set是一个 无序且唯一的字符串集合,支持 O(1) 操作(插入、删除、判断成员)。

主要依赖两种底层数据结构:intset(整数集合)和 hashtable(哈希表)。

intset

typedef struct intset {
    uint32_t encoding; // 编码方式:INTSET_ENC_INT16, INT32, INT64
    uint32_t length;   // 元素个数
    int8_t contents[]; // 柔性数组,按encoding存储实际整数
} intset;
  • 所有整数有序(升序)存储在 contents[] 数组中。
  • encoding 决定每个元素的字节大小(2, 4, 或 8 字节)。例如 INTSET_ENC_INT16 时,contents 相当于 int16_t[]

对于小数据集(通常≤512个元素),有序数组的内存效率更好

hashtable

一旦元素类型非整数或数量超过阈值(由 set-max-intset-entries 控制),自动切换为 hashtable,键为 set 元素,值为 NULL,支持全类型字符串

基本数据类型 - Hash

底层采用两种编码方式:ziplist(压缩列表) 和 hashtable(哈希表)。

Redis 根据配置阈值自动切换编码(redis.conf):

// 默认配置
hash-max-ziplist-entries 512  // ziplist 最大元素数量
hash-max-ziplist-value 64     // 单个 field/value 最大字节数
  • ziplist → hashtable 触发条件(源码 t_hash.c):
    • Field-Value 对数量 > hash-max-ziplist-entries
    • 任意 field 或 value 长度 > hash-max-ziplist-value

基本数据类型 - zset

底层采用:skiplist(跳表) + hashtable(哈希表)+ ziplist/listpack

小规模使用ziplist,大规模使用hashtable进行 O(1)查找,skiplist维护有序。

zset 中的每个元素(member)都是唯一的(就像 set),但每个元素都关联一个浮点数类型的值,称为 score(分数)。

元素在 zset 内部按照 score 的值从小到大进行排序

多个不同的元素 (member) 可以拥有相同的 score。当 score 相同时,这些元素会按照它们的 member 字符串在字典序 (lexicographical order) 中的顺序进行排列(默认是升序,ab 前面)。

Redis 为了优化内存使用,对小型的 zset 采用了不同的内部编码 ziplist,当满足以下任意条件时,切换到skiplist

  • zset 中的 元素数量 > zset-max-ziplist-entries (默认 128)
  • zset任意一个 member 的长度 > zset-max-ziplist-value (默认 64 字节)

skiplist

/* 跳跃列表节点 */
typedef struct zskiplistNode {
    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;
  • 每个节点 zskiplistNode 用可变长度数组 level[] 存储多个层级的“前向指针”和“跨度”(span 用于快速计算元素排名)。
  • 跳表本身 zskiplist 记录当前最高层数 level(最大为 32)以及节点总数与头尾指针。

image.png

跳跃表的查找、插入和删除操作

  • 查找操作

    从最高层索引的头节点开始,

    • 如果当前节点的下一个节点的值小于要查找的值,则向右移动;
    • 如果当前节点的下一个节点的值大于要查找的值,则向下移动,
    • 直到找到目标值或者确定目标值不存在。
  • 插入操作: 首先进行查找操作,找到插入位置。然后随机生成一个层数,根据这个层数在每一层插入新节点。

  • 删除操作: 首先进行查找操作,找到要删除的节点。然后在每一层删除这个节点。

跳表的平衡性依赖于“随机层数”,由 zslRandomLevel() 决定

#define ZSKIPLIST_MAXLEVEL 32
#define ZSKIPLIST_P 0.25

int zslRandomLevel(void) {
    int level = 1;
    while ((random() & 0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
        level += 1;
    return (level < ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}
  • 初始层数为 1,每次以概率 P=0.25 递增一层,直至达到最大层数 32(足够支持 2^32元素)。

  • 这种几何分布保证平均插入、查找、删除复杂度均为 O(log N)。