浅谈redis是如何进一步提升自身的性能的

26 阅读16分钟

前言

最近正在看《Redis设计与实现》让我进一步加深了对redis理解,可以说redis将自身性能优化到了极致。

观点摘抄自《Redis设计与实现》,自己做了归纳总结。

简述

我们都知道redis的快主要是基于两点:

  1. 核心是基于epoll的IO多路复用概念,这是Linux提供的一种系统调用概念
  2. 纯内存操作

具体的我们就不在这做阐述了,我们现在要谈的是redis还做了哪些优化呢?接下来将从这些方面分析下redis在性能优化上做的改进

数据结构

先来说说redis底层的数据结构:

  • 简单动态字符串(SDS)
  • 链表
  • 字典
  • 跳跃表
  • 整数集合
  • 压缩列表

这里重点来讲一下简单动态字符串, 几乎所有的 Redis 模块中都用了 sds (对于Redis保存的键值对来说,键总是一个字符串对象,而值可以是其他几种对象中的一种)

简单动态字符串(SDS)

Redis没有直接直接使用C语言传统的字符串表示(以空字符结尾的字符数组),而是自己构建了一种名为简单动态字符串(simple dynamic string)的抽象类型,并将SDS用作Redis的默认字符类型表示。

举个例子,如果客户端执行命令

redis> RPUSH name "zhangsan" "lisi" "wangwu"

那么redis将在数据库中创建一个新的键值对,其中:

  • 键值对的键是一个字符串对象,对象的底层实现是一个保存了字符串"name"的SDS
  • 键值对的值是一个列表对象,列表对象保护了三个字符串对象,这三个字符串对象分别由三个SDS实现

上面提到几乎所有的 Redis 模块中都用了 sds,除了用来保存数据库中的字符串值之外,SDS还被用作缓冲区:AOF模块中的AOF缓冲区,以及客户端状态中的输入缓冲区都是由SDS实现的。

每个sdshdr结构表示一个SDS值

struct sdshdr {
    // 记录buf数组中已使用的字节的数量
    // 等于SDS所保存的字符串的长度
    int len;

    // 记录buf数组中未使用字节的数量
    int free;

    //字节数组,用于保存字符串
    char buf[];
}

示例:

  • free属性的值为0,表示这个SDS没有分配任何未使用空间
  • len属性的值为5,表示这个SDS保存了一个5字节长的字符串
  • buf属性是一个char类型的数组,数组的最后一个自己保存了空字符‘\0’。

SDS遵循C字符串以空字符结尾的管理,保存空字符的1字节空间不计算在SDS的len属性里面,并且为空字符串分配额外的1字节空间,以及添加空字符到字符串末尾等操作,都是由SDS函数自动完成的,所以这个空字符串对于SDS的使用者来说是完全透明的。遵循空字符结尾这一惯例的好处是,SDS可以直接重用一部分C字符串函数库里面

SDS和C字符串的不同之处,两者的区别

常熟复杂度获取字符串长度

因为C字符串并不记录自身的长度信息,所以为了获取一个C字符串的长度,程序必须便利整个字符串,对遇到的每一个字符进行计数,知道遇到代表字符串结尾的空字符为止,这个操作的复杂度为O(N)。

示例

和C字符串不同,因为SDS在len属性中记录了SDS本身的长度,所以获取一个SDS长度的复杂度为O(1)。

设置和更新SDS长度的工作是由SDS的API在执行时自动完成的,使用SDS无需进行任何手动修改长度的工作。

通过使用SDS而不是C字符串,redis将获取字符串长度的复杂度从O(N)降低到了O(1),这确保了获取字符串长度的工作不会成为redis性能的瓶颈。即使你对一个非常长的字符串,反复的执行STRLEN命令,也不会对系统系能造成影响,因为STRLEN命令的复杂度仅为O(1)。

杜绝缓冲区溢出

C字符串除了获取字符串长度的复杂度高之外,也容易造成缓冲区溢出。

由于C语言不记录自身长度,相邻的两个字符串可能如下图

当我想把‘redis’修改为‘redis123’的时候就会发生这种情况

可之前分配的内存只有5个字节,修改后的字符串需要8个字节才能放下啊,怎么搞?没办法只能侵占相邻字符串的空间,自身数据溢出导致其他字符串的内容被修改。

而SDS完全规避了这点,当我们需要修改数据时,首先会检查当前SDS空间len是否满足,不满足则自动扩容空间至修改所需的大小,然后再执行修改。

  • 例如我们要将'redis' 修改为 'redis cluster

那么将在执行拼接操作之前检查长度是否足够,在发现不足以拼接‘ cluster’之后,就会先扩展空间,然后才执行拼接操作

注意: 不仅对这个SDS进行了拼接操作,它还为SDS分配了13字节的未使用空间,并且拼接之后的字符串长度也正好是13字节长,这和SDS的空间分配策略有关。

减少修改字符串时带来的内存重分配次数

C字符串长度是一定的,所以每次在增长或者缩短字符串时,都要做内存的重分配

  • 如果程序执行的是增长字符串的操作,比如append,那么在执行这个操作之前,程序需要先通过内存重分配来扩展底层数据的空间大小,如果忘了这一步就会产生缓冲区溢出
  • 如果是trim操作,那么在执行之前就需要通过内存重分配来释放字符串不再使用的那部分空间,如果忘了就会产生内存泄漏

而内存重分配涉及复杂的算法并且可能需要执行系统调用,所以通常是一个比较耗时的操作,如果程序不经常修改字符串还是可以接受的。

但很不幸,redis作为一个数据库,数据肯定会被频繁修改,如果每次修改都要执行一次内存重分配,那么就会严重影响性能。

为了避免C字符串的这种缺陷,SDS通过未使用空间解除了字符串长度和底层数据长度直接的关联:在SDS中,buf数组的长度不一定就是字符数量加一,数组里面可以保护未使用的字节,而这些自己的数量就由free属性记录。

通过未使用空间,SDS实现了空间预分配和惰性空间释放两种优化策略

空间预分配

空间预分配策略用于优化SDS字符串增长操作,当修改字符串并需对SDS的空间进行扩展时,不仅会为SDS分配修改所必要的空间,还会为SDS分配额外的未使用空间free,下次再修改就先检查未使用空间free是否满足,满足则不用在扩展空间。

通过空间预分配策略,redis可以有效的减少字符串连续增长操作,所产生的内存重分配次数。

例如再次对前面拼接好的‘redis cluster’拼接新的字符串‘ hello’

额外分配未使用空间free的规则:

  • 如果对 SDS 字符串修改后,len 值小于 1M,那么此时额外分配未使用空间 free 的大小与len相等。
  • 如果对 SDS 字符串修改后,len 值大于等于 1M,那么此时额外分配未使用空间 free 的大小为1M。

通过这种预分配策略,SDS将连续增长N次字符串所需的内存重分配次数从必定N次降低为最多N次

惰性空间释放

惰性空间释放策略则用于优化SDS字符串缩短操作,当缩短SDS字符串后,并不会立即执行内存重分配来回收多余的空间,而是用free属性将这些空间记录下来,如果后续有增长操作,则可直接使用。

与此同时,SDS也提供了相应的API,让我们可以在需要的时候,真正地释放SDS的未使用空间,所以不用担心惰性空间释放策略会造成内存浪费。

二进制安全

C字符串中的字符必须符合某些特定的编码格式,而且上边我们也提到,C字符串以‘\0’空字符结尾标识一个字符串结束,所以字符串里边是不能包含‘\0’的,不然就会被误认是多个。

由于这种限制,使得C字符串只能保存文本数据,像音视频、图片等二进制格式的数据是无法存储的。

redis 会以处理二进制的方式操作Buf数组中的数据,所以对存入其中的数据做任何的限制、过滤,只要存进来什么样,取出来还是什么样。

对象

Redis并没有直接使用上面那些数据结构来实现键值对数据库, 而是基于这些数据结构创建了一个对象系统,每种对象至少都用到了一种前面的数据结构。

redis中的每个对象都由一个redisObject结构表示

struct redisObject {
    // 类型
    unsigned type:4;

    //编码
    unsigned encoding:4;

    //指向底层实现数据结构的指针
    void *ptr;
    
    ...
        
}

redis在执行命令之前,根据对象的类型来判断一个对象释放可以执行给定的命令。使用对象的另一个好处就是,可以针对不同的使用场景,为对象设置不同的数据结构实现,从而优化对象在不同场景下的使用效率

内存回收

因为C语言并不具备自动内存回收的功能,所以redis在自己的对象系统中构建了一个引用计数器技术实现的内存回收机制,通过这一机制程序可以通过跟踪每个对象的引用计数信息,在适当的时候自动释放对象并进行内存回收,对象的引用计数器由redisObject结构的refcount记录

struct redisObject {
    
    ...
        
    // 引用计数器
    int refcount;
    
    ...
        
}

对象的引用计数会随着对象的使用状态而不断变化

  • 在创建一个新对象时,引用计数的值会被初始化为1
  • 当对象被一个新程序使用时,它的引用计数值会被加1
  • 当对象不再被一个新程序使用时,它的引用计数值会被减1
  • 当对象的引用计数值变为0时,对象所占用的内存会被释放

对象共享

除了用于实现引用计数内存回收机制之外, 对象的引用计数属性还带有对象共享的作用。

举个例子, 假设键 A 创建了一个包含整数值 100 的字符串对象作为值对象, 如图 8-20 所示。

如果这时键 B 也要创建一个同样保存了整数值 100 的字符串对象作为值对象, 那么服务器有以下两种做法:

  1. 为键 B 新创建一个包含整数值 100 的字符串对象;
  2. 让键 A 和键 B 共享同一个字符串对象;

以上两种方法很明显是第二种方法更节约内存。

在 Redis 中, 让多个键共享同一个值对象需要执行以下两个步骤:

  1. 将数据库键的值指针指向一个现有的值对象;
  2. 将被共享的值对象的引用计数增一。

举个例子, 图 8-21 就展示了包含整数值 100 的字符串对象同时被键 A 和键 B 共享之后的样子, 可以看到, 除了对象的引用计数从之前的 1 变成了 2 之外, 其他属性都没有变化。

共享对象机制对于节约内存非常有帮助, 数据库中保存的相同值对象越多, 对象共享机制就能节约越多的内存。

比如说, 假设数据库中保存了整数值 100 的键不只有键 A 和键 B 两个, 而是有一百个, 那么服务器只需要用一个字符串对象的内存就可以保存原本需要使用一百个字符串对象的内存才能保存的数据。

目前来说, Redis 会在初始化服务器时, 创建一万个字符串对象, 这些对象包含了从 0 到 9999 的所有整数值, 当服务器需要用到值为 0 到 9999 的字符串对象时, 服务器就会使用这些共享对象, 而不是新创建对象。

注意

创建共享字符串对象的数量可以通过修改 redis.h/REDIS_SHARED_INTEGERS 常量来修改。

举个例子, 如果我们创建一个值为 100 的键 A , 并使用 OBJECT REFCOUNT 命令查看键 A 的值对象的引用计数, 我们会发现值对象的引用计数为 2 :

redis> SET A 100 OK redis> OBJECT REFCOUNT A (integer) 2

引用这个值对象的两个程序分别是持有这个值对象的服务器程序, 以及共享这个值对象的键 A , 如图 8-22 所示。

如果这时我们再创建一个值为 100 的键 B , 那么键 B 也会指向包含整数值 100 的共享对象, 使得共享对象的引用计数值变为 3 :

redis> SET B 100 OK redis> OBJECT REFCOUNT A (integer) 3 redis> OBJECT REFCOUNT B (integer) 3

图 8-23 展示了共享值对象的三个程序。

另外, 这些共享对象不单单只有字符串键可以使用, 那些在数据结构中嵌套了字符串对象的对象(linkedlist 编码的列表对象、 hashtable 编码的哈希对象、 hashtable 编码的集合对象、以及 zset 编码的有序集合对象)都可以使用这些共享对象。

为什么 Redis 不共享包含字符串的对象?

当服务器考虑将一个共享对象设置为键的值对象时, 程序需要先检查给定的共享对象和键想创建的目标对象是否完全相同, 只有在共享对象和目标对象完全相同的情况下, 程序才会将共享对象用作键的值对象, 而一个共享对象保存的值越复杂, 验证共享对象和目标对象是否相同所需的复杂度就会越高, 消耗的 CPU 时间也会越多:

  • 如果共享对象是保存整数值的字符串对象, 那么验证操作的复杂度为 O(1) ;
  • 如果共享对象是保存字符串值的字符串对象, 那么验证操作的复杂度为 O(N) ;
  • 如果共享对象是包含了多个值(或者对象的)对象, 比如列表对象或者哈希对象, 那么验证操作的复杂度将会是 O(N^2) 。

因此, 尽管共享更复杂的对象可以节约更多的内存, 但受到 CPU 时间的限制, Redis 只对包含整数值的字符串对象进行共享。

对象空转时长

除了前面介绍过的 type 、 encoding 、 ptr 和 refcount 四个属性之外, redisObject 结构包含的最后一个属性为 lru 属性, 该属性记录了对象最后一次被命令程序访问的时间

typedfe struct redisObject {
    ...
    unsigned lru:22;
    ...     
}robj;

}
OBJECT IDLETIME 命令可以打印出给定键的空转时长, 这一空转时长就是通过将当前时间减去键的值对象的 lru 时间计算得出的:

redis> SET msg "hello world"
OK
  • 等待一小段时间
redis> OBJECT IDLETIME msg
(integer) 20
  • 等待一阵子
redis> OBJECT IDLETIME msg
(integer) 180
  • 访问 msg 键的值
redis> GET msg
"hello world"
  • 键处于活跃状态,空转时长为 0
redis> OBJECT IDLETIME msg
(integer) 0

注意

  • OBJECT IDLETIME 命令的实现是特殊的, 这个命令在访问键的值对象时, 不会修改值对象的 lru 属性。
  • 除了可以被 OBJECT IDLETIME 命令打印出来之外, 键的空转时长还有另外一项作用: 如果服务器打开了 maxmemory 选项, 并且服务器用于回收内存的算法为 volatile-lru 或者 allkeys-lru , 那么当服务器占用的内存数超过了 maxmemory 选项所设置的上限值时, 空转时长较高的那部分键会优先被服务器释放, 从而回收内存。
  • 配置文件的 maxmemory 选项和 maxmemory-policy 选项的说明介绍了关于这方面的更多信息。

总结

  • redis底层数据结构通过使用SDS来降低获取字符串长度的查询复杂度,也杜绝了缓冲区溢出,减少修改字符串时所需的内存重分配次数。
  • 通过使用对象系统针对不同的类型,优化了对象在不同场景下的使用效率,还实现了基于引用计数的内存回收机制,当程序不在使用某个对象的时候,对象所占有的内存会被自动释放,另外,还通过引用计数计数实现了对象的共享机制,这一机制可以在适当条件下节约内存
  • redis对象带有访问时间记录信息,服务器开启了maxmemory的情况下,在基于内存淘汰机制,对于空转时间较长的那些键可能会被优先删除

依赖于各个特性,使得redis在性能上能够得到进一步的提升