牺牲速度来节省内存,Redis是觉得自己太快了吗
前言
正常情况下我们选择使用 Redis 就是为了提升查询速度,然而让人意外的是,Redis 当中却有一种比较有意思的数据结构,这种数据结构通过牺牲部分读写速度来达到节省内存的目的,这就是 ziplist(压缩列表),Redis 为什么要这么做呢?难道真的是觉得自己的速度太快了,牺牲一点速度也不影响吗?
什么是压缩列表
ziplist 是为了节省内存而设计出来的一种数据结构。ziplist 是由一系列特殊编码组成的连续内存块的顺序型数据结构,一个 ziplist 可以包含任意多个 entry,而每一个 entry 又可以保存一个字节数组或者一个整数值。
ziplist 作为一种列表,其和普通的双端列表,如 linkedlist 的最大区别就是 ziplist 并不存储前后节点的指针,而 linkedlist 一般每个节点都会维护一个指向前置节点和一个指向后置节点的指针。那么 ziplist 不维护前后节点的指针,它又是如何寻找前后节点的呢?
ziplist 虽然不维护前后节点的指针,但是它却维护了上一个节点的长度和当前节点的长度,然后每次通过长度来计算出前后节点的位置。既然涉及到了计算,那么相对于直接存储指针的方式肯定有性能上的损耗,这就是一种典型的用时间来换取空间的做法。因为每次读取前后节点都需要经过计算才能得到前后节点的位置,所以会消耗更多的时间,而在 Redis 中,一个指针是占了 8 个字节,但是大部分情况下,如果直接存储长度是达不到 8 个字节的,所以采用存储长度的设计方式在大部分场景下是可以节省内存空间的。
ziplist 的存储结构
ziplist 的组成结构为:
<zlbytes> <zltail> <zllen> <entry> <entry> ... <entry> <zlend>
其中 zlbytes,zltail,zllen 为 ziplist 的 head 部分,entry 为 ziplist 的 entries 部分,每一个 entry 代表一个数据,最后 zlend 表示 ziplist 的 end 部分,如下图所示:

ziplist 中每个属性代表的含义如下表格所示:
| 属性 | 类型 | 长 度 | 说明 |
|---|---|---|---|
| zlbytes | uint32_t | 4字节 | 记录压缩列表占用内存字节数(包括本身所占用的 4 个字节)。 |
| zltail | uint32_t | 4字节 | 记录压缩列表尾节点距离压缩列表的起始地址有多少个字节(通过这个值可以计算出尾节点的地址) |
| zllen | uint16_t | 2字节 | 记录压缩列表中包含的节点数量,当列表值超过可以存储的最大值(65535)时,此值固定存储 65535(即 2 的 16 次方 减 1),因此此时需要遍历整个压缩列表才能计算出真实节点数。 |
| entry | 节点 | - | 压缩列表中的各个节点,长度由存储的实际数据决定。 |
| zlend | uint8_t | 1字节 | 特殊字符 0xFF(即十进制 255),用来标记压缩列表的末端(其他正常的节点没有被标记为 255 的,因为 255 用来标识末尾,后面可以看到,正常节点都是标记为 254)。 |
entry 存储结构
ziplist 的 head 和 end 存的都是长度和标记,而 entry 存储的是具体元素,这又是经过特殊的设计的一种存储格式,每个 entry 都以包含两段信息的元数据作为前缀,每一个 entry 的组成结构为:
<prevlen> <encoding> <entry-data>
prevlen
prevlen 属性存储了前一个 entry 的长度,通过此属性能够从后到前遍历列表。 prevlen 属性的长度可能是 1 字节也可能是 5 字节:
- 当链表的前一个
entry占用字节数小于254,此时prevlen只用1个字节进行表示。
<prevlen from 0 to 253> <encoding> <entry>
- 当链表的前一个
entry占用字节数大于等于254,此时prevlen用5个字节来表示,其中第1个字节的值固定是254(相当于是一个标记,代表后面跟了一个更大的值),后面4个字节才是真正存储前一个entry的占用字节数。
0xFE <4 bytes unsigned little endian prevlen> <encoding> <entry>
注意:1 个字节完全你能存储 255 的大小,之所以只取到 254 是因为 zlend 就是固定的 255,所以 255 这个数要用来判断是否是 ziplist 的结尾。
encoding
encoding 属性存储了当前 entry 所保存数据的类型以及长度。encoding 长度为 1 字节,2 字节或者 5 字节长。前面我们提到,每一个 entry 中可以保存字节数组和整数,而 encoding 属性的第 1 个字节就是用来确定当前 entry 存储的是整数还是字节数组。当存储整数时,第 1 个字节的前两位总是 11,而存储字节数组时,则可能是 00、01 和 10 三种中的一种。
- 当存储整数时,第
1个字节的前2位固定为11,其他位则用来记录整数值的类型或者整数值(下表所示的编码中前两位均为11):
| 编码 | 长度 | entry保存的数据 |
|---|---|---|
| 11000000 | 1字节 | int16_t类型整数 |
| 11010000 | 1字节 | int32_t类型整数 |
| 11100000 | 1字节 | int64_t类型整数 |
| 11110000 | 1字节 | 24位有符号整数 |
| 11111110 | 1字节 | 8位有符号整数 |
| 1111xxxx | 1字节 | xxxx 代表区间 0001-1101,存储了一个介于 0-12 之间的整数,此时 entry-data 属性被省略 |
注意:xxxx 四位编码范围是 0000-1111,但是 0000,1111 和 1110 已经被表格中前面表示的数据类型占用了,所以实际上的范围是 0001-1101,此时能保存数据 1-13,再减去 1 之后范围就是 0-12。至于为什么要减去 1 是从使用习惯来说 0 是一个非常常用的数据,所以才会选择统一减去 1 来存储一个 0-12 的区间而不是直接存储 1-13 的区间。
- 当存储字节数组时,第
1个字节的前2位为00、01或者10,其他位则用来记录字节数组的长度:
| 编码 | 长度 | entry保存的数据 |
|---|---|---|
| 00pppppp | 1字节 | 长度小于等于 63 字节(6 位)的字节数组 |
| 01pppppp qqqqqqqq | 2字节 | 长度小于等于 16383 字节(14 位)的字节数组 |
| 10000000 qqqqqqqq rrrrrrrr ssssssss tttttttt | 5字节 | 长度小于等于 2 的 32 次方减 1 (32 位)的字节数组,其中第 1 个字节的后 6 位设置为 0,暂时没有用到,后面的 32 位(4 个字节)存储了数据 |
entry-data
entry-data 存储的是具体数据。当存储小整数(0-12)时,因为 encoding 就是数据本身,此时 entry-data 部分会被省略,省略了 entry-data 部分之后的 ziplist 中的 entry 结构如下:
<prevlen> <encoding>
压缩列表中 entry 的数据结构定义如下(源码 ziplist.c 文件内),当然实际存储并没有直接使用到这个结构定义,这个结构只是用来接收数据,所以大家了解一下就可以了:
typedef struct zlentry {
unsigned int prevrawlensize;//存储prevrawlen所占用的字节数
unsigned int prevrawlen;//存储上一个链表节点需要的字节数
unsigned int lensize;//存储len所占用的字节数
unsigned int len;//存储链表当前节点的字节数
unsigned int headersize;//当前链表节点的头部大小(prevrawlensize + lensize)即非数据域的大小
unsigned char encoding;//编码方式
unsigned char *p;//指向当前节点的起始位置(因为列表内的数据也是一个字符串对象)
} zlentry;
ziplist 数据示例
上面讲解了大半天,可能大家都觉得枯燥无味了,也可能会觉得云里雾里,这个没有关系,这些只要心里有个概念,用到的时候再查询对应资料就可以了,并不需要全部记住,接下来让我们一起通过两个例子来体会一下 ziplist 到底是如何来组织存储数据的。
下面就是一个压缩列表的存储示例,这个压缩列表里面存储了 2 个节点,节点中存储的是整数 2 和 5:
[0f 00 00 00] [0c 00 00 00] [02 00] [00 f3] [02 f6] [ff]
| | | | | |
zlbytes zltail zllen "2" "5" end
- 第一组
4个字节为zlbytes部分,0f转成二进制就是1111也就是15,代表整个ziplist长度是15个字节。 - 第二组
4个字节zltail部分,0c转成二进制就是1100也就是12,这里记录的是压缩列表尾节点距离起始地址有多少个字节,也就是就是说[02 f6]这个尾节点距离起始位置有12个字节。 - 第三组
2个字节就是记录了当前ziplist中entry的数量,02转成二进制就是10,也就是说当前ziplist有2个节点。 - 第四组
2个字节[00 f3]就是第一个entry,00表示0,因为这是第1个节点,所以前一个节点长度为0,f3转成二进制就是11110011,刚好对应了表格中的编码1111xxxx,所以后面四位就是存储了一个0-12位的整数。0011转成十进制就是3,减去1得到2,所以第一个entry存储的数据就是2。 - 第五组
2个字节[02 f6]就是第二个entry,02即为2,表示前一个节点的长度为2,注意,因为这里算出来的结果是小于254,所以就代表了这里只用到了1个字节来存储上一个节点的长度(如果等于254,这说明接下来4个字节才存储的是长度),所以后面的f6就是当前节点的数据,转换成二进制为11110110,对应了表格中的编码1111xxxx,同样的后四位0110存储的是真实数据,计算之后得出是5。 - 最后一组1个字节[ff]转成二进制就是
11111111,代表这是整个ziplist的结尾。
假如这时候又添加了一个 Hello World 字符串到列表中,那么就会新增一个 entry,如下所示:
[02] [0b] [48 65 6c 6c 6f 20 57 6f 72 6c 64]
- 第一组的
1个字节02转成十进制就是2,表示前一个节点(即上面示例中的[02 f6])长度是2。 - 第 二组的
2个字节0b转成二进制为00001011,以00开头,符合编码00pppppp,而除掉最开始的两位00,计算之后得到十进制11,这就说明后面字节数组的长度是11。 - 第三组刚好是
11个字节,对应了上面的长度,所以这里就是真正存储了Hello World的字节数组。
ziplist 连锁更新问题
上面提到 entry 中的 prevlen 属性可能是 1 个字节也可能是 5 个字节,那么我们来设想这么一种场景:假设一个 ziplist 中,连续多个 entry 的长度都是一个接近但是又不到 254 的值(介于 250~253 之间),那么这时候 ziplist 中每个节点都只用了 1 个字节来存储上一个节点的长度,假如这时候添加了一个新节点,如 entry1 ,其长度大于 254 个字节,此时 entry1 的下一个节点 entry2 的 prelen 属性就必须要由 1 个字节变为 5 个字节,也就是需要执行空间重分配,而此时 entry2 因为增加了 4 个字节,导致长度又大于 254 个字节了,那么它的下一个节点 entry3 的 prelen 属性也会被改变为 5 个字节。依此类推,这种产生连续多次空间重分配的现象就称之为连锁更新。同样的,不仅仅是新增节点,执行删除节点操作同样可能会发生连锁更新现象。
虽然 ziplist 可能会出现这种连锁更新的场景,但是一般如果只是发生在少数几个节点之间,那么并不会严重影响性能,而且这种场景发生的概率也比较低,所以实际使用时不用过于担心。
总结
本文主要讲解了 Redis 当中的 ziplist(压缩列表),一种用时间换取空间的数据结构,在介绍压缩列表存储结构的同时通过一个存储示例来分析了 ziplist 是如何存储数据的,最后介绍了 ziplist 中可能发生的连锁更新问题。
牺牲速度来节省内存,Redis是觉得自己太快了吗的更多相关文章
- [PHP]引用返回与节省内存
PHP中的引用是什么:1.在 PHP 中引用意味着用不同的名字访问同一个变量内容2.引用可以被看作是 Unix 文件系统中的硬链接. 3.使用unset的话,只是删除他这个名字自身对内容的引用,并没有 ...
- python类与对象-如何为创建大量实例节省内存
如何为创建大量实例节省内存 问题举例 在网络游戏中,定义玩家类Player(id, name, level...), 每个玩家在线将创建一个Player实例,当在线人数很多时,将产生大量实例, 如何降 ...
- java内存缓存,节省内存
缓存的对象 这个问题就是我们上面提到的极端情况,在Java中,会对-128到127的Integer对象进行缓存,当创建新的Integer对象时,如果符合这个这个范围,并且已有存在的相同值的对象,则返回 ...
- 这种实现方式比使用 += 要更节省内存和 CPU,尤其是要串联的字符串数目特别多的时候。
这种实现方式比使用 += 要更节省内存和 CPU,尤其是要串联的字符串数目特别多的时候. package main import ( "bytes" "fmt" ...
- JS高级---构造函数通过原型添加方法,原型的作用: 共享数据, 节省内存空间
JS高级---构造函数,通过原型添加方法,原型的作用: 共享数据, 节省内存空间 构造函数 //构造函数 function Person(sex, age) { this.sex = sex; thi ...
- CPU缓存是位于CPU与内存之间的临时数据交换器,它的容量比内存小的多但是交换速度却比内存要快得多。CPU缓存一般直接跟CPU芯片集成或位于主板总线互连的独立芯片上
一.什么是CPU缓存 1. CPU缓存的来历 众所周知,CPU是计算机的大脑,它负责执行程序的指令,而内存负责存数据, 包括程序自身的数据.在很多年前,CPU的频率与内存总线的频率在同一层面上.内存的 ...
- 节省内存的循环banner(一)
循环banner是指scrollview首尾相连,循环播放的效果,使用非常广泛.例如淘宝的广告栏等. 如果是简单的做法可以把所有要显示的图片全部放进一个数组里,创建相同个数的图片视图来显示图片.这样的 ...
- 【转】java节省内存的几条建议
下面是参考网络资源总结的一些在Java编程中尽可能要做到的一些地方. 1. 尽量在合适的场合使用单例 使用单例可以减轻加载的负担,缩短加载的时间,提高加载的效率,但并不是所有地方都适用于单例,简单 ...
- 大数据量场景下storm自定义分组与Hbase预分区完美结合大幅度节省内存空间
前言:在系统中向hbase中插入数据时,常常通过设置region的预分区来防止大数据量插入的热点问题,提高数据插入的效率,同时可以减少当数据猛增时由于Region split带来的资源消耗.大量的预分 ...
随机推荐
- P5327 [ZJOI2019]语言
一边写草稿一边做题吧.要看题解的往下翻,或者是旁边的导航跳一下. 草稿 因为可以开展贸易活动的条件是存在一种通用语 \(L\) 满足 \(u_i\) 到 \(v_i\) 的最短路径上都会 \(L\) ...
- 刚开始学习Javascript的一些基础小知识,从入门到崩溃,希望对大家有帮助(只适合初学者)
一.简介 1.JavaScript一种直译式脚本语言,是一种动态类型.弱类型.基于原型的语言,内置支持类型,js不能操作文件. 重要的开始啦!!!!! 引入javascript: 行间js <d ...
- 深入理解Java虚拟机(五)——JDK故障处理工具
进程状况工具:jps jps(JVM Process Status Tool) 作用 用于虚拟机中正在运行的所有进程. 显示虚拟机执行的主类名称以及这些进程的本地虚拟机唯一ID. 可以通过RMI协议查 ...
- stringbuilder和stringbuffer速度比较
同样的代码,只改了类型,分别为stringbuilder和stringbuffer,只比较一下,执行引擎为hive. 当数据量为100000条,string builder耗时280秒,stringb ...
- webstorm2017.02版本如何使用material theme
本想废话一番,表达对material theme的喜欢.还是直接说方法吧~ file-settings-Plugins-Browse repositories-搜索 material theme -选 ...
- docker容器之间通过bridge进行通信
创建用户自定义bridge docker network create my-net # 创建了一个名为"my-net"的网络 将容器加入到"my-net"中 ...
- Python轻松入门到项目实战-实用教程
本课程完全基于Python3讲解,针对广大的Python爱好者与同学录制.通过本课程的学习,可以让同学们在学习Python的过程中少走弯路.整个课程以实例教学为核心,通过对大量丰富的经典实例的讲解.让 ...
- centos7安装Hive及其问题解决
本地如何安装hive (安装hive之前需要安装hadoop并启动hadoop的相关集群,mysql数据库) hadoop集群是两台,一台作为master,两台作为slaver,mysql单独占用一台 ...
- Hibernate Tools插件在线安装
1.查看你的Eclipse的版本:Help | About Eclipse Version: Oxygen.2 Release(4.11.0) 2.HibernateTools的下载地址为:http: ...
- 悉数 Python 函数传参的语法糖
TIOBE排行榜是程序开发语言的流行使用程度的有效指标,对世界范围内开发语言的走势具有重要参考意义.随着数据挖掘.机器学习和人工智能相关概念的风行,Python一举收获2018年年度语言,这也是Pyt ...