redis内存占用估算

Jan 17, 2023


前言

Redis作为目前最流行的高速缓存组件,几乎所有大型分布式系统都离不开它,在实际使用redis的过程中,我们很少精确的去估算业务服务占用redis的内存,而是直接使用,等到出现内存不足的时候才开始考虑扩容。

这种做法实际上是节约前期的设计和调研成本,转而耗费更大的更大的后期运维成本。

在Redis的几种部署模式中,只有cluster模式可以横向扩容,单机部署和哨兵模式的扩容都需要停机修改服务器的物理内存才能实现扩容,成本巨大。

因此对于无法预估缓存占用的服务强烈建议使用集群模式,当然本文为了让我们能够更精确的预估内存占用,本文我们将着重讨论Redis的内存占用估算方法。

本文中所有的内容基于Redis 6.2.9版本讨论,不过五种常见数据类型的存储结构在各个版本间差异不大,可以作为各个版本的内存占用估算参考依据。

Redis的存储结构

Redis目前常用的共有5中数据类型,String,Hash,List,Set,ZSet

还有一些不常用的数据类型:

数据类型 引入版本 用途
HyperLogLog 2.8.9 用于估算集合基数
Streams 5.0 存储流数据,用于支持订阅发布
Geospatial 3.2 存储坐标信息
Bitmap 2.2.0 把string的key当成一个位向量使用,可以用于高效设置一个整数集合或者用于对象权限存储

在本文我们只讨论5中常见数据类型的存储空间评估方法。

Redis的存储结构

在讨论每种类型的数据结构在Redis中存储的方式之前,我们先看看Redis整体的存储逻辑结构是怎么样的,如下图:

最顶层的redisServer是一个抽象概念,可以理解为一个redis实例,只有在集群中才会有多个redisServer。 第二层是redisDB,是一个Redis实例中划分的逻辑区域(在cluster模式下不可用),默认情况下是16个DB。 第三层是dict,一个字典表,是数据真正存储的结构 第四层是dictht,在一个dict里有两个dictht,一般情况下只用一个,第二个dictht是在rehash的时候使用 第五层是dictEntry,就是保存每一个键值对的对象,这里key是一个sds对象,value是一个redisObject对象。

由上图我们知道,Redis中存储一个键值对的原子对象是dictEntry,这个结构体的定义如下:

typedef struct dictEntry {
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next;
} dictEntry;

包括一个key,一个代表value的共用体,还有一个指向下一个dictEntry的指针。

这里使用共用体表示value,是因为不同类型的value存储的方式不一样,但是无论哪种类型,value在dictEntry中最终存储都是占了8字节。

在dictEntry中,key和value存储的字符串值实际上是一个指针,对于key,一般是string,指向的是Redis定义的sds(simple dynamic string)对象,对于value,则是存储为redisObject这个结构体的对象,如下图:

dictEntry存储结构

我们来看下reidsObject的定义:

typedef struct redisObject {
    unsigned type:4;
    unsigned encoding:4;
    unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
                            * LFU data (least significant 8 bits frequency
                            * and most significant 16 bits access time). */
    int refcount;
    void *ptr;
} robj;
  • type用于存储value的类型
  • encoding用于存储value的编码方式,在redis中,五种常见类型的数据都是以sds的方式存储的,只是不同场景下的编码方式有差异,因此这里用encoding表示编码方式
  • refcount是一个引用计数器,对于0-9999范围内的数字类型数据,会用内置的数值型对象代替redisObject和sds,这时对共享对象的计数器进行+1操作,有利于节省内存
  • ptr是一个指针对象,指向真正存储value的sds对象

现在我们可以看一下sds的定义了,在redis中,定义了sdshdr5,sdshdr8,sdshdr16,sdshdr32,sdshdr64几个结构体,用于支持不同长度的字符串,其中sdshdr5比较特殊,只在key中使用,value最小使用sdshdr8。 通常情况下,redis需要尽量避免使用过长的key和value,因此最常用的是sdshdr8,这里我们只讨论value为sdshdr8的情况,但是key则需要讨论sdshdr5和sdshdr8的情况,下面是这两个结构体的定义:

struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
    char buf[];
};

struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* used */
    uint8_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};

sdshdr5中:

  • flags是一个标志符,低3位用于表示类型,高5位用于表示buf的长度,最大可以表示长度为2^7-1=31的数组
  • buf[]是实际存储值的数组

sdshdr8中:

  • len表示当前存储的值长度
  • alloc表示当前已经申请的内存长度,即buf最大能存储的长度,但是这里不包含终止符\0,因此一个sds实际的占用内存还需要加上一个终止符字节
  • flags是标志位,表示当前数据的类型,虽然是一个char,实际上值用了3个位(3个位足够表示5种数据类型),高位的5个位都为0
  • buf[]数组就是真正存储value的地方

对于sds的内存占用,计算的时候只需要alloc+1即可,因为alloc是已经申请的内存,实际上即使暂时不存储数据,内存也已经占用了,而加1是为了包含sds终止符的长度,Redis采用类似C的做法存储字符串,也就是以\0结尾,\0只作为字符串的定界符,alloc的长度不包含这个,因此需要加1。

到这里我们已经完整的看完redis中一个键值对的存储方式和实现了,现在可以给出一个计算公式来计算一个键值对的空间占用:

          dictEnrty                             (key的长度大于31时)sdshdr8                                                                             redisObject                                                                  value sds
  ┌───────────┼───────────┐           ┌───────────┬───────────┼──────────────┬──────────────────┐            ┌─────────────────┬──────────────────┬─────┴─────────┬──────────────────┬──────────┐          ┌───────────┬───────────┼─────────────────┬─────────────────┐
  |           |           |           |           |           |              |                  |            |                 |                  |               |                  |          |          |           |           |                 |                 |        
key指针    value指针    next指针       len        alloc       flags        buf.length          标志位\0    type占4个bit    encoding占4个bit      lru占24个bit    一个byte是8个bit      refcount    ptr指针       len        alloc       flags           buf.length         标志位\0
  |           |           |           |           |           |              |                  |            |                 |                  |               |                  |          |          |           |           |                 |                 |
  8     +     8     +     8     +     1     +     1     +     1     +   {key_alloc}      +      1     +     (4        +        4           +     24)      /       8         +        4     +    8     +    1     +     1     +     1     +     {value_alloc}     +     1
                                                              1     +   {key_alloc}      +      1
                                                              |              |                  |
                                                               ──────────────┼──────────────────
                                                                (key的长度小于31时)sdshdr5

最终结论需要分key的两种情况讨论: 当key的长度小于31时,redis会采用sdshdr5存储,可以节约一些空间,内存占用计算公式如下:

(46 + {key_alloc} + {value_alloc})(byte)

特别注意,Redis采用Jemalloc内存分配器,每次都以8byte为单位进行分配,因此当我们使用的key长度达到30之后,加上标志位\0flags正好达到32,此时如果继续增加key的长度,Jemalloc会再增加8个byte,达到40个byte,那sdshdr5就无法表示了, 这种情况下redis会直接将key的存储改为sdshdr8,因此如果希望key使用sdshdr5保存,我们实际能使用的最大key长度是30,第31个byte留给了\0标志位。

当key的长度大于31时,sdshdr5已经不足以表示这个长度,redis会采用sdshdr8存储,计算公式如下:

(48 + {key_alloc} + {value_alloc})(byte)

那么问题来了,如何估算{key_alloc}{value_alloc}

{key_alloc}是每个key序列化之后存储的byte数组长度,{value_alloc}是每个value序列化之后存储的长度,这就涉及到不同数据类型的序列化算法了,我们针对每种类型进行分析。

由于key是sds直接存储的,因此{key_alloc}的长度通常就是key的ascii字符数量(如果key使用中文或者其他特殊字符,则需要看序列化之后的数组长度,非常不建议这么做,后续我们讨论都以key是纯ascii字符的情况讨论)。 下面主要讨论value不同类型的情况下的{value_alloc}估算。

String

String类型大概是Redis中使用最广泛的类型了,在我们讨论如何估算String类型的存储空间之前,我们得先了解String类型在Redis中是如何存储的,在Redis中,String类型存储并不是简单的char数组,而是分两种情况存储:

  1. 当字符串可以被解析为int类型时, 定义了一个sds(simple dynamic string)的类型

工具

Redis提供了一个非常简单好用的网页工具,用于帮我们估算业务缓存对Redis的内存占用:

Redis内存占用估算