思路
前几天在群里,看到几个朋友在聊Redis,我感觉很多人把Redis看的太简单了,总觉得Redis就是get,set。其实表面上确实是get,set。但是这个get,set也不是随便用的,也涉及很多底层技术的了解才能在项目上得心应手!
整篇文章大概的思路就是把Redis的五大常用数据类型以及三个扩展类型从头梳理一下。希望能帮助你在真实的业务场景中,可以选择更优的数据类型。
String
String类型是我们大多数业务需求中,最先考虑到的一个类型,但是这个类型也是歧义最大的,Java中string字符串是由字符决定的,每个汉字是2个字节,每个英文是1个字节。在Redis中不是2个字节,也不是1个,4个。而是 64个字节。
优点: String类型可以保存字符串,JSON字符串,二进制字节流等,几乎用String类型可以解决万事万物。简单,省事,粗暴。
缺点: 内存占用过大
String除了记录实际数据以外,还需要额外的空间存储数据长度,空间使用等信息,这些信息也叫过元信息。如果保存的数据比较大还好,还划算一些。如果保存的是一些比较小的数值就非常不划算了。
String类型主要有三种编码方式保存数据,如下图(图片来自蒋德均老师)
- 当保存64位有符号整数时,String类型会把它保存为一个8个字节的Long类型整数,这种方式也叫 int编码
- 当保存是字符时,String类型就会用简单动态字符串结构体来保存,(下文中的简单动态字符串我们就用SDS来保存了)。如下图所示。
- len 表示buf的已用长度
- alloc 表示buf 的实际分配长度
- buf 表示真实数据(因为Redis是C写的,所以末尾结束符是 ‘\0’ )
除了String类型自身的结构占用,还有Redis外层的结构占用,外层的结构下文我们就称为RedisObject(主要包含最后一次的访问时间,被引用的次数等)。
一个RedisObject包含8个字节的元数据和8字节指针,这个指针再指向上面的SDS。
Redis在保存Long类型整数时,RedisObject中的指针直接赋值为整数数据,这样就不用额外的指针再指向整数了,同时也节省了指针的空间开销。
Redis在保存的字符串数据小于44字节,RedisObject中的元数据,指针和SDS是一块连续的区域,这样可以避免内存碎片,这种布局方式也被称为 enbstr编码
- Redis保存的字符串数据大于44字节时,SDS的数据量就越来越多了,为了内存考虑,Redis就不把SDS与RedisObject放一块了,而是会给SDS分配独立的空间,并用指针指向SDS结构。这种布局方式也被称为 raw编码
介绍完String类型的三种编码方式,RedisObject,SDS,这两个结构体占用不了64字节,那么剩余的内存是由什么占用的呢?dictEntry结构体 和 jemalloc
Redis会用一个全局哈希表保存所有的键值对关系,哈希表的每一项是一个 dictEntry 的结构体,用来指向一个键值对。dictEntry 结构中有三个 8 字节的指针,分别指向 key、value 以及下一个 dictEntry,三个指针共 24 字节,如下图所示
jemalloc是Redis使用的内存分配库,他会根据我们申请的字节数A,找一个比A大的,但是最接近A的2的幂次数作为分配空间,这样可以减少频繁分配的次数。
举个例子,如果我们要存放20位的订单号,我们申请一个8个字节的Long类型,jemalloc实际会分配16个字节。所以dictEntry会占用16个字节。16(数据分配的长度)+24(dictEntry指针)+16(RedisObject)+12(SDS)
以上就是为什么Redis的String类型占用64字节的原理。从SDS,RedisObject,dictEntry,jemalloc分配。
ziplist
ziplist是压缩列表,list类型的底层实现是双向列表+压缩列表!
String内存过大的这个缺点,可以考虑部分需求采用压缩列表ziplist来存储。这是一种非常节省内存的结构。
压缩列表主要有表头,数据,表尾,列表结束。表头:有三个字段 zlbytes、zltail 和 zllen,分别表示列表长度、列表尾的偏移量,以及列表中的 entry 个数。之所以能节省内存是因为它是使用一条连续的entry保存数据的。这些enrty会挨个位置放在内存中,不需要额外的指针进行连接,这样就节省了指针的空间占用。
enrey
- prev_len,表示前一个 entry 的长度。prev_len 有两种取值情况:1 字节或 5 字节。取值 1 字节时,表示上一个 entry 的长度小于 254 字节。虽然 1 字节的值能表示的数值范围是 0 到 255,但是压缩列表中 zlend 的取值默认是 255,因此,就默认用 255 表示整个压缩列表的结束,其他表示长度的地方就不能再用 255 这个值了。所以,当上一个 entry 长度小于 254 字节时,prev_len 取值为 1 字节,否则,就取值为 5 字节。
- len:表示自身长度,占用4 字节;
- encoding:表示编码方式,占用1 字节;
- content:保存实际数据。
Hash
在处理单值的键值对时,可以采用基于 Hash 类型的二级编码方法。二级编码就是把一个单值的数据拆分成两部分,前一部分作为 Hash 集合的 key,后一部分作为 Hash 集合的 value,这样我们就可以把单值数据保存到 Hash 集合中了。
String类型消耗了64字节,采用Hash二级编码的方式只用了16字节。满足了我们节省内存空间的需求。我们在第一篇中介绍了Hash的底层是由压缩列表+哈希表实现的。那么什么时候使用压缩列表,什么时候使用哈希表呢?
用压缩列表
Hash 类型设置了用压缩列表保存数据时的两个阈值,一旦超过了阈值,Hash 类型就会用哈希表来保存数据了。
- hash-max-ziplist-entries:表示用压缩列表保存时哈希集合中的最大元素个数。
- hash-max-ziplist-value:表示用压缩列表保存时哈希集合中单个元素的最大长度。
聚合统计
聚合统计,就是指统计多个集合元素的聚合结果。交集统计,差集统计,并集统计等。
交集统计:处理有些留存用户,连续签到,连续登录这些业务需求,共同好友,共同关注等
差集统计:处理每天新增的用户
并集统计:处理每种情况的总和数据
交,差,并集处理中,我们首选的还是Set集合。但是Set集合的计算复杂度较高,在数据量较大的情况下,如果直接计执行这些计算会导致Redis实例阻塞。所以在平时处理时,可以从主从集群中选择一个从库,让它专门负责聚合计算,或者是把数据读取到客户端,在客户端来完成聚合统计,这样就可以规避阻塞主库实例和其他从库实例的风险了。
排序统计
排序的话能处理的类型就多了。Redis有4种集合类型(List、Hash、Set、Sorted Set)。list和sorted set属于有序集合,hash和set属于无序集合。
list是根据插入的顺序进行排序的,sorted set属于根据某个字段值的权重进行排序的。具体选择哪个还是要看业务需求,下面我们分别介绍一下两种的利弊。
list
举一个商品评论的例子,如果处理商品商品的例子时,list恐怕就不行了,简单来说list处理不了带有分页需求的场景。
假设当前的评论 List 是{A, B, C, D, E, F}(其中,A 是最新的评论,以此类推,F 是最早的评论),在展示第一页的 3 个评论时,可以得到 A、B、C。展示第二页的3个评论时,可以得到D、E、F。如果在展示第二页前,来了一条新评论,这个时候的集合就为{G, A, B, C, D, E, F}。这个时候就变成了C、D、E。
这个时候C就被展示了两遍,如果新增数据量大的话,有可能第一页展示一遍的数据之后,第二页还会再显示一遍。这样的业务场景就不符合了。
sorted set
和list相比,sort不存在这个问题,它是根据实际权重来排序和获取数据的。我们可以按照评论的创建时间排序然后保存到sorted set集合中。这样的话即使数据频繁更新,每次插入时也会根据评论的创建时间排序重新获取。
所以,在面对需要展示最新列表、排行榜等场景时,如果数据更新频繁或者需要分页显示,建议你优先考虑使用 Sorted Set。
二值状态统计
二值状态统计可以采用Bitmap。Bitmap本身是用String类型作为底层数据结构实现的一种统计二值状态的数据类型。String 类型是会保存为二进制的字节数组,所以,Redis 就把字节数组的每个 bit 位利用起来,用来表示一个元素的二值状态。
二值状态的起始值是从0开始的,当对这个bit进行写操作时,就会变成1。如果只需要统计数据的二值状态,例如商品有没有、用户在不在等,就可以使用 Bitmap,因为它只用一个 bit 位就能表示 0 或 1。在记录海量数据时,Bitmap 能够有效地节省内存空间。
基数统计
基数统计就是指统计一个集合中不重复的元素个数。对应到我们刚才介绍的场景中,就是统计网页的 UV。我们首先考虑的数据类型就是Set类型。Set的去重功能保证了不会重复记录一个用户的访问次数。这样用户就是独立的档案了。
set虽好但是有一个缺点就是如果每个页面的火爆程度不一致,就会导致某个页面的访问用户达到了千万,某个冷门界面寥寥无几的几个人。这样的确是可能存在的,双十一就是一个很好的例子。
我们可以考虑一下HyperLogLog类型。HyperLogLog 是一种用于统计基数的数据集合类型,它的最大优势就在于,当集合元素数量非常多时,它计算基数所需的空间总是固定的,而且还很小。
在 Redis 中,每个 HyperLogLog 只需要花费 12 KB 内存,就可以计算接近 2^64 个元素的基数。你看,和元素越多就越耗费内存的 Set 和 Hash 类型相比,HyperLogLog 就非常节省空间。
不过,有一点需要注意一下,HyperLogLog 的统计规则是基于概率完成的,所以它给出的统计结果是有一定误差的,标准误算率是 0.81%。这也就意味着,你使用 HyperLogLog 统计的 UV 是 100 万,但实际的 UV 可能是 101 万。虽然误差率不算大,但是,如果你需要精确统计结果的话,最好还是继续用 Set 或 Hash 类型。
GEO地理位置
日常生活中,附近的餐馆,附件的王者荣耀队友,微信摇一摇附近的人,打车软件等都是GEO的应用。
聊到GEO必然离不开GeoHash编码了,为了能高效地对经纬度进行比较,Redis 采用了业界广泛使用的 GeoHash 编码方法,这个方法的基本原理就是 “二分区间,区间编码”。
我们要对一组经纬度编码,肯定要分别把经度编码,再把纬度编码,最终再把经纬度的编码合成一个编码。这个来了解一下编码过程吧。
GeoHash编码会把一个经度编码成一个N位的二进制值,它的经度范围是[-180,180],我们对经纬度范围做N次二分区操作时,经度范围[-180,180]会被分成两个子区间:[-180,0) 和[0,180](我称之为左、右分区)。此时,我们可以查看一下要编码的经度值落在了左分区还是右分区。如果是落在左分区,我们就用 0 表示;如果落在右分区,就用 1 表示。这样一来,每做完一次二分区,我们就可以得到 1 位编码值。
得出经纬度的各自编码值,下面的工作就是把两个编码值合成一个了。
- 拿经度的第0位,充当新编码的第0位。
- 拿纬度的第0位,充当新编码的第1位。
- 以此类推。。。。
用了 GeoHash 编码后,原来无法用一个权重分数表示的一组经纬度(116.37,39.86)就可以用 1110011101 这一个值来表示,就可以保存为 Sorted Set 的权重分数了。
如何保存时间序列数据
这个应该算是一道面试题了,也算是对上述介绍的Redis数据类型的一个检验吧。下面我们了解一下
如何保存时间序列数据呢?我举一个类似的业务场景的例子吧。养殖棚里要记录一天的气温变化,或者工厂里的气温变化。如果是你,你会怎么处理呢?可以先思考一下!
分析思路:数据是源源不断写入的,所以通常是持续的高并发写入,比如数万个设备的实时状态值。一旦插入之后就不会有改动,也就是说修改几乎没有。这种业务需求还是比较好处理的,只需要写入快就好了!
我们可以采用Hash类型和SortedSet类型共同实现这个需求。
- Hash类型的插入复杂度是O(1),可以解决插入速度的要求
- Hash很快但是致命的缺点就是不支持范围查询,所以我们需要借助SorttedSet这个有序的特性实现范围查找**(把时间戳作为Hash的key)**
- 数据的原子性我们可以依赖Redis的 MULTI 和 EXEC 帮我们完成
一路顺风,但是拿到的数据我们无法得到有效的分析,因为SortedSet支持范围查找,无法直接进行聚合计算,所以这里的唯一办法就是把Redis的数据拿到客户端通过程序我们来处理。
如果是这样的话另一个风险点又出现了,客户端与Redis大量的交互数据,这会和其他操作命令竞争网络资源,导致其他操作变慢。
无奈之下,我们采用了 RedisTimeSeries ,上面那种方案还是可以的,可以学习那种各取所长的特性完成我们的业务需求。
RedisTimeSeries是Redis的一个扩展模块,它专门面向时间序列数据提供了数据类型和访问接口,并且支持在Redis实例直接对数据进行按时间范围的聚合计算。
这个类型了解一下即可!
因为 RedisTimeSeries 不属于 Redis 的内建功能模块,在使用时,我们需要先把它的源码单独编译成动态链接库 redistimeseries.so,再使用 loadmodule 命令进行加载。
当用于时间序列数据存取时,RedisTimeSeries 的操作主要有 5 个:
- 用 TS.CREATE 命令创建时间序列数据集合;
- 用 TS.ADD 命令插入数据;
- 用 TS.GET 命令读取最新数据;
- 用 TS.MGET 命令按标签过滤查询数据集合;
- 用 TS.RANGE 支持聚合计算的范围查询。
Redis可以作为消息队列?
Redis是可以作为消息队列的,它是一个非常优秀的轻量级消息队列。
Redis在存储数据时,必须满足消息保序,重复消息处理,消息可靠性保证
消息保序
一定要确保每个消息进入Redis的顺序的一致的,因为如果不是有序的话,整个数据就乱了。比如修改库存一样,第一条记录修改为5,第二条记录修改为3。如果顺序又问题,就会先执行修改为3再修改为5。这样就和我们预定的效果不一样了。
所以一定要保证每条消息进入的顺序以及排序顺序。这里我们可以优先想到List集合处理。
重复消息处理
这点也是MQ的致命问题,这里简单介绍一下,后续介绍MQ的时候再详细聊一下。
消费者从消息队列读取消息时,有时会因为网络问题出现消息重传。如果消费者收到了消息并且多次执行同一个重复消息就嗝屁了。我们还是举上述商品的库存为例。如果购买商品,原本买了一件,库存减一件。消费两次的话,库存的值就不对了。这样肯定是不行的。
消息可靠性保证
一旦服务宕机,肯定要有恢复数据的能力,要不然毫无安全措施,怎么用呢?
技术选型分析
通过上述三种要求,我们可以定位到List类型和Stream两种数据类型。我们先来了解一下List如何处理消息队列问题。
List集合本身就是按先进先出的顺序对数据进行存取的,支持LPUSH,LPOP,RPUSH,RPOP。所以如果用List作为消息队列的保序问题是再合适不过的了。
扩展 :list不会主动通知消费者的功能,如果想要及时的处理的话可以采用while1的方式,这种方式非常损耗性能。
关于List的消息通知问题可以采用Redis提供的BRPOP命令。也称阻塞式读取,客户端在没有读到队列数据时,自动阻塞,直到有新的数据写入队列,再开始读取新数据。和消费者程序自己不停地调用 RPOP 命令相比,这种方式能节省 CPU 开销。
接着再解决消息重复的问题。消息队列要给每一个消息提供全局唯一ID,同时消费者也要把处理的每一个消息的ID都记录下来。通过双重校验的方式就不会重复消费了。
下面就是消息可靠性问题了,List是不支持可靠性的。Redis提供了BRPOPLPUSH命令。这个命令的作用是让消费者程序从一个 List 中读取消息,同时,Redis 会把这个消息再插入到另一个 List(可以叫作备份 List)留存。这样一来,如果消费者程序读了消息但没能正常处理,等它重启后,就可以从备份 List 中重新读取消息并进行处理了。
当生产者消息发送很快,而消费者处理消息的速度比较慢,这就导致 List 中的消息越积越多,给 Redis 的内存带来很大压力。这个时候,我们希望启动多个消费者程序组成一个消费组,一起分担处理 List 中的消息。但是,List 类型并不支持消费组的实现,Stream就诞生了!
介绍完用List实现的相关原理,我们介绍一下Stream的相关实现吧。
Streams 是 Redis 专门为消息队列设计的数据类型,它提供了丰富的消息队列操作命令。
- XADD:插入消息,保证有序,可以自动生成全局唯一 ID;
- XREAD:用于读取消息,可以按 ID 读取数据;
- XREADGROUP:按消费组形式读取消息;
- XPENDING 和 XACK:XPENDING 命令可以用来查询每个消费组内所有消费者已读取但尚未确认的消息,而 XACK 命令用于向消息队列确认消息处理已完成。
不论是全局ID问题,还是重复消费问题,Redis都提供了,暂时没啥好讲的。后续有知识的深入再回头输出吧!
结尾
- 主要介绍了Redis的类型的底层实现以及技术,类型选择的依据
- 通过时间序列数据引出多种类型的搭配使用思路以及扩展一下RedisTimeSeries模块的使用。
- Redis作为消息队列也是高频的面试问题,通过这一问题延伸了List的优劣和Streams的应用
每个知识点都是自己整理浓缩表达出来的,部分有些不容易懂的地方请及时指出,我们一起共同进步!