Redis底层数据结构的设计原因

12 阅读13分钟

这篇文章是小编的第一次写,如果有哪里写的不好的地方,希望各位大佬可以指正一下,也是我在学习Redis底层的一些感悟,希望可以给大家一些启发

1.大家有想过为什么Redis的底层数据结构要这样子设计吗?

Redis 底层数据结构的设计哲学,核心在于在“时间效率”与“空间效率”之间寻找极致的平衡。它并没有采用单一的数据结构,而是根据数据量的大小、元素的类型,为每种数据类型设计了多种底层编码,并支持动态转换

当然这些大家稍稍思考一下脑子里就会有一些答案,比如说Redis是基于内存的,内存贵;我都用Redis了,那我肯定要使我处理数据更高效更快,不然我为什么要引入Redis,也就是我们说的性能追求;还有一点大家可能忽略的是Redis是基于C语言开发的,C语言的原生结构是有缺陷的,带着这三个思考,那接下来我说一下我的了解

极致的内存节省:以空间效率为第一优先级

在 Redis 的设计哲学中,内存是极其昂贵且稀缺的资源。如果所有数据都采用标准的哈希表或双向链表来存储,大量的指针开销和内存对齐机制会导致严重的内存浪费。因此,Redis 针对小规模数据设计了极度紧凑的底层编码。

最典型的代表是压缩列表(ZipList)和整数集合(IntSet)。以 ZipList 为例,它被设计为一种内存紧凑型的数据结构,将数据紧凑地存储在连续的内存块中。它彻底抛弃了传统双向链表中每个节点都需要存储 prev 和 next 指针的做法,而是通过在每个节点中记录“前一个节点的长度”(prevlen)和“当前节点的编码与长度”(encoding)来定位数据。这种"时间换空间"的设计,不仅消除了指针带来的额外内存开销,还能充分利用 CPU 缓存行(Cache Line)的局部性原理,极大提升了内存的利用率。此外,当 Set 集合中全为整数时,Redis 会使用 IntSet 进行存储,它本质上是一个紧凑的连续数组,避免了哈希表带来的巨大空间冗余。面对数以千万计的小 Key,这种底层的极致压缩累计可节省数 GB 甚至数十GB 的内存。

保证高性能操作:以时间效率为兜底底线

紧凑结构虽然极大地节省了内存,但其代价是牺牲了部分读写性能。由于缺乏索引,ZipList 等结构在查询和修改时的时间复杂度通常是 O(N)。当数据量逐渐增大时,这种线性查找会成为严重的性能瓶颈。为了防止性能崩溃,Redis 引入了自适应的编码转换机制,为大规模数据提供了高效的时间复杂度兜底。

当 Hash 或 Set 的数据量超过系统设定的阈值(如元素个数超过 512 个,或字符串长度超过 64 字节)时,Redis 会自动将其底层结构升级为哈希表(Hashtable),从而保证增删改查操作的时间复杂度稳定在 O(1)。而对于 ZSet(有序集合)这种特殊结构,它既要支持按分数排序,又要支持范围查询。Redis 巧妙地选择了跳跃表(SkipList)作为底层实现。跳跃表通过在基础链表上构建多级索引,实现了 O(log N) 的查找效率,其性能足以媲美平衡树,但在实现范围遍历(如 ZRANGEBYSCORE)时却比红黑树更加简单高效。这种"小数据用紧凑结构省内存,大数据用通用结构保性能"的动态切换机制,完美兼顾了空间与时间的双重需求。

看到这里不知道大家有没有去了解过Redis底层数据结构的自适应编码转换的问题,比如说:他自动切换的流程是怎么样的?性能如何?会卡住写入吗?


自适应编码转换机制

Redis 的转换机制遵循一个核心原则:当数据量小时偏向“省空间”,当数据量大时偏向“保时间” 。这种转换是由系统底层自动完成的,对上层业务代码完全透明,且通常是单向不可逆的(即只能从小内存结构转换为大内存结构,以防止频繁转换消耗 CPU)

以常见的几种数据类型为例,其转换触发条件如下:

  1. String(字符串)
    • 存储纯 64 位整数时,采用 int 编码(直接利用指针字段存整数,零额外内存开销)。
    • 字符串长度 ≤ 44 字节时,采用 embstr 编码(RedisObject 和 SDS 结构体分配在同一块连续内存中,减少碎片)。
    • 字符串长度 > 44 字节或包含非整数时,自动转换为 raw 编码(RedisObject 和 SDS 分离存储,支持动态修改)。
  2. Hash(哈希表)
    • 当元素个数小于 512 且所有值都小于 64 字节时,使用紧凑的 ziplist(压缩列表)编码。
    • 当任一条件被打破(如插入第 513 个元素,或某个 Value 长度达到 65 字节),自动转换为 hashtable(哈希表)编码。
  3. List(列表)与 ZSet(有序集合)
    • 同样遵循“元素少且小用 ziplist,数据量大时转 linkedlist(双向链表)或 skiplist + hashtable(跳表+哈希表)”的逻辑

自动切换的流程,简单举一个例子

转换过程在 Redis 执行写入命令(如 HSETLPUSH)时触发,具体流程如下:

  1. 接收写入请求:客户端向 Redis 发送一个写入命令。
  2. 检查当前编码:Redis 检查该 Key 当前的底层编码类型。
  3. 阈值判定:如果当前是紧凑编码(如 ziplist),Redis 会判断新写入的数据是否会导致超出阈值(例如元素数量 + 1 是否大于 512)。
  4. 执行转换:如果超出阈值,Redis 会在内存中申请新的通用数据结构(如 hashtable),将原有 ziplist 中的数据遍历并迁移到新结构中,最后释放旧内存,更新该 Key 的 encoding 属性。
  5. 完成写入:将本次请求的新数据写入新结构中,并返回结果

性能如何?会卡住写入吗?

答案是:转换瞬间确实会产生性能抖动,有极小概率导致写入阻塞(卡顿),但 Redis 通过多种机制将这种影响降到了最低。

  1. 转换的代价
    当数据从 ziplist 转换为 hashtable 时,Redis 必须遍历原有的紧凑列表,并将所有元素重新插入到新的哈希表中。这个操作的时间复杂度是 O(N) 。如果此时数据量刚好达到阈值(例如 512 个元素),这个 O(N) 的耗时通常在微秒级别,对整体性能影响微乎其微。但如果阈值被人为调得非常大(例如几万个元素),转换时的 CPU 计算和内存分配开销就会显著增加,可能导致短暂的写入延迟(毛刺)。

  2. 为什么依然被认为是高效的?

    • 单次开销可控:由于默认阈值(如 512 个元素)非常小,转换耗时极短。
    • 一劳永逸:这种转换是单向不可逆的。一旦升级为 hashtable,即使后续删除了大量元素,也不会再退化为 ziplist。这避免了数据在临界值上下波动时,底层结构频繁转换带来的巨大 CPU 消耗。
    • 渐进式 Rehash 的补充:虽然编码转换(ziplist -> hashtable)是一次性完成的,但当 hashtable 本身因为数据暴增需要扩容时,Redis 采用了渐进式 Rehash 机制。它不会一次性迁移所有数据,而是将迁移动作分摊到后续的每一次增删改查请求中,从而彻底避免了集中式的性能抖动

总结:Redis 的自适应转换机制是用“偶尔的微小代价”换取了“长期的极致内存节省”。在绝大多数生产环境中,只要不随意修改默认的阈值配置,这种自动切换对业务写入的影响几乎可以忽略不计


弥补 C 语言原生结构的缺陷:为高性能量身定制

Redis 基于 C 语言开发,但 C 语言原生的字符串和数组存在两个致命缺陷:获取长度需要 O(N) 遍历、遇到 \0 会截断导致无法处理二进制数据,以及每次修改都需要重新分配内存。为了突破这些限制,Redis 放弃了复用原生结构,自建了简单动态字符串(SDS)。

SDS 在结构体头部显式记录了 len(已使用长度)和 free(未使用空间)字段。这一设计带来了三大核心优势:首先,获取字符串长度的操作从 O(N) 降维到了 O(1);其次,SDS 依赖 len 字段而非 \0 来确定边界,天然支持二进制安全,能够无缝存储序列化对象、图片等任意二进制数据;最后,SDS 引入了空间预分配和惰性释放策略。当字符串需要追加内容时,SDS 会根据当前长度自动分配 1 倍或 1MB 的额外空间,大幅减少了底层 malloc 和 realloc 的调用次数。在内存数据库中,减少内存分配的次数就意味着减少了 CPU 的上下文切换与系统调用开销,这是 Redis 能够保持微秒级响应速度的关键基石。

总结来说,Redis 的底层设计并非一成不变,而是遵循“小数据用紧凑结构省内存,大数据用通用结构保性能,不断迭代解决历史缺陷”的演进路线。这也是为什么 Redis 能够在保持极低内存占用的同时,依然维持十万级 QPS 的核心原因

好,说到这里,难道大家不好奇为什么Redis的qps可以到达十万级呢

我们都知道qps的瓶颈一般由磁盘IO、CPU负载、内存IO、网络IO几项组成,那我们的Redis是怎么解决的呢?

一、 内存 IO 优化:以内存为核心,消除磁盘瓶颈

传统数据库(如 MySQL)的瓶颈往往在于磁盘 IO(单次查询可能耗时 20ms 以上),而 Redis 的核心优势在于纯内存操作。内存的访问速度比磁盘快 10 万倍,Redis 直接在内存中进行哈希查找,单次操作耗时通常在 0.1ms 以内。为了将内存性能发挥到极致,Redis 进行了以下优化:

  • 极致的数据结构:针对不同场景设计了深度定制的底层结构,如简单动态字符串(SDS)实现 O(1) 的长度获取并杜绝缓冲区溢出;压缩列表(ziplist)利用连续内存避免指针开销并充分利用 CPU 缓存;跳表(Skip List)实现高效的范围查询。
  • 渐进式 Rehash:在哈希表扩容时,Redis 不会一次性迁移所有键值对,而是将搬迁开销平摊到后续的每一次请求中,避免了集中扩容导致的内存 IO 阻塞。
  • 禁用交换分区:在系统层面,Redis 建议将 vm.swappiness 设为 0,禁用系统的 Swap 交换分区,防止内存数据被换入磁盘导致延迟飙升。

二、 CPU 负载优化:单线程模型与 IO 多路复用

Redis 的命令执行核心采用了单线程模型,这听起来反直觉,但却是其高并发的核心支柱:

  • 消除锁竞争:由于没有多线程并发修改共享内存,Redis 完全避免了多线程上下文切换和锁竞争带来的巨大 CPU 开销。
  • IO 多路复用:Redis 利用 epoll 等 IO 多路复用技术,仅用一个主线程就能同时监控成千上万个网络连接。只有当连接有读写事件就绪时才会进行处理,极大降低了 CPU 的空转等待。
  • 多线程处理网络 IO(Redis 6.0+) :随着网卡带宽的提升,单线程处理网络读写可能成为瓶颈。Redis 6.0 引入了多线程机制,但命令的执行依然保持单线程以保证原子性。多线程仅负责并行读取客户端请求和写回响应结果,从而充分利用多核 CPU 的网络处理能力。
  • 高 CPU 优先级:在系统层面,可通过 renice 命令为 Redis 进程设置高 CPU 优先级,避免被低优先级进程抢占计算资源。

三、 网络 IO 优化:Pipeline 与连接池

网络传输的延迟往往是高并发场景下的隐形杀手,Redis 通过以下方式降低网络开销:

  • Pipeline(流水线)批量操作:客户端可以将多条命令打包一次性发送给服务端,大幅减少了网络往返时间(RTT)。在开启 Pipeline 的情况下,Redis 的吞吐量可轻松突破 50万 甚至 100万+ QPS。
  • 二进制存储:将 JSON 等文本数据转为 Protobuf 等二进制格式存储,有效减小了 Value 的体积,降低了网络传输的数据量。
  • 内核级网络参数调优:在 Linux 系统层面,通过增大 TCP 监听队列(somaxconn)、重用 TIME_WAIT 连接、增加网络缓冲区大小等内核参数,消除了底层网络栈的瓶颈。

四、 磁盘 IO 优化:持久化策略与硬件升级

虽然 Redis 是内存数据库,但持久化(RDB/AOF)不可避免地会产生磁盘 IO:

  • 持久化降级或异步化:在追求极致吞吐量的压测或特定场景下,可以直接关闭持久化;或者采用宽松的 RDB 配置、配合 appendfsync everysec(每秒同步一次)的 AOF 策略,将磁盘 IO 对主线程的影响降至最低。
  • 使用 SSD 固态硬盘:如果必须开启持久化,强烈建议使用 SSD。SSD 的随机写性能是机械硬盘的 100 倍以上,能大幅降低持久化带来的 IO 延迟。
  • 主从架构剥离 IO:在读写分离架构中,可以配置主库关闭持久化,将持久化带来的磁盘 IO 开销完全交由从库承担,从而保障主库极高的写入性能。

总结:Redis 实现 10万+ QPS 并非依赖单一技术,而是建立在纯内存计算的基础上,通过单线程+IO多路复用榨干 CPU 性能,利用 Pipeline 降低网络延迟,并通过合理的持久化策略规避磁盘 IO 瓶颈,最终实现了极致的并发处理能力。

看到这里,小编的文章也就结束了,如果哪里讲的不好希望各位大佬多多指正,感谢大家的观看