大家好,我是「码哥」,大家能够叫我靓仔。
这次码哥跟大家分享一些优化神技,当你面试或者工作中你遇到如下问题,那就使出明天学到的绝招,一招定乾坤!
如何用更少的内存保留更多的数据?
咱们应该从 Redis 是如何保留数据的原理开展,剖析键值对的存储构造和原理。
从而持续延展出每种数据类型底层的数据结构,针对不同场景应用更失当的数据结构和编码实现更少的内存占用。
为了保留数据,Redis 须要先申请内存,数据过期或者内存淘汰须要回收内存,从而拓展出内存碎片优化。
最初,说下 key、value 应用标准和技巧、Bitmap 等高阶数据类型,使用这些技巧奇妙解决无限内存去存储更多数据难题……
这一套组合拳下来间接封神。
具体详情,且看「码哥」一一道来。
次要优化神技如下:
- 键值对优化;
- 小数据汇合的编码优化;
- 应用对象共享池;
- 应用 Bit 比特位或 byte 级别操作
- 应用 hash 类型优化;
- 内存碎片优化;
- 应用 32 位的 Redis。
在优化之前,咱们先把握 Redis 是如何存储数据的。
Redis 如何存储键值对
Redis 以 redisDb
为核心存储,redis 7.0 源码在 https://github.com/redis/redis/blob/7.0/src/server.h
:
- dict:最重要的属性之一,就是靠这个定义了保留了对象数据键值对,dcit 的底层构造是一个哈希表。
- expires:保留着所有 key 的过期信息.
- blocking_keys 和 ready_keys 次要为了 实现 BLPOP 等阻塞命令
- watched_keys 用于实现 watch 命令,记录正在被 watch 的一些 key,与事务相干。
- id 为 以后数据库的 id,redis 反对单个服务多数据库,默认有 16 个;
- clusterSlotToKeyMapping:cluster 模式下,存储 key 与哈希槽映射关系的数组。
Redis 应用「dict」构造来保留所有的键值对(key-value)数据,这是一个全局哈希表,所以对 key 的查问能以 O(1) 工夫失去。
所谓哈希表,咱们能够类比 Java 中的 HashMap
,其实就是一个数组,数组的每个元素叫做哈希桶。
dict 构造如下,源码在 https://github.com/redis/redis/blob/7.0/src/dict.h
:
struct dict {
// 特定类型的处理函数
dictType *type;
// 两个全局哈希表指针数组,与渐进式 rehash 无关
dictEntry **ht_table[2];
// 记录 dict 中现有的数据个数。unsigned long ht_used[2];
// 记录渐进式 rehash 进度的标记,-1 示意以后没有执行 rehash
long rehashidx;
// 小于 0 示意 rehash 暂停
int16_t pauserehash;
signed char ht_size_exp[2];
};
- dictType:存储了 hash 函数,key 和 value 的复制等函数;
- ht_table:长度为 2 的 数组,失常状况应用 ht_table[0] 存储数据,当执行 rehash 的时候,应用 ht_table[1] 配合实现。
key 的哈希值最终会映射到 ht_table 的一个地位,如果产生哈希抵触,则拉出一个哈希链表。
大家重点关注 dictEntry
的 ht_table,ht_table 数组每个地位咱们也叫做哈希桶,就是这玩意保留了所有键值对。
码哥,Redis 反对那么多的数据类型,哈希桶咋保留?
哈希桶的每个元素的构造由 dictEntry 定义:
typedef struct dictEntry {
// 指向 key 的指针
void *key;
union {
// 指向理论 value 的指针
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
// 哈希抵触拉出的链表
struct dictEntry *next;
} dictEntry;
- key 指向键值对的键的指针,key 都是 string 类型。
- value 是个 union(联合体)当它的值是 uint64_t、int64_t 或 double 类型时,就不再须要额定的存储,这有利于缩小内存碎片。(为了节俭内存操碎了心)当然,val 也能够是 void 指针,指向值的指针,以便能存储任何类型的数据。
- next 指向另一个 dictEntry 构造,多个 dictEntry 能够通过 next 指针串连成链表,从这里能够看出,ht_table 应用链地址法来解决键碰撞:当多个不同的键领有雷同的哈希值时,哈希表用一个链表将这些键连接起来。
哈希桶并没有保留值自身,而是指向具体值的指针,从而实现了哈希桶能存不同数据类型的需要。
而哈希桶中,键值对的值都是由一个叫做 redisObject
的对象定义,源码地址:https://github.com/redis/redis/blob/7.0/src/server.h
。
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS;
int refcount;
void *ptr;
} robj;
- type:记录了对象的类型,string、set、hash、Lis、Sorted Set 等,依据该类型才能够确定是哪种数据类型,应用什么样的 API 操作。
- encoding:编码方式,示意 ptr 指向的数据类型具体数据结构,即这个对象应用了什么数据结构作为底层实现 保留数据。同一个对象应用不同编码实现内存占用存在显著差别,外部编码对内存优化十分重要。
- lru:LRU_BITS:LRU 策略下对象最初一次被拜访的工夫,如果是 LFU 策略,那么低 8 位示意拜访频率,高 16 位示意拜访工夫。
- refcount:示意援用计数,因为 C 语言并不具备内存回收性能,所以 Redis 在本人的对象零碎中增加了这个属性,当一个对象的援用计数为 0 时,则示意该对象曾经不被任何对象援用,则能够进行垃圾回收了。
- ptr 指针:指向对象的底层实现数据结构,指向 值的指针。
如下图是由 redisDb、dict、dictEntry、redisObejct 关系图:
「码哥」再唠叨几句,void key 和 void value 指针指向的是 redis 对象,因为 Redis 中每个对象都是用 redisObject
示意。
晓得了 Redis 存储原理以及不同数据类型的存储数据结构后,咱们持续看如何做性能优化。
1. 键值对优化
当咱们执行 set key value
的命令,*key
指针指向 SDS 字符串保留 key,而 value
的值保留在 *ptr
指针指向的数据结构,耗费的内存:key + value。
第一个优化神技:升高 Redis 内存应用的最粗犷的形式就是缩减键(key)与值(value)的长度。
在《Redis 很强,不懂应用标准就糟践了》中我说过对于键值对的应用标准,对于 key 的命名应用「业务模块名: 表名: 数据惟一 id」这样的形式不便定位问题。
比方:users:firends:996 示意用户零碎中,id = 996 的敌人信息。咱们能够简写为:u:fs:996
对于 key 的优化:应用单词简写形式优化内存占用。
对于 value 的优化那就更多了:
- 过滤不必要的数据:不要大而全的一股脑将所有信息保留,想方法去掉一些不必要的属性,比方缓存登录用户的信息,通常只须要存储昵称、性别、账号等。
- 精简数据:比方用户的会员类型:0 示意「屌丝」、1 示意「VIP」、2 示意「VVIP」。而不是存储 VIP 这个字符串。
- 数据压缩:对数据的内容进行压缩,比方应用 GZIP、Snappy。
-
使用性能好,内存占用小的序列化形式。比方 Java 内置的序列化不论是速度还是压缩比都不行,咱们能够抉择 protostuff,kryo 等形式。如下图 Java 常见的序列化工具空间压缩比:
靓仔们,咱们通常应用 json 作为字符串存储在 Redis,用 json 存储与二进制数据存储有什么优缺点呢?
json 格局的长处:不便调试和跨语言;毛病是:同样的数据相比字节数组占用的空间更大。
肯定要 json 格局的话,那就先通过压缩算法压缩 json,再把压缩后的数据存入 Redis。比方 GZIP 压缩后的 json 可升高约 60% 的空间。
2. 小数据汇合编码优化
key 对象都是 string 类型,value 对象次要有五种根本数据类型:String、List、Set、Zset、Hash。
数据类型与底层数据结构的关系如下所示:
特地阐明下在最新版(非稳固版本,工夫 2022-7-3),ziplist 压缩列表由 quicklist
代替(3.2 版本引入),而双向链表由 listpack 代替。
另外,同一数据类型会依据键的数量和值的大小也有不同的底层编码类型实现。
在 Redis 2.2 版本之后,存储汇合数据(Hash、List、Set、SortedSet)在满足某些状况下会采纳内存压缩技术来实现应用更少的内存存储更多的数据。
当这些汇合中的数据元素数量小于某个值且元素的值占用的字节大小小于某个值的时候,存储的数据会用十分节俭内存的形式进行编码,实践上至多节俭 10 倍以上内存(均匀节俭 5 倍以上)。
比方 Hash 类型外面的数据不是很多,尽管哈希表的工夫复杂度是 O(1),ziplist 的工夫复杂度是 O(n),然而应用 ziplist 保留数据的话会节俭了内存,并且在大量数据状况下效率并不会升高很多。
所以咱们须要尽可能地管制汇合元素数量和每个元素的内存大小,这样能充分利用紧凑型编码缩小内存占用。
并且,这些编码对用户和 api 是无感知的,当汇合数据超过配置文件的配置的最大值,Redis 会主动转成失常编码。
数据类型对应的编码规定如下所示
String 字符串
- int:整数且数字长度小于 20,间接保留在 *ptr 中。
- embstr:开拓一块间断调配的内存(字符串长度小于等于 44 字节)。
- raw:动静字符串(大于 44 字节的字符串,同时字符串小于 512 MB)。
List 列表
-
ziplist:元素个数小于
hash-max-ziplist-entries
配置,同时所所的元素的值大小都小于hash-max-ziplist-value
配置。 - linkedlist:3.0 版本之前当列表类型无奈满足 ziplist 的条件时,Redis 会应用 linkedlist 作为列表的外部实现。
- quicklist:Redis 3.2 引入,并作为 List 数据类型的底层实现,不再应用双端链表 linkedlist 和 ziplist 实现。
Set 汇合
- intset 整数汇合:元素都是整数,且元素个数小于
set-max-intset-entries
配置 - hashtable 哈希表:汇合类型无奈满足 intset 的条件时就会应用 hashtable 编码。
Hash 哈希表
- ziplist:元素个数小于
hash-max-ziplist-entries
配置,同时任意一个 value 的占用字节大小都小于hash-max-ziplist-value
。 - hashtable:hash 类型无奈满足 intset 的条件时就会应用 hashtable。
Sorted Set 有序汇合
- ziplist:元素个数小于
zset-max-ziplist-entries
同时每个元素的 value 小于`zset-max-ziplist-value
配置。 - skiplist:当 ziplist 条件不满足时,有序汇合会应用 skiplist 作为外部实现。
以下是 Redis redis.conf 配置文件默认编码阈值配置:
hash-max-ziplist-entries 512
hash-max-ziplist-value 64
zset-max-ziplist-entries 128
zset-max-ziplist-value 64
set-max-intset-entries 512
下图是 reidsObject
对象的 type 和 encoding 对应关系图:
码哥,为啥对一种数据类型实现多种不同编码方式?
次要起因是想通过不同编码实现效率和空间的均衡。
比方当咱们的存储只有 100 个元素的列表,当应用双向链表数据结构时,须要保护大量的外部字段。
比方每个元素须要:前置指针,后置指针,数据指针等,造成空间节约。
如果采纳间断内存构造的压缩列表(ziplist),将会节俭大量内存,而因为数据长度较小,存取操作工夫复杂度即便为 O(n) 性能也相差不大,因为 n 值小 与 O(1) 并显著差异。
数据编码优化技巧
ziplist 存储 list 时每个元素会作为一个 entry,存储 hash 时 key 和 value 会作为相邻的两个 entry。
存储 zset 时 member 和 score 会作为相邻的两个 entry,当不满足上述条件时,ziplist 会降级为 linkedlist, hashtable 或 skiplist 编码。
因为目前大部分 Redis 运行的版本都是在 3.2 以上,所以 List 类型的编码都是 quicklist。
quicklist 是 ziplist 和 linkedlist 的混合体,它将 linkedlist 按段切分,每一段应用 ziplist 来紧凑存储,多个 ziplist 之间应用双向指针串接起来。
思考了综合均衡空间碎片和读写性能两个维度所以应用了个新编码 quicklist。
ziplist 的有余
每次批改都可能触发 realloc 和 memcopy, 可能导致连锁更新(数据可能须要移动)。
因而批改操作的效率较低,在 ziplist 的元素很多时这个问题更加突出。
优化伎俩:
- key 尽量管制在 44 字节以内,走 embstr 编码。
- 汇合类型的 value 对象的元素个数不要太多太大,充分利用 ziplist 编码实现内存压缩。
3. 对象共享池
整数咱们常常在工作中应用,Redis 在启动的时候默认后生成一个 0 ~9999 的整数对象共享池用于对象复用,缩小内存占用。
比方执行set 码哥 18; set 吴彦祖 18;
key 等于「码哥」和「吴彦祖」的 value 都指向同一个对象。
如果 value 能够应用整数示意的话尽可能应用整数,这样即便大量键值对的 value 大量保留了 0~9999 范畴内的整数,在实例中,其实只有一份数据。
靓仔们,有两个大坑遇到留神,它会导致对象共享池生效。
-
Redis 中设置了 maxmemory 限度最大内存占用大小且启用了 LRU 策略(allkeys-lru 或 volatile-lru 策略)。
码哥,为啥呀?
因为 LRU 须要记录每个键值对的拜访工夫,都共享一个整数 对象,LRU 策略就无奈进行统计了。
-
汇合类型的编码采纳 ziplist 编码,并且汇合内容是整数,也不能共享一个整数对象。
这又是为啥呢?
应用了 ziplist 紧凑型内存构造存储数据,判断整数对象是否共享的效率很低。
4. 应用 Bit 比特位或 byte 级别操作
比方在一些「二值状态统计」的场景下应用 Bitmap 实现,对于网页 UV 应用 HyperLogLog 来实现,大大减少内存占用。
码哥,什么是二值状态统计呀?
也就是汇合中的元素的值只有 0 和 1 两种,在签到打卡和用户是否登陆的场景中,只需记录 签到 (1)
或 未签到 (0)
, 已登录 (1)
或未登陆(0)
。
如果咱们在判断用户是否登陆的场景中应用 Redis 的 String 类型实现(key -> userId,value -> 0 示意下线,1 – 登陆),如果存储 100 万个用户的登陆状态,如果以字符串的模式存储,就须要存储 100 万个字符串,内存开销太大。
String 类型除了记录理论数据以外,还须要额定的内存记录数据长度、空间应用等信息。
Bitmap 的底层数据结构用的是 String 类型的 SDS 数据结构来保留位数组,Redis 把每个字节数组的 8 个 bit 位利用起来,每个 bit 位 示意一个元素的二值状态(不是 0 就是 1)。
能够将 Bitmap 看成是一个 bit 为单位的数组,数组的每个单元只能存储 0 或者 1,数组的下标在 Bitmap 中叫做 offset 偏移量。
为了直观展现,咱们能够了解成 buf 数组的每个字节用一行示意,每一行有 8 个 bit 位,8 个格子别离示意这个字节中的 8 个 bit 位,如下图所示:
8 个 bit 组成一个 Byte,所以 Bitmap 会极大地节俭存储空间。 这就是 Bitmap 的劣势。
对于 Bitmap 的具体解答,大家可移步 ->《Redis 实战篇:巧用 Bitmap 实现亿级数据统计》。
5. 妙用 Hash 类型优化
尽可能把数据抽象到一个哈希表里。
比如说零碎中有一个用户对象,咱们不须要为一个用户的昵称、姓名、邮箱、地址等独自设置一个 key,而是将这个信息寄存在一个哈希表里。
如下所示:
hset users: 深圳:999 姓名 码哥
hset users: 深圳:999 年龄 18
hset users: 深圳:999 喜好 女
为啥应用 String 类型,为每个属性设置一个 key 会占用大量内存呢?
因为 Redis 的数据类型有很多,不同数据类型都有些雷同的元数据要记录(比方最初一次拜访的工夫、被援用的次数等)。
所以,Redis 会用一个 RedisObject 构造体来对立记录这些元数据,用 *prt 指针指向理论数据。
当咱们为每个属性都创立 key,就会创立大量的 redisObejct
对象占用内存。
如下所示 redisObject 内存占用:
用 Hash 类型的话,每个用户只须要设置一个 key。
6. 内存碎片优化
Redis 开释的内存空间可能并不是间断的,这些不间断的内存空间很有可能处于一种闲置的状态。
尽管有闲暇空间,Redis 却无奈用来保留数据,不仅会缩小 Redis 可能理论保留的数据量,还会升高 Redis 运行机器的老本回报率。
比方,Redis 存储一个整形数字汇合须要一块占用 32 字节的间断内存空间,以后尽管有 64 字节的闲暇,然而他们都是不间断的,导致无奈保留。
内存碎片是如何造成呢?
两个层面起因导致:
- 操作系统内存分配机制:内存调配策略决定了无奈做到按需分配。因为分配器是依照固定大小来分配内存。
- 键值对被批改和删除,从而导致内存空间的扩容和开释。
碎片优化能够升高内存使用率,进步拜访效率,在 4.0 以下版本,咱们只能应用重启复原:重启加载 RDB 或者通过高可用主从切换实现数据的从新加载缩小碎片。
在 4.0 以上版本,Redis 提供了主动和手动的碎片整顿性能,原理大抵是把数据拷贝到新的内存空间,而后把老的空间开释掉,这个是有肯定的性能损耗的。
因为 Redis 是单线程,在数据拷贝时,Redis 只能等着,这就导致 Redis 无奈解决申请,性能就会升高。
手动整顿碎片
执行 memory purge
命令即可。
主动整顿内存碎片
应用 config set activedefrag yes
指令或者在 redis.conf 配置 activedefrag yes
将 activedefrag 配置成 yes 示意启动主动清理性能。
这个配置还不够,至于啥时候清理还须要看上面的两个配置:
- active-defrag-ignore-bytes 200mb:内存碎片的大小达到 200MB,开始清理。
- active-defrag-threshold-lower 6:示意内存碎片空间占操作系统调配给 Redis 的总空间比例达到 6% 时,开始清理。
只有满足这两个条件,Redis 才会执行内存碎片主动清理。
除此之外,Redis 为了避免清理碎片对 Redis 失常解决指令造成影响,有两个参数用于管制清理操作占用 CPU 的工夫比例上上限。
- active-defrag-cycle-min 15:主动清理过程所用 CPU 工夫的比例不低于 15%,保障清理能无效开展。
- active-defrag-cycle-max 50:示意主动清理过程所用 CPU 工夫的比例不能大于 50%,一旦超过,就进行清理,从而防止在清理时,大量的内存拷贝阻塞 Redis 执行命令。
7. 应用 32 位的 Redis
应用 32 位的 redis,对于每一个 key, 将应用更少的内存,因为 32 位程序,指针占用的字节数更少。
然而 32 的 Redis 整个实例应用的内存将被限度在 4G 以下。咱们能够通过 cluster 模式将多个小内存节点形成一个集群,从而保留更多的数据。
另外小内存的节点 fork 生成 rdb 的速度也更快。
RDB 和 AOF 文件是不辨别 32 位和 64 位的(包含字节程序), 所以你能够应用 64 位的 Redis 复原 32 位的 RDB 备份文件,相同亦然。
总结
打完出工,这一套神技下来,只想说一个字「绝」。
心愿这篇文章,能帮你应用全局视角去破解内存优化难题,我是「码哥」,下回 Redis 再见。
想要加技术群的少年留步,扫描备注「加群」,进专属技术读者群一起吹牛逼。
看到这里的靓仔,给个「三连」写个评论呀,靓仔。
参考文献
[1]https://redis.io/docs/referen…
[2]《Redis 核心技术与实战》
[3] https://segmentfault.com/a/11…