目录
一、缓存通识
1.1 ⽆处不在的缓存
1.2 多级缓存 (重点)
二、Redis简介
2.1 什么是Redis
2.2 Redis的应用场景
三、Redis数据存储的细节
3.1 Redis数据类型
3.2 内存结构
3.3 内存分配器
3.4 redisObject
3.4.1 type
3.4.2 encoding
3.4.3 ptr
3.4.4 refcount
3.4.5 lru
3.4.6 ⼩结
3.5 SDS
3.5.1 SDS内存结构
3.5.2 SDS与C字符串的⽐较
四、Redis的对象类型与内存编码
4.1 字符串
4.1.1 概况
4.1.2 内部编码
4.2 列表
4.2.1 概况
4.2.2 内部编码
4.3 哈希
4.3.1 概况
4.3.2 内部编码
4.3.3 编码转换
4.4 集合
4.4.1 概况
4.4.2 内部编码
4.4.3 编码转换
4.5 有序集合
4.5.1 概况
4.5.2 内部编码
4.5.3 编码转换
4.5.4 跳跃表
五、Redis 设计优化
5.1 估算Redis内存使⽤量
5.2 优化内存占用
5.2.1 利用jemalloc特性进行优化
5.2.2 使用整型/长整型
5.2.3 共享对象
5.2.4 缩短键值对的存储长度
六、Reids 内存⽤量统计
6.1 used_memory
6.2 used_memory_rss
6.3 mem_fragmentation_ratio
6.4 mem_allocator
七、Redis内存划分
7.1 数据内存
7.2 进程内存
7.3 缓冲内存
7.4 内存碎⽚
一、缓存通识
缓存:存储在计算机上的⼀个原始数据复制集,以便于访问。
缓存是介于数据访问者和数据源之间的⼀种⾼速存储,当数据需要多次读取的时候,⽤于加快读取的速 度。
缓存(Cache) 和 缓冲(Buffer) 的分别?
缓存:⼀般是为了数据多次读取。
缓冲:⽐如CPU要把数据先硬盘,因为硬盘⽐较慢,先到缓冲设备Buffer,⽐如内存,Buffer读和写都需要。
1.1 ⽆处不在的缓存
CPU 缓存
操作系统缓存
数据库缓存
JVM 编译缓存
CDN 缓存
代理与反向代理缓存
前端缓存
应⽤程序缓存
分布式对象缓存
1.2 多级缓存 (重点)
二、Redis简介
2.1 什么是Redis
Redis是⽤C语⾔开发的⼀个开源的⾼性能键值对(key-value)的NoSQL数据库。它通过提供多种键值数据类型来适应不同场景下的存储需求。
Redis作为⼀个单线程的应⽤,为什么处理请求性能如此NB?IO多路复⽤
NoSQL,泛指⾮关系型的数据库,NoSQL即Not-Only SQL,它可以作为关系型数据库的良好补充。
2.2 Redis的应用场景
缓存(数据查询、短连接、新闻内容、商品内容等等)。(最多使⽤)
分布式集群架构中的session分离。
聊天室的在线好友列表。
任务队列。(秒杀、抢购、12306等等)
应⽤排⾏榜。
⽹站访问统计。
数据过期处理(可以精确到毫秒)
三、Redis数据存储的细节
3.1 Redis数据类型
Redis整体上是⼀个KV结构,但是它的Value⼜可以分⽂以下五种数据类型。
⽬前为⽌Redis⽀持的键值数据类型如下:
字符串类型(string) set key value
散列类型(hash) hset key field value
列表类型(list) lpush key a b c d
集合类型(set) sadd key a b c d
有序集合类型(zset/sortedset) zadd key a score b score
3.2 内存结构
下图是执⾏set hello world时,所涉及到的数据模型。
1. dictEntry:Redis是Key-Value数据库,因此对每个键值对都会有⼀个dictEntry,⾥⾯存储了指向Key和Value的指针;next指向下⼀个dictEntry,与本Key-Value⽆关。
2. Key:图中右上⻆可⻅,Key(”hello”)并不是直接以字符串存储,⽽是存储在SDS结构中。
3. redisObject:Value(“world”)既不是直接以字符串存储,也不是像Key⼀样直接存储在SDS中,⽽是存储在redisObject中。实际上,不论Value是5种类型的哪⼀种,都是通过redisObject来存储的;⽽redisObject中的type字段指明了Value对象的类型,ptr字段则指向对象所在的地址。不过可以看出,字符串对象虽然经过了redisObject的包装,但仍然需要通过SDS存储。实际上,redisObject除了type和ptr字段以外,还有其他字段图中没有给出,如⽤于指定对象内部编码的字段;后⾯会详细介绍。
4. jemalloc:⽆论是DictEntry对象,还是redisObject、SDS对象,都需要内存分配器(如
jemalloc)分配内存进⾏存储。
3.3 内存分配器
Redis在编译时便会指定内存分配器;内存分配器可以是 libc 、jemalloc或者tcmalloc,默认是jemalloc。
jemalloc作为Redis的默认内存分配器,在减⼩内存碎⽚⽅⾯做的相对⽐较好。jemalloc在64位系统中,将内存空间划分为⼩、⼤、巨⼤三个范围;每个范围内⼜划分了许多⼩的内存块单位;当Redis存储数据时,会选择⼤⼩最合适的内存块进⾏存储。
在 jemalloc 类⽐过来的物流系统中,同城仓库相当于 tcache —— 线程独有的内存仓库;区域仓库相当于 arena —— ⼏个线程共享的内存仓库;全国仓库相当于全局变量指向的内存仓库,为所有线程可⽤。
在 jemalloc 中,整块批发内存,之后或拆开零售,或整块出售。整块批发的内存叫做 chunk,对于⼩件和⼤件订单,则进⼀步拆成 run。Chunk 的⼤⼩为 4MB(可调)或其倍数,且为 4MB 对⻬;⽽ run ⼤⼩为⻚⼤⼩的整数倍。
在 jemalloc 中,⼩件订单叫做 small allocation,范围⼤概是 1-57344 字节。并将此区间分成 44 档,每次⼩分配请求归整到某档上。例如,⼩于8字节的,⼀律分配 8 字节空间;17-32分配请求,⼀律分配32 字节空间。
对于上述 44 档,有对应的 44 种 runs。每种 run 专⻔提供此档分配的内存块(叫做 region)。
⼤件订单叫做 large allocation,范围⼤概是 57345-4MB不到⼀点的样⼦,所有⼤件分配归整到⻚⼤⼩。
jemalloc划分的内存单元如下图所示:
例如,如果需要存储⼤⼩为130字节的对象,jemalloc会将其放⼊160字节的内存单元中。
3.4 redisObject
Redis对象有5种类型;⽆论是哪种类型,Redis都不会直接存储,⽽是通过redisObject对象进⾏存储。
redisObject对象⾮常重要,Redis对象的类型、内部编码、内存回收、共享对象等功能,都需要redisObject⽀持,下⾯将通过redisObject的结构来说明它是如何起作⽤的。
Redis中的每个对象都是由如下结构表示(列出了与保存数据有关的三个属性)
{
unsigned type:4;//类型 五种对象类型
unsigned encoding:4;//编码
void *ptr;//指向底层实现数据结构的指针
//...
int refcount;//引⽤计数
//...
unsigned lru:24;//记录最后⼀次被命令程序访问的时间
//...
}robj;
3.4.1 type
type字段表示对象的类型,占4个⽐特;⽬前包括REDIS_STRING(字符串)、REDIS_LIST (列表)、REDIS_HASH(哈希)、REDIS_SET(集合)、REDIS_ZSET(有序集合)。当我们执⾏type命令时,便是通过读取RedisObject的type字段获得对象的类型;如下图所示:
3.4.2 encoding
encoding表示对象的内部编码,占4个⽐特。
对于Redis⽀持的每种类型,都有⾄少两种内部编码,例如对于字符串,有int、embstr、raw三种编码。通过encoding属性,Redis可以根据不同的使⽤场景来为对象设置不同的编码,⼤⼤提⾼了Redis的灵活性和效率。以列表对象为例,有压缩列表和双端链表两种编码⽅式;如果列表中的元素较少,Redis倾向于使⽤压缩列表进⾏存储,因为压缩列表占⽤内存更少,⽽且⽐双端链表可以更快载⼊;当列表对象元素较多时,压缩列表就会转化为更适合存储⼤量元素的双端链表。
通过object encoding命令,可以查看对象采⽤的编码⽅式,如下图所示:
5种对象类型对应的编码⽅式以及使⽤条件,将在后⾯介绍。
3.4.3 ptr
ptr指针指向具体的数据,如前⾯的例⼦中,set hello world,ptr指向包含字符串world的SDS。
3.4.4 refcount
refcount与共享对象
refcount记录的是该对象被引⽤的次数,类型为整型。refcount的作⽤,主要在于对象的引⽤计数和内存回收。
当创建新对象时,refcount初始化为1;当有新程序使⽤该对象时,refcount加1;当对象不再被⼀个新程序使⽤时,refcount减1;当refcount变为0时,对象占⽤的内存会被释放。
共享对象的具体实现
Redis的共享对象⽬前只⽀持整数值的字符串对象。之所以如此,实际上是对内存和CPU(时间)的平衡:共享对象虽然会降低内存消耗,但是判断两个对象是否相等却需要消耗额外的时间。对于整数值,判断操作复杂度为O(1);对于普通字符串,判断复杂度O(n);⽽对于哈希、列表、集合和有序集合,判断的复杂度为O(n^2)。虽然共享对象只能是整数值的字符串对象,但是5种类型都可能使⽤共享对象(如哈希、列表等的元素可以使⽤)。
共享对象池
共享对象池是指Redis内部维护[0-9999]的整数对象池。
创建⼤量的整数类型redisObject存在内存开销,每个redisObject内部结构⾄少占16字节,甚⾄超过了整数⾃身空间消耗。
所以Redis内存维护⼀个[0-9999]的整数对象池,⽤于节约内存。
除了整数值对象,其他类型如list、hash、set、zset内部元素也可以使⽤整数对象池。
因此开发中在满⾜需求的前提下,尽量使⽤整数对象以节省内存。
就⽬前的实现来说,Redis服务器在初始化时,会创建10000个字符串对象,值分别是0~9999的整数值;当Redis需要使⽤值为0~9999的字符串对象时,可以直接使⽤这些共享对象。10000这个数字定义在源码的 OBJ_SHARED_INTEGERS 常量中定义。共享对象的引⽤次数可以通过object refcount命令查看,如下图所示。命令执⾏的结果⻚佐证了只有0~9999之间的整数会作为共享对象。
3.4.5 lru
lru记录的是对象最后⼀次被命令程序访问的时间,占据的⽐特数不同的版本有所不同(2.6版本占22⽐特,4.0版本占24⽐特)。
通过对⽐lru时间与当前时间,可以计算某个对象的闲置时间;object idletime命令可以显示该闲置时间(单位是秒)。object idletime命令的⼀个特殊之处在于它不改变对象的lru值。
lru值除了通过object idletime命令打印之外,还与Redis的内存回收有关系:如果Redis打开了maxmemory选项,且内存回收算法选择的是volatile-lru或allkeys—lru,那么当Redis内存占⽤超过maxmemory指定的值时,Redis会优先选择空转时间最⻓的对象进⾏释放。
3.4.6 ⼩结
综上所述,redisObject的结构与对象类型、编码、内存回收、共享对象都有关系;⼀个redisObject对象的⼤⼩为16字节:
4bit(类型)+4bit(编码)+24bit(lru)+4Byte(refcount)+8Byte(指针)=16Byte
3.5 SDS
3.5.1 SDS内存结构
Redis没有直接使⽤C字符串(即以空字符’\0’结尾的字符数组)作为默认的字符串表示,⽽是使⽤了SDS。SDS是简单动态字符串(Simple Dynamic String)的缩写。
(1) 3.2之前
struct sdshdr{
//记录buf数组中已使⽤字节的数量
//等于 SDS 保存字符串的⻓度
int len;
//记录 buf 数组中未使⽤字节的数量
int free;
//字节数组,⽤于保存字符串
char buf[];
}
其中,buf表示字节数组,⽤来存储字符串;len表示buf已使⽤的⻓度,free表示buf未使⽤的⻓度。下⾯是两个例⼦。
通过SDS的结构可以看出,[buf数组的⻓度=free+len+1(其中1表示字符串结尾的空字符);所以,⼀个SDS结构占据的空间为:free所占⻓度+len所占⻓度+ buf数组的⻓度+1=4+4+字符串⻓度+1=字符串⻓度+9。
(2) 3.2之后
typedef char *sds;
struct __attribute__ ((__packed__)) sdshdr5 { // 对应的字符串⻓度⼩于 1<<5 32字 节
unsigned char flags; /* 3 lsb of type, and 5 msb of string length int
embstr*/
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 { // 对应的字符串⻓度⼩于 1<<8 256
uint8_t len; /* used */ //⽬前字符创的⻓度 ⽤1字节存储
uint8_t alloc; //已经分配的总⻓度 ⽤1字节存储
unsigned char flags; //flag⽤3bit来标明类型,类型后续
解释,其余5bit⽬前没有使⽤ embstr raw
char buf[]; //柔性数组,以'\0'结尾
};
struct __attribute__ ((__packed__)) sdshdr16 { // 对应的字符串⻓度⼩于 1<<16
uint16_t len; /*已使⽤⻓度,⽤2字节存储*/
uint16_t alloc; /* 总⻓度,⽤2字节存储*/
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 { // 对应的字符串⻓度⼩于 1<<32
uint32_t len; /*已使⽤⻓度,⽤4字节存储*/
uint32_t alloc; /* 总⻓度,⽤4字节存储*/
unsigned char flags;/* 低3位存储类型, ⾼5位预留 */
char buf[];/*柔性数组,存放实际内容*/
};
struct __attribute__ ((__packed__)) sdshdr64 { // 对应的字符串⻓度⼩于 1<<64
uint64_t len; /*已使⽤⻓度,⽤8字节存储*/
uint64_t alloc; /* 总⻓度,⽤8字节存储*/
unsigned char flags; /* 低3位存储类型, ⾼5位预留 */
char buf[];/*柔性数组,存放实际内容*/
};
flag属性保存的是当前使⽤的SDS类型:
3.5.2 SDS与C字符串的⽐较
获取字符串⻓度:SDS是O(1),C字符串是O(n)
缓冲区溢出:使⽤C字符串的API时,如果字符串⻓度增加(如strcat操作)⽽忘记重新分配内存,很容易造成缓冲区的溢出;⽽SDS由于记录了⻓度,相应的API在可能造成缓冲区溢出时会⾃动重新分配内存,杜绝了缓冲区溢出。
修改字符串时内存的重分配:对于C字符串,如果要修改字符串,必须要重新分配内存(先释放再申请),因为如果没有重新分配,字符串⻓度增⼤时会造成内存缓冲区溢出,字符串⻓度减⼩时会造成内存泄露。⽽对于SDS,由于可以记录len和free,因此解除了字符串⻓度和空间数组⻓度之间的关联,可以在此基础上进⾏优化:空间预分配策略(即分配内存时⽐实际需要的多)使得字符串⻓度增⼤时重新分配内存的概率⼤⼤减⼩;惰性空间释放策略使得字符串⻓度减⼩时重新分配内存的概率⼤⼤减⼩。
存取⼆进制数据:SDS可以,C字符串不可以。因为C字符串以空字符作为字符串结束的标识,⽽对于⼀些⼆进制⽂件(如图⽚等),内容可能包括空字符串,因此C字符串⽆法正确存取;⽽SDS以字符串⻓度len来作为字符串结束标识,因此没有这个问题。
四、Redis的对象类型与内存编码
Redis⽀持5种对象类型,⽽每种结构都有⾄少两种编码;
这样做的好处在于:
⼀⽅⾯接⼝与实现分离,当需要增加或改变内部编码时,⽤户使⽤不受影响;
另⼀⽅⾯可以根据不同的应⽤场景切换内部编码,提⾼效率。
Redis各种对象类型⽀持的内部编码如下图所示(只列出重点的):
4.1 字符串
4.1.1 概况
字符串是最基础的类型,因为所有的键都是字符串类型,且字符串之外的其他⼏种复杂类型的元素也是字符串。字符串⻓度不能超过512MB。
4.1.2 内部编码
字符串类型的内部编码有3种,它们的应⽤场景如下:
int:8个字节的⻓整型。字符串值是整型时,这个值使⽤long整型表示。
embstr:<=44字节的字符串。embstr与raw都使⽤redisObject和sds保存数据,区别在于,embstr的使⽤只分配⼀次内存空间(因此redisObject和sds是连续的),⽽raw需要分配两次内存空间(分别为redisObject和sds分配空间)。因此与raw相⽐,embstr的好处在于创建时少分配⼀次空间,删除时少释放⼀次空间,以及对象的所有数据连在⼀起,寻找⽅便。⽽embstr的坏处也很明显,如果字符串的⻓度增加需要重新分配内存时,整个redisObject和sds都需要重新分配空间,因此redis中的embstr实现为只读。
raw:⼤于44个字节的字符串
3.2之后 embstr和raw进⾏区分的⻓度,是44;是因为redisObject的⻓度是16字节,sds的⻓度是4+字符串⻓度;因此当字符串⻓度是44时,embstr的⻓度正好是16+4+44 =64,jemalloc正好可以分配64字节的内存单元。
3.2 之前embstr和raw进⾏区分的⻓度,是39,因为redisObject的⻓度是16字节,sds的⻓度是9+字符串⻓度;因此当字符串⻓度是39时,embstr的⻓度正好是16+9+39 =64,jemalloc正好可以分配64字节的内存单元。
4.2 列表
4.2.1 概况
列表(list)⽤来存储多个有序的字符串,每个字符串称为元素;
⼀个列表可以存储2^32-1个元素。
Redis中的列表⽀持两端插⼊和弹出,并可以获得指定位置(或范围)的元素,可以充当数组、队列、栈等。
4.2.2 内部编码
Redis3.0之前列表的内部编码可以是压缩列表(ziplist)或双端链表(linkedlist)。选择的折中⽅案是两种数据类型的转换,但是在3.2版本之后 因为转换也是个费时且复杂的操作,引⼊了⼀种新的数据格式,结合了双向列表linkedlist和ziplist的特点,称之为quicklist。所有的节点都⽤quicklist存储,省去了到临界条件是的格式转换。
(1) 压缩列表
当⼀个列表只包含少量列表项时,并且每个列表项时⼩整数值或短字符串,那么Redis会使⽤压缩列表来做该列表的底层实现。
压缩列表(ziplist)是Redis为了节省内存⽽开发的,是由⼀系列特殊编码的连续内存块组成的顺序型数据结构,⼀个压缩列表可以包含任意多个节点(entry),每个节点可以保存⼀个字节数组或者⼀个整数值。放到⼀个连续内存区
previous_entry_length: 记录压缩列表前⼀个字节的⻓度。
encoding:节点的encoding保存的是节点的content的内容类型
content:content区域⽤于保存节点的内容,节点内容类型和⻓度由encoding决定。
(2) 双向链表
双向链表(linkedlist):由⼀个list结构和多个listNode结构组成;
通过图中可以看出,双端链表同时保存了表头指针和表尾指针,并且每个节点都有指向前和指向后的指针;链表中保存了列表的⻓度;dup、free和match为节点值设置类型特定函数,所以链表可以⽤于保存各种不同类型的值。⽽链表中每个节点指向的是type为字符串的redisObject。
(3) 快速列表
简单的说,我们仍旧可以将其看作⼀个双向列表,但是列表的每个节点都是⼀个ziplist,其实就是linkedlist和ziplist的结合。quicklist中的每个节点ziplist都能够存储多个数据元素。
Redis3.2开始,列表采⽤quicklist进⾏编码。
//32byte 的空间
typedef struct quicklist {
// 指向quicklist的头部
quicklistNode *head;
// 指向quicklist的尾部
quicklistNode *tail;
// 列表中所有数据项的个数总和
unsigned long count;
// quicklist节点的个数,即ziplist的个数
unsigned int len;
// ziplist⼤⼩限定,由list-max-ziplist-size给定
// 表示不⽤整个int存储fill,⽽是只⽤了其中的16位来存储
int fill : 16;
// 节点压缩深度设置,由list-compress-depth给定
unsigned int compress : 16;
} quicklist;
typedef struct quicklistNode {
struct quicklistNode *prev; // 指向上⼀个ziplist节点
struct quicklistNode *next; // 指向下⼀个ziplist节点
unsigned char *zl; // 数据指针,如果没有被压缩,就指向ziplist结构,反之指向 quicklistLZF结构
unsigned int sz; // 表示指向ziplist结构的总⻓度(内存占⽤⻓度)
unsigned int count : 16; // 表示ziplist中的数据项个数
unsigned int encoding : 2; // 编码⽅式,1--ziplist,2--quicklistLZF
unsigned int container : 2; // 预留字段,存放数据的⽅式,1--NONE,2--ziplist
unsigned int recompress : 1; // 解压标记,当查看⼀个被压缩的数据时,需要暂时解压,标记此参数为1,之后再重新进⾏压缩
unsigned int attempted_compress : 1; // 测试相关
unsigned int extra : 10; // 扩展字段,暂时没⽤
} quicklistNode;
4.3 哈希
4.3.1 概况
哈希(作为⼀种数据结构),不仅是Redis对外提供的5种对象类型的⼀种(与字符串、列表、集合、有序结合并列),也是Redis作为Key-Value数据库所使⽤的数据结构。为了说明的⽅便,后⾯当使⽤“内层的哈希”时,代表的是Redis对外提供的5种对象类型的⼀种;使⽤“外层的哈希”代指Redis作为Key-Value数据库所使⽤的数据结构。
4.3.2 内部编码
内层的哈希使⽤的内部编码可以是压缩列表(ziplist)和哈希表(hashtable)两种;Redis的外层的哈希则只使⽤了hashtable。
压缩列表前⾯已介绍。与哈希表相⽐,压缩列表⽤于元素个数少、元素⻓度⼩的场景;其优势在于集中存储,节省空间;同时,虽然对于元素的操作复杂度也由O(1)变为了O(n),但由于哈希中元素数量较少,因此操作的时间并没有明显劣势。
hashtable:⼀个hashtable由1个dict结构、2个dictht结构、1个dictEntry指针数组(称为bucket)和多个dictEntry结构组成。
正常情况下(即hashtable没有进⾏rehash时)各部分关系如下图所示:
(1) dict
⼀般来说,通过使⽤dictht和dictEntry结构,便可以实现普通哈希表的功能;但是Redis的实现中,在dictht结构的上层,还有⼀个dict结构。下⾯说明dict结构的定义及作⽤。
dict结构如下:
typedef struct dict{
dictType *type; // type⾥⾯主要记录了⼀系列的函数,可以说是规定了⼀系列的接⼝
void *privdata; // privdata保存了需要传递给那些类型特定函数的可选参数
//两张哈希表
dictht ht[2];//便于渐进式rehash
int trehashidx; //rehash 索引,并没有rehash时,值为 -1
//⽬前正在运⾏的安全迭代器的数量
int iterators;
} dict;
其中,type属性和privdata属性是为了适应不同类型的键值对,⽤于创建多态字典
ht属性和trehashidx属性则⽤于rehash,即当哈希表需要扩展或收缩时使⽤。ht是⼀个包含两个项的数组,每项都指向⼀个dictht结构,这也是Redis的哈希会有1个dict、2个dictht结构的原因。通常情况下,所有的数据都是存在放dict的ht[0]中,ht[1]只在rehash的时候使⽤。dict进⾏rehash操作的时候,将ht[0]中的所有数据rehash到ht[1]中。然后将ht[1]赋值给ht[0],并清空ht[1]。
因此,Redis中的哈希之所以在dictht和dictEntry结构之外还有⼀个dict结构,⼀⽅⾯是为了适应不同类型的键值对,另⼀⽅⾯是为了rehash。
(2) dictht
dictht结构如下:
typedef struct dictht{
//哈希表数组,每个元素都是⼀条链表
dictEntry **table;
//哈希表⼤⼩
unsigned long size;
// 哈希表⼤⼩掩码,⽤于计算索引值
// 总是等于 size - 1
unsigned long sizemask;
// 该哈希表已有节点的数量
unsigned long used;
}dictht;
其中,各个属性的功能说明如下:
table属性是⼀个指针,指向bucket;
size属性记录了哈希表的⼤⼩,即bucket的⼤⼩;
used记录了已使⽤的dictEntry的数量;
sizemask属性的值总是为size-1,这个属性和哈希值⼀起决定⼀个键在table中存储的位置。
(3) bucket
bucket是⼀个数组,数组的每个元素都是指向dictEntry结构的指针。Redis中bucket数组的⼤⼩计算规则如下:⼤于dictEntry的、最⼩的2^n;
例如,如果有1000个dictEntry,那么bucket⼤⼩为1024;如果有1500个dictEntry,则bucket⼤⼩为2048。n%32 = n&(32-1)
(4) dictEntry
dictEntry结构⽤于保存键值对,结构定义如下:
// 键
typedef struct dictEntry{
void *key;
union{ //值v的类型可以是以下三种类型
void *val;
uint64_tu64;
int64_ts64;
}v;
// 指向下个哈希表节点,形成链表
struct dictEntry *next;
}dictEntry;
其中,各个属性的功能如下:
key:键值对中的键
val:键值对中的值,使⽤union(即共⽤体)实现,存储的内容既可能是⼀个指向值的指针,也可能是64位整型,或⽆符号64位整型;
next:指向下⼀个dictEntry,⽤于解决哈希冲突问题
在64位系统中,⼀个dictEntry对象占24字节(key/val/next各占8字节)。
4.3.3 编码转换
如前所述,Redis中内层的哈希既可能使⽤哈希表,也可能使⽤压缩列表。
只有同时满⾜下⾯两个条件时,才会使⽤压缩列表:
哈希中元素数量⼩于512个;
哈希中所有键值对的键和值字符串⻓度都⼩于64字节。
下图展示了Redis内层的哈希编码转换的特点:
4.4 集合
4.4.1 概况
集合(set)与列表类似,都是⽤来保存多个字符串,但集合与列表有两点不同:集合中的元素是⽆序的,因此不能通过索引来操作元素;集合中的元素不能有重复。
⼀个集合中最多可以存储2^32-1个元素;除了⽀持常规的增删改查,Redis还⽀持多个集合取交集、并集、差集。
4.4.2 内部编码
集合的内部编码可以是整数集合(intset)或哈希表(hashtable)。
哈希表前⾯已经讲过,这⾥略过不提;需要注意的是,集合在使⽤哈希表时,值全部被置为null。
整数集合的结构定义如下:
typedef struct intset{
uint32_t encoding; // 编码⽅式
uint32_t length; // 集合包含的元素数量
int8_t contents[]; // 保存元素的数组
} intset;
其中,encoding代表contents中存储内容的类型,虽然contents(存储集合中的元素)是int8_t类型,但实际上其存储的值是int16_t、int32_t或int64_t,具体的类型便是由encoding决定的;
length表示元素个数。
整数集合适⽤于集合所有元素都是整数且集合元素数量较⼩的时候,与哈希表相⽐,整数集合的优势在于集中存储,节省空间;同时,虽然对于元素的操作复杂度也由O(1)变为了O(n),但由于集合数量较少,因此操作的时间并没有明显劣势。
4.4.3 编码转换
只有同时满⾜下⾯两个条件时,集合才会使⽤整数集合:
集合中元素数量⼩于512个;
集合中所有元素都是整数值。
如果有⼀个条件不满⾜,则使⽤哈希表;且编码只可能由整数集合转化为哈希表,反⽅向则不可能。下图展示了集合编码转换的特点:
4.5 有序集合
4.5.1 概况
有序集合与集合⼀样,元素都不能重复;但与集合不同的是,有序集合中的元素是有顺序的。与列表使⽤索引下标作为排序依据不同,有序集合为每个元素设置⼀个分数(score)作为排序依据。
4.5.2 内部编码
有序集合的内部编码可以是压缩列表(ziplist)或跳跃表(skiplist)。ziplist在列表和哈希中都有使⽤,前⾯已经讲过,这⾥略过不提。
跳跃表是⼀种有序数据结构,通过在每个节点中维持多个指向其他节点的指针,从⽽达到快速访问节点的⽬的。除了跳跃表,实现有序数据结构的另⼀种典型实现是平衡树;⼤多数情况下,跳跃表的效率可以和平衡树媲美,且跳跃表实现⽐平衡树简单很多,因此redis中选⽤跳跃表代替平衡树。跳跃表⽀持平均O(logN)、最坏O(N)的复杂点进⾏节点查找,并⽀持顺序操作。
4.5.3 编码转换
Redis的跳跃表实现由zskiplist和zskiplistNode两个结构组成:前者⽤于保存跳跃表信息(如头结点、尾节点、⻓度等),后者⽤于表示跳跃表节点。
只有同时满⾜下⾯两个条件时,才会使⽤压缩列表:
1)有序集合中元素数量⼩于128个;
2)有序集合中所有成员⻓度都不⾜64字节。
如果有⼀个条件不满⾜,则使⽤跳跃表;且编码只可能由压缩列表转化为跳跃表,反⽅向则不可能。
下图展示了有序集合编码转换的特点:
4.5.4 跳跃表
(1) 数据结构定义
有许多数据结构的定义其实是按照(结点+组织⽅式)来的,结点就是⼀个数据点,组织⽅式就是把结点组织起来形成数据结构,⽐如 双端链表 (ListNode+list)、字(dictEntry+dictht+dict)等,今天所说的SkipList其实也⼀样,我们⾸先看下它的 结点 定义:
typedef struct zskiplistNode {
sds ele; //数据域
double score; //分值
struct zskiplistNode *backward; //后向指针,使得跳表第⼀层组织为双向链表
struct zskiplistLevel { //每⼀个结点的层级
struct zskiplistNode *forward; //某⼀层的前向结点
unsigned int span; //某⼀层距离下⼀个结点的跨度
} level[]; //level本身是⼀个柔性数组,最⼤值为32,由ZSKIPLIST_MAXLEVEL 定义
} zskiplistNode;
接下来是组织⽅式,即使⽤上⾯的 zskiplistNode 组织起⼀个 SkipList :
typedef struct zskiplist {
struct zskiplistNode *header; //头部
struct zskiplistNode *tail; //尾部
unsigned long length; //⻓度,即⼀共有多少个元素
int level; //最⼤层级,即跳表⽬前的最⼤层级
} zskiplist;
核⼼的数据结构就是上⾯两个。
(2) 具体图表示
普通单向链表:
跳跃表(跳表):
查询
查找⼀个节点时,我们只需从⾼层到低层,⼀个个链表查找,每次找到该层链表中⼩于等于⽬标节点的 最⼤节点,直到找到为⽌。由于⾼层的链表迭代时会“ 跳过 ” 低层的部分节点,所以跳跃表会⽐正常的链 表查找少查部分节点,这也是skiplist 名字的由来。
例如:
查找 46 : 55---21---55--37--55--46
(4) 插⼊
L1 层
概率算法
在此还是以上图为例:跳跃表的初试状态如下图,表中没有⼀个元素:
如果我们要插⼊元素2,⾸先是在底部插⼊元素2,如下图:
然后我们抛硬币,结果是正⾯,那么我们要将2插⼊到L2层,如下图:
继续抛硬币,结果是反⾯,那么元素 2 的插⼊操作就停⽌了,插⼊后的表结构就是上图所示。接下来,我 们插⼊元素33 ,跟元素 2 的插⼊⼀样,现在 L1 层插⼊ 33 ,如下图:
然后抛硬币,结果是反⾯,那么元素 33 的插⼊操作就结束了,插⼊后的表结构就是上图所示。接下来, 我们插⼊元素55 ,⾸先在 L1 插⼊ 55 ,插⼊后如下图:
然后抛硬币,结果是正⾯,那么L2层需要插⼊55,如下图:
继续抛硬币,结果⼜是正⾯,那么L3层需要插⼊55,如下图:
继续抛硬币,结果⼜是正⾯,那么要在L4插⼊55,结果如下图:
继续抛硬币,结果是反⾯,那么55的插⼊结束,表结构就如上图所示。
以此类推,我们插⼊剩余的元素。当然因为规模⼩,结果很可能不是⼀个理想的跳跃表。但是如果元素 个数n 的规模很⼤,学过概率论的同学都知道,最终的表结构肯定⾮常接近于理想跳跃表(隔⼀个⼀跳)。
(5) 删除
直接删除元素,然后调整⼀下删除元素后的指针即可。跟普通的链表删除操作完全⼀样。
typedef struct zskiplistNode {
//层
struct zskiplistLevel{
//前进指针 后边的节点
struct zskiplistNode *forward;
//跨度
unsigned int span;
}level[];
//后退指针
struct zskiplistNode *backward;
//分值
double score;
//成员对象
robj *obj;
} zskiplistNode
--链表
typedef struct zskiplist{
//表头节点和表尾节点
structz skiplistNode *header, *tail;
//表中节点的数量
unsigned long length;
//表中层数最⼤的节点的层数
int level;
}zskiplist;
①、搜索:从最⾼层的链表节点开始,如果⽐当前节点要⼤和⽐当前层的下⼀个节点要⼩,那么则往下 找,也就是和当前层的下⼀层的节点的下⼀个节点进⾏⽐较,以此类推,⼀直找到最底层的最后⼀个节 点,如果找到则返回,反之则返回空。
②、插⼊:⾸先确定插⼊的层数,有⼀种⽅法是假设抛⼀枚硬币,如果是正⾯就累加,直到遇⻅反⾯为 ⽌,最后记录正⾯的次数作为插⼊的层数。当确定插⼊的层数k 后,则需要将新元素插⼊到从底层到 k 层。
③、删除:在各个层中找到包含指定值的节点,然后将节点从链表中删除即可,如果删除以后只剩下头 尾两个节点,则删除这⼀层。
五、Redis 设计优化
5.1 估算Redis内存使⽤量
要估算 redis 中的数据占据的内存⼤⼩,需要对 redis 的内存模型有⽐较全⾯的了解,包括 hashtable、 sds 、 redisobject 、各种对象类型的编码⽅式等。
下⾯以最简单的字符串类型来进⾏说明。
假设有 90000 个键值对,每个 key 的⻓度是 12 个字节 ,每个 value 的⻓度也是 12 个字节 (且 key 和 value都不是整数);
下⾯来估算这 90000 个键值对所占⽤的空间。在估算占据空间之前,⾸先可以判定字符串类型使⽤的编码⽅式:embstr 。
90000 个键值对占据的内存空间主要可以分为两部分: ⼀部分是 90000 个 dictEntry 占据的空间;⼀部分 是键值对所需要的bucket 空间。
每个 dictEntry 占据的空间包括:
(1) ⼀个 dictEntry 结构, 24 字节, jemalloc 会分配 32 字节的内存块 ( 64 位操作系统下,⼀个指针 8 字 节,⼀个dictEntry 由三个指针组成 )
(2) ⼀个 key , 12 字节,所以 SDS(key) 需要 12+4=16 个字节( [SDS 的⻓度 =4+ 字符串⻓度), jemalloc 会分配16 字节的内存块
(3) ⼀个 redisObject , 16 字节, jemalloc 会分配 16 字节的内存块 ( 4bit+4bit+24bit+4Byte+8Byte=16Byte )
(4) ⼀个 value , 12 字节,所以 SDS(value) 需要 12+4=16 个字节( [SDS 的⻓度 =4+ 字符串⻓度), jemalloc会分配 16 字节的内存块
(5) 综上,⼀个 dictEntry 所占据的空间需要 32+16+16+16=80 个字节。
bucket 空间:
bucket 数组的⼤⼩为⼤于 90000 的最⼩的 2^n ,是 131072 ;每个 bucket 元素( bucket 中存储的都是指针元素 )为 8 字节( 因为 64 位系统中指针⼤⼩为 8 字节 )。
因此,可以估算出这 90000 个键值对占据的内存⼤⼩为: [90000*80 + 131072*8 = 8248576
作为对⽐ 将 key 和 value 的⻓度由 12 字节增加到 13 字节 ,则对应的 SDS 变为 17 个字节, jemalloc 会 分配32 个字节,因此每个 dictEntry 占⽤的字节数也由 80 字节变为 112 字节。此时估算这 90000 个键 值对占据内存⼤⼩为: 90000*112 + 131072*8 = 11128576 。
5.2 优化内存占用
了解 redis 的内存模型,对优化 redis 内存占⽤有很⼤帮助。下⾯介绍⼏种优化场景和⽅式
5.2.1 利用jemalloc特性进行优化
上⼀⼩节所讲述的 90000 个键值便是⼀个例⼦。由于 jemalloc 分配内存时数值是不连续的,因此
key/value 字符串变化⼀个字节,可能会引起占⽤内存很⼤的变动;在设计时可以利⽤这⼀点。
例如,如果 key 的⻓度如果是 13 个字节,则 SDS 为 17 字节, jemalloc 分配 32 字节;此时将 key ⻓度 缩减为12 个字节,则 SDS 为 16 字节, jemalloc 分配 16 字节;则每个 key 所占⽤的空间都可以缩⼩⼀ 半。
5.2.2 使用整型/长整型
如果是整型 / ⻓整型, Redis 会使⽤ int 类型(8字节)存储来代替字符串,可以节省更多空间。因此在可以 使⽤⻓整型/ 整型代替字符串的场景下,尽量使⽤⻓整型 / 整型
5.2.3 共享对象
利⽤共享对象,可以减少对象的创建(同时减少了 redisObject 的创建),节省内存空间。⽬前 redis 中的 共享对象只包括10000 个整数( 0-9999 );可以通过调整 OBJ_SHARED_INTEGERS 参数提⾼共享对象的个数;
5.2.4 缩短键值对的存储长度
键值对的⻓度是和性能成反⽐的,⽐如我们来做⼀组写⼊数据的性能测试,执⾏结果如下:
从以上数据可以看出,在 key 不变的情况下, value 值越⼤操作效率越慢,因为 Redis 对于同⼀种数据 类型会使⽤不同的内部编码进⾏存储,⽐如字符串的内部编码就有三种:int (整数编码)、 raw (优化 内存分配的字符串编码)、embstr (动态字符串编码),这是因为 Redis 的作者是想通过不同编码实现 效率和空间的平衡,然⽽数据量越⼤使⽤的内部编码就越复杂,⽽越是复杂的内部编码存储的性能就越低。
这还只是写⼊时的速度,当键值对内容较⼤时,还会带来另外⼏个问题:
内容越⼤需要的持久化时间就越⻓,需要挂起的时间越⻓, Redis 的性能就会越低;
内容越⼤在⽹络上传输的内容就越多,需要的时间就越⻓,整体的运⾏速度就越低;
内容越⼤占⽤的内存就越多,就会更频繁的触发内存淘汰机制,从⽽给 Redis 带来了更多的运⾏负 担。
因此在保证完整语义的同时,我们要尽量的缩短键值对的存储⻓度,必要时要对数据进⾏序列化和压缩再存储,以 Java 为例,序列化我们可以使⽤ protostuff 或 kryo ,压缩我们可以使⽤ snappy 。
六、Reids 内存⽤量统计
查看 Redis 内存统计
127.0.0.1:6379> info memory
# Memory
#Redis分配的内存总量,包括虚拟内存(字节)
used_memory:853464
#占操作系统的内存,不包括虚拟内存(字节)
used_memory_rss:12247040
#内存碎⽚⽐例 如果⼩于1说明使⽤了虚拟内存
mem_fragmentation_ratio:15.07
#内存碎⽚字节数
mem_fragmentation_bytes
#Redis使⽤的内存分配器
mem_allocator:jemlloc-5.1.0
6.1 used_memory
由 Redis 内存分配器 分配的 数据内存 和 缓冲内存 的内存总量(单位是字节),包括使⽤的虚拟内存(即 swap) used_memory_human 只是显示更加⼈性化。
6.2 used_memory_rss
记录的是由 操作系统分配 的 Redis 进程内存 和 Redis 内存中⽆法再被 jemalloc 分配的 内存碎⽚
(单位是字节)。
used_memory和used_memory_rss的区别:
前者是从 Redis ⻆度得到的量,后者是从操作系统⻆度得到的量。⼆者之所以有所不同,⼀⽅⾯是 因为内存碎⽚和Redis 进程运⾏需要占⽤内存,使得前者可能⽐后者⼩,另⼀⽅⾯虚拟内存的存在,使得前者可能⽐后者⼤。
由于在实际应⽤中, Redis 的数据量会⽐较⼤,此时进程运⾏占⽤的内存与 Redis 数据量和内存碎⽚相⽐,都会⼩得多;因此used_memory_rss 和 used_memory 的⽐例,便成了衡量 Redis 内存碎⽚率的参 数;这个参数就是mem_fragmentation_ratio 。
6.3 mem_fragmentation_ratio
内存碎⽚⽐率 ,该值是 used_memory_rss / used_memory 的⽐值。
mem_fragmentation_ratio ⼀般⼤于 1 ,且 该值越⼤,内存碎⽚⽐例越⼤。
mem_fragmentation_ratio<1 ,说明 Redis 使⽤了虚拟内存,由于虚拟内存的媒介是磁盘,⽐内存速度 要慢很多, 当这种情况出现时,应该及时排查,如果内存不⾜应该及时处理,如增加 Redis 节点、增加 Redis服务器的内存、优化应⽤等 。
⼀般来说, mem_fragmentation_ratio 在 1.03 左右是⽐较健康的状态(对于 jemalloc 来说);刚开始的 mem_fragmentation_ratio值很⼤,是因为还没有向 Redis 中存⼊数据, Redis 进程本身运⾏的内存使得 used_memory_rss ⽐ used_memory ⼤得多。
6.4 mem_allocator
Redis 使⽤的内存分配器,在编译时指定;可以是 libc 、 jemalloc 或者 tcmalloc , 默认是 jemalloc ;
七、Redis内存划分
Redis 作为内存数据库,在内存中存储的内容主要是数据(键值对);通过前⾯的叙述可以知道,除了数 据以外,Redis 的其他部分也会占⽤内存。
Redis 的内存占⽤主要可以划分为以下⼏个部分:
7.1 数据内存
作为数据库,数据是最主要的部分;这部分占⽤的内存会统计在used_memory中。
Redis 使⽤键值对存储数据,其中的值(对象)包括 5 种类型,即字符串、哈希、列表、集合、有序集 合。这5 种类型是 Redis 对外提供的,实际上,在 Redis 内部,每种类型可能有 2 种或更多的内部编码实 现;此外,Redis 在存储对象时,并不是直接将数据扔进内存,⽽是会对对象进⾏各种包装:如 redisObject、 SDS 等;
7.2 进程内存
Redis 主进程本身运⾏肯定需要占⽤内存,如代码、常量池等等;这部分内存 ⼤约⼏兆 ,在⼤多数⽣产环 境中与Redis 数据占⽤的内存相⽐可以忽略。 这部分内存不是由 jemalloc 分配,因此不会统计在 used_memory中。
7.3 缓冲内存
缓冲内存包括客户端缓冲区、复制积压缓冲区、 AOF 缓冲区等;其中,客户端缓冲存储客户端连接的输 ⼊输出缓冲;复制积压缓冲⽤于部分复制功能;AOF 缓冲区⽤于在进⾏ AOF 重写时,保存最近的写⼊命 令。在了解相应功能之前,不需要知道这些缓冲的细节; 这部分内存由 jemalloc 分配,因此会统计在 used_memory中 。
7.4 内存碎⽚
内存碎⽚是 Redis 在分配、回收物理内存过程中产⽣的。 例如,如果对数据的更改频繁,⽽且数据之间的 ⼤⼩相差很⼤,可能导致redis 释放的空间在物理内存中并没有释放,但 redis ⼜⽆法有效利⽤,这就形成 了内存碎⽚。 内存碎⽚不会统计在 used_memory 中。
内存碎⽚的产⽣与对数据进⾏的操作、数据的特点等都有关;此外,与使⽤的内存分配器也有关系:如 果内存分配器设计合理,可以尽可能的减少内存碎⽚的产⽣。jemalloc 便在控制内存碎 ⽚⽅⾯做的很好。
如果 Redis 服务器中的内存碎⽚已经很⼤,可以通过安全重启的⽅式减⼩内存碎⽚:因为重启之后, Redis重新从备份⽂件中读取数据,在内存中进⾏重排,为每个数据重新选择合适的内存单元,减⼩内存碎⽚。