Redis 使用场景

Redis 使用场景

聚合统计

统计手机 App 每天的新增用戶数和第二天的留存用戶数,正好对应了聚合统计。

要完成这个统计任务,我们可以用一个集合记录所有登录过App的用戶ID,同时,用另一个集合记录每一天登录过App的用戶ID。然后,再对这两个集合做聚合统计。我们来看下具体的操作。

记录所有登录过App的用戶ID还是比较简单的,我们可以直接使用Set类型,把key设置为user280680,表示记录的是用戶ID,value就是一个Set集合,里面是所有登录过App的用戶ID,我们可以把这个Set叫作累计用戶Set,如下图所示:

需要注意的是,累计用戶Set中没有日期信息,我们是不能直接统计每天的新增用戶的。所以,我们还需要把每一天登录的用戶ID,记录到一个新集合中,我们把这个集合叫作每日用戶Set,它有两个特点:

    1. key是user280680以及当天日期,例如user280680:20200803
    1. value是Set集合,记录当天登录的用戶ID。

在统计每天的新增用戶时,我们只用计算每日用戶Set和累计用戶Set的差集就行。

Set的差集、并集和交集的计算复杂度较高,在数据量较大的情况下,如果直接执行这些计算,会导致Redis实例阻塞。所以,我给你分享一个小建议:你可以从主从集群中选择一个从库,让它专⻔负责聚合计算,或者是把数据读取到客戶端,在客戶端来完成聚合统计者是把数据读取到客戶端,在客戶端来完成聚合统计,这样就可以规避阻塞主库实例和其他从库实例的⻛险了。

排序统计

最新评论列表

最新评论列表包含了所有评论中的最新留言,这就要求集合类型能对元素保序,也就是说,集合中的元素可以按序排列,这种对元素保序的集合类型叫作有序集合。

在Redis常用的4个集合类型中(List、Hash、Set、Sorted Set),List和Sorted Set就属于有序集合。

List是按照元素进入List的顺序进行排序的,而Sorted Set可以根据元素的权重来排序,我们可以自己来决定每个元素的权重值。比如说,我们可以根据元素插入Sorted Set的时间确定权重值,先插入的元素权重小,后插入的元素权重大。

我先说说用List的情况。每个商品对应一个List,这个List包含了对这个商品的所有评论,而且会按照评论时间保存这些评论,每来一个新评论,就用LPUSH命令把它插入List的队头。

在只有一⻚评论的时候,我们可以很清晰地看到最新的评论,但是,在实际应用中,网站一般会分⻚显示最新的评论列表,一旦涉及到分⻚操作,List就可能会出现问题了。

二值状态统计

所以,如果只需要统计数据的二值状态,例如商品有没有、用戶在不在等,就可以使用Bitmap,因为它只用一个bit位就能表示0或1。在记录海量数据时,Bitmap能够有效地节省内存空间。

String 不适合存储大量键值对

当时,我们要开发一个图片存储系统,要求这个系统能快速地记录图片ID和图片在存储系统中保存时的ID(可以直接叫作图片存储对象ID)。同时,还要能够根据图片ID快速查找到图片存储对象ID。

图片ID和图片存储对象ID正好一一对应,是典型的“键-单值”模式。所谓的“单值”,就是指键值对中的值就是一个值,而不是一个集合,这和String类型提供的“一个键对应一个值的数据”的保存形式刚好契合。

而且,String类型可以保存二进制字节流,就像“万金油”一样,只要把数据转成二进制字节数组,就可以保存了。

刚开始,我们保存了1亿张图片,大约用了6.4GB的内存。但是,随着图片数据量的不断增加,我们的Redis内存使用量也在增加,结果就遇到了大内存Redis实例因为生成RDB而响应变慢的问题。很显然,String类型并不是一种好的选择,我们还需要进一步寻找能节省内存开销的数据类型方案。

String类型并不是适用于所有场合的,它有一个明显的短板,就是它保存数据时所消耗的内存空间较多

同时,我还仔细研究了集合类型的数据结构。我发现,集合类型有非常节省内存空间的底层实现结构,但是,集合类型保存的数据模式,是一个键对应一系列值,并不适合直接保存单值的键值对。所以,我们就使用二级编码的方法,实现了用集合类型保存单值键值对,Redis实例的内存空间消耗明显下降了。

String 内存开销大

在刚才的案例中,我们保存了1亿张图片的信息,用了约6.4GB的内存,一个图片ID和图片存储对象ID的记录平均用了64字节。

但问题是,一组图片ID及其存储对象ID的记录,实际只需要16字节就可以了。

其实,除了记录实际数据,String类型还需要额外的内存空间记录数据⻓度、空间使用等信息,这些信息也叫作元数据。当实际保存的数据较小时,元数据的空间开销就显得比较大了,有点“喧宾夺主”的意思。

当你保存64位有符号整数时,String类型会把它保存为一个8字节的Long类型整数,这种保存方式通常也叫作int编码方式。

但是,当你保存的数据中包含字符时,String类型就会用简单动态字符串(Simple Dynamic String,SDS)结构体来保存,如下图所示:

可以看到,在SDS中,buf保存实际数据,而lenalloc本身其实是SDS结构体的额外开销。另外,对于String类型来说,除了SDS的额外开销,还有一个来自于RedisObject结构体的开销。

因为Redis的数据类型有很多,而且,不同数据类型都有些相同的元数据要记录(比如最后一次访问的时间、被引用的次数等),所以,Redis会用一个RedisObject结构体来统一记录这些元数据,同时指向实际数据。

一个RedisObject包含了8字节的元数据和一个8字节指针,这个指针再进一步指向具体数据类型的实际数据所在,例如指向String类型的SDS结构所在的内存地址,可以看一下下面的示意图。

为了节省内存空间,Redis还对Long类型整数和SDS的内存布局做了专⻔的设计。

一方面,当保存的是Long类型整数时,RedisObject中的指针就直接赋值为整数数据了,这样就不用额外的指针再指向整数了,节省了指针的空间开销。

另一方面,当保存的是字符串数据,并且字符串小于等于44字节时,RedisObject中的元数据、指针和SDS是一块连续的内存区域,这样就可以避免内存碎片。这种布局方式也被称为embstr编码方式。

当然,当字符串大于44字节时,SDS的数据量就开始变多了,Redis就不再把SDS和RedisObject布局在一起了,而是会给SDS分配独立的空间,并用指针指向SDS结构。这种布局方式被称为raw编码模式。

因为10位数的图片ID和图片存储对象ID是Long类型整数,所以可以直接用int编码的RedisObject保存。每个int编码的RedisObject元数据部分占8字节,指针部分被直接赋值为8字节的整数了。此时,每个ID会使用16字节,加起来一共是32字节。但是,另外的32字节去哪儿了呢?

Redis会使用一个全局哈希表保存所有键值对,哈希表的每一项是一个dictEntry的结构体,用来指向一个键值对。dictEntry结构中有三个8字节的指针,分别指向key、value以及下一个dictEntry,三个指针共24字节,如下图所示:

但是,这三个指针只有24字节,为什么会占用了32字节呢?这就要提到Redis使用的内存分配库jemalloc了。

jemalloc在分配内存时,会根据我们申请的字节数N,找一个比N大,但是最接近N的2的幂次数作为分配的空间,这样可以减少频繁分配的次数。

如果你申请6字节空间,jemalloc实际会分配8字节空间;如果你申请24字节空间,jemalloc则会分配32字节。所以,在我们刚刚说的场景里,dictEntry结构就占用了32字节。

如果要保存的图片有1亿张,那么1亿条的图片ID记录就需要6.4GB内存空间,其中有4.8GB的内存空间都用来保存元数据了,额外的内存空间开销很大。

Hash 类型二级编码保存

在保存单值的键值对时,可以采用基于Hash类型的二级编码方法。这里说的二级编码,就是把一个单值的数据拆分成两部分,前一部分作为Hash集合的key,后一部分作为Hash集合的value,这样一来,我们就可以把单值数据保存到Hash集合中了。

以图片ID 1101000060和图片存储对象ID 3302000080为例,我们可以把图片ID的前7位(1101000)作为Hash类型的键,把图片ID的最后3位(060)和图片存储对象ID分别作为Hash类型值中的key和value。

不过,你可能也会有疑惑:“二级编码一定要把图片ID的前7位作为Hash类型的键,把最后3位作为Hash类型值中的key吗?”其实,二级编码方法中采用的ID⻓度是有讲究的。

Redis Hash类型的两种底层实现结构,分别是压缩列表和哈希表。

Hash类型设置了用压缩列表保存数据时的两个阈值,一旦超过了阈值,Hash类型就会用哈希表来保存数据了。

  • hash-max-ziplist-entries:表示用压缩列表保存时哈希集合中的最大元素个数。
  • hash-max-ziplist-value:表示用压缩列表保存时哈希集合中单个元素的最大⻓度。

如果我们往Hash集合中写入的元素个数超过了hash-max-ziplist-entries,或者写入的单个元素大小超过了hash-max-ziplist-value,Redis就会自动把Hash类型的实现结构由压缩列表转为哈希表。

一旦从压缩列表转为了哈希表,Hash类型就会一直用哈希表进行保存,而不会再转回压缩列表了。在节省内存空间方面,哈希表就没有压缩列表那么高效了。

为了能充分使用压缩列表的精简内存布局,我们一般要控制保存在Hash集合中的元素个数。所以,在刚才的二级编码中,我们只用图片ID最后3位作为Hash集合的key,也就保证了Hash集合的元素个数不超过1000,同时,我们把hash-max-ziplist-entries设置为1000,这样一来,Hash集合就可以一直使用压缩列表来节省内存空间了。

保持时间序列数据

时间序列数据的写入特点是要能快速写入,而查询的特点有三个:

  • 点查询,根据一个时间戳,查询相应时间的数据;
  • 范围查询,查询起始和截止时间戳范围内的数据;
  • 聚合计算,针对起始和截止时间戳范围内的所有数据进行计算,例如求最大/最小值,求均值等