Redis为什么快?

1. 纯内存操作

Redis 的核心数据存储在 内存中而不是磁盘上,这是 Redis 高性能的最根本原因。现代计算机系统中,内存访问延迟通常在 几十到一百纳秒级别,而 SSD 的访问延迟通常在 几十到几百微秒,机械硬盘甚至达到 毫秒级。这意味着一次磁盘访问可能比一次内存访问慢 上千到上万倍。Redis 的绝大多数读写操作都直接在内存中完成,从而避免了磁盘 I/O 的高延迟开销。虽然 Redis 提供了 RDB 和 AOF 等持久化机制,但这些机制通常在后台异步执行,不会影响大多数请求的主执行路径,因此 Redis 在绝大多数情况下仍然保持了接近纯内存系统的性能表现。

当程序需要读取一条数据时,本质上是 CPU 去获取数据。数据可能存储在不同的位置,而不同位置的访问速度差别非常大。CPU 获取数据的路径大致如下:

  1. CPU Cache(L1 / L2 / L3)
  2. DRAM 内存
  3. SSD 固态硬盘
  4. HDD 机械硬盘

越靠近 CPU 的存储设备:访问速度越快,容量越小;越远离 CPU 的存储设备:容量越大,访问速度越慢。不同存储介质的访问延迟大致如下:

存储位置 典型延迟 直观理解
CPU Cache 几纳秒 数据就在 CPU 附近
DRAM 内存 50 ~ 100 纳秒 在服务器内存条上
SSD 50 ~ 150 微秒 通过存储控制器访问
HDD 5 ~ 10 毫秒 需要机械寻道

可以看到明显的数量级差距:SSD 比内存 慢约 1000 倍,HDD 比内存 慢约 100000 倍。

因此,只要系统需要访问磁盘,性能就会明显下降。Redis 的核心设计思想就是:让绝大多数操作只发生在内存,而不是磁盘

假设Redis中执行命令:GET user:1,Redis 处理请求的流程可以简单理解为:
客户端请求
Redis 网络层
解析 RESP 协议
在内存哈希表查找 key
读取 value
返回结果

整个过程中最关键的一步是:在内存中的哈希表查找 key,Redis 的 key 通常存储在一个 哈希表(dict) 中。哈希表查找的时间复杂度为:O(1)。也就是说:不需要扫描数据,不需要访问磁盘,只需要极少 CPU 指令,因此 Redis 的 GET 操作通常只需要 微秒级时间。

为了更好理解 Redis 的优势,可以看看磁盘读取的过程,当数据不在内存中时,请求流程通常如下,整个过程通常需要 几十微秒到几毫秒,相比之下,Redis 只需要一次内存访问,因此延迟要低得多:
客户端请求
服务器
查找缓存
缓存未命中
发起磁盘 IO
SSD / HDD 读取
加载到内存
返回结果

做个对比:MySQL 也有 Buffer Pool(内存缓存),为什么 Redis 还是更快?

原因是 Redis 不仅仅是 数据在内存中,它的执行路径也更简单。两者处理请求的流程差异如下:

系统 请求执行步骤
Redis 解析命令 → 哈希表查找 → 返回
MySQL SQL解析 → 查询优化 → 执行计划 → 索引查找 → 行读取 → 事务处理 → 返回

Redis 虽然是内存数据库,但仍然支持数据持久化,否则服务器重启后数据会丢失。Redis 提供两种主要持久化方式:

方式 原理
RDB 定期生成数据快照
AOF 记录所有写命令

RDB方式:
Redis 主线程
继续处理请求
fork 子进程
子进程写入 RDB 文件

AOF 则是通过 追加写日志文件完成,并且通常采用 顺序写,对磁盘非常友好。因此 Redis 可以做到:数据操作在内存完成,数据仍然可以持久化。

当然纯内存也不是没有代价的,把数据全部放在内存中虽然可以获得很高性能,但也会带来一些问题。

问题 原因
内存不足 Redis 所有数据都存储在内存
swap 导致性能下降 系统可能把数据换到磁盘
fork 开销 RDB / AOF rewrite 需要 fork
内存碎片 长时间运行产生碎片

因此生产环境通常会:

  • 禁用 swap
  • 给 Redis 预留足够内存
  • 设置 maxmemory

Swap 是操作系统提供的一种机制,用来在 内存不足时,把部分内存数据临时存放到磁盘上。也就是把磁盘当作"临时内存"使用,很多线上 Redis 故障其实都是 swap 导致的性能灾难,当 Redis 发生 swap 时,通常会出现以下现象:

现象 原因
QPS 突然下降 内存访问变成磁盘访问
延迟突然变高 每次访问需要磁盘 I/O
CPU 使用率不高但系统很慢 时间都耗在磁盘 I/O 上
系统 load 很高 I/O 等待增加

fork 是 Linux/Unix 系统中的一个系统调用,用来创建一个新的进程,fork 创建子进程时,有几个重要特点:

特性 说明
代码相同 子进程和父进程运行的是同一份程序
内存空间相同 子进程会继承父进程的内存
独立运行 父子进程互不影响
返回值不同 父进程和子进程得到不同的返回值

fork的时候,虽然运行的代码是同一份,但是并不会再创建一份内存,否则生产环境的Redis在运行时内存可能十几个G,fork如果还需要这么大内存那肯定是不行的。所以Redis的fork使用了Linux的Copy On Write(写时复制) 技术,fork 时 不真正复制内存,父子进程先共享同一份内存,只有当某一方修改数据时,操作系统才会复制那一页内存,流程可以理解为:

fork 之后:

java 复制代码
父进程 ----\
            → 同一块内存
子进程 ----/

当父进程修改数据:

java 复制代码
父进程 → 新内存
子进程 → 原内存

因此:fork 本身是比较快的,只有在数据发生修改时才会复制内存页

Redis 中 fork 主要用于以下场景:

场景 作用
RDB 快照 子进程生成 RDB 文件
AOF rewrite 子进程重写 AOF 文件
cluster / replication 某些同步操作

2. 高效的 I/O 模型(epoll + 事件循环)

Redis 使用 **I/O 多路复用技术(epoll、kqueue、select 等)**来处理网络连接,而不是采用传统的"一个连接一个线程"的模型。在这种模型下,一个线程可以同时监听和管理成千上万的客户端连接。当某个连接上有数据到达时,操作系统会通过事件通知 Redis,从而避免了线程在多个连接之间轮询或阻塞等待的开销。通过这种方式,Redis 可以在极少线程的情况下处理大量并发连接,同时减少线程切换、上下文切换和锁竞争带来的性能损耗。这种事件驱动的网络模型在高并发服务器(如 Nginx、Node.js)中也被广泛使用,是 Redis 高并发能力的重要基础。

在了解 Redis 的 I/O 模型之前,先思考一个问题:如果有 1万个客户端同时连接 Redis,Redis 应该如何处理这些请求?

通常我们可能会想到两种方案:

  1. 一个线程处理一个请求连接
  2. 使用连接池来处理请求

首先一个线程处理一个请求连接这个肯定是不行的,Redis是要面临高QPS的,当瞬时请求达到10000个的时候,不可能创建10000个线程来处理,这样的话光线程栈空间就会撑死服务器,假设一个线程栈空间需要1M内存,10000个线程就10GB的内存消耗;另外这个方式产生大量的线程,操作系统需要在多个线程之间不断上下文切换,这个过程也会导致CPU繁忙,大量时间用来切换上下文,而不是真正的处理计算。

那么如果使用连接池的话,不会创建太多的线程,可以复用线程,看起来是一个可靠的选择;但是实际任然会有上下文的切换,并且在池中选择线程也会消耗CPU;另外连接池必然产生并发,进而因为数据一致性加锁,对于这种必然会导致:锁竞争,线程阻塞,CPU cache 失效。而Redis本身就是操作简单但是高频,完全可以采用单线程避免这些开销。

所以Redis采用了: I/O 多路复用技术(epoll、kqueue、select 等) 来处理连接。核心思想就是:使用一个线程同时监听多个网络连接,并在有数据到达时再进行处理,在 Linux / Unix 系统中,常见的 I/O 多路复用机制包括:

技术 系统 特点
select Unix / Linux 最早实现,支持的连接数有限
poll Unix / Linux 改进版 select
epoll Linux 高性能,多数高并发服务器使用
kqueue BSD / macOS 类似 epoll

Redis 会根据不同操作系统选择不同实现:

操作系统 Redis 使用
Linux epoll
macOS / BSD kqueue
其他系统 select / poll

epoll 的工作方式可以简单理解为三个步骤:

  1. 注册监听的连接, Redis 会把所有客户端连接注册到 epoll 中交给操作系统监控。
  2. 操作系统内核会持续监听这些连接,例如:是否有数据到达、是否可以写数据、连接是否关闭,当某个连接发生事件时,内核会记录下来。
  3. Redis 通过 epoll_wait() 等待事件发生,当某个连接有数据时,epoll_wait() 返回并把数据告诉Redis处理这些有事件的连接。

在 Redis 的传统架构中,整个服务器主要依赖 一个主线程 + epoll 事件循环。主线程会不断执行一个事件循环:
epoll_wait 等待事件
获取有数据的连接
读取客户端请求
执行 Redis 命令
返回结果

这个过程中,由于是单线程执行命令避免了锁竞争,epoll 管理所有连接支持大量客户端,事件驱动模型高效处理网络请求,所以即使只有一个主线程,Redis 仍然可以处理 几万甚至几十万连接。

但是在Redis 6 之后,引入了 I/O threads 来优化网络读写,但核心的事件循环仍然由主线程负责。在Redis6的时候, I/O threads 只用来优化写操作 ,在Redis7/8之后才开始在读操作 也是用 I/O threads 。这里的这个 读操作 和 写操作 并不是 读取数据 和 写入数据 的意思,而是对于网络数据的读和写 。千万不要认为是Redis的GETSET

类型 实际操作
I/O read 从客户端 socket 读取请求
I/O write 向客户端 socket 发送响应

I/O threads是什么呢?其实就是Redis进程创建的专门用来处理网络IO相关的操作的线程,共享 同一块内存,只是和redis执行内部命令的线程区分开了而已。

为什么要这么做呢?因为当 Redis QPS 很高时,瓶颈往往不是命令执行,而是:网络读写协议解析 。例如:GET key命令,执行的时候只需要一次hash,然后取值返回即可,但是这个命令的网络部分包括:

  • read socket
  • 拆包
  • 解析协议
  • 写 socket

这些操作的 CPU 消耗相对于redis内部命令来说并不小,所以Redis把这些工作并行化,方法就是使用 I/O threads 来处理这些网络部分的工作,主线程只处理redis内部的命令。大致流程如下:

复制代码
epoll 监听连接
        ↓
主线程事件循环
        ↓
I/O线程读取 socket
        ↓
解析 Redis 协议
        ↓
主线程执行命令
        ↓
I/O线程写回响应

epoll 负责管理大量连接,I/O线程负责网络读写,主线程执行所有 Redis 命令。这样Redis 可以同时做到:高并发连接 + 低延迟 + 单线程数据安全

3. 避免锁竞争(单线程执行命令)

Redis 在核心命令执行层采用 单线程模型:所有客户端请求最终都会在同一个线程中顺序执行。这种设计看似限制了并行能力,但实际上带来了非常重要的性能优势。在多线程系统中,为了保证数据一致性,往往需要使用各种锁机制,而锁竞争会带来大量额外开销,例如线程阻塞、上下文切换、缓存一致性维护等。Redis 通过单线程顺序执行命令,从根本上避免了这些问题,使得每个操作都可以在没有锁的情况下快速完成。此外,Redis 的命令执行时间通常非常短(大多数为 O(1) 操作),因此单线程也足以支撑非常高的吞吐量。

单线程执行命令带来的好处可以体现在以下方面:

  1. 没有锁竞争

    Redis 单线程执行命令意味着所有数据结构(dict、skiplist、quicklist 等)的读写操作都是串行进行的,因此完全不需要使用互斥锁、读写锁或自旋锁来保护共享数据。这样可以避免多线程环境中常见的锁竞争问题,比如线程等待、锁抢占和锁唤醒等开销。在高并发场景下,锁竞争往往会导致 CPU 大量时间浪费在调度和同步上,而 Redis 通过单线程执行命令,使 CPU 几乎全部用于真正的数据处理,从而获得更高的吞吐效率。

  2. 不需要线程的上下文切换

    在多线程系统中,当线程被操作系统调度时会发生上下文切换,这需要保存和恢复寄存器、栈信息以及线程状态,这个过程会消耗一定 CPU 时间。如果线程数量较多,频繁的上下文切换会显著降低系统性能。Redis 使用单线程执行命令,因此不会发生命令执行线程之间的调度竞争,从而避免了上下文切换开销,使 CPU 能持续执行同一线程的逻辑,提升整体执行效率。

  3. 不会导致CPU cache 失效

    多线程程序中,不同线程可能会频繁访问同一块共享数据,这会导致 CPU cache line 在不同核心之间不断同步(cache line bouncing)。这种情况会严重影响 CPU cache 的命中率,降低程序执行效率。Redis 单线程执行命令,所有数据结构的访问都在同一个 CPU 核心上进行,大部分热点数据可以长期驻留在 CPU cache 中,从而提高缓存命中率,减少内存访问延迟,这也是 Redis 能够达到极高性能的重要原因之一。

  4. 简化数据结构实现

    如果 Redis 使用多线程并发修改数据结构,那么像 dict、skiplist、listpack、quicklist 这些结构在实现时就必须考虑线程安全问题,需要在插入、删除、扩容等操作中加入复杂的同步机制。单线程模型让这些数据结构可以以最简单、最高效的方式实现,不需要考虑锁、原子操作或并发修改问题,从而使代码更简单、执行路径更短,整体性能更高。

  5. 保证全局操作顺序一致

    Redis 的很多机制(例如 AOF 持久化、主从复制、事务执行、Lua 脚本)都依赖命令执行顺序。如果使用多线程执行命令,就必须额外维护全局顺序,否则可能导致数据不一致。单线程执行模型天然保证所有命令按照到达顺序依次执行,因此日志记录、复制传播以及事务处理都可以依赖同一个顺序执行模型,这大大简化了系统的一致性设计。

4. 优化的内部数据结构

Redis 提供的多种数据类型(String、List、Hash、Set、Zset 等)背后都使用了 专门优化的数据结构实现。例如,Redis 的 Hash 使用自定义的哈希表(dict) ,Zset 使用 跳表(skiplist)+ 哈希表组合实现,List 使用 **quicklist(链表 + 压缩列表)**结构。这些结构不仅保证了大多数操作的时间复杂度为 O(1) 或 O(logN),还针对实际使用场景进行了大量优化,例如小数据量时使用紧凑的内存结构以减少内存占用,大数据量时自动切换到更适合扩展的数据结构。通过这种设计,Redis 在不同数据规模下都能保持较好的性能表现。

1. String

Sting就是Redis里最常用的字符串,Redis 是用 C 语言开发的,C语言的字符串是char*,但是Redis里面的字符串并不是单纯的char*,Redis 自己设计了一种字符串结构,叫 SDS,全称是:Simple Dynamic String。

在C语言中,字符串是这样的:char *s = "hello";,内存里可以粗略理解成这样:h e l l o \0,最后这个 \0 表示字符串结束。这个设计简单有效,但是对于Redis来说有几个问题:想知道长度,要从头数到尾容易发生内存越界不适合频繁修改不太适合存"二进制数据" 。比如你想知道 "hello" 多长,C 语言不会直接告诉你"长度是 5",而是要从头开始,一个字符一个字符找,直到找到 \0 为止;或者说你原来只有 10 个字节空间,但你非要塞进去 20 个字节的数据,就可能把后面的内存给写坏了;如果原来的字符串是hello,你想把它改成hello world,肯定是需要更大内存,就得重新申请一块更大的内存,然后把旧数据拷贝过去;还有就是C 字符串把 \0 当成结束标记,所以如果你的数据中间本身就包含 \0,它就会误以为"字符串到这里结束了"。这些原因导致Redis不能直接使用char*

所以Redis设计了SDS,你可以把它想象成这样:[长度][总容量][真正的数据内容],比如保存 "hello",不是只存:hello,而是更像这样:

java 复制代码
	len = 5
	alloc = 10
	buf = hello

含义分别是:

  • len:当前字符串实际长度
  • alloc:总共分配了多大空间
  • buf:真正存数据的地方

这样设计的好处是:

  • 第一:长度是现成的,不用每次扫描,所以像 STRLEN 这种操作会很轻。
  • 第二:追加时会预留空间,所以不会因为频繁变长就不停申请内存。
    比如命令:SET key hello ; 然后:APPEND key " world"这种追加的操作,或者自增:INCR counter,就会引起字符串内容变长,字符串频繁增长时性能很差,所以SDS会预先分配多余的空间,默认分配规则是:小于1M时每次扩容多一倍的容量,超过1M每次增加1M。以此减少内存分配次数。
  • 第三:它按长度处理数据,不依赖 \0,所以能安全存二进制内容,包括我们常用的json等等。
  • 第四:它是 Redis 自己控制的结构,所以更适合 Redis 这种高频读写、长期运行的服务端程序。

2. List

Redis 对 List 的优化核心不是单纯追求某个数据结构理论复杂度最优,而是在工程上平衡双端操作效率、内存占用、CPU cache 命中率和内存碎片。

早期如果直接用普通双向链表,虽然头尾插入删除很快,但每个元素都要单独分配节点,指针开销大、碎片多、遍历性能差。

所以后来的 Redis 用 quicklist 作为 List 的核心结构,本质上是双向链表的每个节点不再只存一个元素,而是存一个紧凑连续的数据块。这样既保留了链表两端操作快的优点,又通过批量存储减少了内存分配次数、降低了碎片、提升了遍历局部性。在此基础上,Redis 又把节点内部的紧凑结构从 ziplist 演进到 listpack,进一步解决了 ziplist 的连锁更新等问题,让实现更高效、更稳定。

quicklist是"双向链表 + 压缩连续存储块"的组合结构,整体是双向链表,每个节点内部是一段连续内存,这段连续内存中保存多个 list 中的元素。其中结构类似这样的:

java 复制代码
quicklist
  |
  +--> node1 <--> node2 <--> node3 <--> ...
         |         |         |
      一批元素    一批元素    一批元素
     连续存放    连续存放    连续存放

这个结构的好处有:

  • 大幅减少节点指针开销,如果 1000 个元素用普通链表:1000 个节点,1000 组 prev/next 指针,1000 次独立内存分配;如果用 quicklist,假设每个节点装 100 个元素:只需要 10 个节点,只有 10 组 prev/next 指针,只有 10 块主要内存区域。所以元数据大幅减少,这对小元素特别有效。
  • 大幅减少内存碎片,普通链表是很多小块内存分散在堆上,quicklist 是:链表节点数量变少,每个节点内部是一块相对连续的大块数据,这样分配次数更少碎片更少。也就是说:把很多"小碎片分配",变成更少的"大块分配"。
  • 提高遍历性能,因为一个 quicklist 节点内部的元素是连续存放的。所以遍历时:在一个节点内部,可以顺序扫描连续内存,不需要每个元素都跳一次指针,这对 CPU cache 更友好。虽然节点之间还是链起来的,但至少不是"每个元素都散着"。这就比普通链表高效很多。
  • 保留两端操作的优势,因为整体还是链表,所以:在头部追加一个块很方便,在尾部追加一个块也很方便,头尾弹出也很方便,所以 Redis 既保留了 List 的双端队列特性,又减少了传统链表的问题。

在quicklist这个双向链表中,每个节点里虽然是一批连续元素,但 Redis 也没有简单地用"C 数组 + 固定长度元素"去存,因为redis的List中每一个元素都是字符串String,字符串本身就是一个长度不固定的元素,如果用普通定长数组,会导致浪费空间并且不灵活,所以节点内部要用一种:支持变长元素,内存紧凑,可以连续排列的数据结构。为了解决这个问题,早期 Redis 用的是 ziplist,后来优化成 listpackziplist 就是一块连续内存,里面一个元素接一个元素地紧凑存储,当前这个元素存储了前一个元素的大小,优点是非常省内存、非常适合小元素集合,但是他有一个问题:连锁更新(cascade update),就是某个元素长度变化了,后面元素的元数据可能也得跟着改(因为存储了前一个元素的大小),可能引发一连串搬移和修改。因此后来采用了listpack

listpack 是 Redis 用于紧凑存储小集合数据的一种连续内存结构,它由 header + 多个 entry + 结束标志组成。每个 entry 包含 encodingdatabacklen,其中 encoding 描述数据类型和长度,data 存储实际数据,backlen 记录当前 entry 的长度,用于支持反向遍历。相比 ziplist,listpack 去掉了 prevlen 设计,从而避免了 ziplist 的连锁更新问题,使结构更简单、性能更稳定。目前 Redis 在 quicklisthashzset 的小数据场景中广泛使用 listpack 来提升内存利用率。结构大致如下:

java 复制代码
	+------------+
	| total bytes|
	+------------+
	| num elems  |
	+------------+
	| entry 1    |
	+------------+
	| entry 2    |
	+------------+
	| entry 3    |
	+------------+
	| ...        |
	+------------+
	| 0xFF       |
	+------------+

其中:

  • total bytes表示整个 listpack 占多少字节
  • num elements记录 listpack 里有多少元素
  • entry是真正的数据元素
  • 0xFF是结尾标志

这个结构Redis 拿到这块内存时,能快速知道 这块多大、里面有多少项。其中最重要的就是entry,他的结构类似:

java 复制代码
	+-----------+-------+----------+
	| encoding  | data  | backlen  |
	+-----------+-------+----------+

其中:

  • encoding 表示数据类型和数据长度,比如是整数还是字符串
  • data就是实际数据
  • backlen 非常关键,它表示当前这个 entry 一共占了多少字节,为什么要这个东西?因为 listpack 是连续内存,元素一个挨一个,而且每个元素长度不固定。假设现在有三个元素:abc | hello | cat,如果你从前往后走,很容易:读 abc 的长度再跳到 hello,读 hello 的长度再跳到 cat,但是如果你现在站在 cat 这里,想找前一个 hello,怎么办?因为 hello 长短不固定,你没法直接知道前面退多少字节。所以 Redis 的办法是:让每个 entry 自己在尾巴上记住"我有多长"。这样当你站在某个 entry 末尾时,就能知道:这个 entry 占多少字节,往前退这么多,就能找到它的起点,再一步步往前找前一个 entry,这就实现了:不用链表指针,也能支持正向\反向遍历

3. Hash

对于hash这个数据结构,Redis为了同时兼顾省内存和查找快,实际上做了两层优化:

  • 小 Hash 用紧凑结构存listpack,尽量少浪费内存。如果一个 Hash 里面字段不多、field/value 也不长,Redis 不会一上来就给你建哈希桶、节点、指针这些重结构,而是放到一个连续内存块里,也就是 listpack
  • 大 Hash 自动切换成真正的哈希表hashtable,保证读写效率。当 Hash 里的字段数量变多,或者某些 field/value 太长时,继续用 listpack 顺序扫描就不划算了,这时候 Redis 会把它转换成真正的 hashtable 编码

对于小hash或者大hash的判断标准是:

  • hash-max-listpack-entries = 512 field数量不能超过 512
  • hash-max-listpack-value = 64 field 或 value 的长度不能超过 64 字节

不满足的时候就会转换成hashtable

Redis Hash 在小对象场景下会用 listpack 编码,它本质上是一块连续内存上的紧凑顺序表,整体由"头部 + 多个 entry + 结束标记 "组成,而 Hash 的 fieldvalue 会按 [field][value][field][value] 这种相邻顺序存放;每个 entry 又由"编码信息 + 实际数据 + 当前 entry 总长度 "构成,这样既能节省指针和桶结构带来的内存开销,又能支持前向和反向遍历,因此非常适合字段少、字段值短的小 Hash,但当数据量变大时,顺序扫描和连续内存搬移的成本会上升,所以 Redis 才会在超过阈值后把它转换成 hashtable

假如一个Hash对象如下:

java 复制代码
	name -> tom
	age  -> 18
	city -> beijing

listpack的内存大致如下:

java 复制代码
	| header |
	| entry(name) | entry(tom) | entry(age) | entry(18) | entry(city) | entry(beijing) |
	| EOF |

其中每个entry内部如下:

java 复制代码
	| encoding | data | total-len |

这个结构就和上面的List结构中对于listpack所说的基本一致了。如果redis执行HGET user:1 age,那么对于listpack这个结构,查找过程大致如下:

  • 从头找到第一个元素
  • 把它当 field 看,比较是不是 age
  • 不是就跳过后面的 value,再看下一组 field
  • 找到 age 后,取它紧挨着的下一个元素作为 value

也就是说,listpack 下的 Hash 查找本质是顺序扫描,不是哈希寻址。之所以小 Hash 还能快,是因为数据量小,而且都在一块连续内存里,CPU cache 命中通常比较好。

那么对于大Hash,Redis采用hastable这种数据结构,Hashtable 的底层实现是 Redis 的 dict 结构,采用 数组 + 链表 解决冲突,并通过 渐进式 rehash2 的幂扩容位运算定位桶负载因子控制扩容 等机制优化性能,从而在大 Hash 场景下保证 HGET/HSET 等操作平均接近 O(1) 的访问效率,同时避免扩容带来的阻塞。结构大致如下:

java 复制代码
	Redis Hash
     │
     ▼
    dict
     │
     │
     ├── ht[0]  -----> dictht
     │                 │
     │                 ├── table[]
     │                 │     ├── dictEntry
     │                 │     │      ├── key (field)
     │                 │     │      ├── value
     │                 │     │      └── next
     │                 │     │
     │                 │     └── dictEntry -> ...
     │                 │
     │                 ├── size
     │                 ├── sizemask
     │                 └── used
     │
     ├── ht[1]  -----> dictht (rehash 时)
     │
     └── rehashidx

如上所示:dict 内部维护两个 dictht 哈希表用于渐进式 rehash ,而 dictht 是真正的哈希表结构,由 bucket 数组 table[]链表节点 dictEntry 组成;每个 dictEntry 存储一个 fieldvalue,并通过 next 指针解决哈希冲突,因此 Hash 的 field/value 实际就是存储在 dictEntry.keydictEntry.value 中,dictEntry结构大致如下:

java 复制代码
	dictEntry
	{
	    key,
	    value,
	    next   //链表的下一个
	}

总结来说:当 Redis Hash 变大后使用 hashtable 的好处在于:它把原本 listpack 这种连续顺序结构下需要从头到尾遍历、并且在插入或修改时可能触发整段内存搬移的操作,变成了通过哈希函数直接定位到 bucket、再在极小范围内查找或更新的方式,使 HGETHSETHDELHEXISTS 这类核心操作的时间复杂度从 O(N) 降到平均 O(1),同时避免大对象场景下频繁 realloc 和大块内存复制带来的性能抖动,从而保证大 Hash 在字段很多、值较大、更新频繁时依然能维持稳定的访问效率,并减少单线程 Redis 被单次慢操作阻塞的风险。

当发生hash冲突的时候,在同一个桶内采用链表的方式处理,其实也有其他的结构采用 数组+数组 或者 数组+红黑树 的方式来处理hash冲突,但是Redis考虑的事链表可以在不移动已有元素的情况下动态扩展冲突数据,而数组需要连续内存和整体搬迁,代价过高。

4. Set

Redis Set 底层有两种实现:intsethashtable。对于元素全部是整数数量不超过 set-max-intset-entries(默认512)的集合,Redis 会使用 intset,它是一个按序存储的整数数组,支持 16/32/64 位自动升级,并通过二分查找实现 O(logN) 查询,从而节省内存。若出现非整数元素或元素数量超过阈值,则会转换为基于 dicthashtable,实现 O(1) 的插入、删除和存在判断。Redis 通过这种"小数据用压缩结构、大数据用哈希结构"的策略,在内存占用和访问性能之间取得平衡。
对于hashtable就不再重复说明了,上面已经详细说过了,这里记录一下intset的结构和优点,假设现在set中的数据是:1,5,10,20,那么符合
元素全部是整数
数量不超过 set-max-intset-entries(默认512) 的要求,此时存储的结构大致如下:

java 复制代码
	intset
	│
	├ encoding = INT16
	├ length = 4
	└ contents
	      ├ 1
	      ├ 5
	      ├ 10
	      └ 20

其中:

  • encoding: 整数编码类型。因为整数类型并不是全部都是同样的存储内存,就像java中有byte、shaort、int、long等区分一样,encoding有:16bit32bit64bit,分别存储对应整数的类型,并且根据set中的内容按需升级,比如一开始存的是1、2、3、7、8、9这种,那么默认使用16bit,后续如果有更大的整数那么就按需进行一次升级,以此来减少内存占用。
  • length: 元素数量。
  • contents: 连续整数数组。数组是连续的内存,并且这个数组是有序数组,目的就是在查找时可以使用二分法查找,提升查找速度。

并且set并没有使用listpack来存储小数据,因为listpack本质上还是一个连续的数组,而且没有顺序,在查找时只能通过依次扫描,效率低下,所以set在小数据时使用intset,非整数或者大数据量时就换成hashtable了。

5. Zset

Redis 的 ZSet 主要有两层优化。第一层是小数据量优化,小的有序集合会用 listpack 这种连续内存的紧凑结构存储,memberscore 成对顺序保存,这样内存占用低、cache 命中率高,适合元素较少的场景。第二层是大数据量优化,当元素变多后会转成 dict + skiplist 的双结构:dict 用来做 memberscore 的快速查找,平均 O(1);skiplist 用来按 score 维持有序,支持范围查询、排名查询、顺序/逆序遍历,平均 O(logN)。Redis 之所以选 skiplist 而不是红黑树 ,是因为跳表实现更简单,而且天然适合范围扫描。为了支持排名,skiplist 节点里还维护了 span;为了支持逆序遍历,还维护 backward 指针;相同 score 时再按 member 字典序排序,保证结果稳定。整体来看,ZSet 的优化本质就是:小数据省内存,大数据把"按 member 查找"和"按 score 排序"拆开,各自交给最合适的数据结构处理。

Zset既要支持按 member 查找,又要支持按 score 有序,对于按 member 查这个要求很适合哈希表,但是按 score 排序查又适合有序结构,所以Redis按照需求把"按值查找 "和"按分数排序"拆开,各自用最擅长的数据结构来做。

先说小数据量时:当 zset 元素少、memberscore 都比较短时,Redis 不会一上来就用 dict + skiplist,而是用 listpack。判断是否使用listpack的条件如下:

java 复制代码
	zset-max-listpack-entries 128		// zset 元素数量 <= 128
	zset-max-listpack-value 64			// member 的长度 <= 64 字节,score 是 double,固定8字节,所以 真正限制的是 member 的长度

listpack 编码下,zset 的数据不是"节点对象 + 指针"这种形式,而是 连续内存块,假如存入数据:

java 复制代码
	ZADD rank
	100 userA
	300 userC
	200 userB

其数据结构大致如下:

java 复制代码
	listpack
	│
	├── total_bytes
	├── num_elements = 6
	│
	├── entry
	│   ├ encoding
	│   ├ data = "userA"
	│   └ backlen
	│
	├── entry
	│   ├ encoding
	│   ├ data = 100
	│   └ backlen
	│
	├── entry
	│   ├ encoding
	│   ├ data = "userB"
	│   └ backlen
	│
	├── entry
	│   ├ encoding
	│   ├ data = 200
	│   └ backlen
	│
	├── entry
	│   ├ encoding
	│   ├ data = "userC"
	│   └ backlen
	│
	├── entry
	│   ├ encoding
	│   ├ data = 300
	│   └ backlen
	│
	└── 0xFF

可以看到Redis的zset使用listparkmemberscore 成对 按顺序 存储在一块连续内存中,通过顺序存储减少指针和对象开销,大幅节省内存并提升 CPU cache 命中率,从而在小数据量场景下获得更高性能


再来看当处理大数据量的时候,Zset对大数据量处理时的结构:

java 复制代码
ZSET
│
├─ dict (哈希表)       ← 用于 O(1) 按 member 查找
│
└─ skiplist (跳表)    ← 用于按 score 排序

哈希表的结构上面已经分析过了,这里就不再重复了,重点来看skiplist 跳表的结构。Redis的源代码中,跳表节点的源码大致如下:

c 复制代码
typedef struct zskiplistNode {
    sds ele;          // member,例如 user1
    double score;     // 分值,例如 10

    struct zskiplistNode *backward; // 指向前一个节点(只在level0链表)

    struct zskiplistLevel {
        struct zskiplistNode *forward; // 指向下一个节点
        unsigned int span;             // 跨度(排名计算用)
    } level[];

} zskiplistNode;

假如zset中有数据如下:

java 复制代码
user1 10
user2 20
user3 30
user4 50
user5 60
user6 80
user7 90

那么跳表的数据可能如下(跳表层数是随机的

java 复制代码
zskiplist
├── level = 5
├── length = 7
├── header
│    ├── level[4].forward -> user6
│    ├── level[3].forward -> user3
│    ├── level[2].forward -> user3
│    ├── level[1].forward -> user1
│    └── level[0].forward -> user1
├── tail -> user7
│
├── node(user1, 10)
│    ├── backward -> NULL
│    ├── level[1].forward -> user3
│    └── level[0].forward -> user2
│
├── node(user2, 20)
│    ├── backward -> user1
│    └── level[0].forward -> user3
│
├── node(user3, 30)
│    ├── backward -> user2
│    ├── level[3].forward -> user6
│    ├── level[2].forward -> user5
│    ├── level[1].forward -> user5
│    └── level[0].forward -> user4
│
├── node(user4, 50)
│    ├── backward -> user3
│    └── level[0].forward -> user5
│
├── node(user5, 60)
│    ├── backward -> user4
│    ├── level[2].forward -> user6
│    ├── level[1].forward -> user6
│    └── level[0].forward -> user6
│
├── node(user6, 80)
│    ├── backward -> user5
│    ├── level[4].forward -> NULL
│    ├── level[3].forward -> NULL
│    ├── level[2].forward -> NULL
│    ├── level[1].forward -> NULL
│    └── level[0].forward -> user7
│
└── node(user7, 90)
     ├── backward -> user6
     └── level[0].forward -> NULL

在这个结构中:

  • level = 5表示当前整张跳表的最高层数是 5,不是说:每个节点都有 5 层,而是说:整张跳表里,目前最高的那个节点有 5 层
  • length = 7表示当前跳表里有 7 个真实节点,不包含 headerheader 是辅助节点,不算真实数据
  • header头节点 / 哨兵节点 ,它不代表真实业务数据,不是某个 member,它是 作为整张跳表所有层的统一入口,所有查找都从它开始,所有插入也都是从它开始往下找位置,你可以把它理解成:"最左边那个假的起始节点"header下有level[0].forwardlevel[4].forward的数组,这个数组在每个元素的数据中也有,我们留到后续再说
  • tail表示整张跳表最后一个真实节点是 user7,因为在底层完整链表里,user7 是最后一个。这个 tail 的作用是:快速拿到最后一个节点,做逆序遍历更方便,处理尾部场景更方便
  • node表示每一个真实的 member,是Zset中真正的元素节点,与header一样每一个都有level数组
    • backward 表示当前这个节点的前一个节点是哪个,第一个节点的backward null,而不是header

在上面的结构中,node节点和header中都有level数组,这个就是跳表的核心内容。根据上面的这个结构实例图,可以发现当换个角度:取每个node节点和headerlevel[0]拼成一组数据,再取每个node节点和headerlevel[1]拼成一组数据,然后依次取到最高层的level[4],哪个node没有就不取,最后会得到这样的一组数据:

java 复制代码
Level 4:  header ---------------------------------------> user6
Level 3:  header -----------------> user3 --------------> user6
Level 2:  header -----------------> user3 ---> user5 ---> user6
Level 1:  header ---> user1 ------> user3 ---> user5 ---> user6
Level 0:  header -> user1 -> user2 -> user3 -> user4 -> user5 -> user6 -> user7

准确来说就是:header 和所有节点的同层 level[i]forward 指针串起来看,确实就得到了跳表的每一层链表结构。 这个结构就是原数据换角度之后得到的 跳表层级结构数据,通过这个角度来看跳表会容易理解很多。


接下来看如果需要 根据分数查找元素 或者 插入一个元素 时,会发生什么:
当需要根据score查找元素时,从最高层开始,沿着 forward 指针向右遍历,只要下一个节点的 score 小于目标就继续向右;一旦即将超过目标,就下降一层继续查找。这样通过高层快速跳跃缩小范围,在低层精确定位,最终在第0层找到目标或确定插入位置,整体时间复杂度是 O(logN)

跳表插入时,首先从最高层查找目标位置,并记录每一层的前驱节点;然后随机生成新节点的层高,从第0层开始逐层向上,将新节点插入到对应层的链表中,并调整 forward 指针,使其指向正确的后继节点,同时更新 backward 指针,最终完成多层结构的维护。

下面根据我们的例子来说明具体操作时什么样的:

根据上面这个例子来看,比如我们要查找score60的元素:

  1. 从最高层level 4开始,因为header中记录了所有的层数,自然能找到最高层是level 4level4header → user6,那么使用user6.score = 80 > 60判断,user6的值是大于我们传入的值的,所以要往下一层找,注意此时最高这一层是没有往右走的,直接往下走了,所以下一层还是从header开始找
  2. 再根据level 3开始找,上一层没有往右走,所以这一层依然是从header节点开始找,此时level 3header → user3 → user6,传入值先跟user3 比较:user3.score = 30 < 60,所有往右走,再跟user6比较,结果很明显是往下走,但是这一层经过并停止在了user3 ,所以下一层是从user3 开始找
  3. 此时到了level 2,由于上一层停在了user3 ,所以从user3 level 2开始找,从跳表层级这个角度去看是level 2是:header → user3 → user5 → user6,但是是从开始找的,实际就是根据forward找到了user3 节点,然后看到user3 节点的level[2]指向了user5 ,所以先跟user5 判断,user5.score = 60 == 60,这就找到了我们的目标

再来看如果此时要插入一条元素user8,值是70,需要以下几个大步骤:

  1. 从高层往低层查找插入位置
  2. 记录每一层插入位置左边的节点(前驱节点)
  3. 随机生成新节点层高
  4. 在新节点拥有的每一层里做链表插入,修改 forward
  5. 补充维护 backwardtaillengthlevel

接下来详细解释每一步的过程,再开始之前先说明一个点,redis在Zset的跳表插入数据之前,会准备两个数组:update[]rank[]rank[]数组是用来计算排名的这里先不说,讲完插入逻辑之后会详细说明,这里先看update[]数组,它用来记录跳表每一层中,新节点应该插到谁的后面

再贴一下插入前的跳表层级数据:

java 复制代码
Level 4:  header ---------------------------------------> user6(80)
Level 3:  header -----------------> user3(30) ----------> user6(80)
Level 2:  header -----------------> user3(30) -> user5(60) -> user6(80)
Level 1:  header -> user1(10) -> user3(30) -> user5(60) -> user6(80)
Level 0:  header -> user1(10) -> user2(20) -> user3(30) -> user4(50) -> user5(60) -> user6(80) -> user7(90)

然后来一步一步看插入元素user8=70的过程:

  1. header的最高层Level 4开始查找,很明显插入值70 < user6(80),不能往右走,所以要前往下一层,前往之前本层记录:update[4] = header,表示如果新节点有第4层,那么它应该插在 header 后面,也就是Level 4这一层要变成:header -> user8(70) -> user6(80),当然这前提是新节点的forward有4层
  2. 然后来到Level 3层,依然是比较大小,这里就不再详细描述比较的过程了(和前面的查找数据一样的),结果是发现user3(30) < 70 < user6(80),所以要前往下一层,同时记录update[3] = user3(30),表示如果新节点有Level 3层,那么它要插在 user3 后面
  3. 然后再来到Level 2,从user3(30)开始比较,结果是:user5(60) < 70 < user6(80),那么就继续前往下一层,同时记录update[2] = user5(60)
  4. 再来到Level 1,从user5(60)开始比较,结果还是:user5(60) < 70 < user6(80),那么继续前往下一次,同时记录update[1] = user5(60)
  5. 最后一层Level 0,根据上一层继续从user5(60)开始比较,结果是:user5(60) < 70 < user6(80),此时已经到最底层了,不能继续往下了,所以最后一次记录update[0] = user5(60),至此查找结束

查找完成之后来看此时update[]的内容:

java 复制代码
update[4] = header
update[3] = user3(30)
update[2] = user5(60)
update[1] = user5(60)
update[0] = user5(60)

这个表示如果新节点user8=70forward如果有5层,那么插入到跳表的每一层之后,新节点应该在Level 0user5(60)后面,在Level 1user5(60)后面,在Level 2user5(60)后面,在Level 3user3(30)后面,在Level 4header后面。

当确定好update[]之后,接着开始随机生成新节点层高,Redis 跳表通过随机生成节点层高,每一层以固定概率递减(默认 1/4),从而使高层节点越来越稀疏,形成多级索引结构。这种设计避免了像平衡树那样的复杂维护,同时保证了查找、插入、删除操作的时间复杂度稳定在 O(logN),是一种用概率换结构平衡的设计,通过这种"概率递减"的方式随机生成节点层高,使高层节点越来越少,从而形成类似二叉搜索树的分层索引结构

那么假设本次新节点生成的层高是3,那么说明新节点user8forward总共有三层,分别是:level[0]level[1]level[2]。说明本次跳表的层级结构只有level0level1level2 这三层需要修改,那么新节点的结构目前大致就是:

java 复制代码
node(user8, 70)
├── backward -> ?
├── level[2].forward -> ?
├── level[1].forward -> ?
└── level[0].forward -> ?

接下来就是最重要的逐层修改forward,修改的公式是:

base 复制代码
newNode.level[i].forward = update[i].level[i].forward
update[i].level[i].forward = newNode

这个公式的含义就是:新节点先接住"前驱原来的后继",前驱再改成指向新节点 ,这里就不再一层一层的写修改过程了,以Level 0层为例,看一下这一层怎么插入,首先插入前Level 0是:

java 复制代码
header -> user1(10) -> user2(20) -> user3(30) -> user4(50) -> user5(60) -> user6(80) -> user7(90)

当插入新节点时,查看到update[0] = user5,所以需要插入到user5(60)user6(80) 之间,那么根据公式就有:

java 复制代码
user8.level[0].forward = user5.level[0].forward

也就是等价于:user8.level[0].forward = user6,也就是说现在变成了user8(70) -> user6(80),也就是上面的那句:新节点先接住"前驱原来的后继"

然后再处理下一句:前驱再改成指向新节点

java 复制代码
user5.level[0].forward = user8

这些就完成了从user5(60) -> user6(80)user5(60) -> user8(70) -> user6(80)的变化。

到这里跳表的层级角度的数据插入就完成了 ,然后还需要处理backward,这个很简单,就是从user5 -> user6改成user8.backward = user5user6.backward = user8

此时新节点的数据如下:

java 复制代码
node(user8, 70)
├── backward -> user5
├── level[2].forward -> user6
├── level[1].forward -> user6
└── level[0].forward -> user6

再然后跳表的tail字段不需要变化,因为新元素不是最后一个节点。然后再维护levellength字段,level未发生变化,length变成原来的+1,也就是8。插入完成之后的跳表层级结构如下:

java 复制代码
Level 4:  header ---------------------------------------> user6
Level 3:  header -----------------> user3 --------------> user6
Level 2:  header -----------------> user3 ---> user5 ---> user8 ---> user6
Level 1:  header ---> user1 ------> user3 ---> user5 ---> user8 ---> user6
Level 0:  header -> user1 -> user2 -> user3 -> user4 -> user5 -> user8 -> user6 -> user7

到这里就把跳表的 forward指针 和 查询、插入 数据过程大致过了一遍。


但是上面的跳表中还有一个span字段,这个字段表示当前层里,这个 forward 指针到下一个元素过去,底层 level[0] 会跨过多少个节点,用于快速计算排名(rank) ,那么很明显对于Level 0这一层级,span 永远 = 1 。这个span字段是在插入的同时维护好的,前面在说插入的时候维护了一个update[]数组,其实还有另一个数组rank[],其中 rank[i] 表示:从 header 走到 update[i],已经跨了多少个节点

在之前的插入过程中,得到了一个update[]数组,内容如下:

java 复制代码
update[4] = header
update[3] = user3(30)
update[2] = user5(60)
update[1] = user5(60)
update[0] = user5(60)

update[]数组的内容和rank[]数组的内容是同时进行计算维护的,之前过程中只讲了update的计算过程,这里根据上面的过程再走一遍rank[]数组的计算过程。还是贴一下插入前的跳表层级结构,方便对照:

java 复制代码
Level 4:  header ---------------------------------------> user6(80)
Level 3:  header -----------------> user3(30) ----------> user6(80)
Level 2:  header -----------------> user3(30) -> user5(60) -> user6(80)
Level 1:  header -> user1(10) -> user3(30) -> user5(60) -> user6(80)
Level 0:  header -> user1(10) -> user2(20) -> user3(30) -> user4(50) -> user5(60) -> user6(80) -> user7(90)
  1. 首先是Level 4层级,记录update[4] = header时,同时记录rank[4] = 0,因为header是头节点不参与排名计算,所以rank[4] = 0
  2. 然后是Level 3层级,当记录update[3] = user3(30)时,同时记录rank[3] = 3,这个3是怎么来的呢?其实就是header.level[3].span = 3(这个3值是之前插入的时候就已经计算好的),因为上一步没有往后走一步,使用的还是从header节点往后判断,那么Level 3层级的header开始下一个指向的是user3,也就是就是header.level[3],所以这一步rank[3] = 3这里有一点绕,要理解这个必须把这里搞清楚
  3. 然后是Level 2层级,当记录update[2] = user5(60)时,同时记录rank[2] = 5,这个5是因为上一步跳到了user3,并且记录了rank[3] = 3,然后user3.level[2].span = 2,也就是user3(30) -> user5(60)经过了2个元素,那么累加 之后rank[2] = 3 + 2 = 5
  4. 然后是Level 1层级,当记录update[1] = user5(60)时,因为上一层跳到了user5,这一层比较数值过后发现还是user5,所以这一层实际是没有往后经过的,所以rank[1] = 5,这个5就是上一层累加的结果,因为这一层没往后经过任何一个元素,所以不变也就是不累加
  5. 然后是Level 0层级,当记录update[0] = user5(60)时,记录rank[0] = 5,因为往后没有经过任何一个元素,所以不累加,值不变

那么到这里rank[]数组内容是:

java 复制代码
rank[4] = 0
rank[3] = 3
rank[2] = 5
rank[1] = 5
rank[0] = 5

当获取到这个rank[]数组之后,插入节点时,通过 rank 数组计算插入点在原跨度中的位置,将原 span 拆分成两段;对于未插入层,仅增加 span;查找排名时,通过逐层累加 span 实现 O(logN) 的排名计算。

还是通过上面插入user8的例子来看,新节点user8forward3层,那么分别是在:

java 复制代码
Level 4:  header ---------------------------------------> user6(80)
Level 3:  header -----------------> user3(30) ----------> user6(80)
Level 2:  header -----------------> user3(30) -> user5(60) -> 新节点user8 -> user6(80)
Level 1:  header -> user1(10) -> user3(30) -> user5(60) -> 新节点user8 -> user6(80)
Level 0:  header -> user1(10) -> user2(20) -> user3(30) -> user4(50) -> user5(60) -> 新节点user8 -> user6(80) -> user7(90)

变化的点是:

  • Level 0user5(60) -> user6(80)变成user5(60) -> 新节点user8 -> user6(80),原来是:user5 --(1)--> user6,现在变成:user5 --(1)--> user8 --(1)--> user6,本质上就是原来的跨度 = 1,现在变成了1+1
  • Level 1user5(60) -> user6(80)变成user5(60) -> 新节点user8 -> user6(80),和Level 0基本一致
  • Level 2user5(60) -> user6(80)变成user5(60) -> 新节点user8 -> user6(80),和上面依然一致
  • 到了Level 3的时候,虽然前后关系不变,但是实际在最底层Level 0的跨度增加了,由原来的user3 --(3)--> user6变成了user3 --(4)--> user6
  • Level 4Level 3同理

那么根据这个变化点总结规律得出:

java 复制代码
原来的是:A --(oldSpan)--> B
新的是:A --(x)--> new --(y)--> B

根据上面的rank[]数组得出公式如下:

java 复制代码
前驱span / 就是上面的x = (rank[0] - rank[i]) + 1
新节点span / 就是上面的y = oldSpan - (rank[0] - rank[i])

这个公式是这一层级有节点插入时的计算公式,如果这一层没有节点插入,那么只需要span++即可。

查询的时候根据跳表层级跳跃,累加 每一层跳过的span即可得到排名。

5. CPU Cache 友好

在现代 CPU 架构中,CPU 与主内存之间存在多级缓存(L1、L2、L3 Cache),CPU 访问缓存的速度远远快于直接访问内存。因此,数据结构的 **内存局部性(memory locality)**对性能影响非常大。如果数据在内存中是连续存储的,CPU 在读取一个数据时往往会把相邻的数据一起加载到缓存中,从而大幅减少后续访问的延迟。Redis 在设计数据结构时非常注重这一点,例如使用 listpack、intset 等紧凑结构来减少指针跳转,提高缓存命中率。相比传统链表结构频繁的指针跳跃访问,这种设计可以显著减少 CPU cache miss,从而提升整体性能。

其实文章上文已经说了很多Redis对CPU Cache 友好的内容, 这里再总结性说一下吧:

  1. listpack 在Hash、List 这些数据结构中都有体现,通过连续内存、无指针、紧凑编码 完全避免指针跳跃。
  2. intset 在全是整数的Set结构中,连续的顺序数据存储带来高命中率
  3. quicklist 就是Redis List 的最终形态,通过数组和链表的结合,大幅提升CPU缓存命中率的同时又兼容正向\反向遍历
  4. dict 通过"数组寻址 + 减少冲突链长度 + 渐进式 rehash ",在不可避免的指针跳跃场景下,尽量提高 CPU cache 命中率,但整体仍然不如 listpack 这类连续结构 cache 友好

6. 简洁高效的通信协议(RESP)

Redis 使用一种名为 **RESP(Redis Serialization Protocol)**的轻量级通信协议。RESP 的设计非常简单:通过明确的长度前缀来描述数据结构,使得协议解析过程非常高效。相比一些复杂的文本协议或二进制协议,RESP 的解析逻辑简单、实现成本低,并且可以在网络传输和解析过程中减少不必要的 CPU 消耗。此外,RESP 协议天然支持批量请求(pipeline),客户端可以在一次网络往返中发送多个命令,从而减少网络延迟带来的影响。在高并发环境下,这种简单高效的协议设计也对 Redis 的整体性能提升起到了重要作用。

RESP,全称 Redis Serialization Protocol,是 Redis 客户端和服务端之间通信使用的应用层协议,在客户端执行的每一条 Redis 命令,最终都会被编码成 RESP 格式,通过 TCP 发送给 Redis 服务端。它的核心特点是:

  • 结构简单
  • 可读性强
  • 二进制安全
  • 容易解析
  • 适合请求/响应模型
  • 适合 pipeline 批量发送

那么RESP到底是什么呢?举个例子来说,假如有命令:

java 复制代码
SET name redis

实际上发送给Redis的是:

base 复制代码
*3\r\n
$3\r\n
SET\r\n
$4\r\n
name\r\n
$5\r\n
redis\r\n

这个内容的意思是:

base 复制代码
*3        → 表示这是一个数组,有 3 个元素
$3        → 第一个元素长度是 3
SET
$4        → 第二个元素长度是 4
name
$5        → 第三个元素长度是 5
redis

所以本质上:RESP 是一种"长度前缀 + 类型标记"的轻量级协议,它支持以下类型:

  • + 简单字符串,比如:+OK\r\n,看到 +,一直读取到 \r\n,中间内容就是结果,这个例子的内容就是"OK"

  • - 错误,用于返回错误信息,本质上是"带错误语义的字符串"。比如:-ERR unknown command\r\n,看到 -,读取一行(到 \r\n),标记为 error 类型

  • : 整数,用于返回数值结果(计数、长度等)。比如::100\r\n,看到 :读取一行转换成整数(long)

  • $ 批量字符串,用于传输字符串数据(包括 key、value),支持二进制。比如:$5\r\nhello\r\n,这个分两步读取,第一步先读取长度:5\r\n,然后在读取内容:hello。它支持二进制安全(可以包含任意字节)、不依赖分隔符、不需要转义,是 Redis 最核心的数据类型

  • * 数组,用于表示一组数据(Redis 命令本质就是数组)。

    假如一组命令如下:

    base 复制代码
    *3\r\n
    $3\r\nSET\r\n
    $4\r\nname\r\n
    $5\r\nredis\r\n

    解析过程如下:

    1. 读取数组长度,*3\r\n,表示有3个元素
    2. 逐个解析元素(递归),$3 → SET$4 → name$5 → redis。得到命令:SET name redis

    数组这个协议:支持嵌套(数组里可以再套数组),是 RESP 的"骨架结构",Redis 命令本质就是数组。

这套RESP 协议把协议解析问题,从"复杂语法解析",降维成"顺序读取 + 状态机切换"。而且读取协议内容还对CPU cache友好,不需要复杂解析逻辑(比如json),并且非常适合 pipeline。

相关推荐
qq_246839751 小时前
Redis lua本地调试环境配置
数据库·redis·lua
十月南城1 小时前
压测与成本优化实录——服务端、数据库与缓存协同优化与成本敏感点
数据库·缓存
L1624762 小时前
linux中mdadm命令生产环境全流程实战总结
linux·运维·数据库
后季暖2 小时前
kafka优化
数据库·分布式·kafka
电商API&Tina2 小时前
item_video-获得淘宝商品视频 API||商品API
java·大数据·服务器·数据库·人工智能·python·mysql
Zzzzmo_2 小时前
【MySQL】事务
数据库·mysql
努力进修2 小时前
智防SQL注入,金仓数据库SQL防护墙筑牢企业数据安全屏障
数据库·sql·安全
布吉岛没有岛_2 小时前
MySQL 的学习
数据库·sql
gp3210262 小时前
mysql配置环境变量——(‘mysql‘ 不是内部或外部命令,也不是可运行的程序 或批处理文件解决办法)
数据库·mysql·adb