Redis的数据类型结构与Redis特性

来聊聊redis的数据类型和底层的数据结构实现,随便看看在redis6和redis7出现的数据结构,主从复制机制的优化,Stream队列,IO多路复用等特性。
redis是基于内存k-v的数据库,那它都提供了哪些数据类型和应用场景呢?

redis的应用场景

如果我们要选用redis加入自己的系统架构,那我们需要redis为我们做什么呢?或者说为什么要选用redis?

  • 计数器:基于redis的内存读写性能非常高,可以使用string类型的incr/incrby命令实现频繁的自增自减。
  • 分布式ID生成:可以通过自增在redis内存中生成上千个id缓存,不用频繁去db里生成。
  • 海量数据统计:可以通过bitmap来统计访问,请求,日活跃量等。
  • 会话缓存:也可以用redis来保存多个系统的登录session等,实现一个用户登录一次访问多系统的需求。
  • 缓存:当你觉得自己系统响应时间过长了,而db访问速率达到了瓶颈时,那就可以使用redis实现缓存,这是绝大多数公司看重的点,不知道怎么实现高缓存架构的,可以看下这篇文章redis实现高缓存架构。
  • 分布式队列/阻塞队列:可以通过list类型的lpush/rpush+rpop/lpop命令实现队列,也可以通过lpush+brpop实现阻塞队列。
  • 分布式锁:在多系统的情况下,我们就不能使用synchronize来实现同步锁了,这时候可以借助redis实现分布式锁,不知道怎么实现的可以看下这篇文章redis实现分布式锁。
  • 延迟队列:使用zset类型,通过 当前时间戳+延迟时长 作为score,通过不断的轮询消费,删除消息。
  • 热点数据,排行榜,好友交集等等需求都能实现,而且性能高。

redis的基本数据类型和使用

redis主要提供了5种数据类型:string,hash,list,set,sorted set类型,当然还有一些其他的如bitmap,hyperLogLog类型等等,有很多,截个官网的图来瞅一瞅:
Redis的数据类型结构与Redis特性_第1张图片
基本命令就不扯了,直接上酒,也可以在官网里现用现查redis commands:
Redis的数据类型结构与Redis特性_第2张图片
Redis的数据类型结构与Redis特性_第3张图片
Redis的数据类型结构与Redis特性_第4张图片
Redis的数据类型结构与Redis特性_第5张图片
Redis的数据类型结构与Redis特性_第6张图片

redis的数据类型底层数据结构

那么我们来看看这些数据类型的底层是用什么实现的。

string类型的底层实现

redis是使用c语言实现的,也是使用char字符拼接起来的字符串,但是并没有使用c语言已有的char字符,因为c语言是使用‘\0’作为结束符,如果遇到用户使用‘5252\0gsgs’这样的字符,则会被分断成2个字符串。所以字符串自定义了自己的string类型,在redis源码里定义了sds结构体来实现的(在c语言里没有class的定义,而是struct定义),在redis里一切key都是用sds定义的。

Redis的数据类型结构与Redis特性_第7张图片
可以看到源码里设置了5种sds类型,根据string字符串的长度来选择不一样的类型,每个sds类型都有一些属性:
flags标识:前3位用来表示sds类型,后5位根据情况使用。
buf[]:用来存储数据的。
alloc:分配的空间大小。
len:字符串的长度。
Redis的数据类型结构与Redis特性_第8张图片
Redis的数据类型结构与Redis特性_第9张图片

可以根据alloc和len来计算是否需要扩容,扩容时翻倍,但是当alloc为1024*1024=1M时,就不会翻倍扩容,而是每次追加1M的大小空间。

redis的key是用string类型,即sds结构来表示的,但是redis是k-v数据库,那v在redis中是如何表示的呢?v有可能是很多种数据类型的表示,redis是怎么统一实现的呢?

redis的数据库结构

看到k-v结构,相信大家都能联想到java中的map集合,redis跟map的实现是很相似的。v的存储也是通过数组+链表的方式存储的,通过hash(v)来定位槽位,链表来存储hash碰撞的元素。来看看redis的源码结构:
Redis的数据类型结构与Redis特性_第10张图片
上图是redis定义的db结构体,可以看出来db里最主要的就是字典dict。
Redis的数据类型结构与Redis特性_第11张图片
能看到dict里有个dictht类型的数组,长度为2,这是个hashtable数据结构,如下图:
Redis的数据类型结构与Redis特性_第12张图片
什么是渐进式rehash呢?就是需要扩容时,要把ht[0]的hashtable数据搬到ht[1]中,但是不是马上全部搬过去,而是每访问一次时搬运一些(可能是一个槽位),搬完后将ht[1]和ht[0]互换,ht[1]再次变为空的。
扩容时机是:字段used = size时
产生hash碰撞时,使用头插法插入链表中。

还有个dictEntry属性,都不难猜到这个是用来存储数据的。来看看:
Redis的数据类型结构与Redis特性_第13张图片
如上图,dictEntry结构体是使用union来存储value数据的,一般只使用其中的val,这里依旧会有封装起来的数据结构,是通过redisObject封装的。
Redis的数据类型结构与Redis特性_第14张图片
redisObject里有几个属性:

  • type:指定数据类型,熟悉redis的都知道有个type命令,可以查看key的数据类型
type key
  • encoding:指的是编码,同样的有个命令可以查看key的编码
object encoding key
  • lru:数据的淘汰策略
  • refcount:使用引用计数器来实现垃圾回收,为0时表示没有被使用
  • ptr:就是用来存储真实数据的

看到这里脑袋应该是嗡嗡的了,画个图整理整理
Redis的数据类型结构与Redis特性_第15张图片
可以计算下一个redisObject占位16byte空间,在我们使用os cache的时候是64byte的,还有4位的sds占位空间,所以还有44位的空闲空间,为了充分利用这些空间,会使用这些空间来存储数据。
当我们的value长度超过了44位的时候,encoding编码会变为raw编码。

list类型

list是有序的数据结构,采用的是quicklist(双端链表)和ziplist作为list的底层实现。
有点类似于数组+链表的结构。
Redis的数据类型结构与Redis特性_第16张图片
quicklist里有几个属性:
head:指向第一个quicklistNode节点
tail:指向最后一个quicklistNode节点
count:所有entity(list元素)的数量
len:quicklistNode节点的数量
Redis的数据类型结构与Redis特性_第17张图片
quicklistNode是quicklist的节点,包含了ziplist节点。参数说明:
zl:指的是ziplist节点
sz:ziplist的大小
count:ziplist里面最大的entity数,多于这个的话会将ziplist分裂成2个。
ziplist
上图是ziplist结构体,参数说明如下:
zlbytes:表示整个ziplist占用的byte字节
zltail:指向ziplist的最后一个entity
zllen:表示ziplist的长度
zlend:表示ziplist结尾,用255来表示结尾

整体的结构体图如下:
Redis的数据类型结构与Redis特性_第18张图片

hash类型

hash的底层也是通过dict字典实现的,就是我们前面说过的dict,但是有一点不同,当hash的数据量或者单个元素比较小时,底层是使用ziplist存储的,当数据量或者单个元素超过设定值时,底层使用hashtable实现的,即上边提到的正常实现方式。
参数设置在redis.conf文件里有:
Redis的数据类型结构与Redis特性_第19张图片
hash-max-ziplist-entries 512 # 当hash的ziplist里的entity元素超过了512个之后,底层会变成hashtable
hash-max-ziplist-value 64 # 表示当ziplist的某个元素超过了64byte后,底层会变成hashtable

下面来实验一下:
Redis的数据类型结构与Redis特性_第20张图片

set类型

set数据类型是存储着不重复数据的数据结构,底层实现也是dict字典,只是value使用null来表示。
同样的,对元素的大小也有临界值,当元素的大小超过了设置值,则会改用hashtable实现,当只有整型数字时则是使用intset类型。
Redis的数据类型结构与Redis特性_第21张图片
set-max-intset-entries 512 # 当intset结构体的元素超过512个时,则底层使用hashtable来实现。

吃个栗子:
Redis的数据类型结构与Redis特性_第22张图片

sorted-set类型

zset数据类型是有序的,不重复的数据结构,底层实现是使用ditct字典+跳表(skiplist)实现的。
当数据较少时,是使用ziplist编码结构存储。
Redis的数据类型结构与Redis特性_第23张图片
从上图可以看出zset的编码是ziplist
params-zset
参数含义和hash类似。

dict字典上面说过了,那跳表是什么呢?
链表大家应该很熟悉,如下图所示:
list1
当链表数据量特别大的时候,查找某个数的时候就很费事了,消耗时间O(N),为了方便检索,就在链表中间抽一些节点出来再组成一个链表,形成多个链表结构。如下图:
Redis的数据类型结构与Redis特性_第24张图片
这样先查找最上级的链表,然后再定位到下一级链表中,这样检索就快很多,有点类似二分查找。
依此类推,有多少层级取决于数据量和抽取的节点情况来看。

来看看redis的底层源码实现:
Redis的数据类型结构与Redis特性_第25张图片
可以看到zset确实是使用dict+zskiplist实现的。zskiplist包含了跳表的层级,node节点数,zskiplistNode结构体的前后两个节点,指向第一个和最后一个节点。

还有其他的一些额外类型,通过这几个基本类型进行扩展的,比如geospatial indices类型,用来查找位置的,挺方便的,它就是通过zset进行封装的。

到了这里后,就应该知道自己业务该选择哪种数据类型了,比如该选择string还是hash?
从使用方面来说的话,string类型比较灵活,设置过期也比较方便,hash就不行了,只能设置最外层key的过期,但是hash在数据量少的时候还是比较容易管理的。
当数据量大的时候,hash底层也是使用hashtable的,这时候两者都避免不了随便散列,扩容等操作。

bitmap数据结构

redis有提供了一个bitmap数据结构,通过位来存储数据,只有0和1值。
可以用来统计哪些用户登录了系统,哪个用户登录,就将user的id作为bitmap的下标来设置位为1。
bitmap是使用string类型来实现的,可以实践下:
Redis的数据类型结构与Redis特性_第26张图片

可以看到bitmap的type是string类型实现的。可以看下基本命令
Redis的数据类型结构与Redis特性_第27张图片
也可以使用BITOP命令来进行一些位运算,直接上官方案例:
Redis的数据类型结构与Redis特性_第28张图片
Redis的数据类型结构与Redis特性_第29张图片
这个bitmap用起来还是很方便的,可以结合自己的场景酌情使用。

HyperLogLog数据结构

HyperLogLog数据结构,该结构专门用于做大数据量的统计,比如当你的数据量达到亿级别时,要对这数据量做统计,不管是用set还是bitmap的,都会占用大量的内存,特别是每天都要做这样的统计时,日积月累下数据量是非常恐怖的,redis就做了统计,
使用集合类型和 HperLogLog 统计百万级用户访问次数的占用空间对比:

数据类型 1 天 1 个月 1 年
集合类型 80M 2.4G 28G
HyperLogLog 15k 450k 5M

可以看到HyperLogLog非常的节省空间,能够节省空间,那肯定是有其他付出的,那就是准确度,当数据量大的时候会损失大约0.008的精度,所以就很适合用来做访问量统计。
HyperLogLog就是通过伯努利实验做的极大似然估算方法,并做了分桶优化,通过调和平均数(通过倒数来计算的)来排除极端因素,

Redis的数据类型结构与Redis特性_第30张图片
相关的命令详情可以查看redis官网。

在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?
Redis的数据类型结构与Redis特性_第31张图片

Stream队列

从redis5.0开始,新加了个stream队列数据结构,是个可持久化的消息队列,借鉴了kafka的设计。
通过stream将消息存储起来,每条消息有自己的唯一id,格式为timestampInMillis-sequence,比如1527846223512-5,-5表示在这个毫秒内第5条消息,消息是持久化的,添加消息时指定stream name,redis会自动创建,消息内容是k-v形式。

  • 一个stream队列可以被多个消费组消费,每个消费组会自己维护一个last_delivered_id,表示消费到哪条消息
  • 消费组间是独立的,不影响消费组间消费stream里的消息
  • 消费组里任意一个消费者消费了消息,则last_delivered_id会前移
  • 消费者自己会维护一个pending_ids列表,记录着消费中还没确认的消息,官方名为PEL

我们来看看怎么使用:
Redis的数据类型结构与Redis特性_第32张图片

生产者:

生产消息:xadd key id|* field value //参数 * 表示消息id由redis来生成。
Redis的数据类型结构与Redis特性_第33张图片
消息长度:xlen key
xlen
删除消息:xdel key id // 删除指定id消息,逻辑删除,非物理删除
xdel
获取消息列表:xrange
Redis的数据类型结构与Redis特性_第34张图片
删除stream:del
Redis的数据类型结构与Redis特性_第35张图片

消费者与消费组

可以在不加组的情况下进行单个消费者消费,当消费完stream里的消息后可阻塞等待。
Redis的数据类型结构与Redis特性_第36张图片
加上阻塞读取消息
xread-block
创建消费组
xgroup create
查看stream信息
Redis的数据类型结构与Redis特性_第37张图片
查看group信息
Redis的数据类型结构与Redis特性_第38张图片
不一一显示了。。。
所有的命令可查看redis stream 命令右侧命令列表。

stream并没有其他消息中间件功能那么齐全,没有好的社区这些,比如消息队列的管理和监控就需要花
大力气去实现,但是当我们只是需要消费一些不是特别重要的消息时,这时就可以用stream,还不会增加架构的复杂度,又能实现自己想要的功能。

要用好stream队列,得关注一下几个点:

  1. 为了避免stream队列太长,可以使用定长stream,循环使用,xadd命令有个maxlen参数,可以指定
  2. 消息被消费了要ack,不然PEL队列就会越来越长
  3. 可以根据PEL来手动确认消息消费,避免丢失消息
  4. 可设置个死信队列来保存消费不了的消息,后续修复后再消费

redis中的线程模型

相信大家都知道redis的IO多路复用,这是redis即使只使用单线程依然高效的原因之一,这种方式是来源于reactor模式。

什么是reactor模式?

spring的控制反转应该很熟啦,跟这个概念类似,在这模式下,把所有操作行为称为事件,有个反应器(模式的控制中心),还有各种各样的事件处理器,通过将处理器注册到反应器中,当有对应的事件过来时,会交给对应的事件处理器来处理事件。

单线程的reactor模式比较简单:
Redis的数据类型结构与Redis特性_第39张图片
所有的客户端IO连接都是由reactor线程处理的,还有事件的转发,业务的读写处理等,都是由reactor线程独立完成。

这时候就想把业务抽离出来给其他线程处理。

单线程reactor,工作者线程池模式
Redis的数据类型结构与Redis特性_第40张图片
增加了个线程池,reactor反应器觉得自己累死累活的,干不完的事,于是招了个线程池来干活,把读写数据,编解码这些业务活交给了线程池干,自己专注于IO操作。

由于reactor反应器的工作出色,接的活越来越多了,这时候又撑不住了,还是得找帮手。

多reactor线程模式
Redis的数据类型结构与Redis特性_第41张图片
于是reactor反应器就找更多的线程来帮忙,把重要的活都给分配出去,自己就作为mainReactor主反应器,专门谈项目,只负责客户端的连接请求,连接好后就把socketchannel的通信这些应酬交给subReactor子反应器来处理,这样就不会因为通信这些应酬而耽误谈项目(客户端连接请求),这样就能提高整体的一个负载响应。

redis就基于reactor模式设计了自己的线程I/O模型,反应器是使用单线程的,所以大家才说redis是单线程工作的。
Redis的数据类型结构与Redis特性_第42张图片
是不是跟单线程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 服务器通信的整个过程:

  • Redis 启动初始化时,将连接应答处理器跟 AE_READABLE 事件关联。
  • 客户端发起连接,会产生一个 AE_READABLE 事件,然后由连接应答处理器负责和客户端建立连接,创建客户端对应的 socket,同时将这个 socket的 AE_READABLE 事件和命令请求处理器关联,使得客户端可以向主服务器发送命令请求。
  • 客户端向 Redis 发请求时(不管读还是写请求),客户端 socket 都会产生一个 AE_READABLE 事件,触发命令请求处理器。处理器读取客户端的命令内容,然后传给相关程序执行。
  • Redis 服务器准备好给客户端的响应数据后,会将 socket 的 AE_WRITABLE事件和命令回复处理器关联。
  • 当客户端准备好读取响应数据时,会在 socket 产生一个 AE_WRITABLE 事件,由对应命令回复处理器处理,即将准备好的响应数据写入 socket,供客户端读取。
    -命令回复处理器全部写完到 socket 后,就会删除该 socket 的 AE_WRITABLE事件和命令回复处理器的映射。
redis6.0引入多线程模式

因为现在的系统业务越来越复杂,访问量日益剧增,需要更强的QPS了,但是实现集群又要处理很多问题,redis就加入了多线程模式,开启后QPS能够增加一倍,默认是不开启的,开启需要设置一个参数:
Redis的数据类型结构与Redis特性_第43张图片
第一个是设定线程数,可以看到上面的注释,当机器是4核时,建议使用2/3个线程,8核时使用6个线程。
下面那个参数是开启线程读操作的,也可以不设置,作用就是当我们的redis在执行write操作时,其他的read操作是没有响应的,这样交互不友好,所以也需要read操作,这个手动开启。

了解上边的多reactor模式后应该能猜得出redis的多线程模式:
就是将处理流程划分成2块,main线程专门负责前边,后边交给其他线程,虽然说是多线程,但是命令执行这些仍然是单线程执行的,只不过重点是把socket的读写操作给交出去了。
Redis的数据类型结构与Redis特性_第44张图片
那么:

  • redis6.0之前就真的只使用单线程吗?其实不是的,redis只是在执行命令的时候使用单线程,但是像其他一些辅助操作比如清除无用数据,无用连接等操作都是其他线程去操作的。redis开启Thread I/O处也有说明:
    redisio
  • redis6.0之前没什么没有使用多线程?因为单核cpu运行也不会被打满,redis是受限于网络和内存,就内存而已,进行一次IO读写是需要时间的,这就导致了redis的QPS是在十万级别,用不用多线程都是一样的,而且还可以避免多线程导致的问题。

redis7.0特性

主从复制机制的更新

redis7.0对性能做了很大的优化,特别是主从复制原理,那么做了什么改动呢?
redis7.0之前的主从复制原理可以看主从复制原理

我们先来看看有什么问题需要优化:
redis在做主从复制时,会fork一个子线程来进行复制,将rdb文件发送给从节点,当有一个从节点时,就需要一个缓存区来进行数据传输,而且数据基本都是一样的,这就非常消耗空间内存,还有个复制积压区ReplicationBacklog的数据也是一样的,在做增量复制的时候会从这里复制需要的数据。涉及到多份数据,肯定就需要复制吧,数据很大时就会有相关的阻塞了。
Redis的数据类型结构与Redis特性_第45张图片

于是7.0的时候就将这些数据共享,而且将数据拆分成很多份,通过链表的方式连这些数据连接起来,比如有1024kb的数据,将它拆分成16kb,16kb …这样的块状数据,链表连接起来进行维护,
然后在链表中记录replica节点对数据块的引用,这样就能区分各个replica节点对master节点的更新情况。具体是使用引用法来记录着哪块数据块被使用着,
Redis的数据类型结构与Redis特性_第46张图片
refcount 表示 被使用数
这样就通过refcount的变动和引用的修改就能解决数据复制导致的阻塞问题了,大家都是用共享复制缓存区数据。

但是链表检索起来很费时,这时就需要索引结构了,redis使用的是rax树(前缀压缩树)结构,是通过trix树演变而来的。因为我们需要解决一个问题:当从库尝试和主库进行数据复制时,会传自己的repl_offset过来,那么redis怎么定位到对应的replBufBlock数据块呢?

对每64个replBufBlock,就会记录一个索引在rax树里,这样就能快速定位到某个起始块,然后根据链表一个个找到对应的replBufBlock,根据索引定位到链表时,检索链表也不会超过64次,效率就很高。
下图是在网络上找的rax索引示例,是个压缩索引:
Redis的数据类型结构与Redis特性_第47张图片
图中蓝色的块是因为没有了共同前缀,所以将剩下的都放在一个节点里。

就到这了。

你可能感兴趣的:(redis,redis,数据库)