我们都知道Redis提供了丰富的数据类型,常见的有五种:String(字符串),Hash(哈希),List(列表),Set(集合)、Zset(有序集合)。
随着Redis版本的更新,后面又支持了四种数据类型:BitMap(2.2版新增)、HyperLogLog(2.8版新增)、GEO(3.2版新增)、Stream(5.0版新增)。
每种数据对象都各自的应用场景,你能说出它们各自的应用场景吗?
面试过程中,这个问题也很常被问到,又比如会举例一个应用场景来问你,让你说使用哪种Redis数据类型来实现。
所以,这次我们就来学习Redis数据类型的使用以及应用场景。篇幅比较长,大家收藏慢慢看。
String是最基本的key-value结构,key是唯一标识,value是具体的值,value其实不仅是字符串,也可以是数字(整数或浮点数),value最多可以容纳的数据长度是512M。
String类型的底层的数据结构实现主要是int和SDS(简单动态字符串)。
SDS和我们认识的C字符串不太一样,之所以没有使用C语言的字符串表示,因为SDS相比于C的原生字符串:
字符串对象的内部编码(encoding)有3种:int、raw和embstr。
如果一个字符串对象保存的是整数值,并且这个整数值可以用long类型来表示,那么字符串对象会将整数值保存在字符串对象结构的ptr属性里面(将void*转换成long),并将字符串对象的编码设置为int。
如果字符串对象保存的是一个字符串,并且这个字符申的长度小于等于32字节,那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串,并将对象的编码设置为embstr,embstr编码是专门用于保存短字符串的一种优化编码方式:
如果字符串对象保存的是一个字符串,并且这个字符串的长度大于32字节,那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串,并将对象的编码设置为raw:
可以看到embstr和raw编码都会使用SDS来保存值,但不同之处在于embstr会通过一次内存分配函数来分配一块连续的内存空间来保存redisObject和SDS,而raw编码会通过调用两次内存分配函数来分别分配两块空间来保存redisObject和SDS。Redis这样做会有很多好处:
但是embstr也有缺点的:
普通字符串的基本操作:
因为Redis处理命令是单线程,所以执行命令的过程是原子的。因此String数据类型适合计数场景,比如计算访问次数、点赞、转发、库存数量等等。
可以看到,解锁是有两个操作,这时就需要Lua脚本来保证解锁的原子性,因为Redis在执行Lua脚本时,可以以原子性的方式执行,保证了锁释放操作的原子性。
List列表是简单的字符串列表,按照插入顺序排序,可以从头部或尾部向List列表添加元素。
列表的最大长度为2^32-1,也即每个列表支持超过40亿个元素。
List类型的底层数据结构是由双向链表或压缩列表实现的:
但是在Redis3.2版本之后,List数据类型底层数据结构就只由quicklist实现了,替代了双向链表和压缩列表。
Redis的List和Stream两种数据类型,就可以满足消息队列的这三个需求。我们先来了解下基于List的消息队列实现方法,后面在介绍Stream数据类型时候,在详细说说Stream。
1、如何满足消息保序需求?
List本身就是按先进先出的顺序对数据进行存取的,所以,如果使用List作为消息队列保存消息的话,就已经能满足消息保序的需求了。
List可以使用LPUSH+RPOP(或者反过来,RPUSH+LPOP)命令实现消息队列。
不过,在消费者读取数据时,有一个潜在的性能风险点。
在生产者往List中写入数据时,List并不会主动地通知消费者有新消息写入,如果消费者想要及时处理消息,就需要在程序中不停地调用RPOP命令(比如使用一个while(1)循环)。如果有新消息写入,RPOP命令就会返回结果,否则,RPOP命令返回空值,再继续循环。
所以,即使没有新消息写入List,消费者也要不停地调用RPOP命令,这就会导致消费者程序的CPU一直消耗在执行RPOP命令上,带来不必要的性能损失。
为了解决这个问题,Redis提供了BRPOP命令。BRPOP命令也称为阻塞式读取,客户端在没有读到队列数据时,自动阻塞,直到有新的数据写入队列,再开始读取新数据。和消费者程序自己不停地调用RPOP命令相比,这种方式能节省CPU开销。
2、如何处理重复的消息?
消费者要实现重复消息的判断,需要2个方面的要求:
但是List并不会为每个消息生成ID号,所以我们需要自行为每个消息生成一个全局唯一ID,生成之后,我们在用LPUSH命令把消息插入List时,需要在消息中包含这个全局唯一ID。
例如,我们执行以下命令,就把一条全局ID为111000102、库存量为99的消息插入了消息队列:
当消费者程序从List中读取一条消息后,List就不会再留存这条消息了。所以,如果消费者程序在处理消息的过程出现了故障或宕机,就会导致消息没有处理完成,那么,消费者程序再次启动后,就没法再次从List中读取消息了。
为了留存消息,List类型提供了BRPOPLPUSH命令,这个命令的作用是让消费者程序从一个List中读取消息,同时,Redis会把这个消息再插入到另一个List(可以叫作备份List)留存。
这样一来,如果消费者程序读了消息但没能正常处理,等它重启后,就可以从备份List中重新读取消息并进行处理了。
好了,到这里可以知道基于List类型的消息队列,满足消息队列的三大需求(消息保序、处理重复的消息和保证消息可靠性)。
但是,在用List做消息队列时,如果生产者消息发送很快,而消费者处理消息的速度比较慢,这就导致List中的消息越积越多,给Redis的内存带来很大压力。
要解决这个问题,就要启动多个消费者程序组成一个消费组,一起分担处理List中的消息。但是,List类型并不支持消费组的实现。
这就要说起Redis从5.0版本开始提供的Stream数据类型了,Stream同样能够满足消息队列的三大需求,而且它还支持「消费组」形式的消息读取。
Hash是一个键值对(key-value)集合,其中value的形式入:value=[{field1,value1},...{fieldN,valueN}]。Hash特别适合用于存储对象。
Hash与String对象的区别如下图所示:
Hash类型的底层数据结构是由压缩列表或哈希表实现的:
在Redis7.0中,压缩列表数据结构已经废弃了,交由listpack数据结构来实现了。
我们以用户信息为例,它在关系型数据库中的结构是这样的:
我们可以使用如下命令,将用户对象的信息存储到Hash类型:
在介绍String类型的应用场景时有所介绍,String+Json也是存储对象的一种方式,那么存储对象时,到底用String+json还是用Hash呢?
一般对象用String+Json存储,对象中某些频繁变化的属性可以考虑抽出来用Hash类型存储。
以用户id为key,商品id为field,商品数量为value,恰好构成了购物车的3个要素,如下图所示。
涉及的命令如下:
当前仅仅是将商品ID存储到了Redis中,在回显商品具体信息的时候,还需要拿着商品id查询一次数据库,获取完整的商品的信息。
Set类型是一个无序并唯一的键值集合,它的存储顺序不会按照插入的先后顺序进行存储。
一个集合最多可以存储2^32-1个元素。概念和数学中个的集合基本类似,可以交集,并集,差集等等,所以Set类型除了支持集合内的增删改查,同时还支持多个集合取交集、并集、差集。
Set类型和List类型的区别如下:
Set类型的底层数据结构是由哈希表或整数集合实现的:
Set常用操作:
因此Set类型比较适合用来数据去重和保障数据的唯一性,还可以用来统计多个集合的交集、错集和并集等,当我们存储的数据是无序并且需要去重的情况下,比较适合使用集合类型进行存储。
但是要提醒你一下,这里有一个潜在的风险。Set的差集、并集和交集的计算复杂度较高,在数据量较大的情况下,如果直接执行这些计算,会导致Redis实例阻塞。
在主从集群中,为了避免主库因为Set做聚合计算(交集、差集、并集)时导致主库被阻塞,我们可以选择一个从库完成聚合统计,或者把数据返回给客户端,由客户端来完成聚合统计。
key为抽奖活动名,value为员工名称,把所有员工名称放入抽奖箱:
有序集合保留了集合不能有重复成员的特性(分值可以重复),但不同的是,有序集合中的元素可以排序。
Zset类型的底层数据结构是由压缩列表或跳表实现的:
Zset常用操作:
在面对需要展示最新列表、排行榜等场景时,如果数据更新频繁或者需要分页显示,可以优先考虑使用SortedSet。
有序集合比较典型的使用场景就是排行榜。例如学生成绩的排名榜、游戏积分排行榜、视频播放排名、电商系统中商品的销量排名等。
我们以博文点赞排名为例,小林发表了五篇博文,分别获得赞为200、40、100、50、150。
注意:不要在分数不一致的SortSet集合中去使用ZRANGEBYLEX和ZREVRANGEBYLEX指令,因为获取的结果会不准确。
由于bit是计算机中最小的单位,使用它进行储存将非常节省空间,特别适合一些数据量大且使用二值统计的场景。
Bitmap本身是用String类型作为底层数据结构实现的一种统计二值状态的数据类型。
String类型是会保存为二进制的字节数组,所以,Redis就把字节数组的每个bit位利用起来,用来表示一个元素的二值状态,你可以把Bitmap看作是一个bit数组。
bitmap基本操作:
Redis提供了BITPOSkeybitValue[start][end]指令,返回数据表示Bitmap中第一个值为bitValue的offset位置。
在默认情况下,命令将检测整个位图,用户可以通过可选的start参数和end参数指定要检测的范围。所以我们可以通过执行这条命令来获取userID=100在2022年6月份首次打卡日期:
Bitmap提供了GETBIT、SETBIT操作,通过一个偏移值offset对bit数组的offset位置的bit位进行读写操作,需要注意的是offset从0开始。
只需要一个key=login_status表示存储用户登陆状态集合数据,将用户ID作为offset,在线就设置为1,下线设置0。通过GETBIT判断对应的用户是否在线。50000万用户只需要6MB的空间。
假如我们要判断ID=10086的用户的登陆情况:
我们把每天的日期作为Bitmap的key,userId作为offset,若是打卡则将offset位置的bit设置成1。
key对应的集合的每个bit位的数据则是一个用户在该日期的打卡记录。
一共有7个这样的Bitmap,如果我们能对这7个Bitmap的对应的bit位做『与』运算。同样的UserIDoffset都是一样的,当一个userID在7个Bitmap对应对应的offset位置的bit=1就说明该用户7天连续打卡。
结果保存到一个新Bitmap中,我们再通过BITCOUNT统计bit=1的个数便得到了连续打卡3天的用户总数了。
Redis提供了BITOPoperationdestkeykey[key...]这个指令用于对一个或者多个key的Bitmap进行位元操作。
举个例子,比如将三个bitmap进行AND操作,并将结果保存到destmap中,接着对destmap执行BITCOUNT统计。
RedisHyperLogLog是Redis2.8.9版本新增的数据类型,是一种用于「统计基数」的数据集合类型,基数统计就是指统计一个集合中不重复的元素个数。但要注意,HyperLogLog是统计规则是基于概率完成的,不是非常准确,标准误算率是0.81%。
所以,简单来说HyperLogLog提供不精确的去重计数。
HyperLogLog的优点是,在输入元素的数量或者体积非常非常大时,计算基数所需的内存空间总是固定的、并且是很小的。
在Redis里面,每个HyperLogLog键只需要花费12KB内存,就可以计算接近2^64个不同元素的基数,和元素越多就越耗费内存的Set和Hash类型相比,HyperLogLog就非常节省空间。
这什么概念?举个例子给大家对比一下。
用Java语言来说,一般long类型占用8字节,而1字节有8位,即:1byte=8bit,即long数据类型最大可以表示的数是:2^63-1。对应上面的2^64个数,假设此时有2^63-1这么多个数,从0~2^63-1,按照long以及1k=1024字节的规则来计算内存总数,就是:((2^63-1)*8/1024)K,这是很庞大的一个数,存储空间远远超过12K,而HyperLogLog却可以用12K就能统计完。
HyperLogLog的实现涉及到很多数学问题,太费脑子了,我也没有搞懂。
HyperLogLog命令很少,就三个。
所以,非常适合统计百万级以上的网页UV的场景。
在统计UV时,你可以用PFADD命令(用于向HyperLogLog中添加新元素)把访问页面的每个用户都添加到HyperLogLog中。
这也就意味着,你使用HyperLogLog统计的UV是100万,但实际的UV可能是101万。虽然误差率不算大,但是,如果你需要精确统计结果的话,最好还是继续用Set或Hash类型。
RedisGEO是Redis3.2版本新增的数据类型,主要用于存储地理位置信息,并对存储的信息进行操作。
在日常生活中,我们越来越依赖搜索“附近的餐馆”、在打车软件上叫车,这些都离不开基于位置信息服务(Location-BasedService,LBS)的应用。LBS应用访问的数据是和人或物关联的一组经纬度信息,而且要能查询相邻的经纬度范围,GEO就非常适合应用在LBS服务的场景中。
GEO本身并没有设计新的底层数据结构,而是直接使用了SortedSet集合类型。
GEO类型使用GeoHash编码方法实现了经纬度到SortedSet中元素权重分数的转换,这其中的两个关键机制就是「对二维地图做区间划分」和「对区间进行编码」。一组经纬度落在某个区间后,就用区间的编码值来表示,并把编码值作为SortedSet元素的权重分数。
这样一来,我们就可以把经纬度保存到SortedSet中,利用SortedSet提供的“按权重进行有序范围查找”的特性,实现LBS服务中频繁使用的“搜索附近”的需求。
假设车辆ID是33,经纬度位置是(116.034579,39.030452),我们可以用一个GEO集合保存所有车辆的经纬度,集合key是cars:locations。
执行下面的这个命令,就可以把ID号为33的车辆的当前经纬度位置存入GEO集合中:
例如,LBS应用执行下面的命令时,Redis会根据输入的用户的经纬度信息(116.054579,39.030452),查找以这个经纬度为中心的5公里内的车辆信息,并返回给LBS应用。
在前面介绍List类型实现的消息队列,有两个问题:1.生产者需要自行实现全局唯一ID;2.不能以消费组形式消费数据。
基于Stream类型的消息队列就解决上面的问题,它不仅支持自动生成全局唯一ID,而且支持以消费组形式消费数据。
Stream消息队列操作命令:
生产者通过XADD命令插入一条消息:
消费者通过XREAD命令从消息队列中读取消息时,可以指定一个消息ID,并从这个消息ID的下一条消息开始进行读取(注意是输入消息ID的下一条信息开始读取,不是查询输入ID的消息)。
比如,下面这命令,设置了block10000的配置项,10000的单位是毫秒,表明XREAD在读取最新消息时,如果没有消息到来,XREAD将阻塞10000毫秒(即10秒),然后再返回。
Stream可以以使用XGROUP创建消费组,创建消费组之后,Stream可以使用XREADGROUP命令让消费组内的消费者读取消息。
创建一个名为group1的消费组,这个消费组消费的消息队列是mymq:
比如说,我们执行完刚才的XREADGROUP命令后,再执行一次同样的命令,此时读到的就是空值了:
例如,我们执行下列命令,让group2中的consumer1、2、3各自读取一条消息。
Streams会自动使用内部队列(也称为PENDINGList)留存消费组里每个消费者读取的消息,直到消费者使用XACK命令通知Streams“消息已经处理完成”。
如果消费者没有成功处理消息,它就不会给Streams发送XACK命令,消息仍然会留存。此时,消费者可以在重启后,用XPENDING命令查看已读取、但尚未确认处理完成的消息。
例如,我们来查看一下group2中各个消费者已读取、但尚未确认的消息个数,命令如下:
一旦消息1654256265584-0被consumer2处理了,consumer2就可以使用XACK命令通知Streams,然后这条消息就会被删除。
Redis基于Stream消息队列与专业的消息队列有哪些差距?
一个专业的消息队列,必须要做到两大块:
1、RedisStream消息会丢失吗?
使用一个消息队列,其实就分为三大块:生产者、队列中间件、消费者,所以要保证消息就是保证三个环节都不能丢失数据。
RedisStream消息队列能不能保证三个环节都不丢失数据?
可以看到,Redis在队列中间件环节无法保证消息不丢。像RabbitMQ或Kafka这类专业的队列中间件,在使用时是部署一个集群,生产者在发布消息时,队列中间件通常会写「多个节点」,也就是有多个副本,这样一来,即便其中一个节点挂了,也能保证集群的数据不丢失。
2、RedisStream消息可堆积吗?
Redis的数据都存储在内存中,这就意味着一旦发生消息积压,则会导致Redis的内存持续增长,如果超过机器内存上限,就会面临被OOM的风险。所以Redis的Stream提供了可以指定队列最大长度的功能,就是为了避免这种情况发生。
但Kafka、RabbitMQ专业的消息队列它们的数据都是存储在磁盘上,当消息积压时,无非就是多占用一些磁盘空间。
因此,把Redis当作队列来使用时,会面临的2个问题:
所以,能不能将Redis作为消息队列来使用,关键看你的业务场景:
参考资料:
Redis常见的五种数据类型:**String(字符串),Hash(哈希),List(列表),Set(集合)及Zset(sortedset:有序集合)**。
这五种数据类型与底层数据结构对应关系图如下,左边是Redis3.0版本的,也就是《Redis设计与实现》这本书讲解的版本,现在看还是有点过时了,右边是现在Github最新的Redis代码的。
可以看到,Redis数据类型的底层数据结构随着版本的更新也有所不同,比如: