Redis场景使用与思考(一)

 

前言:在工作里面,大家肯定肯定或多或少都用过redis,绝大部分使用redis用作缓存,一部分功能用redis的其他功能。

那么说到redis这个非关系型数据库,想必在我们的工作里面的都思考过。

1.什么时候时候该使用redis呢?或者说什么场景下使用redis比较好。

2.选用redis的哪种数据结构呢?

3.该怎么使用redis呢?因为大部分时候使用redis时,通常配合数据库的一起使用,那么就会想到缓存不一致的问题,那么针对不同的场景,我们应该怎么选择使用的方式呢?

4.那么用的redis,不可避免的就得抉择redis部署方式。

在下面的个人总结里面,我会主要阐述我对前三点的理解;至于第四点,我们知道目前主流的部署方式有单机部署,哨兵部署,集群部署,但是呢,选择哪种方式的部署必须要考虑到整个服务压力等等各种情况而言的,在这里我就暂且不讨论太多。

因为,本人目前主要负责一些运营活动的开发工作。那么结合一些实际的情况来谈谈我的一些想法。

1.redis的数据类型

  我们知道redis是个内存的非关系型数据库。在绝大多数场景下,redis都被用作缓存的存在。而redis之所以被用作缓存,主要得益于两点,(1)是redis是基于内存的, 因此存取就快,显然比mysql这种还需要从b+树扫描或者全表扫描的数据库更快;(2)redis处理数据是单线程。

在讨论redis使用场景前,先来看看redis的几种常见数据类型,只要弄清楚了几个数据类型,我们才能更好地使用的redis。

  • String
  • hash
  • List
  • sort
  • zsort 

1.1String

先来看看String,String是key-value类型的。

常用的命令有:get,set,decr,incr,mset,setex,setnx

String是种最常见的数据类型,现在被大量的应用到了缓存里面去了。但是认识String,还必须了解一下redis String的底层数据结构,String其实用简单动态字符串(Simple Dynamic String,SDS)来保存;redis会用redisObject来同一去保存不同数据类型的数据,对于SDS主要是以下的结构:

  • alloc是指实际分配的长度,占4B
  • len是指实际占用的长度,占4B
  • buf是指保存的String类型,后面还要加\0表示结束。

而实际上redis是以一个redisObject保存一个对象的。因此在一个redisObject里面是这样保存的。

而实际上,String里面存int,String都有点不同,这点暂且按下不表。

因为redis是个hash表,所以里面的结构是这样的。在一个dictionaryEntity里面存储这些字段

在这里我们可以简单的做运算,倘若存一个hello world 的key value ,对于key来说,元数据(8B)+ptr(8B)+SDS(4B+4B+5B)=29B;value也是类似计算也是29B,然后在*Key+*Value+*Next=24B,总共起来29B+29B+24B=82B,但因为Redis 使用的内存分配库 jemalloc,所以会选择和2^n次方最近的数进行补全,因此一个简单的key-value占128B;

1KW的这样简单的key-value约占10000000*128/1024/1024/1024= 1.19GB。

所以需要警惕的是简简单单的一个key-value若是在一些大型高并发场景使用时,可能面对的是每天数十亿的redis缓存,需要认真考虑考虑,redis的部署方式,不然多大的内存都可能不够用。

 说完redis String类型的存储方式后,再来看看redis的一些常用的函数,提示着我们关于redis String的用法。对于incr,decr,用于对key的value的串行加减操作,再加上redis的内存快速的存储操作的结构,incr就很适合公众号之类的阅读量,或者是一些埋点,统计的操作。

对于setnx,很多人就立马想到了分布式锁的操作,if absent的操作,确实在一些低并发,普通场景下确实挺好用的。

setex则是对缓存设置一个过期时间,实际上设置过期时间是个非常重要的特性。对于那些热点数据而言,怎么设置一个过期时间是个需要考虑的点。

1.2hash

在讲hash前,先讲讲hash的底层数据结构,根据《Redis的设计与实现》一书,介绍了hash的底层有两种数据结构,一种是压缩列表,一种是哈希表。

那么什么时候用的压缩列表,什么时候用的是哈希表呢?

redis里面有个这样的参数:hash-max-ziplist-entries:表示用压缩列表保存哈希集合中的最大个数,一般默认是512个。

在这里我们可以先看看压缩列表的结构图

zlbytes 代表列表的长度

zltail 代表列表尾的偏移量

zlen 代表entry的个数

zlend 代表列表的结束

对于hash的ziplist来说,对于一个key的field 和value是连在一起的,因此就是说Entry1是field,Entry2是Entry1的value。

而一个entry里面的结构如下

prev_len 是 前一个entry的长度,prev_len有两种取值,一种是1B,一种是5B;当上一个entry长度小于254B时,取1B;反之去5B;因为ziplist结构的zlend默认用的是255,所以即便1字节能表达0-255,也不用255;

encoding:表示编码方式,1B

len 表示自身长度,4B

content 表示内容

这里继续String里面存1KW的数据比较,这里为了方便计算,我们把hash-max-ziplist-entries变成1000,1KW的数据量大约可以生成10000个的Hash。如果我们简单的运算一下,我们key和value都是简单的对象,对于一个Entry来说是1+4+1+5=11B,但是因为内存分配的原因,实际分配的是16B,key-value总共就32B,1KW的数据大约是32*10000000/1024/1024/1024约等于0.29G,在加上10000个ziplist的长度统计值等仍远小于完全用String类型保存1KW的数据.

常用的命令有: hget,hset,hgetall等。

 根据上面的介绍,可以看到,使用hash类型,很重要的一点就是对key分好类,比如保存一个用户信息,可以用用户id作为key;姓名,年龄,身高等作field和value保存

1.3List

根据<redis设计与实现>里面说到,List的底层数据结构是zipList(压缩列表)或者LinkedList(双端链表)

zipList

LinkedList

 

 注意:

在两种情况下默认选用zipList

1.列表对象保存的所有的字符串元素长度都小于64字节。

2.列表对象保存的元素数量小于512个.

list的一个功能就是可以用作消息队列,比如RPUSH,LPOP命令。把消息都插入到队列尾,然后不停的取队列头的消息。

1.4Set

根据<Redis的设计与实现>,Set的底层数据结构是整型数组或者哈希表。

整型数组:

 hashtable

而对于Set集合来说:常用的命令有:SADD,SPOP,SCARD,SISMEMBER,SMEMBERS,SRANDMEMER,SREM

SADD->往集合里面添加对象

SPOP-> 从集合里面随机选取对象返回,并在集合里面删除这个 对象。

SRANDMEMER->使用  SRANDMEMBER KEY [count]随机返回一个或多个

根据以上几个命令特点,我们其实可以在抽奖的时候的使用到,如果对于一些随机的抽奖,且对于抽奖概率没要求的场景下可以使用SPOP来抽奖;如果对于概率一定的场景使用redis的set抽奖,我们可以使用SRANDMEMER按概率随机抽奖,但是需要注意的是,对于大多数场景,我们的抽奖是有限的,因此对于抽奖,我们需要另一个辅助对象,维持剩余奖品的数量。

除此之外,set集合对象还有SDIFF key1 [key2]SINTER key1 [key2]SUNION key1 [key2]几个命令。

SDIFF是求两个集合的差集;

SINTER是求两个集合的交集;

SUNION是求两个集合的并集;

看到这几个命令,想想新浪微博的一些功能,比如,我和他(她)的共同关注,推荐给你的关注人。这些的关系模型,其实都可以用set的这几个命令来完成。

比如我的他的共同关注,其实就是求我和他所关注人的两个集合的交集;比如点击某人微博时你可能关注时,其实就是求相对于你所关注的集合和他的集合的差集。

所以当我们在进行一些关系模型的问题的时候,不妨考虑一下集合的几个参数。

1.5sorted set

那么继续根据<Redis的设计与实现>,那么sorted set的底层数据结构是ziplist(压缩列表)或者skiplist(跳表).

不知道大家了不了解跳表这个概念, 有兴趣的也可以去百度学习一下;对我个人理解而言,我觉得跳表的设计理念其实和树很像,我们在学习mysql的innodb的时候,都听说过b+树,那么使用b+树的目的则是希望通过树型结构,将数据分层,减少磁盘搜寻的次数,最终达到快速查找数据的数据结构;那么对我个人理解而言,跳表也是如此,通过添加几个稀疏链表来快速的找到有序链表的值。

ZipList(压缩列表结构如下)

SKIPLIST(跳表)

从上面结构图可以看出在跳表结构下的zset其实除了跳表以外,还使用了字典表;我们可以思考一下为什么redis选择了用两个结构来完成这一个结果,一个明显的思路就是如果跳表结构下只使用了跳表结构,那么我们通过成员去查找分值的操作的复杂度将从O(1)上升为O(logN);但是如果只使用字典表的情况下,要对数据进行排序,除了需要额外的空间外,还需要多耗费时间,按照<redis的设计与实现>里面说排序需要多耗费O(NlogN),这里书上没说,但如果想知道为啥是O(NlogN),可能得看下源码,不过如果我们熟悉排序算法的话,O(NlogN)的时间复杂度的排序也就几种。

根据这种结构,根据分值的排序,那么对于zset而言,我们更容易想到的就是用来做排行榜了,通过ZRANGK(从小到大排序),ZRERANK(从大到小排序)

在这里,我总结了一张redis类型与底层架构的图

2.redis的实际问题

 2.1redis缓存的不一致问题

因为大部分场景下,redis都被用做了缓存,防止请求大量的访问到数据库去。那么在工作和学习中,我们最常遇到几个问题了。

1.先设置缓存还是先修改数据库的问题。

2.删除缓存与修改数据库的问题。

关于缓存不一致的问题,有两个关键的点。

1.缓存有数据,那么缓存的值就得和数据库的值一样。

2.缓存没数据,那么数据库的值就得是最新的值。 

接下来,我们可以试着去了解几种情况,以下先来探讨以下只读缓存的情况

1.新增数据(insert)

由于数据库是新增的数据,此时没有缓存,数据库的值是最新的,满足上面的第二点。

2.删除/修改数据(delete/update)

在这种情况下,我们就得考虑以下两种情况。

情景一:先删缓存再删数据库

如果此时删除/修改缓存成功了,但是删除/ 修改数据库失败了了,就会导致下次查询时因为查不到缓存,去数据库查询,结果查询到旧值。

情景二:先删/修改数据库,后删/修改缓存

这个时候如果删除/修改数据库成功了,但是删除/修改缓存失败了,那么下次访问的时候就会访问到旧的缓存值,出现缓存不一致。

这里我们先总结一下上面的两种情况。

情况问题点
先删除/修改缓存,再删除/修改数据库删除/修改数据库失败,对于删除操作,因为缓存查不到,命中数据库,只能查询到旧的值
先删除/修改数据库,再删除/修改缓存删除/修改缓存失败,导致查询的时候,直接命中缓存的旧值造成缓存不一致

 

 

 

 

 

 

解决缓存不一致

那么怎么去解决上面的缓存不一致呢?

那么最简单的方案就是加入重试机制

比如上面说的情况二,删除缓存的时候失败,我们可以选择把需要删除的缓存加入到消息队列(比如kafka),通过判断删除的情况,可以避免重复消费消息的情况。

但是按照这种思路就出现了一个问题,那就是对于删除缓存失败的时候,虽然加入了消息队列去,但是消息队列从生产到broker,到消费的过程是需要一段时间,在一些高并发的场景下,显然还是会出现缓存不一致的问题。

接下来,我们继续按照上面的情况一来想想解决办法:

情况一:先删除缓存,再修改数据库。

 对于这种情况就出现了延迟双删的策略;简单来说就是在更新完数据库之后,我们在sleep一下,然后在删除一次缓存来尽可能地减少在高并发情况下缓存不一致的场景。至于sleep的时间,那就得按照业务的需要,经过统计,可能是95%,可能是99%,也可能是99.9%接口调用所耗时间来确定这个睡眠时间。

至于延迟双删的伪代码如下:

1.redis.delKey(key);

2.updatedataBase;

3.sleep(time);

4.redis.delKey(key);

情况二: 先修改数据库,再删除缓存

在这种场景下,虽然会经历一小段时间的缓存不一致,但经过t3之后,就能恢复

个人经历与思考 

为什么在上面我讲的只读缓存呢,主要还是在最近的运营活动的开发中,会遇到一些像支付宝种水果的活动,做任务领积分然后消费积分来耗减种水果的进度,那么我们来想想应该怎么去设计这个缓存的使用呢?

在那么在我一开始的设想里面,我首先给这个缓存定性,第一点比如这个活动的参与用户量可能是十万级,甚至百万级别;第二点就是这个缓存读写频率应该是82分的,应该8成以上是读,2成是写;第三点就是对于这个缓存很明显的是在某一时间段内频繁的修改,但是在更平常的时间里面是不变,或者是说修改不频繁;

因此基于这几个特点,我选择了只读缓存;

选择只读缓存的原因如下:

1.对于大多数的情况而言,读取数据将从缓存中读书,减缓数据库压力。

2.对于某一时间段而言,用户的操作中不涉及读取缓存的操作,只是对积分增或减,因此我把压力转到某一时间段数据库的写或者修改操作里;等待下次读操作,从数据库读取数据写入缓存。

读操作时如下

写操作

在读操作时,缓存过滤了8成以上的访问量,然后将压力的2成分给数据库。

当然作为运营活动,我们需要很明确的一点就是我们的缓存必须要设置过期时间,不设置过期的对象只能是少数的热点数据,不然redis也会抗不住;但是设置过期时间也要考虑到缓存雪崩的问题,不能让大量的缓存在某一时刻大量同时过期,疯狂的请求同时涌入数据库。

那么除了上面的活动的特定活动而言,还得思考一个问题,对于一些个人活动,或者涉及到个人信息的且用户量惊人的,比如12306的每个人订单情况,商城的一些个人评论,究竟需不需要给每个人数据进行一个缓存操作?

基于这个的思考,我个人认为是数据量少的情况可以用缓存保存;那么对于大量的数据,我个人思考后得出的结果是因为使用大数据数据库保存,比如HBase之类的。不知道你们的想法呢?这点希望你们有想法的可以在留言里说一说,我们交流一下

 

上面我们讨论了只读缓存的情况,但是缓存的使用还有缓存和数据库两个同步更新的场景。

相对于只读缓存,同步更新缓存时,业务代码可以直接从缓存里读取数据,但是里面最大的问题则是需要保证缓存和数据库的数据保持一致,因此就得引入重试或者延迟双删的代码,增加了代码的复杂性。

 

 

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值