我们使用 Redis 时,会接触 Redis 的 5 种对象类型(字符串、哈希、列表、集合、有序集合),丰富的类型是 Redis 相对于 Memcached 等的一大优势。在了解 Redis 的 5 种对象类型的用法和特点的基础上,进一步了解 Redis 的内存模型,对 Redis 的使用有很大帮助。
01 Redis内存统计
在客户端通过 redis-cli 连接服务器后(后面如无特殊说明,客户端一律使用redis-cli),通过 info 命令可以查看内存使用情况:info memory。
其中,info 命令可以显示 Redis 服务器的许多信息,包括服务器基本信息、CPU、内存、持久化、客户端连接信息等等;Memory 是参数,表示只显示内存相关的信息。
返回结果中比较重要的几个说明如下:
(1)used_memory
Redis 分配器分配的内存总量(单位是字节),包括使用的虚拟内存(即 swap);Redis 分配器后面会介绍。used_memory_human 只是显示更友好。
(2)used_memory_rss
Redis 进程占据操作系统的内存(单位是字节),与 top 及 ps 命令看到的值是一致的。
除了分配器分配的内存之外,used_memory_rss 还包括进程运行本身需要的内存、内存碎片等,但是不包括虚拟内存。
(3)mem_fragmentation_ratio
内存碎片比率,该值是 used_memory_rss / used_memory 的比值。
mem_fragmentation_ratio 一般大于 1,且该值越大,内存碎片比例越大;mem_fragmentation_ratio<1,说明 Redis 使用了虚拟内存,由于虚拟内存的媒介是磁盘,比内存速度要慢很多。
02 Redis内存划分
Redis 作为内存数据库,在内存中存储的内容主要是数据(键值对);也有其他部分会占用内存。
Redis 的内存占用主要可以划分为以下几个部分:
(1)数据
作为数据库,数据是最主要的部分;这部分占用的内存会统计在 used_memory 中。
Redis 使用键值对存储数据,其中的值(对象)包括 5 种类型,即字符串、哈希、列表、集合、有序集合。
(2)进程本身运行需要的内存
Redis 主进程本身运行肯定需要占用内存,如代码、常量池等等;这部分内存大约几兆,在大多数生产环境中与 Redis 数据占用的内存相比可以忽略。
(3)缓冲内存
缓冲内存包括客户端缓冲区、复制积压缓冲区、AOF 缓冲区等;其中,客户端缓冲区存储客户端连接的输入输出缓冲;复制积压缓冲区用于部分复制功能;AOF 缓冲区用于在进行 AOF 重写时,保存最近的写入命令。
(4)内存碎片
内存碎片是 Redis 在分配、回收物理内存过程中产生的。例如,如果对数据的更改频繁,而且数据之间的大小相差很大,可能导致 Redis 释放的空间在物理内存中并没有释放。
但 Redis 又无法有效利用,这就形成了内存碎片,内存碎片不会统计在 used_memory 中。
03 Redis数据存储的细节
关于 Redis 数据存储的细节,涉及到以下几个概念。
dictEntry:Redis 是 Key-Value 数据库,因此对每个键值对都会有一个 dictEntry,里面存储了指向 Key 和 Value 的指针;next 指向下一个 dictEntry,与本 Key-Value 无关。
Key:图中右上角可见,Key(”hello”)并不是直接以字符串存储,而是存储在 SDS 结构中。
下面来分别介绍 jemalloc、RedisObject、SDS、对象类型及内部编码。
(1)jemalloc
jemalloc 在 64 位系统中,将内存空间划分为小、大、巨大三个范围;当 Redis 存储数据时,会选择大小最合适的内存块进行存储。
jemalloc 划分的内存单元如下图所示:
例如,如果需要存储大小为 130 字节的对象,jemalloc 会将其放入 160 字节的内存单元中。
(2)RedisObject
前面说到,Redis 对象有 5 种类型;无论是哪种类型,Redis 都不会直接存储,而是通过 RedisObject 对象进行存储。
(3) SDS
Redis 没有直接使用 C 字符串(即以空字符’\0’结尾的字符数组)作为默认的字符串表示,而是使用了 SDS。SDS 是简单动态字符串(Simple Dynamic String)的缩写。
SDS 结构
其中,buf 表示字节数组,用来存储字符串;len 表示 buf 已使用的长度;free 表示 buf 未使用的长度。
举个例子:
通过 SDS 的结构可以看出,buf 数组的长度=free+len+1(其中 1 表示字符串结尾的空字符)。
所以,一个 SDS 结构占据的空间为:free 所占长度+len 所占长度+ buf 数组的长度=4+4+free+len+1=free+len+9。
04 Redis的对象类型与内部编码
Redis 支持 5 种对象类型,而每种结构都有至少两种编码。
这样做的好处在于:一方面接口与实现分离,当需要增加或改变内部编码时,用户使用不受影响,另一方面可以根据不同的应用场景切换内部编码,提高效率。
Redis 各种对象类型支持的内部编码如下图所示(图中版本是 Redis3.0):
关于 Redis 内部编码的转换,都符合以下规律:编码转换在 Redis 写入数据时完成,且转换过程不可逆,只能从小内存编码向大内存编码转换。
(1)字符串
字符串是最基础的类型,因为所有的键都是字符串类型,且字符串之外的其他几种复杂类型的元素也是字符串,字符串长度不能超过 512MB。
内部编码
字符串类型的内部编码有 3 种,它们的应用场景如下:
int:8 个字节的长整型。字符串值是整型时,这个值使用 long 整型表示。
embstr:<=39 字节的字符串。embstr 与 raw 都使用 RedisObject 和 sds 保存数据。
区别在于:embstr 的使用只分配一次内存空间(因此 RedisObject 和 sds 是连续的),而 raw 需要分配两次内存空间(分别为 RedisObject 和 sds 分配空间)。
raw:大于 39 个字节的字符串。
示例如下图所示:
embstr 和 raw 进行区分的长度,是 39;是因为 RedisObject 的长度是 16 字节,sds 的长度是 9+ 字符串长度。
因此当字符串长度是 39 时,embstr 的长度正好是 16+9+39=64,jemalloc 正好可以分配 64 字节的内存单元。
编码转换
当 int 数据不再是整数,或大小超过了 long 的范围时,自动转化为 raw。而对于 embstr,由于其实现是只读的,因此在对 embstr 对象进行修改时,都会先转化为 raw 再进行修改。
因此,只要是修改 embstr 对象,修改后的对象一定是 raw 的,无论是否达到了 39 个字节。
示例如下图所示:
(2)列表
列表(list)用来存储多个有序的字符串,每个字符串称为元素;一个列表可以存储 2^32-1 个元素。
Redis 中的列表支持两端插入和弹出,并可以获得指定位置(或范围)的元素,可以充当数组、队列、栈等。
内部编码
列表的内部编码可以是压缩列表(ziplist)或双端链表(linkedlist)。
双端链表:由一个 list 结构和多个 listNode 结构组成;典型结构如下图所示:
通过图中可以看出,双端链表同时保存了表头指针和表尾指针,并且每个节点都有指向前和指向后的指针。
压缩列表:压缩列表是 Redis 为了节约内存而开发的,是由一系列特殊编码的连续内存块(而不是像双端链表一样每个节点是指针)组成的顺序型数据结构,具体结构相对比较复杂。
当节点数量较少时,可以使用压缩列表;压缩列表不仅用于实现列表,也用于实现哈希、有序列表;使用非常广泛。
编码转换
只有同时满足下面两个条件时,才会使用压缩列表:列表中元素数量小于 512 个;列表中所有字符串对象都不足 64 字节。
如果有一个条件不满足,则使用双端列表;且编码只可能由压缩列表转化为双端链表,反方向则不可能。
下图展示了列表编码转换的特点:
其中,单个字符串不能超过 64 字节,是为了便于统一分配每个节点的长度。
(3)哈希
哈希(作为一种数据结构),不仅是 Redis 对外提供的 5 种对象类型的一种(与字符串、列表、集合、有序结合并列),也是 Redis 作为 Key-Value 数据库所使用的数据结构。
内部编码
内层的哈希使用的内部编码可以是压缩列表(ziplist)和哈希表(hashtable)2 种;Redis 的外层的哈希则只使用了 hashtable。
hashtable:一个 hashtable 由 1 个 dict 结构、2 个 dictht 结构、1 个 dictEntry 指针数组(称为 bucket)和多个 dictEntry 结构组成。
正常情况下(即 hashtable 没有进行 rehash 时),各部分关系如下图所示:
下面从底层向上依次介绍各个部分:
dictEntry:dictEntry 结构用于保存键值对,结构定义如下。
其中,各个属性的功能如下:
key:键值对中的键。
val:键值对中的值,使用 union(即共用体)实现,存储的内容既可能是一个指向值的指针,也可能是 64 位整型,或无符号 64 位整型。
next:指向下一个 dictEntry,用于解决哈希冲突问题。
在 64 位系统中,一个 dictEntry 对象占 24 字节(key/val/next 各占 8 字节)。
bucket:bucket 是一个数组,数组的每个元素都是指向 dictEntry 结构的指针。Redis 中 bucket 数组的大小计算规则如下:大于 dictEntry 的、最小的 2^n。
例如,如果有 1000 个 dictEntry,那么 bucket 大小为 1024;如果有 1500 个 dictEntry,则 bucket 大小为 2048。
编码转换
下图展示了 Redis 内层的哈希编码转换的特点:
(4)集合
集合中的元素是无序的,因此不能通过索引来操作元素;集合中的元素不能有重复。一个集合中最多可以存储 2^32-1 个元素。
内部编码
集合的内部编码可以是整数集合(intset)或哈希表(hashtable)。
整数集合的结构定义如下:
其中,encoding 代表 contents 中存储内容的类型,虽然 contents(存储集合中的元素)是 int8_t 类型。
但实际上其存储的值是 int16_t、int32_t 或 int64_t,具体的类型便是由 encoding 决定的,length 表示元素个数。
编码转换
下图展示了集合编码转换的特点:
(5)有序集合
有序集合与集合一样,元素都不能重复;但与集合不同的是,有序集合中的元素是有顺序的。有序集合为每个元素设置一个分数(score)作为排序依据。
内部编码
跳跃表是一种有序数据结构,通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。
Redis 的跳跃表实现由 zskiplist 和 zskiplistNode 两个结构组成:前者用于保存跳跃表信息(如头结点、尾节点、长度等),后者用于表示跳跃表节点,具体结构相对比较复杂。
编码转换
只有同时满足下面两个条件时,才会使用压缩列表:有序集合中元素数量小于 128 个;有序集合中所有成员长度都不足 64 字节。
如果有一个条件不满足,则使用跳跃表;且编码只可能由压缩列表转化为跳跃表,反方向则不可能。
下图展示了有序集合编码转换的特点:
Redis在国内外各大公司都能看到其身影,比如我们熟悉的新浪,阿里,腾讯,百度,搜狐,优酷,美团,小米等等。
大数据时代下,一个好的程序员必须要有大数据思维,理解大和小的概念,使用 Redis 的项目均具有庞大的数据量和访问量,这就要求我们不仅需要有良好的代码意识,还需要在未来大数据时代中对项目有更好的扩展能力。