来聊聊redis的数据类型和底层的数据结构实现,随便看看在redis6和redis7出现的数据结构,主从复制机制的优化,Stream队列,IO多路复用等特性。
redis是基于内存k-v的数据库,那它都提供了哪些数据类型和应用场景呢?
如果我们要选用redis加入自己的系统架构,那我们需要redis为我们做什么呢?或者说为什么要选用redis?
redis主要提供了5种数据类型:string,hash,list,set,sorted set类型,当然还有一些其他的如bitmap,hyperLogLog类型等等,有很多,截个官网的图来瞅一瞅:
基本命令就不扯了,直接上酒,也可以在官网里现用现查redis commands:
那么我们来看看这些数据类型的底层是用什么实现的。
redis是使用c语言实现的,也是使用char字符拼接起来的字符串,但是并没有使用c语言已有的char字符,因为c语言是使用‘\0’作为结束符,如果遇到用户使用‘5252\0gsgs’这样的字符,则会被分断成2个字符串。所以字符串自定义了自己的string类型,在redis源码里定义了sds结构体来实现的(在c语言里没有class的定义,而是struct定义),在redis里一切key都是用sds定义的。
可以看到源码里设置了5种sds类型,根据string字符串的长度来选择不一样的类型,每个sds类型都有一些属性:
flags标识:前3位用来表示sds类型,后5位根据情况使用。
buf[]:用来存储数据的。
alloc:分配的空间大小。
len:字符串的长度。
可以根据alloc和len来计算是否需要扩容,扩容时翻倍,但是当alloc为1024*1024=1M时,就不会翻倍扩容,而是每次追加1M的大小空间。
redis的key是用string类型,即sds结构来表示的,但是redis是k-v数据库,那v在redis中是如何表示的呢?v有可能是很多种数据类型的表示,redis是怎么统一实现的呢?
看到k-v结构,相信大家都能联想到java中的map集合,redis跟map的实现是很相似的。v的存储也是通过数组+链表的方式存储的,通过hash(v)来定位槽位,链表来存储hash碰撞的元素。来看看redis的源码结构:
上图是redis定义的db结构体,可以看出来db里最主要的就是字典dict。
能看到dict里有个dictht类型的数组,长度为2,这是个hashtable数据结构,如下图:
什么是渐进式rehash呢?就是需要扩容时,要把ht[0]的hashtable数据搬到ht[1]中,但是不是马上全部搬过去,而是每访问一次时搬运一些(可能是一个槽位),搬完后将ht[1]和ht[0]互换,ht[1]再次变为空的。
扩容时机是:字段used = size时
产生hash碰撞时,使用头插法插入链表中。
还有个dictEntry属性,都不难猜到这个是用来存储数据的。来看看:
如上图,dictEntry结构体是使用union来存储value数据的,一般只使用其中的val,这里依旧会有封装起来的数据结构,是通过redisObject封装的。
redisObject里有几个属性:
type key
object encoding key
看到这里脑袋应该是嗡嗡的了,画个图整理整理
可以计算下一个redisObject占位16byte空间,在我们使用os cache的时候是64byte的,还有4位的sds占位空间,所以还有44位的空闲空间,为了充分利用这些空间,会使用这些空间来存储数据。
当我们的value长度超过了44位的时候,encoding编码会变为raw编码。
list是有序的数据结构,采用的是quicklist(双端链表)和ziplist作为list的底层实现。
有点类似于数组+链表的结构。
quicklist里有几个属性:
head:指向第一个quicklistNode节点
tail:指向最后一个quicklistNode节点
count:所有entity(list元素)的数量
len:quicklistNode节点的数量
quicklistNode是quicklist的节点,包含了ziplist节点。参数说明:
zl:指的是ziplist节点
sz:ziplist的大小
count:ziplist里面最大的entity数,多于这个的话会将ziplist分裂成2个。
上图是ziplist结构体,参数说明如下:
zlbytes:表示整个ziplist占用的byte字节
zltail:指向ziplist的最后一个entity
zllen:表示ziplist的长度
zlend:表示ziplist结尾,用255来表示结尾
hash的底层也是通过dict字典实现的,就是我们前面说过的dict,但是有一点不同,当hash的数据量或者单个元素比较小时,底层是使用ziplist存储的,当数据量或者单个元素超过设定值时,底层使用hashtable实现的,即上边提到的正常实现方式。
参数设置在redis.conf文件里有:
hash-max-ziplist-entries 512 # 当hash的ziplist里的entity元素超过了512个之后,底层会变成hashtable
hash-max-ziplist-value 64 # 表示当ziplist的某个元素超过了64byte后,底层会变成hashtable
set数据类型是存储着不重复数据的数据结构,底层实现也是dict字典,只是value使用null来表示。
同样的,对元素的大小也有临界值,当元素的大小超过了设置值,则会改用hashtable实现,当只有整型数字时则是使用intset类型。
set-max-intset-entries 512 # 当intset结构体的元素超过512个时,则底层使用hashtable来实现。
zset数据类型是有序的,不重复的数据结构,底层实现是使用ditct字典+跳表(skiplist)实现的。
当数据较少时,是使用ziplist编码结构存储。
从上图可以看出zset的编码是ziplist
参数含义和hash类似。
dict字典上面说过了,那跳表是什么呢?
链表大家应该很熟悉,如下图所示:
当链表数据量特别大的时候,查找某个数的时候就很费事了,消耗时间O(N),为了方便检索,就在链表中间抽一些节点出来再组成一个链表,形成多个链表结构。如下图:
这样先查找最上级的链表,然后再定位到下一级链表中,这样检索就快很多,有点类似二分查找。
依此类推,有多少层级取决于数据量和抽取的节点情况来看。
来看看redis的底层源码实现:
可以看到zset确实是使用dict+zskiplist实现的。zskiplist包含了跳表的层级,node节点数,zskiplistNode结构体的前后两个节点,指向第一个和最后一个节点。
还有其他的一些额外类型,通过这几个基本类型进行扩展的,比如geospatial indices类型,用来查找位置的,挺方便的,它就是通过zset进行封装的。
到了这里后,就应该知道自己业务该选择哪种数据类型了,比如该选择string还是hash?
从使用方面来说的话,string类型比较灵活,设置过期也比较方便,hash就不行了,只能设置最外层key的过期,但是hash在数据量少的时候还是比较容易管理的。
当数据量大的时候,hash底层也是使用hashtable的,这时候两者都避免不了随便散列,扩容等操作。
redis有提供了一个bitmap数据结构,通过位来存储数据,只有0和1值。
可以用来统计哪些用户登录了系统,哪个用户登录,就将user的id作为bitmap的下标来设置位为1。
bitmap是使用string类型来实现的,可以实践下:
可以看到bitmap的type是string类型实现的。可以看下基本命令
也可以使用BITOP命令来进行一些位运算,直接上官方案例:
这个bitmap用起来还是很方便的,可以结合自己的场景酌情使用。
HyperLogLog数据结构,该结构专门用于做大数据量的统计,比如当你的数据量达到亿级别时,要对这数据量做统计,不管是用set还是bitmap的,都会占用大量的内存,特别是每天都要做这样的统计时,日积月累下数据量是非常恐怖的,redis就做了统计,
使用集合类型和 HperLogLog 统计百万级用户访问次数的占用空间对比:
数据类型 | 1 天 | 1 个月 | 1 年 |
---|---|---|---|
集合类型 | 80M | 2.4G | 28G |
HyperLogLog | 15k | 450k | 5M |
可以看到HyperLogLog非常的节省空间,能够节省空间,那肯定是有其他付出的,那就是准确度,当数据量大的时候会损失大约0.008的精度,所以就很适合用来做访问量统计。
HyperLogLog就是通过伯努利实验做的极大似然估算方法,并做了分桶优化,通过调和平均数(通过倒数来计算的)来排除极端因素,
在redis中实现中,HyperLogLog占用12kb大小,一个value会被hash成64位二进制数,其中14位用来分槽位,即2的14方=16384,每个槽位占6位,即16384*6/8/1024 = 12kb。知道cluster集群的应该很熟悉16384这个数据,那为什么redis会分成16384个槽位呢?这是他们经过计算得到的一个均衡值,redis建议最大的节点数是1000,这样分配到每个节点数的槽位都不会太少,而且槽位是存储我们的数据的,如果槽位太多,也会导致槽位信息占用太多内存,连心跳都有大消耗,16384占用2kb。
官方给的一个解答:
redis设置16284?
从redis5.0开始,新加了个stream队列数据结构,是个可持久化的消息队列,借鉴了kafka的设计。
通过stream将消息存储起来,每条消息有自己的唯一id,格式为timestampInMillis-sequence,比如1527846223512-5,-5表示在这个毫秒内第5条消息,消息是持久化的,添加消息时指定stream name,redis会自动创建,消息内容是k-v形式。
生产消息:xadd key id|* field value //参数 * 表示消息id由redis来生成。
消息长度:xlen key
删除消息:xdel key id // 删除指定id消息,逻辑删除,非物理删除
获取消息列表:xrange
删除stream:del
可以在不加组的情况下进行单个消费者消费,当消费完stream里的消息后可阻塞等待。
加上阻塞读取消息
创建消费组
查看stream信息
查看group信息
不一一显示了。。。
所有的命令可查看redis stream 命令右侧命令列表。
stream并没有其他消息中间件功能那么齐全,没有好的社区这些,比如消息队列的管理和监控就需要花
大力气去实现,但是当我们只是需要消费一些不是特别重要的消息时,这时就可以用stream,还不会增加架构的复杂度,又能实现自己想要的功能。
要用好stream队列,得关注一下几个点:
相信大家都知道redis的IO多路复用,这是redis即使只使用单线程依然高效的原因之一,这种方式是来源于reactor模式。
spring的控制反转应该很熟啦,跟这个概念类似,在这模式下,把所有操作行为称为事件,有个反应器(模式的控制中心),还有各种各样的事件处理器,通过将处理器注册到反应器中,当有对应的事件过来时,会交给对应的事件处理器来处理事件。
单线程的reactor模式比较简单:
所有的客户端IO连接都是由reactor线程处理的,还有事件的转发,业务的读写处理等,都是由reactor线程独立完成。
这时候就想把业务抽离出来给其他线程处理。
单线程reactor,工作者线程池模式
增加了个线程池,reactor反应器觉得自己累死累活的,干不完的事,于是招了个线程池来干活,把读写数据,编解码这些业务活交给了线程池干,自己专注于IO操作。
由于reactor反应器的工作出色,接的活越来越多了,这时候又撑不住了,还是得找帮手。
多reactor线程模式:
于是reactor反应器就找更多的线程来帮忙,把重要的活都给分配出去,自己就作为mainReactor主反应器,专门谈项目,只负责客户端的连接请求,连接好后就把socketchannel的通信这些应酬交给subReactor子反应器来处理,这样就不会因为通信这些应酬而耽误谈项目(客户端连接请求),这样就能提高整体的一个负载响应。
redis就基于reactor模式设计了自己的线程I/O模型,反应器是使用单线程的,所以大家才说redis是单线程工作的。
是不是跟单线程reactor模式对应上,文件事件分派器就是我们的reactor反应器,最右边就是事件处理器。左边多了个IO多路复用器,这个又是什么呢?
正常的网络模型是一个客户端socket就对应一个服务端serviceSocket,都是一一对应分配的,redis就把这种方式给改了,所有的客户端socket都由同一个serviceSocket来处理连接,多个客户端之间交互就方便多了,这就是IO多路复用的意思。
具体体现就是:
文件事件分派器接收 I/O 多路复用程序传来的 socket, 并根据 socket 产生的事件类型, 调用相应的事件处理器。
服务器会为执行不同任务的套接字关联不同的事件处理器, 这些处理器是一个个函数, 它们定义了某个事件发生时, 服务器应该执行的动作。Redis 为各种文件事件需求编写了多个处理器,若客户端连接 Redis,对连接服务器的各个客户端进行应答,就需要将 socket 映射到连接应答处理器写数据到 Redis,接收客户端传来的命令请求,就需要映射到命令请求处理器从 Redis读数据,向客户端返回命令的执行结果,就需要映射到命令回复处理器当主服务器和从服务器进行复制操作时, 主从服务器都需要映射到特别为复制功能编写的复制处理器。
I/O 多路复用程序可以监听多个 socket 的 ae.h/AE_READABLE 事件和ae.h/AE_WRITABLE 事件。
I/O 多路复用程序可以同时监听 AE_REABLE 和 AE_WRITABLE 两种事件,要是一个 socket 同时产生这两种事件,那么文件事件分派器优先处理 AE_REABLE 事件。即一个 socket 又可读又可写时, Redis 服务器先读后写 socket。
客户端和 Redis 服务器通信的整个过程:
因为现在的系统业务越来越复杂,访问量日益剧增,需要更强的QPS了,但是实现集群又要处理很多问题,redis就加入了多线程模式,开启后QPS能够增加一倍,默认是不开启的,开启需要设置一个参数:
第一个是设定线程数,可以看到上面的注释,当机器是4核时,建议使用2/3个线程,8核时使用6个线程。
下面那个参数是开启线程读操作的,也可以不设置,作用就是当我们的redis在执行write操作时,其他的read操作是没有响应的,这样交互不友好,所以也需要read操作,这个手动开启。
了解上边的多reactor模式后应该能猜得出redis的多线程模式:
就是将处理流程划分成2块,main线程专门负责前边,后边交给其他线程,虽然说是多线程,但是命令执行这些仍然是单线程执行的,只不过重点是把socket的读写操作给交出去了。
那么:
redis7.0对性能做了很大的优化,特别是主从复制原理,那么做了什么改动呢?
redis7.0之前的主从复制原理可以看主从复制原理
我们先来看看有什么问题需要优化:
redis在做主从复制时,会fork一个子线程来进行复制,将rdb文件发送给从节点,当有一个从节点时,就需要一个缓存区来进行数据传输,而且数据基本都是一样的,这就非常消耗空间内存,还有个复制积压区ReplicationBacklog的数据也是一样的,在做增量复制的时候会从这里复制需要的数据。涉及到多份数据,肯定就需要复制吧,数据很大时就会有相关的阻塞了。
于是7.0的时候就将这些数据共享,而且将数据拆分成很多份,通过链表的方式连这些数据连接起来,比如有1024kb的数据,将它拆分成16kb,16kb …这样的块状数据,链表连接起来进行维护,
然后在链表中记录replica节点对数据块的引用,这样就能区分各个replica节点对master节点的更新情况。具体是使用引用法来记录着哪块数据块被使用着,
refcount 表示 被使用数
这样就通过refcount的变动和引用的修改就能解决数据复制导致的阻塞问题了,大家都是用共享复制缓存区数据。
但是链表检索起来很费时,这时就需要索引结构了,redis使用的是rax树(前缀压缩树)结构,是通过trix树演变而来的。因为我们需要解决一个问题:当从库尝试和主库进行数据复制时,会传自己的repl_offset过来,那么redis怎么定位到对应的replBufBlock数据块呢?
对每64个replBufBlock,就会记录一个索引在rax树里,这样就能快速定位到某个起始块,然后根据链表一个个找到对应的replBufBlock,根据索引定位到链表时,检索链表也不会超过64次,效率就很高。
下图是在网络上找的rax索引示例,是个压缩索引:
图中蓝色的块是因为没有了共同前缀,所以将剩下的都放在一个节点里。
就到这了。