Redis内存容量评估

发布时间 2023-04-05 21:31:29作者: Yxh_blogs

业务侧申请redis服务器资源时,需要事先对redis容量做一个大致评估,之前的容量评估公式基本只是简单的 (key长度 value长度)* key个数,误差较大,后期经常需要进行缩扩容调整,因此提出一个较精确的redis容量评估模型就显得很有必要。

先来查看一个命令:

info memory
used_memory:847624 
used_memory_human:827.76K
used_memory_rss:2592768
used_memory_rss_human:2.47M
used_memory_peak:882896
used_memory_peak_human:862.20K
used_memory_peak_perc:96.00%
used_memory_overhead:836086
used_memory_startup:786456
used_memory_dataset:11538
used_memory_dataset_perc:18.86%
total_system_memory:16570413056
total_system_memory_human:15.43G
used_memory_lua:37888
used_memory_lua_human:37.00K
maxmemory:0
maxmemory_human:0B
maxmemory_policy:noeviction
mem_fragmentation_ratio:3.06
mem_allocator:jemalloc-4.0.3
active_defrag_running:0
lazyfree_pending_objects:0

看一下这个命令下的一些参数详解

参数 详解
used_memory 由 Redis 分配器分配的内存总量,包含了redis进程内部的开销和数据占用的内存,以字节(byte)为单位
used_memory_human 以更直观的可读格式显示返回使用的内存量。
used_memory_rss rss是Resident Set Size的缩写,表示该进程所占物理内存的大小,是操作系统分配给Redis实例的内存大小。即Redis进程占据操作系统的内存(单位是字节),与top及ps命令看到的值是一致的;除了分配器分配的内存之外,used_memory_rss还包括进程运行本身需要的内存、内存碎片等,但是不包括虚拟内存。

因此,used_memory和used_memory_rss,前者是从Redis角度得到的量,后者是从操作系统角度得到的量。二者之所以有所不同,一方面是因为内存碎片和Redis进程运行需要占用内存,使得前者可能比后者小,另一方面虚拟内存的存在,使得前者可能比后者大。
used_memory_rss_human 以更直观的可读格式显示该进程所占物理内存的大小。
used_memory_peak redis的内存消耗峰值(以字节为单位)
used_memory_peak_human 以更直观的可读格式显示返回redis的内存消耗峰值
used_memory_peak_perc 使用内存达到峰值内存的百分比,即(used_memory/ used_memory_peak) *100%
used_memory_overhead Redis为了维护数据集的内部机制所需的内存开销,包括所有客户端输出缓冲区、查询缓冲区、AOF重写缓冲区和主从复制的backlog。
used_memory_startup Redis服务器启动时消耗的内存
used_memory_dataset 数据占用的内存大小,即used_memory-used_memory_overhead
used_memory_dataset_perc 数据占用的内存大小的百分比,100%*(used_memory_dataset/(used_memory- used_memory_startup))
total_system_memory 整个系统内存
total_system_memory_human 以更直观的可读格式显示整个系统内存
used_memory_lua Lua脚本存储占用的内存
used_memory_lua_human 以更直观的可读格式显示Lua脚本存储占用的内存
maxmemory Redis实例的最大内存配置
maxmemory_human 以更直观的可读格式显示Redis实例的最大内存配置
maxmemory_policy 当达到maxmemory时的淘汰策略
mem_fragmentation_ratio 内存的碎片率,used_memory_rss/used_memory 比值 --4.0版本之后可以使用memory purge手动回收内存
由于在实际应用中,Redis的数据量会比较大,此时进程运行占用的内存与Redis数据量和内存碎片相比,都会小得多;因此used_memory_rss和used_memory的比例便成了衡量Redis内存碎片率的参数;这个参数就是mem_fragmentation_ratio。

mem_fragmentation_ratio一般大于1,且该值越大,内存碎片比例越大。如果mem_fragmentation_ratio小于1,说明Redis使用了虚拟内存,由于虚拟内存的媒介是磁盘,比内存速度要慢很多,当这种情况出现时,应该及时排查,如果内存不足应该及时处理,如增加Redis节点、增加Redis服务器的内存、优化应用等。
mem_allocator Redis使用的内存分配器,在编译时指定,可以是 libc 、jemalloc或者tcmalloc,默认是jemalloc。截图中使用的便是默认的jemalloc。
active_defrag_running 表示没有活动的defrag任务正在运行,1表示有活动的defrag任务正在运行(defrag:表示内存碎片整理)
lazyfree_pending_objectsr 表示redis执行lazy free操作,在等待被实际回收内容的键个数

redis常用数据结构

1.SDS

redis没有直接使用c语言传统的字符串(以空字符为结尾的字符数组),而是自己创建了一种名为SDS(简单动态字符串)的抽象类型,用作redis默认的字符串。

SDS的定义如下(sds.h/sdshdr):

struct sdshdr {
int len; // 记录buf数组中已使用字节的数量
int free; // 记录buf数组中未使用字节的数量
char buf[]; // 字节数组,用于保存实际字符串
}

上图的SDS实例中存储了字符串“Redis”, sdshdr中对应的free长度为5,len长度为5, SDS占用的总字节数为sizeof(int) * 2 + 5 + 5 + 1 = 19。

2.链表

链表在redis中的应用非常广泛,列表键的底层实现之一就是链表。每个链表节点使用一个listNode结构来表示,具体定义如下(adlist.h/listNode):

typedef struct listNode {
    struct listNode *prev;              // 前置节点
    struct listNode *next;              // 后置节点
    void *value;                        // 节点的值
} listNode;

redis另外还使用了list结构来管理链表,以方便操作,具体定义如下(adlist.h/list):

typedef struct list {
    listNode *head;                             // 表头节点
    listNode *tail;                             // 表尾结点
    void *(*dup)(void *ptr);                    // 节点值复制函数
    void (*free)(void *ptr);                    // 节点值释放函数
    int (*match)(void *ptr, void *key);         // 节点值对比函数
    unsigned int len;                           // 链表所包含的节点数量
} list;

listNode结构占用的总字节数为24,list结构占用的总字节数为48。

3.跳跃表

redis采用跳跃表(skiplist)作为有序集合键的底层实现之一,跳跃表是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。跳跃表可以理解为多层的有序双向链表。

zskiplistNode 结构占用的总字节数为(24 16*n),n为level数组的大小。

zskiplist结构则用于保存跳跃表节点的相关信息,header和tail分别指向跳跃表的表头和表尾节点,length记录节点总数量,level记录跳跃表中层高最大的那个节点的层数量。zskiplist结构占用的总字节数为32。

4.字典

字典在redis中的应用很广泛,redis的数据库就是使用字典作为底层实现的,具体数据结构定义如下(dict.h/dict):

typedef struct dict {
    dictType *type;      // 字典类型
    void *privdata;      // 私有数据
    dictht ht[2];        // 哈希表数组
    int rehashidx;       // rehash索引,当不进行rehash时,值为-1
    int iterators;       // 当前该字典迭代器个数
} dict;

type属性和privdata属性是为了针对不同类型的键值对而设置的,此处了解即可。dict中还保存了一个长度为2的dictht哈希表数组,哈希表负责保存具体的键值对,一般情况下字典只使用ht[0]哈希表,只有在rehash时才使用ht[1]。dict结构占用的总节数为88。

5.对象

内存分配规则

jemalloc是一种facebook推出的通用的内存管理方法,着重于减少内存碎片和支持可伸缩的并发性,我们部门的redis版本中就引入了jemalloc,做redis容量评估前必须对jemalloc的内存分配规则有一定了解。除了jemalloc,还有ptmalloc和tcmalloc等等

在最新的Redis2.4.4版本中,jemalloc已经作为源码包的一部分包含在源码包中,所以可以直接被使用。而如果你要使用tcmalloc的话,是需要自己安装的。

jemalloc基于申请内存的大小把内存分配分为三个等级:small,large,huge:

  • Small Object的size以8字节,16字节,32字节等分隔开,小于页大小;
  • Large Object的size以分页为单位,等差间隔排列,小于chunk的大小;
  • Huge Object的大小是chunk大小的整数倍。

对于64位系统,一般chunk大小为4M,页大小为4K,内存分配的具体规则如下:

下面是jemalloc size class categories,左边是用户申请内存范围,右边是实际申请的内存大小

  1 – 4 size class:4   
  5 – 8 size class:8   
  9 – 16 size class:16   
  17 – 32 size class:32   
  33 – 48 size class:48   
  49 – 64 size class:64   
  65 – 80 size class:80   
  81 – 96 size class:96   
  97 – 112 size class:112   
  113 – 128 size class:128   
  129 – 192 size class:192   
  193 – 256 size class:256   
  257 – 320 size class:320   
  321 – 384 size class:384   
  385 – 448 size class:448   
  449 – 512 size class:512   
  513 – 768 size class:768   
  769 – 1024 size class:1024   
  1025 – 1280 size class:1280   
  1281 – 1536 size class:1536   
  1537 – 1792 size class:1792   
  1793 – 2048 size class:2048   
  2049 – 2304 size class:2304   
  2305 – 2560 size class:2560

容量评估

1.string

一个简单的set命令最终会产生4个消耗内存的结构,中间free掉的不考虑

  • 1个dictEntry结构,24字节,负责保存具体的键值对;jemalloc会分配32字节的内存块。
  • 1个redisObject结构,16字节,用作val对象;jemalloc会分配16字节的内存块。
  • 1个SDS结构,(key长度 9)字节,用作key字符串;
  • 1个SDS结构,(val长度 9)字节,用作val字符串;

当key个数逐渐增多,redis还会以rehash的方式扩展哈希表节点数组,即增大哈希表的bucket个数,每个bucket元素都是个指针(dictEntry*),占8字节,bucket个数是超过key个数向上求整的2的n次方。

真实情况下,每个结构最终真正占用的内存还要考虑jemalloc的内存分配规则,综上所述,string类型的容量评估模型为:

总内存消耗 = (dictEntry大小 + redisObject大小 + key_SDS大小 + val_SDS大小)× key个数 + bucket个数 × 指针大小

2.hash

哈希对象的底层实现数据结构可能是zipmap或者hashtable,当同时满足下面这两个条件时,哈希对象使用zipmap这种结构(此处列出的条件都是redis默认配置,可以更改):

  • 哈希对象保存的所有键值对的键和值的字符串长度都小于64字节;
  • 哈希对象保存的键值对的数量都小于512个;

可以看出,业务侧真实使用场景基本都不能满足这两个条件,所以哈希类型大部分都是hashtable结构,与string类型不同的是,hash类型的值对象并不是指向一个SDS结构,而是指向又一个dict结构,dict结构保存了哈希对象具体的键值对,

一个hmset命令最终会产生以下几个消耗内存的结构:

  • 1个dictEntry结构,24字节,负责保存当前的哈希对象;
  • 1个SDS结构,(key长度 9)字节,用作key字符串;
  • 1个redisObject结构,16字节,指向当前key下属的dict结构;
  • 1个dict结构,88字节,负责保存哈希对象的键值对;
  • n个dictEntry结构,24*n字节,负责保存具体的field和value,n等于field个数;
  • n个redisObject结构,16*n字节,用作field对象;
  • n个redisObject结构,16*n字节,用作value对象;
  • n个SDS结构,(field长度 9)*n字节,用作field字符串;
  • n个SDS结构,(value长度 9)*n字节,用作value字符串;

因为hash类型内部有两个dict结构,所以最终会有产生两种rehash,一种rehash基准是field个数,另一种rehash基准是key个数,结合jemalloc内存分配规则,hash类型的容量评估模型为:

总内存消耗 = [(redisObject大小 × 2 field_SDS大小 + val_SDS大小 + dictEntry大小)× field个数 + field_bucket个数 × 指针大小 + dict大小 + redisObject大小 + key_SDS大小 + dictEntry大小 ] × key个数 key_bucket个数 × 指针大小

3.zset

同哈希对象类似,有序集合对象的底层实现数据结构也分两种:ziplist或者skiplist,当同时满足下面这两个条件时,有序集合对象使用ziplist这种结构(此处列出的条件都是redis默认配置,可以更改):

  • 有序集合对象保存的元素数量小于128个;
  • 有序集合保存的所有元素成员的长度都小于64字节;

业务侧真实使用时基本都不能同时满足这两个条件,因此这里只讲skiplist结构的情况。skiplist类型的值对象指向一个zset结构,zset结构同时包含一个字典和一个跳跃表,占用的总字节数为16,具体定义如下(redis.h/zset):

  • 1个dictEntry结构,24字节,负责保存当前的有序集合对象;
  • 1个SDS结构,(key长度 9)字节,用作key字符串;
  • 1个redisObject结构,16字节,指向当前key下属的zset结构;
  • 1个zset结构,16字节,负责保存下属的dict和zskiplist结构;
  • 1个dict结构,88字节,负责保存集合元素中成员到分值的映射;
  • n个dictEntry结构,24*n字节,负责保存具体的成员和分值,n等于集合成员个数;
  • 1个zskiplist结构,32字节,负责保存跳跃表的相关信息;
  • 1个32层的zskiplistNode结构,24 16*32=536字节,用作跳跃表头结点;
  • n个zskiplistNode结构,(24 16m)n字节,用作跳跃表节点,m等于节点层数;
  • n个redisObject结构,16*n字节,用作集合中的成员对象;
  • n个SDS结构,(value长度 9)*n字节,用作成员字符串;

因为每个zskiplistNode节点的层数都是根据幂次定律随机生成的,而容量评估需要确切值,因此这里采用概率中的期望值来代替单个节点的大小,结合jemalloc内存分配规则,经计算,单个zskiplistNode节点大小的期望值为53.336。

zset类型内部同样包含两个dict结构,所以最终会有产生两种rehash,一种rehash基准是成员个数,另一种rehash基准是key个数,zset类型的容量评估模型为:

总内存消耗 = [(val_SDS大小 + redisObject大小 + zskiplistNode大小 + dictEntry大小)× value个数 value_bucket个数 × 指针大小 + 32层zskiplistNode大小 + zskiplist大小 + dict大小 + zset大小 + redisObject大小 key_SDS大小 + dictEntry大小 ] × key个数 + key_bucket个数 × 指针大小

4.list

列表对象的底层实现数据结构同样分两种:ziplist或者linkedlist,当同时满足下面这两个条件时,列表对象使用ziplist这种结构(此处列出的条件都是redis默认配置,可以更改):

  • 列表对象保存的所有字符串元素的长度都小于64字节;
  • 列表对象保存的元素数量小于512个;

因为实际使用情况,这里同样只讲linkedlist结构。

一个rpush或者lpush命令最终会产生以下几个消耗内存的结构:

  • 1个dictEntry结构,24字节,负责保存当前的列表对象;
  • 1个SDS结构,(key长度 9)字节,用作key字符串;
  • 1个redisObject结构,16字节,指向当前key下属的list结构;
  • 1个list结构,48字节,负责管理链表节点;
  • n个listNode结构,24*n字节,n等于value个数;
  • n个redisObject结构,16*n字节,用作链表中的值对象;
  • n个SDS结构,(value长度 9)*n字节,用作值对象指向的字符串;

list类型内部只有一个dict结构,rehash基准为key个数,综上,list类型的容量评估模型为:

总内存消耗 = [(val_SDS大小 + redisObject大小 + listNode大小)× value个数 + list大小 + redisObject大小 + key_SDS大小 + dictEntry大小 ] × key个数 key_bucket个数 × 指针大小

实际操作

以会员登录态存储作为例子:

其存储结构如下:


127.0.0.1:6379> keys *
"gateway:session-group:1:1:2181"
"gateway:1:1:cx_no2XUnt-epl+HShc3qhZV"
"gateway:1:1:EetQpVwcYDvoZ5hF8ETUJqqfjPFz4XqsPFcsO122WKp6MEAsCo3VIRtoiMKD9wD8lxU_33JofzT_Rn38MAlWmA"

127.0.0.1:6379> get gateway:1:1:cx_no2XUnt-epl+HShc3qhZV
"2181|EetQpVwcYDvoZ5hF8ETUJqqfjPFz4XqsPFcsO122WKp6MEAsCo3VIRtoiMKD9wD8lxU_33JofzT_Rn38MAlWmA"

127.0.0.1:6379> get gateway:1:1:EetQpVwcYDvoZ5hF8ETUJqqfjPFz4XqsPFcsO122WKp6MEAsCo3VIRtoiMKD9wD8lxU_33JofzT_Rn38MAlWmA
"2181"

127.0.0.1:6379> ZRANGE gateway:session-group:1:1:2181 0 -1 
"cx_no2XUnt-epl+HShc3qhZV|app.android"

此时假定为:每个人只在一个设备登录一次的情况(没有踢人的状况)。

refresh-token的key长度为36,value91个
access-token的key长度为98,value4个
session-group的key长度为19,元素有2个,长度为36

按照计算来就是

string:
总内存消耗 = (dictEntry大小 + redisObject大小 + key_SDS大小 + val_SDS大小)× key个数 + bucket个数 × 指针大小

  • 一个dictEntry,24字节,jemalloc会分配32字节的内存块。
  • 一个RedisObject,16字节,jemalloc会分配16字节的内存块。
  • 一个key,36字节,所以SDS(key)需要36+9=45个字节,jemalloc会分配48字节的内存块。
  • 一个value,91字节,所以SDS(value)需要91+9=100个字节,jemalloc会分配112字节的内存块。

bucket空间:bucket数组的大小为大于10000的最小的2^14,是16384,每个bucket元素为8字节(因为64位系统中指针大小为8字节)。

(24+16+48+112)×10000+8×16384 = 131072+2000000=2.131072M(线上计算2.197266)
(24+16+112+4)×10000+8×16384 = 131072+1560000=1.691072M(线上计算1.831055)

zset:
总内存消耗 = [(val_SDS大小 + redisObject大小 + zskiplistNode大小 + dictEntry大小)× value个数 value_bucket个数 × 指针大小 + 32层zskiplistNode大小 + zskiplist大小 + dict大小 + zset大小 + redisObject大小 key_SDS大小 + dictEntry大小 ] × key个数 + key_bucket个数 × 指针大小

  • 1个dictEntry结构,24字节,负责保存当前的有序集合对象;
  • 1个SDS结构,(key长度 9)字节,用作key字符串;
  • 1个redisObject结构,16字节,指向当前key下属的zset结构;
  • 1个zset结构,16字节,负责保存下属的dict和zskiplist结构;
  • 1个dict结构,88字节,负责保存集合元素中成员到分值的映射;
  • n个dictEntry结构,24*n字节,负责保存具体的成员和分值,n等于集合成员个数;
  • 1个zskiplist结构,32字节,负责保存跳跃表的相关信息;
  • 1个32层的zskiplistNode结构,24 16*32=536字节,用作跳跃表头结点;
  • n个zskiplistNode结构,(24 16m)n字节,用作跳跃表节点,m等于节点层数;
  • n个redisObject结构,16*n字节,用作集合中的成员对象;
  • n个SDS结构,(value长度 9)*n字节,用作成员字符串;

[(48 + 16 + 53.336 + 32)× 10000 + 16384 × 8 + 640 + 32 + 96 + 16 + 16 + 32 + 32] × 10000 + 16384 × 8 = 16.316610784 (18.001556)

参考链接