1. 纯内存操作
Redis 的核心数据存储在 内存中而不是磁盘上,这是 Redis 高性能的最根本原因。现代计算机系统中,内存访问延迟通常在 几十到一百纳秒级别,而 SSD 的访问延迟通常在 几十到几百微秒,机械硬盘甚至达到 毫秒级。这意味着一次磁盘访问可能比一次内存访问慢 上千到上万倍。Redis 的绝大多数读写操作都直接在内存中完成,从而避免了磁盘 I/O 的高延迟开销。虽然 Redis 提供了 RDB 和 AOF 等持久化机制,但这些机制通常在后台异步执行,不会影响大多数请求的主执行路径,因此 Redis 在绝大多数情况下仍然保持了接近纯内存系统的性能表现。
当程序需要读取一条数据时,本质上是 CPU 去获取数据。数据可能存储在不同的位置,而不同位置的访问速度差别非常大。CPU 获取数据的路径大致如下:
- CPU Cache(L1 / L2 / L3)
- DRAM 内存
- SSD 固态硬盘
- 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 应该如何处理这些请求?
通常我们可能会想到两种方案:
- 一个线程处理一个请求连接
- 使用连接池来处理请求
首先一个线程处理一个请求连接这个肯定是不行的,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 的工作方式可以简单理解为三个步骤:
- 注册监听的连接, Redis 会把所有客户端连接注册到 epoll 中交给操作系统监控。
- 操作系统内核会持续监听这些连接,例如:是否有数据到达、是否可以写数据、连接是否关闭,当某个连接发生事件时,内核会记录下来。
- 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的GET和SET。
| 类型 | 实际操作 |
|---|---|
| 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) 操作),因此单线程也足以支撑非常高的吞吐量。
单线程执行命令带来的好处可以体现在以下方面:
-
没有锁竞争
Redis 单线程执行命令意味着所有数据结构(dict、skiplist、quicklist 等)的读写操作都是串行进行的,因此完全不需要使用互斥锁、读写锁或自旋锁来保护共享数据。这样可以避免多线程环境中常见的锁竞争问题,比如线程等待、锁抢占和锁唤醒等开销。在高并发场景下,锁竞争往往会导致 CPU 大量时间浪费在调度和同步上,而 Redis 通过单线程执行命令,使 CPU 几乎全部用于真正的数据处理,从而获得更高的吞吐效率。
-
不需要线程的上下文切换
在多线程系统中,当线程被操作系统调度时会发生上下文切换,这需要保存和恢复寄存器、栈信息以及线程状态,这个过程会消耗一定 CPU 时间。如果线程数量较多,频繁的上下文切换会显著降低系统性能。Redis 使用单线程执行命令,因此不会发生命令执行线程之间的调度竞争,从而避免了上下文切换开销,使 CPU 能持续执行同一线程的逻辑,提升整体执行效率。
-
不会导致CPU cache 失效
多线程程序中,不同线程可能会频繁访问同一块共享数据,这会导致 CPU cache line 在不同核心之间不断同步(cache line bouncing)。这种情况会严重影响 CPU cache 的命中率,降低程序执行效率。Redis 单线程执行命令,所有数据结构的访问都在同一个 CPU 核心上进行,大部分热点数据可以长期驻留在 CPU cache 中,从而提高缓存命中率,减少内存访问延迟,这也是 Redis 能够达到极高性能的重要原因之一。
-
简化数据结构实现
如果 Redis 使用多线程并发修改数据结构,那么像 dict、skiplist、listpack、quicklist 这些结构在实现时就必须考虑线程安全问题,需要在插入、删除、扩容等操作中加入复杂的同步机制。单线程模型让这些数据结构可以以最简单、最高效的方式实现,不需要考虑锁、原子操作或并发修改问题,从而使代码更简单、执行路径更短,整体性能更高。
-
保证全局操作顺序一致
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,后来优化成 listpack,ziplist 就是一块连续内存,里面一个元素接一个元素地紧凑存储,当前这个元素存储了前一个元素的大小,优点是非常省内存、非常适合小元素集合,但是他有一个问题:连锁更新(cascade update),就是某个元素长度变化了,后面元素的元数据可能也得跟着改(因为存储了前一个元素的大小),可能引发一连串搬移和修改。因此后来采用了listpack:
listpack 是 Redis 用于紧凑存储小集合数据的一种连续内存结构,它由 header + 多个 entry + 结束标志组成。每个 entry 包含 encoding、data 和 backlen,其中 encoding 描述数据类型和长度,data 存储实际数据,backlen 记录当前 entry 的长度,用于支持反向遍历。相比 ziplist,listpack 去掉了 prevlen 设计,从而避免了 ziplist 的连锁更新问题,使结构更简单、性能更稳定。目前 Redis 在 quicklist、hash 和 zset 的小数据场景中广泛使用 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 = 512field数量不能超过 512hash-max-listpack-value = 64field 或 value 的长度不能超过 64 字节
不满足的时候就会转换成hashtable。
Redis Hash 在小对象场景下会用 listpack 编码,它本质上是一块连续内存上的紧凑顺序表,整体由"头部 + 多个 entry + 结束标记 "组成,而 Hash 的 field 和 value 会按 [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 结构,采用 数组 + 链表 解决冲突,并通过 渐进式 rehash 、2 的幂扩容 、位运算定位桶 、负载因子控制扩容 等机制优化性能,从而在大 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 存储一个 field 和 value,并通过 next 指针解决哈希冲突,因此 Hash 的 field/value 实际就是存储在 dictEntry.key 和 dictEntry.value 中,dictEntry结构大致如下:
java
dictEntry
{
key,
value,
next //链表的下一个
}
总结来说:当 Redis Hash 变大后使用 hashtable 的好处在于:它把原本 listpack 这种连续顺序结构下需要从头到尾遍历、并且在插入或修改时可能触发整段内存搬移的操作,变成了通过哈希函数直接定位到 bucket、再在极小范围内查找或更新的方式,使 HGET、HSET、HDEL、HEXISTS 这类核心操作的时间复杂度从 O(N) 降到平均 O(1),同时避免大对象场景下频繁 realloc 和大块内存复制带来的性能抖动,从而保证大 Hash 在字段很多、值较大、更新频繁时依然能维持稳定的访问效率,并减少单线程 Redis 被单次慢操作阻塞的风险。
当发生hash冲突的时候,在同一个桶内采用链表的方式处理,其实也有其他的结构采用 数组+数组 或者 数组+红黑树 的方式来处理hash冲突,但是Redis考虑的事链表可以在不移动已有元素的情况下动态扩展冲突数据,而数组需要连续内存和整体搬迁,代价过高。
4. Set
Redis Set 底层有两种实现:intset 和 hashtable。对于元素全部是整数 且数量不超过 set-max-intset-entries(默认512)的集合,Redis 会使用 intset,它是一个按序存储的整数数组,支持 16/32/64 位自动升级,并通过二分查找实现 O(logN) 查询,从而节省内存。若出现非整数元素或元素数量超过阈值,则会转换为基于 dict 的 hashtable,实现 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有:16bit、32bit、64bit,分别存储对应整数的类型,并且根据set中的内容按需升级,比如一开始存的是1、2、3、7、8、9这种,那么默认使用16bit,后续如果有更大的整数那么就按需进行一次升级,以此来减少内存占用。length: 元素数量。contents: 连续整数数组。数组是连续的内存,并且这个数组是有序数组,目的就是在查找时可以使用二分法查找,提升查找速度。
并且set并没有使用listpack来存储小数据,因为listpack本质上还是一个连续的数组,而且没有顺序,在查找时只能通过依次扫描,效率低下,所以set在小数据时使用intset,非整数或者大数据量时就换成hashtable了。
5. Zset
Redis 的 ZSet 主要有两层优化。第一层是小数据量优化,小的有序集合会用 listpack 这种连续内存的紧凑结构存储,member 和 score 成对顺序保存,这样内存占用低、cache 命中率高,适合元素较少的场景。第二层是大数据量优化,当元素变多后会转成 dict + skiplist 的双结构:dict 用来做 member 到 score 的快速查找,平均 O(1);skiplist 用来按 score 维持有序,支持范围查询、排名查询、顺序/逆序遍历,平均 O(logN)。Redis 之所以选 skiplist 而不是红黑树 ,是因为跳表实现更简单,而且天然适合范围扫描。为了支持排名,skiplist 节点里还维护了 span;为了支持逆序遍历,还维护 backward 指针;相同 score 时再按 member 字典序排序,保证结果稳定。整体来看,ZSet 的优化本质就是:小数据省内存,大数据把"按 member 查找"和"按 score 排序"拆开,各自交给最合适的数据结构处理。
Zset既要支持按 member 查找,又要支持按 score 有序,对于按 member 查这个要求很适合哈希表,但是按 score 排序查又适合有序结构,所以Redis按照需求把"按值查找 "和"按分数排序"拆开,各自用最擅长的数据结构来做。
先说小数据量时:当 zset 元素少、member 和 score 都比较短时,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使用listpark将 member 和 score 成对 按顺序 存储在一块连续内存中,通过顺序存储减少指针和对象开销,大幅节省内存并提升 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 个真实节点,不包含header,header是辅助节点,不算真实数据header是头节点 / 哨兵节点 ,它不代表真实业务数据,不是某个member,它是 作为整张跳表所有层的统一入口,所有查找都从它开始,所有插入也都是从它开始往下找位置,你可以把它理解成:"最左边那个假的起始节点" ,header下有level[0].forward到level[4].forward的数组,这个数组在每个元素的数据中也有,我们留到后续再说tail表示整张跳表最后一个真实节点是 user7,因为在底层完整链表里,user7 是最后一个。这个 tail 的作用是:快速拿到最后一个节点,做逆序遍历更方便,处理尾部场景更方便node表示每一个真实的member,是Zset中真正的元素节点,与header一样每一个都有level数组backward表示当前这个节点的前一个节点是哪个,第一个节点的backward是null,而不是header
在上面的结构中,node节点和header中都有level数组,这个就是跳表的核心内容。根据上面的这个结构实例图,可以发现当换个角度:取每个node节点和header的level[0]拼成一组数据,再取每个node节点和header的level[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 指针,最终完成多层结构的维护。
下面根据我们的例子来说明具体操作时什么样的:
根据上面这个例子来看,比如我们要查找score为60的元素:
- 从最高层
level 4开始,因为header中记录了所有的层数,自然能找到最高层是level 4,level4是header → user6,那么使用user6.score = 80 > 60判断,user6的值是大于我们传入的值的,所以要往下一层找,注意此时最高这一层是没有往右走的,直接往下走了,所以下一层还是从header开始找 - 再根据
level 3开始找,上一层没有往右走,所以这一层依然是从header节点开始找,此时level 3是header → user3 → user6,传入值先跟user3比较:user3.score = 30 < 60,所有往右走,再跟user6比较,结果很明显是往下走,但是这一层经过并停止在了user3,所以下一层是从user3开始找 - 此时到了
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,需要以下几个大步骤:
- 从高层往低层查找插入位置
- 记录每一层插入位置左边的节点(前驱节点)
- 随机生成新节点层高
- 在新节点拥有的每一层里做链表插入,修改
forward - 补充维护
backward、tail、length、level
接下来详细解释每一步的过程,再开始之前先说明一个点,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的过程:
- 从
header的最高层Level 4开始查找,很明显插入值70 < user6(80),不能往右走,所以要前往下一层,前往之前本层记录:update[4] = header,表示如果新节点有第4层,那么它应该插在header后面,也就是Level 4这一层要变成:header -> user8(70) -> user6(80),当然这前提是新节点的forward有4层 - 然后来到
Level 3层,依然是比较大小,这里就不再详细描述比较的过程了(和前面的查找数据一样的),结果是发现user3(30) < 70 < user6(80),所以要前往下一层,同时记录update[3] = user3(30),表示如果新节点有Level 3层,那么它要插在user3后面 - 然后再来到
Level 2,从user3(30)开始比较,结果是:user5(60) < 70 < user6(80),那么就继续前往下一层,同时记录update[2] = user5(60) - 再来到
Level 1,从user5(60)开始比较,结果还是:user5(60) < 70 < user6(80),那么继续前往下一次,同时记录update[1] = user5(60) - 最后一层
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=70的forward如果有5层,那么插入到跳表的每一层之后,新节点应该在Level 0的user5(60)后面,在Level 1的user5(60)后面,在Level 2的user5(60)后面,在Level 3的user3(30)后面,在Level 4的header后面。
当确定好update[]之后,接着开始随机生成新节点层高,Redis 跳表通过随机生成节点层高,每一层以固定概率递减(默认 1/4),从而使高层节点越来越稀疏,形成多级索引结构。这种设计避免了像平衡树那样的复杂维护,同时保证了查找、插入、删除操作的时间复杂度稳定在 O(logN),是一种用概率换结构平衡的设计,通过这种"概率递减"的方式随机生成节点层高,使高层节点越来越少,从而形成类似二叉搜索树的分层索引结构。
那么假设本次新节点生成的层高是3,那么说明新节点user8的forward总共有三层,分别是:level[0],level[1],level[2]。说明本次跳表的层级结构只有level0、level1、level2 这三层需要修改,那么新节点的结构目前大致就是:
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 = user5和user6.backward = user8。
此时新节点的数据如下:
java
node(user8, 70)
├── backward -> user5
├── level[2].forward -> user6
├── level[1].forward -> user6
└── level[0].forward -> user6
再然后跳表的tail字段不需要变化,因为新元素不是最后一个节点。然后再维护level 和 length字段,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)
- 首先是
Level 4层级,记录update[4] = header时,同时记录rank[4] = 0,因为header是头节点不参与排名计算,所以rank[4] = 0。 - 然后是
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,这里有一点绕,要理解这个必须把这里搞清楚 - 然后是
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 - 然后是
Level 1层级,当记录update[1] = user5(60)时,因为上一层跳到了user5,这一层比较数值过后发现还是user5,所以这一层实际是没有往后经过的,所以rank[1] = 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的例子来看,新节点user8的forward有3层,那么分别是在:
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 0的user5(60) -> user6(80)变成user5(60) -> 新节点user8 -> user6(80),原来是:user5 --(1)--> user6,现在变成:user5 --(1)--> user8 --(1)--> user6,本质上就是原来的跨度 = 1,现在变成了1+1 - 从
Level 1的user5(60) -> user6(80)变成user5(60) -> 新节点user8 -> user6(80),和Level 0基本一致 - 从
Level 2的user5(60) -> user6(80)变成user5(60) -> 新节点user8 -> user6(80),和上面依然一致 - 到了
Level 3的时候,虽然前后关系不变,但是实际在最底层Level 0的跨度增加了,由原来的user3 --(3)--> user6变成了user3 --(4)--> user6 Level 4和Level 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 友好的内容, 这里再总结性说一下吧:
listpack在Hash、List 这些数据结构中都有体现,通过连续内存、无指针、紧凑编码 完全避免指针跳跃。intset在全是整数的Set结构中,连续的顺序数据存储带来高命中率quicklist就是Redis List 的最终形态,通过数组和链表的结合,大幅提升CPU缓存命中率的同时又兼容正向\反向遍历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解析过程如下:
- 读取数组长度,
*3\r\n,表示有3个元素 - 逐个解析元素(递归),
$3 → SET、$4 → name、$5 → redis。得到命令:SET name redis
数组这个协议:支持嵌套(数组里可以再套数组),是 RESP 的"骨架结构",Redis 命令本质就是数组。
- 读取数组长度,
这套RESP 协议把协议解析问题,从"复杂语法解析",降维成"顺序读取 + 状态机切换"。而且读取协议内容还对CPU cache友好,不需要复杂解析逻辑(比如json),并且非常适合 pipeline。