面试官常问:Redis 为什么快?这篇回答满分!
在百万 QPS 的微服务系统中,Redis 几乎是无可替代的高性能组件。无论是做缓存、限流、排行榜还是唯一用户统计,它总能以微秒级响应速度完成任务。
但 Redis 为何如此之快?它到底快在哪里?如果你只知道回答“因为Redis是基于内存实现的”,那你真的要认真看看这篇文章了。
本文从存储结构、线程模型、持久化机制到底层数据结构实现,系统地揭示 Redis 性能背后的秘密,相信你看完一定会有收获。
文章字数:6000字
阅读时长:15mins
文章目录
- 1. Redis基于内存的存储数据
- 2. Redis的线程协作模型
- 2.1 Redis是单线程的吗
- 2.2 I/O多线程机制
- 2.3 后台线程
- 2.4 总结
- 3. Redis的异步数据持久化
- 3.1 RDB持久化
- 3.2 AOF持久化
- 3.3 RDB + AOF混合持久化
- 4.Redis的高效数据结构实现
- 4.1 前置数据结构
- 4.2 Redis数据类型及实现
1. Redis基于内存的存储数据
最直接也是最根本的原因在于:Redis 是一个内存数据库。与传统关系型数据库将数据存储在磁盘不同,Redis 将所有数据存储于内存中。内存访问的速度远远高于磁盘,通常在数百纳秒级别,而磁盘 I/O 即使采用 SSD 也存在毫秒级延迟。
那么为什么不直接用本地缓存呢,也就是直接在应用服务器或客户端上存储数据副本的缓存机制,从纯粹性能角度来看,本地缓存相对于Redis更快:
- 本地缓存通常以键值对的形式存储数据,查找、更新操作时间复杂度 O(1)。
- 数据就保存在当前JVM进程中,无任何网络通信延迟。
- 无需序列化/反序列化过程。
然而性能并不是唯一要考虑的维度,尽管本地缓存更快,但Redis 提供了本地缓存难以具备的多种能力。
- 跨节点共享数据,统一缓存中心:Redis 作为远程缓存,服务于整个集群,是共享缓存中心。本地缓存只能服务于当前节点,每个服务节点都维护一份缓存副本,带来维护与一致性问题。
- 丰富的数据结构与操作:Redis 支持 String、List、Hash、Set、ZSet、Bitmap等结构,提供多种场景应用,如范围查询、排行榜、去重统计、布隆过滤器等。而本地缓存多数仅支持简单的 KV 结构,扩展能力受限。
- 数据容量更大,淘汰策略灵活:Redis 存储于专用服务中,可配置数10GB 内存并通过淘汰策略(LRU/LFU)自动清理低频数据。而本地缓存受限于 JVM Heap 大小,过多数据易造成 OOM,
- 支持高可用与持久化:Redis 支持主从复制、哨兵模式、集群分片,支持持久化(RDB、AOF),可以用于持久缓存、降级缓存等场景。而本地缓存断电即失,服务重启则缓存失效。
- 适用于微服务架构:Redis 具备“远程访问能力”,适用于微服务之间共享缓存数据。而本地缓存更适合“单体服务”或副本隔离不敏感的业务场景。
在实际工程中,两种缓存并非互斥,而是结合使用,构建“多级缓存架构”:
- 一级缓存:本地缓存,优先从内存中快速命中,适用于极端性能要求、数据量小、节点间不需共享的场景,比如用户Session、本地热数据。
- 二级缓存:Redis,作为统一缓存中心和兜底,适用于跨节点共享、数据结构复杂或缓存策略多样的场景,比如商品详情、用户画像、热点推荐。
2. Redis的线程协作模型
在并发系统中多线程几乎是性能优化的代名词,你是否有看到过说“Redis是单线程模型”的文章,这听起来有些不可思议,Redis真的是单线程模型吗?别着急,接着往下看。
2.1 Redis是单线程的吗
Redis整体不是单线程的,所说的Redis单线程是指所有命令执行操作(包括读取、写入、过期检查等)都由主线程串行处理,不管有多少条连接去操作redis的数据,redis对命令的处理都在一个线程完成。
这归因于Redis最基本的特性——所有核心数据结构均为内存操作,访问速度极快,主线程串行执行命令可以避免多线程并发带来的复杂性(加锁、死锁、竞态条件)。
它之所以能够处理高并发连接而不阻塞,关键在于其高效的 事件循环机制,这个机制本质上是 Reactor 模式 的一种实现,它通过 I/O 多路复用技术(如 epoll、kqueue、select)来统一管理多个客户端连接的读写事件。
epoll是Linux内核为处理大批量文件描述符而改进的I/O多路复用机制,高效管理大量的文件描述符(FDs)并响应各类I/O事件。它通过事件驱动的方式,监听多个文件描述符上的事件(如可读、可写、错误等),并在事件发生时通知应用程序进行处理。
整个事件循环的工作流程大致可以划分为以下几个阶段:
- 监听:主线程在空闲时会调用操作系统底层的 I/O 多路复用接口,持续监听一组文件描述符上是否发生以下事件:客户端连接、客户端发来的读/写请求、定时任务(键过期检查)、文件事件(如持久化操作)。
- 唤醒:当有一个或多个事件发生时,操作系统会通知 Redis,Redis 主线程立即被唤醒,进入事件处理流程。
- 分发:Redis 会遍历这批就绪事件,并根据事件的类型将它们分发到对应的处理函数,涉及到内存管理和持久化的操作都会交给后台线程处理。
- 执行:事件被分发后,Redis 会依次执行每个事件的处理逻辑(接收命令、命令解析、数据操作、写回响应),执行过程是串行的,避免了加锁与并发控制的问题。
- 处理完一轮所有事件后,Redis 会清理状态,释放临时资源,再次进入监听状态,继续等待下一批事件。
从上面的过程可以总结出,Redis的主线程负责:接收客户端命令→解析命令→执行命令:操作内存数据结构→写回响应结果。
2.2 I/O多线程机制
随着客户端数量不断上升,网络通信开始成为 Redis 的性能瓶颈。为了解决这一问题,从 Redis 6.0 起引入了 I/O 多线程机制,用于并发处理网络请求的读写操作。
原来Redis主线程职责:“接收客户端命令→解析命令→执行命令:操作内存数据结构→写回响应结果。”中的客户端命令→解析命令和写回响应结果交由I/O多线程来完成。
总结:
- 读阶段(读 socket + 协议解析):由多个 I/O 线程并行处理;
- 执行阶段(命令执行、数据访问):仍由主线程串行完成。
- 写阶段(将响应写回 socket):也由 I/O 线程并行执行;
I/O多线程机制充分利用了多核 CPU 的处理能力,使 Redis 在连接数庞大、请求密集的情况下依然保持高吞吐。
启用方式:
io-threads 4 # 开启 4 个 I/O 线程
io-threads-do-reads yes # 开启 I/O 多线程读
2.3 后台线程
Redis中存在一些无法避免的耗时操作(阻塞IO或者CPU运算数据的时间比较长),比如:持久化写盘、文件关闭、内存回收。
如果这些操作由主线程直接执行,必然会引起卡顿。因此 Redis 提供了专用的后台线程(BIO thread)来完成这些工作, Redis 中的后台线程包括:
bio_aof_fsync
:将 AOF 日志刷入磁盘(执行fsync
等操作)。bio_close_file
:异步关闭文件,释放文件描述符。bio_lazy_free
:异步释放大对象的内存,避免长时间free
阻塞主线程。jemalloc_bg_thd
:内存池管理、碎片整理
此外,Redis 在进行 RDB 快照或 AOF 重写时,会 fork 子进程来执行磁盘写操作,这些任务同样是异步的,主线程可以继续正常提供服务,进一步确保服务不中断。
2.4 总结
总的来说,Redis的线程协作由主线程、I/O多线程、后台线程三部分组成。
- 命令执行过程由主线程来完成。
- 网络请求的接受、解析、写回由I/O多线程来完成。
- 文件管理、内存管理由后台线程来完成。
3. Redis的异步数据持久化
Redis保证数据库高性能读取的基础上,也要保障数据在系统异常时不丢失,也就是数据持久化,而Redis 的所有写入操作并不是直接写入磁盘,而是**异步地将数据持久化,从而避免了主线程阻塞,最大限度提升响应速度。
Redis 提供了两种主要的持久化方式:RDB(快照持久化)和 AOF(追加日志持久化),二者都可以配置为异步进行,即在主线程外通过子进程或后台线程完成持久化操作。
3.1 RDB持久化
RDB 全称为 Redis DataBase 文件,它的本质是一种“数据库的快照”,即在某一时刻将 Redis 中全部键值数据生成快照,保存为一个 .rdb
二进制文件(通常是 dump.rdb
),存储在磁盘上。常用命令有两个:
SAVE
:阻塞主线程,立即生成 RDB 文件。BGSAVE
:在后台 fork 子进程生成快照,主线程继续处理请求。
当执行 BGSAVE
时:
- Redis 会 fork 出一个子进程。
- 子进程负责遍历数据库中所有键值对,生成一个独立的 RDB 文件。
- 生成完成后替换旧文件。
RDB持久化是全量快照,执行频率不能太频繁。如果设置成5分钟保存一份快照,这时Redis宕机,最多可能丢失5 分钟数据。
3.2 AOF持久化
AOF 全称为 Append Only File,AOF 会以日志追加方式记录 Redis 所执行的写命令(如 SET
、INCR
等),保存到文件中(通常是 appendonly.aof
)。Redis 重启时可通过重新执行 AOF 文件中的命令恢复数据状态。
AOF 使用了缓冲区 + 异步刷新机制,写入过程和MySQL的日志持久化过程有“异曲同工”之妙:
- 主线程写完命令后,会将其转为 RESP 文本协议格式,写入aof_buf(主线程缓冲区)。
- 后台线程首先将
aof_buf
写入操作系统的页缓存,此时还未真正刷到物理磁盘。 - 只有在调用
fsync
后,操作系统才会将数据从页缓存 flush 到磁盘,这一步非常耗时,因此 Redis 提供了异步选项来控制刷盘策略。
可以通过 appendfsync
参数配置写入策略:
always
:每个命令都同步写入磁盘(最安全但最慢)everysec
(默认):每秒异步 fsync 一次,权衡性能与持久性no
:完全依赖操作系统调度(性能高,但不可靠)
为避免 AOF 文件过大,当 AOF 文件大于 64M 时,Redis 会在后台异步进行 AOF 文件重写(rewrite):
-
重写时,读取当前数据库中的所有键值对,然后将每一个键值对用一条命令记录到「新的 AOF 文件」,等到全部记录完后,就将新的 AOF 文件替换掉现有的 AOF 文件。
重写机制的妙处在于,尽管某个键值对被多条写命令反复修改,最终也只需要根据这个「键值对」当前的最新状态,然后用一条命令去记录键值对,代替之前记录这个键值对的多条命令,这样就减少了 AOF 文件中的命令数量。
-
重写过程由子进程
bgrewriteaof
完成,写期间仍接收命令并缓存在临时缓冲区。
3.3 RDB + AOF混合持久化
对比RDB和AOF的特性,可以总结出它们的优缺点:
RDB | AOF | |
---|---|---|
数据丢失风险 | 全量快照,执行频率不能太频繁。设置成5分钟保存一份快照,最多可能丢失5 分钟数据。 | 以秒级的方式记录操作命令,所以丢失的数据就相对更少。 |
启动速度 | RDB 文件是压缩的二进制文件,加载时速度快。 | 恢复数据时需要重新执行所有写命令,启动时间比 RDB 长。 |
磁盘占用 | RDB 文件较小,适合备份和数据迁移。 | 通常比 RDB 文件大,尤其在高频写操作的场景下。 |
可重放 | 无 | 通过重放 AOF 中的命令,可以恢复数据到任意时间点。 |
其他 | 无 | 重写可能导致产生碎片 |
上述特性中最重要的就是数据丢失风险和启动速度这两项了,那么应该怎么把RDB启动速度快和AOF数据丢失风险小的优点结合起来呢?
从 Redis 4.0 起,支持“混合持久化模式”,这个过程涉及两个阶段:
- 生成RDB快照:Redis fork出子进程,子进程遍历主线程共享的内存数据,并将当前内存快照以RDB二进制格式写入到新的AOF临时文件(前半段)。
- 记录增量操作:在子进程写入RDB快照的同时,主线程仍在处理客户端请求,所有写命令会同步写入AOF缓冲区,当子进程写完RDB部分后,Redis会将重写缓冲区中的命令追加到AOF临时文件的末尾(后半段)。
- 最后使用新的混合 AOF 文件原子替换旧文件。
当Redis重启并加载该混合AOF文件时,首先解析前半段RDB 部分,恢复数据库的主干数据结构,然后 replay 后半段 AOF 命令,恢复最后阶段的增量更新,这样既保证了快速加载,又保留了近实时持久化能力
在 redis.conf
中开启混合持久化模式:
aof-use-rdb-preamble yes
4.Redis的高效数据结构实现
Redis是一个键值存储系统,它提供了多种基础和复合的数据类型,每一种数据类型都对应不同的数据结构实现,它们在设计上都优先保证查询、更新、插入、删除操作的时间复杂度控制在 O(1) 或 O(logN),以此支撑其高吞吐的性能需求。
4.1 前置数据结构
在讲Redis数据类型之前,先来看看实现它们所依赖的五种数据结构:SDS、ziplist、intset、skiplist、hashtable。
-
SDS
SDS(Simple Dynamic String)是简单动态字符串。本质上是一个结构体,它内部记录了字符串的长度、分配容量和实际存储字符内容。所有String类型,Hash、List、Set中的字段和值都可以用SDS实现。
struct sdshdr {int len; // 已使用长度int alloc; // 已分配总长度(不含 '\0')char buf[]; // 实际存储字符内容 };
- 获取长度时间复杂度O(1):不需要
strlen
遍历。 - 支持预分配内存的机制,在字符串扩容时,不是只分配刚好所需的空间,而是按比例增长容量,从而减少频繁 realloc 的开销。
- SDS 是二进制安全的,允许字符串中包含
\0
字节,可以用它来保存压缩数据、图片等非文本内容。
- 获取长度时间复杂度O(1):不需要
-
ziplist
ziplist是一种连续内存块结构,数据紧密排列、无指针跳转。在存储小型集合(如字段较少的 Hash、元素较少的 List或ZSet)时,Redis 并不直接使用链表或哈希表,而是优先使用 ziplist(压缩列表)。
ziplist结构组成如下:
zlbytes
:ziplist 总字节数;zltail
:最后一个 entry 的偏移量,便于快速从尾部插入;zllen
:元素个数;entry
:存储实际数据(可能是整数或字符串);zlend
:结尾标志0xFF
。
Redis 对 ziplist 的使用是自动的,当 Hash、List、ZSet 中的元素个数或元素长度超过配置的阈值时(默认超过 512 个元素,或任意元素长度超过 64 字节),Redis 会自动将 ziplist 升级为更强大的数据结构(如 hashtable 或 skiplist)。
-
inset
当 Redis 的Set集合只包含整数值时,为了节省内存和提升效率,会使用
intset
(整数集合)代替通用哈希表,intset结构体定义如下:typedef struct intset {uint32_t encoding; // 每个元素的编码方式:16/32/64 位uint32_t length; // 当前元素个数int8_t contents[]; // 存储数据(升序排列) } intset;
- intset 是类型感知的,根据存储的整数范围动态选择使用 16 位、32 位或 64 位编码,从而最大限度压缩内存。
- 内部所有元素按照升序排列,支持快速查找和插入。
-
skiplist
跳表是一种查找结构,可以作为key或者key/value的查找模型,它是一种多层索引链表,在有序链表的基础上发展而来,每个节点根据随机概率生成多个“层级指针”,顶层索引稀疏,底层索引密集。
跳表在平均情况下可以在 O(logN) 的时间复杂度内完成插入、删除和查找操作。具体结构可以参考下图。
详细设计思路可以看这篇文章:https://blog.csdn.net/2301_76269963/article/details/136194737。
-
hashtable
hashtable我们都比较了解,通常用来实现key-value映射,这里重点讲一下它的rehash过程(扩容过程)。
当负载因子超过阈值,开始rehash,新哈希表ht[1]的容量通常是旧哈希表ht[0]容量的两倍大小。
Redis 采用的是渐进式 rehash 策略,而不是一次性将所有键值对从旧表移动到新表。具体实施如下:
-
设置一个 rehash 索引计数器 rehashidx,初始值设为 0。
-
在后续执行键值对的添加、删除、查找或更新操作时,除了正常执行相应操作之外,还会检查 rehashidx 是否小于旧哈希表 ht[0] 的大小。
查找一个 key 的值,先会在 ht[0] 里面进行查找,如果没找到,就会继续到 ht[1] 里面进行找到。
-
若满足条件,则将 ht[0] 在 rehashidx 索引处的所有键值对 rehash 到 ht[1] 中,然后递增 rehashidx。
-
如此反复,每次操作都会推进 rehash 进程一点点,直至 rehashidx 达到 ht[0] 的大小,表明 rehash 完成。
当 rehash 过程完成后,Redis 会将旧的哈希表 ht[0] 替换掉,并将新表 ht[1] 设为新的 ht[0]。
渐进式 rehash 可以有效地分散 rehash 所带来的内存操作的压力,避免在某一瞬间阻塞服务的可能性。
-
4.2 Redis数据类型及实现
Redis的基础数据类型包括String字符串、Hash散列、List列表、Set集合、Zset有序集合,其它数据类型包括Bitmap、HyperLogLog、GEO等。
-
String字符串:SDS
- String 可以存储文本、二进制数据、整数或浮点数等,功能涵盖了计数器、缓存数据、分布式锁等场景。
- String字符串基于SDS结构实现,内部记录了字符串长度,避免每次遍历计算。
-
List列表:quicklist:ziplist + 双向链表
-
List 是一个有序的链表结构,支持从两端插入和弹出元素,常用于消息队列、任务列表等场景。
-
List 不再使用原始的双向链表,而是用 quicklist 替代,quicklist 是 ziplist + 双向链表 的混合结构,每个 quicklist node 是一个压缩列表(ziplist),节省内存,并通过双向链表串联,支持高效插入与删除,还可以配置 ziplist 的最大长度与压缩深度。
-
-
Hash散列:ziplist → hashtable
-
Hash 是用于存储对象属性或结构化数据(如用户资料)的键值对集合,每个字段和对应的值都以字符串形式存储。
-
数据结构实现由ziplist和hashtable组成。
①字段少、数据小(默认字段数 < 512 且每个值 < 64 字节)时采用ziplist
②字段多或任一字段较大时自动转为hashtable,查找时间复杂度为 O(1)。
-
-
Set无序集合:intset → hashtable
-
Set 是一个不允许重复元素的无序集合,常用于用户标签、权限集合、抽奖池等。
-
数据结构实现由intset 与 hashtable组成。
①当所有元素是整数且数量较少时使用intset结构(默认 < 512 个),它是一种紧凑数组结构。
②元素多或出现非整数时自动转为hashtable,查找和插入时间复杂度O(1) 。
-
-
ZSet有序集合:ziplist → skiplist + hashtable
-
Sorted Set 是每个元素带有一个 score(分值)的集合,按照 score 进行排序,常用于排行榜、延时队列、区间查询等。
-
数据结构实现由hashtable和skiplist、ziplist组成。
①hashtable存储 member → score 映射,支持快速判断元素是否存在。跳表有序存储元素,支持按 score 查询、范围查找与排名。
②当元素数量不多时(默认当元素数量小于128,每个元素都小于64时),hashtable和skipList的优势不明显,而且更耗内存,ZSet会采用ziplist结构。
-
-
Bitmap:String
- Bitmap 是通过位操作压缩布尔状态的结构,每个 bit 代表一个元素的状态,常用于签到、唯一用户统计、布尔标记等。
- Bitmap基于String实现,String 是一个二进制安全的字节数组(SDS 实现),Bitmap 就是以 bit 为最小单位,在这个字节数组上进行按位访问、设置、统计的过程。
-
HyperLogLog:String
-
HyperLogLog是一种算法,redis将它用于统计海量数据中不重复元素的数量(基数),可用于 UV 去重、唯一对象估算。
-
基于概率算法实现,使用多个寄存器记录哈希值前缀中 0 的最大个数,空间恒定(约 12 KB),误差控制在 0.81% 左右,适合大数据量去重估算,不能获取具体元素集合。
详细原理可以看这篇文章:https://juejin.cn/post/6844903785744056333#heading-1。
-
-
Geo:ZSet
- 用于存储经纬度坐标,可进行地理位置存储、距离计算、范围查询等。
- 基于 ZSet 存储,通过 GeoHash 编码将二维经纬度编码成 一维score,通过跳表实现排序与范围检索。
Redis 的快,不止因为它“跑在内存里”,更在于它对每一处性能细节的极致优化:线程协作机制、异步持久化策略、数据结构设计,从运行机制到底层结构,都为高性能系统提供了基础。
如果这篇文章对你有帮助,欢迎点赞、转发、留言。