Redis内存优化-怎么用缓存支持千亿级数据

我们经常会计算我们有多少钱,每个月吃饭开销多少,交通开销多少,以及其它开销多少,方便管理,避免透支。

在使用Redis时也要有同样的意识,各种数据占用多少内存,要做到心中有数,避免Redis内存不够用,造成问题。

Redis整体内存结构-主要

(1) 2012年微博是怎么用缓存支撑千亿级数据存储的

[WeiDesign]微博计数器的设计(下)

(1.1) 背景

当时(2012年)微博总数量,千亿级而且每秒都在飞速增长中。每条微博都有一个64位的唯一id。

访问量 每秒百万级 还在稳步增长中。根据微博的id来访问。

主要接口 获取评论数 + 获取转发数 。

(1.2) 微博计数服务方案

(1.2.1) 方案一 用MySQL

这里就直接过了,在业务早期数据量小的时候可以用, 详细分析的可以看 计数器设计-数据库方案
MySQL扛不住

(1.2.2) 方案二 用缓存Redis

当时(2012年)的Redis是2.4.16版本
用Redis技术上是可行的,但是 内存利用率太低,太费钱
详细分析的可以看 计数器设计-数据库方案

(1.2.3) 方案三 优化Redis内存占用

针对Redis内存利用率低的问题进行优化,改源码,牺牲扩展性,换取高内存利用率。

改动如下:

  1. 数据结构的优化,改源码,修改dictEntry,把dictEntry结构体里的key v next指针全部废弃,使用业务定制的msg_idrepost_numcomment_num
  2. redis哈希冲突解决办法从链地址法改为开放定址法(双重散列),节省next指针(8字节)开销。
  3. 业务维度,转发数、评论数为0的数据不存储,查不到默认为0,节省内存开销。 因为大量微博(一半以上)没有转发或没有评论,甚至是都没有。
  4. 业务维度,评论数和转发数 存储在一起,避免存2条数据,节省内存开销。 因为微博的评论数和转发数的关联度非常的高。

优化前,存储一条微博评论数 消耗 76 字节
优化前内存占用

优化后,存储一条微博转发数+评论数 消耗 16字节
优化后内存占用


(2) Redis-Server内存使用原理

  1. Redis内存划分
  2. Redis info memory内存统计。
  3. 查看单个key的内存占用 MEMORY USAGE。

(2.1) Redis内存划分

结合 Redis 源码 和 info memory 统计信息,Redis内存划分情况如下

Redis Server info memory

used_memory_rss = used_memory + 其它
操作系统分配的内存 = Redis内存分配器分配的内存 + 其它

used_memory = used_memory_overhead + used_memory_dataset
Redis内存分配器分配的内存大小 = 服务器分配用于管理其内部数据结构的所有开销的总和 + 数据集大小

详细分析见 Redis info


(2.2) Redis info memory内存统计

通过 info命令可以查看Redis Server的基本信息、CPU、内存、持久化、客户端连接信息等等;
通过 info memory 可以查看Redis Server的内存相关信息。

这里列出部分主要的参数,详细所有参数见 Redis info

127.0.0.1:6379> info memory
# Memory
used_memory:153492864
used_memory_human:146.38M
used_memory_rss:163676160
used_memory_rss_human:156.09M
used_memory_overhead:49426784
used_memory_startup:1020736
used_memory_dataset:104066080
used_memory_dataset_perc:68.25%
127.0.0.1:6379>

参数对应说明参考 https://redis.io/commands/info/

参数 含义 Redis启动时对应的值 插入百万数据后对应的值(libc内存分配器) 插入百万数据(jemalloc内存分配器)
used_memory_human Redis使用其分配器(标准libc、jemalloc 或替代分配器,如 tcmalloc)分配的字节总数 1.05M 146.38M 138.72M
used_memory_rss_human 操作系统实际给Redis分配的内存数 2.23M 156.09M 143.95M
used_memory_overhead 服务器分配用于管理其内部数据结构的所有开销的总和(以字节为单位) 1037952 49426784 49421464
used_memory_startup Redis 启动时消耗的初始内存量(以字节为单位) 1020512 1020736 1012352
used_memory_dataset 数据集的大小(以字节为单位)(从used_memory中减去used_memory_overhead) 66080 104066080 96041480
mem_allocator 内存分配器,在编译时选择。 libc libc jemalloc-5.1.0

(2.3) 从数据类型和数据结构维度看Redis内存使用

根据 Redis 6.0 源码整理出的,按照结构体维度的整体内存占用情况

Redis整体内存结构

从上图可以看出,Redis为了扩展性,存储缓存数据的结构体元数据其实很大。

(2.3.1) 小试牛刀

如果要存简单的 用户评论数,用string类型,会耗费多少内存?

如果使用 set key val,key Long类型 8字节,val Int类型 4字节,有效业务数据12字节,但是Redis会耗费77字节

  1. 指向dictEntry的指针,需要8个字节
  2. dictEntry 24字节
  3. 存储key的sdshdr 29字节 (sdshdr64占用9字节 + 字符长度19字节 + 结束字符1字节)
  4. 存储val的16字节 (RedisObject 16字节 + Redis存储ebmstr内存优化,把redisObject的指针ptr直接存储int类型,节省了一个指针的开销)
Redis-set-key-val-ex

有效业务数据 12字节
Redis内存开销 77字节
单条数据内存利用率 12字节 / 77字节 ≈ 0.1558


如果使用 set key val ex 86400000,内存开销会更大

如果考虑上缓存过期,还会在 expires dict 创建一个 key,消耗内存69字节
过期key的内存开销

  1. 指向dictEntry的指针,需要8个字节。
  2. dictEntry 24字节。
  3. 存储key的sdshdr 29字节 (sdshdr64占用9字节 + 字符长度19字节 + 结束字符1字节)。
  4. 存储过期时间的8字节。

Redis-set-key-val-ex

有效业务数据 12字节
Redis内存开销 77字节 + 69字节 = 146字节
单条数据内存利用率 12字节 / 146字节 ≈ 0.0821


如果再考虑上 内存分配器jemalloc 内存分配和内存碎片的开销,数据利用率会更小。

也就是说从业务角度看,我其实想存12字节数据,但是却耗费146字节内存,绝大部分内存是浪费的。真是大冤种。

如果有上亿个评论数据要存储,其实浪费的内存挺多的。


(2.4) 查看单个key的内存占用 MEMORY USAGE

// 

(3) Redis-Server本身提供哪些内存优化

如果看过Redis源码,就会发现,Redis本身已经做过很多内存优化,如果合理使用会节省很多内存。

如果没兴趣,这块可以跳过

术语解释

名词 含义 备注
Redis对象 Redis里的data-type、数据类型,常用的有 string、list、hash、set、sorted-set等 见源码 和 redis官网 https://redis.io/docs/data-types/
Redis对象编码 encoding、data-structure 常见的有 int、enmstr、raw、ziplist、quicklist、intset、hashtable、skiplist 等 见源码
Redis对象类型 Redis里对象管理系统,redisObject 见源码

(3.1) Redis数据类型(对象)维度的内存优化

Redis对象都是由Redis对象系统(redisObject)管理。

(3.1.1) Redis对象系统-redisObject怎么省内存

redisObject结构

// file: src/server.h 

/*
 * redis对象
 */
typedef struct redisObject {
    unsigned type:4;  // 数据类型 (string/list/hash/set/zset等)
    unsigned encoding:4;  // 编码方式 
    unsigned lru:LRU_BITS;  // LRU时间(相对于全局 lru_clock) 
                            // 或 LFU数据(低8位保存频率 和 高16位保存访问时间)。  
                            // LRU_BITS为24个bits
    int refcount;  // 引用计数  4字节
    void *ptr;  // 指针 指向对象的值  8字节
} robj;

可以看到redisObject有一个字段是refcount引用计数,了解过垃圾回收的同学是不是很熟悉, 通过对象复用来节省内存,有点像享元模式。

详细分析见 RedisObject

(3.1.2) Redis对象不同编码(数据结构)内存优化

redisObject 、Redis对象(data-type) 以及 encoding(data-structure) 三者之间的关系:

redisObject、数据类型、数据结构之间的关系

详细的不同类型对应不同编码 可以看 RedisObject同一类型对应不同编码


(3.2) Redis数据结构(data-structure)维度的内存优化

这里以常用的2种Redis对象 stringhash 来介绍对应的数据结构(data-structure) SDSziplisthashtable


(3.2.1) 简单动态字符串-SDS(embstr/raw)

数据类型string对应的encoding有 int、embstr、raw,其中 embstr和raw用的 SDS

SDS 结构里包含了一个字符数组 buf[],用来保存实际数据。
SDS结构里还包含了三个元数据,分别是字符数组现有长度 len、分配给字符数组的空间长度 alloc,以及 SDS 类型 flags。

sdshdr结构

类型 sdschar* 的别名(alias)
结构 sdshdr 则保存了 lenallocflagsbuf[] 四个属性
SDS 本质还是char数组,只是在char数组基础上增加了额外的元数据。


(3.2.1.1) 定义多种SDS在不同场景使用省内存

为了节省内存,Redis定义了4种sdshdr,方便在不同场景使用。

各种sdshdr区别

元数据各自占用的内存空间在 sdshdr8、sdshdr16、sdshdr32、sdshdr64 类型中,则分别是 1字节、2字节、4字节 和 8字节。

SDS之所以设计不同的结构头(即不同类型),是为了能灵活保存不同大小的字符串,从而有效节省内存空间。
在保存不同大小的字符串时,结构头占用的内存空间也不一样,这样一来,在保存小字符串时,结构头占用空间也比较少。

详细分析见 Redis SDS


(3.2.1.2) 创建新字符串-内存预分配

在创建新的字符串时,Redis 会调用 SDS 创建函数 sdsnewlen。

sds内存分配

根据分配内存的长度,合理的字符串长度可以减少内存分配的浪费。


(3.2.2) 压缩列表-ziplist

ziplist是一种特殊编码的双向链表,旨在提高内存效率。
它存储字符串和整数值,其中整数被编码为实际整数而不是一系列字符。
它允许在O(1)时间内对列表的任一侧进行推送和弹出操作。
由于每个操作都需要重新分配 ziplist 使用的内存,因此实际的复杂性与 ziplist 使用的内存量有关。

area        |<---- ziplist header ---->|<----------- entries ------------->|<-end->|

size          4 bytes  4 bytes  2 bytes    ?        ?        ?        ?     1 byte
            +---------+--------+-------+--------+--------+--------+--------+-------+
component   | zlbytes | zltail | zllen | entry1 | entry2 |  ...   | entryN | zlend |
            +---------+--------+-------+--------+--------+--------+--------+-------+
                                       ^                          ^        ^
address                                |                          |        |
                                ZIPLIST_ENTRY_HEAD                |   ZIPLIST_ENTRY_END
                                                                  |
                                                         ZIPLIST_ENTRY_TAIL

压缩列表是一块连续的内存空间。
为了方便查询,在头部记录了压缩列表占用的总字节数、entry尾部的地址 以及 压缩列表的元素个数。

字段 长度 作用
zlbytes 4字节 整个ziplist占用的内存字节数,对ziplist进行内存重分配,或者计算末端时使用。
zltail 4字节 ziplist尾节点的偏移量。通过这个偏移量,可以快速获取尾部节点。
zllen 2字节 快速获取ziplist长度
zlend 1字节 结束符

详细分析见 Redis 压缩列表

(3.2.3) 哈希表-dict

哈希表

/* 
 * 这是哈希表结构。
 * 每个字典都有两个这样的字典,因为我们实现了增量重新哈希,从旧表到新表。 
 */
typedef struct dictht {
    dictEntry **table;  // 哈希节点数组  一维数组的指针
    unsigned long size;  // 哈希表大小
    unsigned long sizemask;  // 哈希表大小掩码,用于计算索引值,等于 size - 1
    unsigned long used;  // 哈希表已使用节点个数
} dictht;
/*
 * 哈希表节点
 */
typedef struct dictEntry {
    void *key; // key的指针
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;  // value  联合结构体(不同场景使用不同大小,省内存)
    struct dictEntry *next; // 链表的下一个节点的指针
} dictEntry;

可以看到 哈希表dictht里定义了 二维数组(**table)、哈希表大小、
哈希节点里定义了 键(key)、值(v)、链表的下一个节点(next),其中值(v)是一个联合体,联合体中包含了指向实际值的指针 *val,还包含了无符号的 64 位整数、有符号的 64 位整数,以及 double 类的值。

为什么要使用联合体呢?
节省内存,因为当值为整数或双精度浮点数时,由于其本身就是64位,就可以不用指针,而是可以直接存在键值对的结构体中,这样就避免了再用一个指针64位,从而节省了内存空间。

哈希表是通过空间换时间,所以整体上会比较费内存,这个无论在什么语言避免不了。

想优化哈希表的内存使用,可以考虑用其它数据结构,比如 字典树(分词)、跳表(Lucene3.0前词典的数据结构)、基数树(Redis Stream底层数据结构)、有向无环图(Elasticsearch底层Lucene词典数据结构FST) 等来节省内存。

详细分析见 Redis 哈希表


(3.3) 内存分配器维度的优化

内存分配器的目的主要是为了方便管理内存
redis内存分配器有 jemalloctcmalloclibc

mem_fragmentation_ratio = used_memory_rss / used_memory
内存碎片率 = Redis进程占据操作系统的内存 / Redis分配器分配的内存总量


(4) 实验

(4.1) key是否过期内存占用对比

todo

(4.2) 不同长度key内存占用对比

(4.3) 同一数据对象不同数据编码(encoding)内存占用对比

todo

(4.4) 不同内存分配器内存占用对比

todo

(4.5) 存在大key内存占用对比

todo

(4.6) 主从复制内存占用对比

todo

(4.7) 客户端连接数内存占用对比

todo

(4.8) 有AOF和AOF重写内存占用对比

todo

(4.9) lua内存占用对比

todo


(4.10) 其它

Redis删除数据不一定是是实时删除。

Redis在删除数据后并不是立马释放内存,需要重启 Redis Server 才能释放操作系统分配的内存。


(5) 作为研发可以做的Redis内存优化

作为研发,在不改Redis源码的情况下,在日常的开发中可以做到哪些优化?

  1. 选择合适的数据类型(string、list、hash、set、zset)
  2. 根据业务场景和数据量合理选择key是否过期;
    甲. key不带过期时间可以减小内存占用。
    乙. 如果key数量上千万,可以考虑MQ+key不过期方案,通过MQ定时删过期数据。
  3. 合理设置key的长度。
    甲. 如果key有过期时间,key会用在keyspace的dict里,也会用在expire的dict里,如果key长度比较长,会比较消耗内存。
    乙. 如果key数量较多,内存消耗比比较大。
  4. 并发场景,val的长度尽量别超过1KB,尽量小。
  5. 设计合理的业务模型
  6. 根据Redis配置合理设置集合里的数据条数和大小

TODO


(5.1) 选择合适的数据类型(string、list、hash、set、zset)

比如统计每日活跃用户数、app弹窗频率限制、用户是否绑定银行卡等场景,可以不适用 set key val

(5.1.1) 统计每日活跃用户数

统计每日活跃用户数,可以使用set,通过 SADD key val添加当日活跃用户,通过SCARD key获取当日活跃用户总数;
但是set是比较耗费内存的,可以考虑使用 bitmap、HyperLogLog 来节省内存。

假设有一亿用户,set耗费的内存大概是x (todo)。

如果把上亿用户减去同一个基数后,区间在 1 ~ 2^31 - 1,使用bitemap,最多消耗512M内存。

如果使用 HyperLogLog 来统计大概得活跃用户数据,只消耗12KB内存,误差率大概是0.81% 。 详细介绍可以看 https://redis.io/docs/data-types/probabilistic/hyperloglogs/

(5.1.2) app弹窗频率限制

app弹窗

(5.1.3) 用户是否绑定银行卡

用户是否绑定银行卡

  1. 可以用 string类型, 通过 set key val 添加数据,通过 get key 来判断用户是否绑卡。
  2. 可以用 set类型,通过 SADD key val 添加数据,通过 SISMEMBER key member 来判断用户是否绑卡。
  3. 也可以用 bitmap,把用户ID减去共同的计数使其在 1 ~ 2^31 - 1 这个区间,通过 SETBIT key offset 1 添加数据,通过 GETBIT key offset 来判断用户是否绑卡。
  4. 也可以用 bloomfilter,通过 BF.ADD key item 添加数据,通过BF.EXISTS key item 来判断用户是否绑卡。

但是上面4种方法消耗的内存差距很大
假设有1亿用户,
用string类型,大概消耗 xx 内存
用 set 类型,大概消耗 xx 内存
用 bitmap,大概消耗 xx 内存
用 bloomfilter,误差率设置为 0.00001(万分之一) 大概消耗 20KB 内存。


(5.2) 根据业务场景和数据量合理选择key是否过期

(5.3) 合理设置key的长度和类型

  1. 从性能和内存角度考虑,使用数字类型(8字节)性能最高,而且省内存。 type=string encoding=int
    注意不要多个业务共用缓存,存在key冲突(一样)的情况
  2. 如果无法使用数字类型,建议字符串长度不要超过44字节。
  3. 最坏的情况就是字符串长度大于44字节,这种情况下,内存利用率会打折

参考资料

[1] Redis-6.0源码
[2] redis.conf
[3] [WeiDesign]微博计数器的设计(下)
[4] Storing hundreds of millions of simple key-value pairs in Redis
[5] 解码Redis最易被忽视的CPU和内存占用高问题-张鹏义-腾讯云数据库高级工程师
[6] 排查Redis实例内存使用率高的问题
[7] 一文了解 Redis 内存监控和内存消耗
[8] 如何估算Redis内存占用量