帮助中心

>网络  > 

为什么Redis能支持高并发

一、Redis为什么快

  • 纯内存K-V操作

    数据库的工作模式按存储方式分为了磁盘数据库和内存数据库。Redis将数据存储在内存中,并且绝大多数命令都不会受到磁盘 IO 速度的限制,所以速度极快。此外,Redis内部采用了 HashMap 这种数据结构,从根本上获得了优势,因为 HashMap 无论是查找和操作的时间复杂度都是O(1)

  • 采用了多路复用的I/O机制

    Redis是单线程的,但它底层使用了多路复用 I/O 机制。多路 /O 复用模型是指利用select、poll、epoll 同时监察多个流的 I/O 事件,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有 I/O 事件时,就从阻塞态中唤醒。程序会轮询一遍所有的流(epoll 是只轮询那些真正发出了事件的流),并且只依次顺序的处理就绪的流,这种做法避免了大量的无用操作。

  • 数据结构简单,操作节省时间

    在 Redis 中,有以下五种常用的数据结构:

    此外,Redis对数据结构做了很多优化,诸如压缩表、对短数据进行压缩存储、跳表等,都加快了读取速度。

    • String:字符串类型,通常用作缓存、计数器和分布式锁等;

    • List:链表类型,用作队列、微博关注人时间轴列表等;

    • Hash:可以用于存储用户信息、hash表等;

    • Set:集合类型,用于去重、赞、踩、共同好友等;

    • Zset:有序集合,用于访问量排行榜、点击量排行榜等。

二、为什么Redis是单线程的

Redis是单线程的! 这一点是毋庸置疑的(好吧,Redis 6.0 网络读写部分是多线程的)。那为什么Redis使用单线程呢?而且还能有这么快的响应速度。

Redis is single threaded. How can I exploit multiple CPU / cores?

It’s not very frequent that CPU becomes your bottleneck with Redis, as usually Redis is either memory or network bound. For instance, using pipelining Redis running on an average Linux system can deliver even 1 million requests per second, so if your application mainly uses O(N) or O(log(N)) commands, it is hardly going to use too much CPU.

However, to maximize CPU usage you can start multiple instances of Redis in the same box and treat them as different servers. At some point a single box may not be enough anyway, so if you want to use multiple CPUs you can start thinking of some way to shard earlier.

You can find more information about using multiple Redis instances in the Partitioning page.

However with Redis 4.0 we started to make Redis more threaded. For now this is limited to deleting objects in the background, and to blocking commands implemented via Redis modules. For future releases, the plan is to make Redis more and more threaded.

上面这段文字来自官网,解释说明了 Redis 的瓶颈并不在线程,也不在获取CPU资源,而往往是网络带宽和计算机的内存大小,这也就是 Redis 使用单线程的原因。

当然啦,单线程对于多线程来说并不能提高CPU利用率,但是单线程也有其优点嘛:

  • 省去上下文切换

    上下文不难理解,就是CPU寄存器和程序计数器。主要作用就是存放没有被分配到资源的线程,多线程操作的时候,不是每一个线程都能够直接获取到CPU资源的,我们之所以能够看到我们电脑上能够运行很多的程序,是应为多线程的执行和CPU不断对多线程的切换。但是总有线程获取到资源,也总有线程需要等待获取资源,这个时候,等待获取资源的线程就需要被挂起,也就是我们的寄存。这个时候我们的上下文就产生了,当我们的上下文再次被唤起,得到资源的时候,就是我们上下文的切换。

  • 避免竞争资源

    竞争资源相对来说比较好理解,CPU对上下文的切换其实就是一种资源分批,但是在切换之前,到底切换到哪一个上下文,就是资源竞争的开始。在 Redis 中由于是单线程的,所以所有的操作都不会涉及到资源的竞争。

  • 避免锁的消耗

    对于多线程的来讲,不能回避的就是锁的问题。如果说多线程操作出现并发,有可能导致数据不一致,或者操作达不到预期的效果。这个时候我们就需要锁来解决这些问题。当我们的线程很多的时候,就需要不断的加锁,释放锁,该操作就会消耗掉很多的时间。

那么如何发挥多核 CPU 的性能呢?

  • 具体可以通过在单机开多个 Redis 实例来完善!

此外,Redis 用单线程实现但是还能保持高效率的原因,就是I/O多路复用:

Redis 采用网络 I/O 多路复用技术来保证在多连接的时候,系统的高吞吐量。

其中多路是指多个 socket 连接,复用是指复用一个线程。多路复用主要有三种技术:select、poll、epoll。

采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络IO的时间消耗)。

在这里插入图片描述

三、Redis在数据结构方面的优化

Redis 在数据结构方面做的优化有很多,这里就简单地介绍几个。

1. 字符串的优化

虽然 Redis 是用 C 语言编写的,但 Redis 中的字符串并没有沿用 C 语言中的字符串。因为 C 语言里面的字符串的标准形式是以 NULL (即0x\\0)结尾的,要获取以 NULL 结尾的字符串长度使用的是 strlen 标准库函数,这个函数的时间复杂度是 O(n),这对 Redis 可不友好,所以 Redis 中的字符串是一种被称为 SDS (Simple Dynamic String)的结构体来保存字符串。

struct SDS<T> {
    T capacity;     // 数组容量
    T len;          // 数组长度
    byte flags;     // 特殊标志位
    byte[] content; // 数组内容}123456

所以 SDS 中直接使用一个变量来标志字符串的长度,所以提升了效率。

除此之外,SDS还会有一个预分配的操作,这一点类似于 Java 语言的 ArrayList 结构,需要比实际的内容多分配一些冗余的空间。capacity 表示所分配数组的长度,len 表示字符串的实际长度。

具体的分配规则如下如果对 :SDS 修改后,len 的长度小于 1M,那么程序将分配和 len 相同长度的未使用空间。举个例子,如果 len=10,重新分配后,buf 的实际长度会变为 10(已使用空间)+10(额外空间)+1(空字符)=21。如果对 SDS 修改后 len 长度大于 1M,那么程序将分配 1M 的未使用空间。

这一点相对于C语言要优化了不少,在 C 中,当我们频繁的对一个字符串进行修改(append 或 trim)操作的时候,需要频繁的进行内存重新分配的操作,十分影响性能。

2. 字典的优化

字典是 Redis 中最为常用的复合型数据结构,它与 Java 中的 HashMap 类似。Redis 本身就是一个 K-V 服务器,除了 Redis 数据库本身外,字典也是哈希键的底层实现。字典的数据结构如下所示:

typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];
    int trehashidx;}123456
typedef struct dictht {
    dectEntry **table;    // 哈希表数组
    unsigned long size;   // 第一维数组的长度
    unsigned long sizemask;
    unsigned long used;   // 哈希表中元素个数}123456

重要的两个字段是 dictht 和 trehashidx,这两个字段与 rehash 有关,下面重点介绍 rehash。

2.1 Rehash

学过 Java 中的 HashMap 就会知道 rehash 操作。Redis 中的字典同样的会有 Rehash 过程。看上面代码,dict 中存储了一个 dictht 的数组,长度为2,表明这个数据结构中实际存储着两个哈希表 ht[0] 和 ht[1],为什么要存储两张表呢?当然是为了 Rehash,Rehash 的过程如下:

  • 为 ht[1] 分配空间。如果是扩容操作,ht[1]的大小为原数组的两倍;

  • 将 ht[0] 中的键值 Rehash 到 ht[1] 中。

  • 当 ht[0] 全部迁移到 ht[1] 中后,释放 ht[0],将 ht[1] 置为 ht[0],并为 ht[1] 创建一张表,为下一次 Rehash 做准备。

2.2 渐进式 Rehash

Redis 中的 Rehash 操作是将 ht[0] 中的键值全部迁移到 ht[1]。所以在大字典的扩容时是很耗时间的,作为单线程的 Redis 很难承受这样的耗时的过程,所以 Redis 使用渐进式 rehash 小步搬迁。这个过程大概如下:

  • 为 ht[1] 分配空间,让字典同时拥有 ht[0] 和 ht[1] 两个哈希表。

  • 字典中维护一个 rehashidx,并将它置为 0,表示 Rehash 开始。

  • 在 Rehash 期间,每次对字典操作(如hset、hdel)时,程序还顺便将 ht[0] 在 rehashidx 索引上的所有键值对 rehash 到 ht[1] 中,当 Rehash 完成后,将 rehashidx 属性+1。当全部 rehash 完成后,将 rehashidx 置为 -1,表示 rehash 完成。

注意:

  • Redis 不止通过客户端操作对Rehash进行埋点,同时还会设置定时认为,进行渐进式 Rehash;

  • Redis 如果正在 bgsave,为了减少内存页的过多分离,Redis 尽量不去扩容,但是如果 hash 已经非常满了,元素的个数已经达到第一位数组长度的五倍,说明此时 hash 表已经过于拥挤了,这个时候就会强制扩容。


以上是否解决了您的问题