Redis存储原理与数据模型

小捏哩 2026-04-04 09:26:52

Redis存储原理与数据模型

目录

  • Redis存储原理与数据模型
  • 1. Redis真的是单线程吗?
  • 1.1 为什么命令处理是单线程的?
  • 1.2 单线程如何处理高并发网络IO?
  • 1.3 redis为什么这么高效?
  • 2. 对象编码:灵活的数据结构适配
  • 2.1 字符串 (string)
  • 2.2 列表 (list)
  • 2.3 哈希 (hash)
  • 2.4 集合 (set)
  • 2.5 有序集合 (zset)
  • 3.跳表(skiplist)
  • 3.1 什么是跳表?
  • 3.2 Redis中的跳表实现
  • 3.3 跳表 vs B+树
  • 3.4 跳表操作示例
  • 4.数据组织:全局哈希表与渐进式rehash
  • 3.1 哈希冲突与负载因子
  • 3.2 渐进式rehash
  • 5. IO多线程:突破网络瓶颈
  • 5.1 工作原理
  • 5.2 配置与建议
  • 6. 总结

Redis作为当下最流行的键值对存储系统,以其高性能、丰富的数据结构和简洁的API赢得了广大开发者的青睐。然而,要真正用好Redis,理解其背后的存储原理与数据模型至关重要。本文将从线程模型、对象编码、数据组织、内存优化等多个维度,深入剖析Redis的内部世界。


1. Redis真的是单线程吗?

很多初学者会认为Redis是单线程的,实际上这个说法并不准确。准确的说法是:Redis处理客户端命令请求的核心逻辑是单线程的,但Redis进程本身并不只有这一个线程。

一个典型的Redis Server进程包含多个线程:

  • redis-server主线程:负责处理命令请求、解析协议、执行命令、响应客户端,以及执行常规的维护任务(如过期键清理、rehash等)。
  • 后台线程(bio,Background I/O):通常有三个,分别处理三种不同类型的后台任务:
    • bio_close_file:关闭文件描述符(如AOF重写后的旧文件)。
    • bio_aof_fsync:将AOF缓冲区数据刷盘(fsync)。
    • bio_lazy_free:惰性释放大对象内存(如删除大key时,避免阻塞主线程)。
  • IO线程(io_thd_\*:从Redis 6.0开始引入,用于分担网络读写的CPU开销(后面会详细讲解)。
  • jemalloc后台线程:内存分配器jemalloc本身也会启动后台线程进行内存管理。

所以,Redis并不是单线程的,只是命令执行阶段被设计为单线程。那么问题来了:为什么命令处理要单线程?单线程不会成为瓶颈吗?

1.1 为什么命令处理是单线程的?

Redis选择单线程处理命令,主要基于以下几点考虑:

  • 内存操作极快:Redis是内存数据库,所有数据都在内存中,操作速度是磁盘的十万倍以上。即使单线程,CPU也往往不是瓶颈,网络IO和内存带宽才是。
  • 避免多线程竞争:多线程环境下需要加锁来保护共享数据结构,加锁不仅带来性能损耗,还会增加代码复杂度和死锁风险。单线程天然避免了这些问题。
  • 减少上下文切换:多线程频繁切换CPU上下文,会浪费大量CPU时间,而单线程则没有这个开销。
  • 数据结构高效:Redis精心设计的数据结构(如哈希表、跳表、压缩列表等)在单线程下已经能达到极高的吞吐量。
  • 渐进式rehash:通过将耗时的rehash操作分摊到每次请求中,避免了集中式扩容带来的卡顿。

正是这些设计,使得单线程的Redis在大多数场景下依然能达到每秒十万甚至百万级别的QPS。

1.2 单线程如何处理高并发网络IO?

虽然命令执行是单线程,但Redis却能同时处理成千上万个客户端连接,这得益于 IO多路复用 机制。Redis在6.0版本之前,网络读写也是单线程的,但它通过操作系统提供的IO多路复用接口实现了非阻塞网络IO。

简单来说,主线程会维护一个事件循环,将所有客户端的套接字注册到epoll中。当某个套接字可读或可写时,epoll会通知主线程,主线程再依次处理这些就绪的事件。这种模型下,一个线程就能高效地管理海量连接,完全不需要为每个连接创建单独的线程。

Redis 6.0引入IO多线程后,网络数据的读取和解析才被交给多个线程并行处理,但事件监听和命令执行依然在主线程完成。

1.3 redis为什么这么高效?

除了单线程和IO多路复用,Redis的高效还体现在对各类任务的精细优化上:

  • 内存数据库:直接操作内存,速度很快。
  • CPU密集型计算:使用hashtable将大部分操作的时间复杂度降至O(1);对于扩容缩容,采用渐进式rehash将耗时的迁移工作分散到多个命令中。同时redis的数据结构也很高效,可以在执行效率和空间占用间保持平衡。
  • 网络IO密集型任务:采用Reactor模型,并在6.0之后引入IO多线程来加速数据读写和协议解析。
  • 磁盘IO密集型任务:在RDB持久化和AOF重写时,利用fork创建子进程,并借助操作系统的写时复制(COW)技术,避免主线程阻塞。

2. 对象编码:灵活的数据结构适配

Redis之所以支持丰富的数据类型(字符串、列表、哈希、集合、有序集合),是因为底层为每种类型提供了多种编码方式,根据数据特征动态切换,以在时间和空间上达到最佳平衡。

在深入了解各类型之前,需要先认识Redis中的通用对象结构——redisObject。每个键值对的值都被包装成一个redisObject,其定义如下(简化):

typedef struct redisObject {
    unsigned type:4;      // 对象类型(string, list, hash, set, zset)
    unsigned encoding:4;  // 底层编码(int, embstr, raw, hashtable, ziplist...)
    unsigned lru:24;      // LRU时间戳或LFU计数器
    int refcount;         // 引用计数,用于共享对象和内存回收
    void *ptr;            // 指向底层数据结构的指针
} robj;

redisObject固定占用16字节(4+4+24位共4字节,加上4字节refcount和8字节ptr)。下面我们分别看每种类型的编码方式。

2.1 字符串 (string)

字符串的编码分为三种:

  • int:如果字符串可以表示为64位有符号整数(长度不超过20字节,且能转为long),则直接以整数形式存储,节省空间。
  • embstr:如果字符串长度小于等于44字节,采用嵌入式字符串。所谓embstr,就是将redisObject和字符串内容分配在连续的内存块中(一次内存分配),并且充分利用CPU缓存行(64字节)。
  • raw:字符串长度大于44字节,使用简单动态字符串(SDS)存储,redisObject和SDS分开分配。

为什么分界线是44字节?
这与内存分配器、缓存行和SDS结构有关。

  • redisObject固定占用16字节(4位类型+4位编码+24位LRU+4字节引用计数+8字节指针)。
  • 内存分配器jemalloc或tcmalloc通常按2的幂次分配内存(如16、32、64字节)。
  • CPU缓存行通常为64字节,一次连续读取64字节效率最高。
  • SDS(简单动态字符串)在Redis 5.0之后分为sdshdr8等类型,占用3字节头部(len、alloc、flags),加上字符串末尾的'\0'占1字节,可以更好的适配c的字符串函数。
  • 因此,64 - 16 (redisObject) - 3 (sdshdr8) - 1 ('\0') = 44,恰好是64字节缓存行内能容纳的最大字符串长度,且只需一次内存分配。

SDS的优势:除了常数时间的获取长度、预分配内存减少重分配外,SDS是二进制安全的,可以存储任意数据(包括'\0'),并能兼容C字符串函数。

2.2 列表 (list)

Redis 3.2之前,列表使用ziplist(压缩列表)或linkedlist(双向链表)。3.2及之后版本统一使用 quicklist 编码。quicklist是双向链表和压缩列表的结合体:每个节点是一个ziplist,节点之间用指针连接。

  • ziplist:内存连续,存储紧凑,适合元素少、元素小的场景。但插入删除中间元素需要内存移动,复杂度O(N)。
  • linkedlist:每个节点独立分配,插入删除快,但内存碎片多,且每个节点需要两个指针(prev、next),开销较大。
  • quicklist:将大的linkedlist拆分成多个小的ziplist,每个ziplist大小可以通过list-max-ziplist-size配置(默认为-2,表示单个ziplist不超过8KB)。这样既保留了链表插入删除快的特性,又通过ziplist减少了指针开销,提升了内存效率。

2.3 哈希 (hash)

哈希有两种编码:

  • ziplist:当哈希键的所有字段和值的字符串长度都小于64字节,且字段个数小于512时,使用压缩列表。ziplist将字段和值顺序存储,节省内存。
  • hashtable:当条件不满足时,转换为字典(dict)结构,以支持O(1)的查找。

2.4 集合 (set)

集合也有两种编码:

  • intset:当所有元素都是整数,且元素个数不超过512时,使用整数集合。intset是一个有序的整数数组,支持二分查找。
  • hashtable:当不满足上述条件时,转换为字典,键为元素值,值为NULL。这里只用到了字典的键,值部分浪费了8字节指针,但为了统一实现,这种开销可以接受。

2.5 有序集合 (zset)

有序集合同样有两种编码:

  • ziplist:当元素个数小于128,且所有成员字符串长度小于64字节时,使用压缩列表。元素按分数升序排列,节省内存。
  • skiplist:当条件不满足时,同时使用跳表(skiplist)和哈希表(dict)。跳表维护有序性,哈希表实现O(1)的成员查找。两者通过指针共享相同的元素对象,避免内存翻倍。

3.跳表(skiplist)

跳表是Redis实现有序集合(编码为skiplist时)的关键数据结构。理解跳表有助于深入掌握ZSET的各种操作性能。

请添加图片描述

3.1 什么是跳表?

跳表是一种多层级的有序链表,它通过在原始链表上建立若干级“索引”来实现近似二分查找的效果。平均时间复杂度为O(logn),最坏情况O(n),但通过随机化层高,实际性能接近平衡树。

跳表的性质

  • 每个节点包含一个成员(member)和分数(score),节点按分数升序排列,分数相同时按成员字典序排列。
  • 每个节点有一个随机的层高(level),层高为k的节点包含k个向前的指针(level[0]到level[k-1])。
  • 第0层(最底层)是一个包含所有节点的有序链表。
  • 第i层是第i-1层的子集,形成“跳跃”的索引。
  • 查询时从最高层开始,每一层尽可能向右走,直到无法前进再降层,最终在最底层找到目标。

3.2 Redis中的跳表实现

Redis对经典跳表做了以下定制:

  • 最大层高限制为32ZSKIPLIST_MAXLEVEL)。这是经过权衡的:2^32 > 40亿,对于最多容纳数亿个元素的有序集合完全足够,同时避免了过高的内存开销。

  • 层高随机概率为1/4(而不是常见的1/2)。每次创建新节点时,随机生成层高,P=0.25。这意味着平均每个节点有1/(1-0.25)=1.33个指针,内存占用更低,同时查找效率依然接近O(logn)(实际常数略大,但可接受)。

  • 每个节点除了前进指针外,还包含一个后退指针(backward),用于实现反向遍历(如ZREVRANGE)。后退指针只存在于第0层。

  • 同时使用哈希表:为了支持O(1)的按成员查找分数(如ZSCORE),Redis在跳表之外还维护了一个字典(dict),键为成员,值为分数和跳表节点的指针(或直接存储分数)。两者通过指针共享同一个节点对象。

3.3 跳表 vs B+树

为什么有序集合不选用B+树?

  • 实现复杂度:跳表的插入、删除、查找逻辑简单,不需要像B+树那样处理节点的分裂、合并、平衡旋转等复杂操作,代码易维护且不易出错。
  • 范围查询性能:跳表的最底层链表天然支持范围查询(ZRANGEZREVRANGE),只需找到起始节点然后向后遍历即可。B+树同样支持范围查询,但需要额外维护叶子节点的链表指针。
  • 内存占用:Redis限制了跳表的最大层高为32,且概率为1/4,平均每个节点的指针数约为1/(1-0.25)=1.33个,而B+树每个节点通常有几十到上百个指针(取决于阶数),在元素数量较少时,B+树的内存效率反而更低。
  • 并发友好性:虽然Redis是单线程执行命令,但持久化时fork子进程会复制页表。跳表指针结构简单,写时复制影响小。

综上,跳表在Redis的场景下是一个简单、高效、内存友好的选择。

3.4 跳表操作示例

查找:从最高层开始,比较当前节点的下一个节点的分数与目标分数。若下一个节点分数小于目标,则继续向右移动;若大于目标或为NULL,则降一层。重复直到第0层,再向右找到目标。

插入:先随机生成新节点的层高(1~32)。然后从最高层向下查找,记录每一层插入位置的前驱节点。最后新建节点,调整每一层前驱节点的前进指针,使其指向新节点,新节点指向原来后继。同时更新后退指针。

删除:类似插入,找到每层的前驱节点,然后逐个修改指针。如果删除的节点是最高层中唯一节点,可能需要降低跳表的最大层高。

跳表的性质:

4.数据组织:全局哈希表与渐进式rehash

Redis将所有键值对存储在一个全局哈希表中,这个哈希表的结构由dict.h/dict定义:

typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];        // 两个哈希表,用于渐进式rehash
    long rehashidx;      // rehash进度,-1表示未进行
    int16_t pauserehash; // 是否暂停rehash
} dict;

请添加图片描述

  • ht[0]是主哈希表,ht[1]用于rehash时的备用表。
  • 每个哈希表(dictht)是一个数组,数组元素是dictEntry(键值对+链表指针),用于解决哈希冲突。

3.1 哈希冲突与负载因子

当多个键经过哈希函数计算后映射到同一个数组槽位时,就发生了哈希冲突。Redis采用链地址法解决:每个槽位对应一个链表,冲突的节点通过next指针串联。

负载因子 = used / size,其中used是已存储元素个数,size是哈希表数组长度。

  • 负载因子越小,冲突概率越低,但内存利用率低。
  • 负载因子越大,冲突越严重,查找效率下降。

Redis的负载因子阈值设计如下:

  • 扩容:当负载因子 > 1 时,触发扩容(新size为原size的2倍)。
  • 特殊扩容:如果正在进行RDB持久化或AOF重写(fork子进程),为了避免内存页复制(写时复制)带来的额外开销,会暂时阻止扩容。但如果负载因子 > 5,说明冲突已非常严重,即使正在fork也会立即扩容。
  • 缩容:当负载因子 < 0.1 时,触发缩容。新size取恰好大于等于used的2的N次方(例如used=9,则新size=16)。

3.2 渐进式rehash

扩容和缩容都需要重新计算所有键的哈希值并迁移到新数组,这个过程如果一次性完成,会导致Redis长时间阻塞。因此Redis采用渐进式rehash

  • 准备两个哈希表,ht[0]ht[1],将ht[1]扩展为指定大小。
  • rehashidx从0开始,表示当前迁移到ht[0]的哪个桶。
  • 每次执行增删改查命令时,顺带将ht[0]rehashidx位置上的整个链表迁移到ht[1],然后rehashidx++
  • 此外,Redis的定时任务也会主动帮忙迁移,每次最多执行1毫秒(以100个桶为步长)。
  • 在rehash过程中,新添加的键值对直接存入ht[1],保证ht[0]只减不增。
  • ht[0]所有元素迁移完毕,将ht[0]指向ht[1]ht[1]重置,rehashidx置为-1。

注意:渐进式rehash期间,查找操作需要同时查两个表,插入只插入ht[1],删除和更新也需要在两个表中进行。但整个过程中不会再次触发新的rehash。


5. IO多线程:突破网络瓶颈

尽管Redis命令执行是单线程,但随着硬件性能提升(万兆网卡),单线程处理网络读写可能成为瓶颈。Redis 6.0引入了IO多线程,将网络数据的读取和解析、结果返回等操作交给多个线程并行处理,而命令执行仍然由主线程单线程完成,从而保持了无锁设计的简单性。

请添加图片描述

5.1 工作原理

  1. 主线程负责监听客户端连接,并将建立的连接分配给IO线程组。
  2. 当有读事件发生时,主线程将读任务分发给多个IO线程,这些线程并行读取数据、解析协议(这部分是CPU密集型操作)。
  3. 解析完成后,主线程会收集所有解析好的命令,单线程执行命令,生成响应数据。
  4. 然后主线程再将写任务分发给IO线程,由它们并行将响应数据写回客户端。

整个过程是典型的Reactor模型多线程Worker的结合。需要注意的是,IO多线程只适用于多连接场景,如果只有一个连接,多线程反而会增加开销。

5.2 配置与建议

  • io-threads:设置IO线程数(默认4,最大128)。
  • io-threads-do-reads:是否开启读线程(默认no,因为读操作通常较轻量,实测开启后提升有限)。

建议开启IO线程处理写操作(io-threads-do-reads yes),因为写数据量大时,压缩协议并发送给客户端会消耗较多CPU。


6. 总结

Redis的高性能源于多方面精巧的设计:

  • 内存存储奠定了速度基础。
  • 单线程命令处理消除了锁竞争和上下文切换开销。
  • 多种对象编码在内存和速度之间取得完美平衡。
  • 渐进式rehash保证了扩容时服务平稳。
  • IO多线程进一步释放了网络吞吐能力。
  • 高效的数据结构(跳表、压缩列表、整数集合等)针对特定场景优化。

理解这些底层原理,不仅能帮助我们在使用Redis时做出更合理的设计(例如预估内存、选择合适的编码阈值),还能在遇到性能问题时快速定位瓶颈。

https://github.com/0voice

...全文
44 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

555

社区成员

发帖
与我相关
我的任务
社区描述
零声学院,目前拥有上千名C/C++开发者,我们致力将我们的学员组织起来,打造一个开发者学习交流技术的社区圈子。
nginx中间件后端 企业社区
社区管理员
  • Linux技术狂
  • Yttsam
  • 零声教育-晚晚
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告

请新加入的VIP学员,先将自己参加活动的【所有文章】,同步至社区:

【内容管理】-【同步至社区-【零声开发者社区】

试试用AI创作助手写篇文章吧