Redis为什么这么快?
作者:程序员马丁
热门项目实战社群,收获国内众多知名公司面试青睐,近千名同学面试成功!助力你在校招或社招上拿个offer。
答题思路
核心围绕存储方式、架构设计与使用方式回答:
回答话术
Redis 官方早前发布过一套基准测试,在 Redis 服务连接数小于 1 万 时,并发数量每秒可以达到 10-12 万左右。连接数在 3-6 万 时,也能支持每秒 5-6 万的并发。
我觉得 Redis 之所以操作这么快,主要有以下几方面原因:
- 从存储方式上看:Redis 是基于内存的数据库,而直接访问内存的速度要比访问磁盘高上几个数量级。这是 Redis 快最主要的原因。
- 从设计上看:Redis 在架构上采用了 IO 多路复用提高了资源利用率,通过多线程非阻塞式 IO 提高请求的处理效率 ,使用单线程执行大部分命令以避免上下文切换,部分重命令则允许异步执行,并且在设计上针对最底层的数据结构进行了精细的优化,以保证任何操作都具备尽可能低的复杂度。
- 从使用方式上看:Redis 的功能非常纯粹,用户直接面向经过精心设计的数据结构进行操作,因此效率极高,此外,用户还可以根据自己的业务场景采用最合适的数据结构,这也间接提高了操作效率。
问题详解
1. 基于内存操作
Redis 是基于内存操作的数据库,这是它快的最根本原因。
一般情况下,计算机访问一次 SSD 硬盘大概需要 50 ~ 150 微秒,如果是传统的硬盘时间则需要 1 ~ 10 毫秒,而访问一次内存仅需要 120 纳秒。可见,磁盘和内存访问的速度差了数个数量级。
除了少数一些需要需要跟磁盘打交道的时候(比如持久化),大部分时候 Redis 都 只在内存中进行读写,因此它的效率极高。
2. 合理的线程模型
我们一般说“Redis”是单线程的,实际上是指 Redis 使用单个主线程来执行大部分的命令。这个设计使得 Redis 可以避免因频繁的上下文切换以及各种同步机制带来的性能开销,同上也避免的了为了保证数据结构支持并发操作而引入的代码复杂度。
不过,Redis 也并非真的就是单线程的,从 4.0 开始,Redis 就引入了 UNLINK
这类命令,用于异步执行删除等重操作,并在 6.0 以后引入了专门的 IO 线程,实现了多线程的非阻塞式 IO,它们也进一步的提升了 Redis 的执行效率。
这里我们要顺带强调一下,虽然 Redis 的单个主线程模型确实带来的不少的好处,但是这个设计更多的还是在性能与设计之间取得的一个平衡。实际上不少市面上开源或者大公司内部自研的 KV 数据库 —— 比如 KeyDB 或者 Dragonfly —— 都是基于多线程模型实现的,它们以单机模式运行在多核机器上时也确实表现出了比 Redis 更高的性能。
3. 高效的 IO 模型
3.1. IO 多路复用
为了提高资源利用率,提高服务吞吐量,Redis 在内部实现了一套网络事件库,它支持基于 Solaris 中的 evport、Linux 中的 epoll、Mac OS/FreeBSD 中的 kQueue ……等操作系统函数实现高效的 IO 多路复用。
在这个模型中,它将会来自客户端的网络请求作为一个事件发布到队列中,然后线程将同步的获取事件并派发到不同的处理器,而处理器处理完毕后又会再发布另一个事件……整个主流程都由 Redis 的主线程在一个不间断的循环中完成,这就是事件循环。
熟悉 Netty 的同学可能会觉得有点既视感,因为两者都可以认为基于反应器模式实现的 IO 模型,不过 Netty 可以有多个事件循环,并且还可以划分为 Boss 和 Worker 两类事件循环组,而 Redis 只有一个事件循环,并且在早期版本只有一个 IO 线程(也就是主线程本身)。
3.2. 多线程非阻塞式 IO
随着请求规模的扩大,单个线程在网络 IO 上消耗的 CPU 时间越来越多,它逐渐成为了 Redis 的性能瓶颈。因此在 6.0 版及以上版本,Redis 正式引入了多线程来处理网络 IO。
在新的版本中,Redis 依然使用单个主线程来执行命令,但是使用多个线程来处理 IO 请求,主线程不再负责包括建立连接、读取数据和回写数据这些事情,而只是专注于执行命令。
这个做法在保证单注线程设计的原有优点的情况下,又进一步提高了网 络 IO 的处理效率。
4. 数据结构
Redis 的高性能很大程度上依赖于它丰富而高效的数据结构,而它们在底层实现上,都针对不同的使用场景进行了精心的设计和优化。
这些设计体现在这些数据结构的很多方面,这里我们简单列举几个 Redis 自己都在使用的数据结构。
4.1. 简单动态字符串
字符串是 Redis 中最常用的数据结构,不过作者并没有使用 C 标准款的实现,而是自己实现了一套简单动态字符串(SDS)作为替代。
SDS 的特点是在保留 C 字符串特性的同时:
- 通过 len 记录了字符串长度:实现了 O(1) 复杂度的
strlen
操作,并保证了二进制安全性。 - 通过 alloc 记录了分配的内存大小 :这使得修改字符串的时候可以通过计算,仅当空间不足时再扩展。
- 通过 flags 表示不同的类型:Redis 在内部针对区分了多种 SDS 类型,不同大小的字符串会对应不同的 SDS 实现,有效的节省内存。
可见,除了优化了内存占用外,Redis 的 SDS 也从最底层优化了包括内存分配和长度获取等基本操作,提升了性能。
字符串底层参见文章:✅ Redis 字符串底层数据结构?
4.2. 字典/哈 希表
Redis 的哈希表与 Java 中相似,也是基于 Key 得到的哈希值计算桶下标,再采用拉链法解决冲突,并在装载因子超过预定值时自动扩容。
它的特殊之处在于,当扩容的时候,它会基于扩容后的大小创建一张新的哈希表,然后在访问旧表的时候,每次将访问到的桶中的链表转移到新表中。
在这个过程中,每次操作的时候都会先访问旧表,然后再访问新表,直到旧表的数据组件的全部转移到新表以后,旧表会被回收,只留下新表。
Redis 通过这个被称为渐进式哈希的设计,巧妙地避免的在一次操作中大批量的进行数据迁移,而是将其分摊到多次请求中,这也有效提高的了该数据结构的使用性能。
4.3. 跳表
每个元素在不同层次的链表中出现,从最底层链表开始,每个级别的链表包含前一个级别链表的子集。当我们进行查找时,可以从最高层的 k 索引开始遍历,当我们确认一个元素大于 k 层的某个节点时,就进入 k-1 层,从这个节点开始继续向前遍历……直到找到为止。
相比起正常的列表,它在插入、删除和搜索时都具备 O(logN) 的复杂度,并且相比起树实现起来更加简单。
具体可以参见文章:✅ Redis 的跳表是什么?