复习要点?
本文围绕以下几点进行阐述
1、为什么使用redis
2、使用redis有什么缺点
3、单线程的redis为什么这么快
4、redis的数据类型,以及每种数据类型的使用场景
5、redis的过期策略以及内存淘汰机制
6、redis和数据库双写一致性问题
7、如何应对缓存穿透和缓存雪崩问题
8、如何解决redis的并发竞争问题
正文
1、为什么使用redis
分析:博主觉得在项目中使用redis,主要是从两个角度去考虑:性能和并发。当然,redis还具备可以做分布式锁等其他功能,但是如果只是为了分布式锁这些其他功能,完全还有其他中间件(如zookpeer等)代替,并不是非要使用redis。因此,这个问题主要从性能和并发两个角度去答。
回答:如下所示,分为两点
(一)性能
如下图所示,我们在碰到需要执行耗时特别久,且结果不频繁变动的SQL,就特别适合将运行结果放入缓存。这样,后面的请求就去缓存中读取,使得请求能够迅速响应。
题外话:忽然想聊一下这个迅速响应的标准。其实根据交互效果的不同,这个响应时间没有固定标准。不过曾经有人这么告诉我:"在理想状态下,我们的页面跳转需要在瞬间解决,对于页内操作则需要在刹那间解决。另外,超过一弹指的耗时操作要有进度提示,并且可以随时中止或取消,这样才能给用户最好的体验。"
那么瞬间、刹那、一弹指具体是多少时间呢?
根据《摩诃僧祗律》记载
一刹那者为一念,二十念为一瞬,二十瞬为一弹指,二十弹指为一罗预,二十罗预为一须臾,一日一夜有三十须臾。
那么,经过周密的计算,一瞬间为0.36 秒,一刹那有 0.018 秒.一弹指长达 7.2 秒。
(二)并发
如下图所示,在大并发的情况下,所有的请求直接访问数据库,数据库会出现连接异常。这个时候,就需要使用redis做一个缓冲操作,让请求先访问到redis,而不是直接访问数据库。
2、使用redis有什么缺点
分析:大家用redis这么久,这个问题是必须要了解的,基本上使用redis都会碰到一些问题,常见的也就几个。
回答:主要是四个问题
(一)缓存和数据库双写一致性问题
(二)缓存雪崩问题
(三)缓存击穿问题
(四)缓存的并发竞争问题
这四个问题,我个人是觉得在项目中,比较常遇见的,具体解决方案,后文给出。
3、单线程的redis为什么这么快
redis是单线程工作模型,redis集群的每个节点里只有一个线程负责接受和执行所有客户端发送的请求。技术上使用多路复用I/O,使用Linux的epoll函数,这样一个线程就可以管理很多socket连接。
除此之外,选择单线程还有以下这些原因:
1、redis都是对内存的操作,速度极快(10W+QPS)
2、整体的时间主要都是消耗在了网络的传输上
3、如果使用了多线程,则需要多线程同步,这样实现起来会变的复杂
4、线程的加锁时间甚至都超过了对内存操作的时间
5、多线程上下文频繁的切换需要消耗更多的CPU时间
6、还有就是单线程天然支持原子操作,而且单线程的代码写起来更简单
题外话:我们现在要仔细的说一说I/O多路复用机制,因为这个说法实在是太通俗了,通俗到一般人都不懂是什么意思。博主打一个比方:小曲在S城开了一家快递店,负责同城快送服务。小曲因为资金限制,雇佣了一批快递员,然后小曲发现资金不够了,只够买一辆车送快递。
经营方式一
客户每送来一份快递,小曲就让一个快递员盯着,然后快递员开车去送快递。慢慢的小曲就发现了这种经营方式存在下述问题
- 几十个快递员基本上时间都花在了抢车上了,大部分快递员都处在闲置状态,谁抢到了车,谁就能去送快递
- 随着快递的增多,快递员也越来越多,小曲发现快递店里越来越挤,没办法雇佣新的快递员了
- 快递员之间的协调很花时间
综合上述缺点,小曲痛定思痛,提出了下面的经营方式
经营方式二
小曲只雇佣一个快递员。然后呢,客户送来的快递,小曲按送达地点标注好,然后依次放在一个地方。最后,那个快递员依次的去取快递,一次拿一个,然后开着车去送快递,送好了就回来拿下一个快递。
对比
上述两种经营方式对比,是不是明显觉得第二种,效率更高,更好呢。在上述比喻中:
- 每个快递员------------------>每个线程
- 每个快递-------------------->每个socket(I/O流)
- 快递的送达地点-------------->socket的不同状态
- 客户送快递请求-------------->来自客户端的请求
- 小曲的经营方式-------------->服务端运行的代码
- 一辆车---------------------->CPU的核数
于是我们有如下结论
1、经营方式一就是传统的并发模型,每个I/O流(快递)都有一个新的线程(快递员)管理。
2、经营方式二就是I/O多路复用。只有单个线程(一个快递员),通过跟踪每个I/O流的状态(每个快递的送达地点),来管理多个I/O流。
下面类比到真实的redis线程模型,如图所示
参照上图,简单来说,就是。我们的redis-client在操作的时候,会产生具有不同事件类型的socket。在服务端,有一段I/0多路复用程序,将其置入队列之中。然后,文件事件分派器,依次去队列中取,转发到不同的事件处理器中。
需要说明的是,这个I/O多路复用机制,redis还提供了select、epoll、evport、kqueue等多路复用函数库,大家可以自行去了解。
4、redis的数据类型,以及每种数据类型的使用场景
分析:是不是觉得这个问题很基础,其实我也这么觉得。然而根据面试经验发现,至少百分八十的人答不上这个问题。建议,在项目中用到后,再类比记忆,体会更深,不要硬记。基本上,一个合格的程序员,五种类型都会用到。
回答:一共五种
(一)String
这个其实没啥好说的,最常规的set/get操作,value可以是String也可以是数字。一般做一些复杂的计数功能的缓存。
(二)hash
这里value存放的是结构化的对象,比较方便的就是操作其中的某个字段。博主在做单点登录的时候,就是用这种数据结构存储用户信息,以cookieId作为key,设置30分钟为缓存过期时间,能很好的模拟出类似session的效果。
(三)list
使用List的数据结构,可以做简单的消息队列的功能。另外还有一个就是,可以利用lrange命令,做基于redis的分页功能,性能极佳,用户体验好。
(四)set
因为set堆放的是一堆不重复值的集合。所以可以做全局去重的功能。为什么不用JVM自带的Set进行去重?因为我们的系统一般都是集群部署,使用JVM自带的Set,比较麻烦,难道为了一个做一个全局去重,再起一个公共服务,太麻烦了。
另外,就是利用交集、并集、差集等操作,可以计算共同喜好,全部的喜好,自己独有的喜好等功能。
(五)sorted set
sorted set多了一个权重参数score,集合中的元素能够按score进行排列。可以做排行榜应用,取TOP N操作。另外,参照另一篇《分布式之延时任务方案解析》,该文指出了sorted set可以用来做延时任务。最后一个应用就是可以做范围查找。
5、redis的过期策略以及内存淘汰机制
分析:这个问题其实相当重要,到底redis有没用到家,这个问题就可以看出来。比如你redis只能存5G数据,可是你写了10G,那会删5G的数据。怎么删的,这个问题思考过么?还有,你的数据已经设置了过期时间,但是时间到了,内存占用率还是比较高,有思考过原因么?
回答:
redis采用的是定期删除+惰性删除策略。
为什么不用定时删除策略?
定时删除,用一个定时器来负责监视key,过期则自动删除。虽然内存及时释放,但是十分消耗CPU资源。在大并发请求下,CPU要将时间应用在处理请求,而不是删除key,因此没有采用这一策略.
定期删除+惰性删除是如何工作的呢?
定期删除,redis默认每个100ms检查,是否有过期的key,有过期key则删除。需要说明的是,redis不是每个100ms将所有的key检查一次,而是随机抽取进行检查(如果每隔100ms,全部key进行检查,redis岂不是卡死)。因此,如果只采用定期删除策略,会导致很多key到时间没有删除。
于是,惰性删除派上用场。也就是说在你获取某个key的时候,redis会检查一下,这个key如果设置了过期时间那么是否过期了?如果过期了此时就会删除。
采用定期删除+惰性删除就没其他问题了么?
不是的,如果定期删除没删除key。然后你也没即时去请求key,也就是说惰性删除也没生效。这样,redis的内存会越来越高。那么就应该采用内存淘汰机制。
在redis.conf中有一行配置
# maxmemory-policy volatile-lru
该配置就是配内存淘汰策略的(什么,你没配过?好好反省一下自己)
1)noeviction:当内存不足以容纳新写入数据时,新写入操作会报错。应该没人用吧。
2)allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的key。推荐使用,目前项目在用这种。
3)allkeys-random:当内存不足以容纳新写入数据时,在键空间中,随机移除某个key。应该也没人用吧,你不删最少使用Key,去随机删。
4)volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的key。这种情况一般是把redis既当缓存,又做持久化存储的时候才用。不推荐
5)volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个key。依然不推荐
6)volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的key优先移除。不推荐
ps:如果没有设置 expire 的key, 不满足先决条件(prerequisites); 那么 volatile-lru, volatile-random 和 volatile-ttl 策略的行为, 和 noeviction(不删除) 基本上一致。
6、redis和数据库双写一致性问题
分析:一致性问题是分布式常见问题,还可以再分为最终一致性和强一致性。数据库和缓存双写,就必然会存在不一致的问题。答这个问题,先明白一个前提。就是如果对数据有强一致性要求,不能放缓存。我们所做的一切,只能保证最终一致性。另外,我们所做的方案其实从根本上来说,只能说降低不一致发生的概率,无法完全避免。因此,有强一致性要求的数据,不能放缓存。
回答:《分布式之数据库和缓存双写一致性方案解析》给出了详细的分析,在这里简单的说一说。首先,采取正确更新策略,先更新数据库,再删缓存。其次,因为可能存在删除缓存失败的问题,提供一个补偿措施即可,例如利用消息队列。
7、如何应对缓存穿透和缓存雪崩问题
分析:这两个问题,说句实在话,一般中小型传统软件企业,很难碰到这个问题。如果有大并发的项目,流量有几百万左右。这两个问题一定要深刻考虑。
回答:如下所示
缓存穿透,即黑客故意去请求缓存中不存在的数据,导致所有的请求都怼到数据库上,从而数据库连接异常。
解决方案:
(一)利用互斥锁,缓存失效的时候,先去获得锁,得到锁了,再去请求数据库。没得到锁,则休眠一段时间重试
(二)采用异步更新策略,无论key是否取到值,都直接返回。value值中维护一个缓存失效时间,缓存如果过期,异步起一个线程去读数据库,更新缓存。需要做缓存预热(项目启动前,先加载缓存)操作。
(三)提供一个能迅速判断请求是否有效的拦截机制,比如,利用布隆过滤器,内部维护一系列合法有效的key。迅速判断出,请求所携带的Key是否合法有效。如果不合法,则直接返回。
缓存雪崩,即缓存同一时间大面积的失效,这个时候又来了一波请求,结果请求都怼到数据库上,从而导致数据库连接异常。
解决方案:
(一)给缓存的失效时间,加上一个随机值,避免集体失效。
(二)使用互斥锁,但是该方案吞吐量明显下降了。
(三)双缓存。我们有两个缓存,缓存A和缓存B。缓存A的失效时间为20分钟,缓存B不设失效时间。自己做缓存预热操作。然后细分以下几个小点
- I 从缓存A读数据库,有则直接返回
- II A没有数据,直接从B读数据,直接返回,并且异步启动一个更新线程。
- III 更新线程同时更新缓存A和缓存B。
8、如何解决redis的并发竞争key问题
分析:这个问题大致就是,同时有多个子系统去set一个key。这个时候要注意什么呢?大家思考过么。需要说明一下,博主提前百度了一下,发现答案基本都是推荐用redis事务机制。博主不推荐使用redis的事务机制。因为我们的生产环境,基本都是redis集群环境,做了数据分片操作。你一个事务中有涉及到多个key操作的时候,这多个key不一定都存储在同一个redis-server上。因此,redis的事务机制,十分鸡肋。
回答:如下所示
(1)如果对这个key操作,不要求顺序
这种情况下,准备一个分布式锁,大家去抢锁,抢到锁就做set操作即可,比较简单。
(2)如果对这个key操作,要求顺序
假设有一个key1,系统A需要将key1设置为valueA,系统B需要将key1设置为valueB,系统C需要将key1设置为valueC.
期望按照key1的value值按照 valueA-->valueB-->valueC的顺序变化。这种时候我们在数据写入数据库的时候,需要保存一个时间戳。假设时间戳如下
系统A key 1 {valueA 3:00}
系统B key 1 {valueB 3:05}
系统C key 1 {valueC 3:10}
那么,假设这会系统B先抢到锁,将key1设置为{valueB 3:05}。接下来系统A抢到锁,发现自己的valueA的时间戳早于缓存中的时间戳,那就不做set操作了。以此类推。
其他方法,比如利用队列,将set方法变成串行访问也可以。总之,灵活变通。
数据结构而非类型
很多文章都会说,redis支持5种常用的数据类型,这其实是存在很大的歧义。redis里存的都是二进制数据,其实就是字节数组(byte[]),这些字节数据是没有数据类型的,只有把它们按照合理的格式解码后,可以变成一个字符串,整数或对象,此时才具有数据类型。
这一点必须要记住。所以任何东西只要能转化成字节数组(byte[])的,都可以存到redis里。管你是字符串、数字、对象、图片、声音、视频、还是文件,只要变成byte数组。
因此redis里的String指的并不是字符串,它其实表示的是一种最简单的数据结构,即一个key只能对应一个value。这里的key和value都是byte数组,只不过key一般是由一个字符串转换成的byte数组,value则根据实际需要而定。
在特定情况下,对value也会有一些要求,比如要进行自增或自减操作,那value对应的byte数组必须要能被解码成一个数字才行,否则会报错。
那么List这种数据结构,其实表示一个key可以对应多个value,且value之间是有先后顺序的,value值可以重复。
Set这种数据结构,表示一个key可以对应多个value,且value之间是没有先后顺序的,value值也不可以重复。
Hash这种数据结构,表示一个key可以对应多个key-value对,此时这些key-value对之间的先后顺序一般意义不大,这是一个按照名称语义来访问的数据结构,而非位置语义。
Sorted Set这种数据结构,表示一个key可以对应多个value,value之间是有大小排序的,value值不可以重复。每个value都和一个浮点数相关联,该浮点数叫score。元素排序规则是:先按score排序,再按value排序。
相信现在你对这5种数据结构有了更清晰的认识,那它们的对应命令对你来说就是小case了。
Redis 事务,以及事务失败的处理
事务命令
- Redis事务,会将所有命令放入一个命令队列中,再由队列中执行命令。
- 事务的命令分为:
- MULTI 开始事务---->各种命令操作,SET,GET,SADD等等(每一步操作后,会显示QUEUED,表示已经入队命令队列)---->EXEC执行命令队列
- DISCARD:取消一个事务,清空事务命令队列,客户端调整为非事务状态
- WATCH:在进入事务之前执行,监听任意数量的key,当调用EXEC时,如果这个key被其他用户修改了,那么事务不在执行,返回失败。
- 以下示例展示了一个执行失败的事务例子:
redis> WATCH name
OK
redis> MULTI
OK
redis> SET name peter
QUEUED
redis> EXEC (nil)
以下执行序列展示了上面的例子是如何失败的:
时间 客户端 A 客户端 B
T1 WATCH
name
T2 MULTI
T3SET
name
peter
T4 SET
name
john
T5EXEC
在时间 T4 ,客户端 B 修改了 name
键的值, 当客户端 A 在 T5 执行 EXEC 时,Redis 会发现 name
这个被监视的键已经被修改, 因此客户端 A 的事务不会被执行,而是直接返回失败。
ACID
Redis 的事务保证了 ACID 中的一致性(C)和隔离性(I),但并不保证原子性(A)和持久性(D)。
1.原子性(一个事务所有操作要么全部成功,要么全部失败)
redis的单个命令是原子性的,但是事务中的的redis命令,就算后面的命令失败/终止了(比如KILL进程,主机宕机等),此时事务失败,前面执行的命令也不会回滚。
2.一致性
(1)不正确入队命令的事务不会被执行,不会影响数据库的一致性
(2)命令执行中的错误,将错误包含在事务结果中,不会中断事务,不影响已执行结果,也不影响后面命令的执行,对事务的一致性也没有影响。
(3)进程终结
- 内存模式:如果 Redis 没有采取任何持久化机制,那么重启之后的数据库总是空白的,所以数据总是一致的。
- RDB 模式:在执行事务时,Redis 不会中断事务去执行保存 RDB 的工作,只有在事务执行之后,保存 RDB 的工作才有可能开始。所以当 RDB 模式下的 Redis 服务器进程在事务中途被杀死时,事务内执行的命令,不管成功了多少,都不会被保存到 RDB 文件里。恢复数据库需要使用现有的 RDB 文件,而这个 RDB 文件的数据保存的是最近一次的数据库快照(snapshot),所以它的数据可能不是最新的,但只要 RDB 文件本身没有因为其他问题而出错,那么还原后的数据库就是一致的。
- AOF 模式:因为保存 AOF 文件的工作在后台线程进行,所以即使是在事务执行的中途,保存 AOF 文件的工作也可以继续进行,因此,根据事务语句是否被写入并保存到 AOF 文件,有以下两种情况发生:
1)如果事务语句未写入到 AOF 文件,或 AOF 未被 SYNC 调用保存到磁盘,那么当进程被杀死之后,Redis 可以根据最近一次成功保存到磁盘的 AOF 文件来还原数据库,只要 AOF 文件本身没有因为其他问题而出错,那么还原后的数据库总是一致的,但其中的数据不一定是最新的。
2)如果事务的部分语句被写入到 AOF 文件,并且 AOF 文件被成功保存,那么不完整的事务执行信息就会遗留在 AOF 文件里,当重启 Redis 时,程序会检测到 AOF 文件并不完整,Redis 会退出,并报告错误。需要使用 redis-check-aof 工具将部分成功的事务命令移除之后,才能再次启动服务器。还原之后的数据总是一致的,而且数据也是最新的(直到事务执行之前为止)。
3.隔离性
Redis 是单进程程序,并且它保证在执行事务时,不会对事务进行中断,事务可以运行直到执行完所有事务队列中的命令为止。因此,Redis 的事务是总是带有隔离性的。
4.持久性
由redis的持久化模式决定:
- 内存模式-无持久化
- RDB-服务器可能在事务执行之后、RDB 文件更新之前的这段时间失败,所以 RDB 模式下的 Redis 事务也是不持久的。
- AOF-后台每秒
fsync
策略,不是主线程阻塞执行,如果在保存时宕机,可能有一秒数据丢失,也非持久的。
事务大家都知道,就是把多个操作捆绑在一起,要么都执行(成功了),要么一个也不执行(回滚了)。redis也是支持事务的,但可能和你想要的不太一样,一起来看看吧。
redis的事务可以分为两步,定义事务和执行事务。使用multi命令开启一个事务,然后把要执行的所有命令都依次排上去。这就定义好了一个事务。此时使用exec命令来执行这个事务,或使用discard命令来放弃这个事务。
你可能希望在你的事务开始前,你关心的key不想被别人操作,那么可以使用watch命令来监视这些key,如果开始执行前这些key被其它命令操作了则会取消事务的。也可以使用unwatch命令来取消对这些key的监视。
redis事务具有以下特点:
1、如果开始执行事务前出错,则所有命令都不执行
2、一旦开始,则保证所有命令一次性按顺序执行完而不被打断
3、如果执行过程中遇到错误,会继续执行下去,不会停止的
4、对于执行过程中遇到错误,是不会进行回滚的
看完这些,真想问一句话,你这能叫事务吗?很显然,这并不是我们通常认为的事务,因为它连原子性都保证不了。保证不了原子性是因为redis不支持回滚,不过它也给出了不支持的理由。
不支持回滚的理由:
1、redis认为,失败都是由命令使用不当造成
2、redis这样做,是为了保持内部实现简单快速
3、redis还认为,回滚并不能解决所有问题
哈哈,这就是霸王条款,因此,好像使用redis事务的不太多
管道
客户端和集群的交互过程是串行化阻塞式的,即客户端发送了一个命令后必须等到响应回来后才能发第二个命令,这一来一回就是一个往返时间。如果你有很多的命令,都这样一个一个的来进行,会变得很慢。
redis提供了一种管道技术,可以让客户端一次发送多个命令,期间不需要等待服务器端的响应,等所有的命令都发完了,再依次接收这些命令的全部响应。这就极大地节省了许多时间,提升了效率。
聪明的你是不是意识到了另外一个问题,多个命令就是多个key啊,这不就是上面提到的多key操作嘛,那么问题来了,你如何保证这多个key都是同一个节点上的啊,哈哈,redis集群又放弃了对管道的支持。
不过可以在客户端模拟实现,就是使用多个连接往多个节点同时发送命令,然后等待所有的节点都返回了响应,再把它们按照发送命令的顺序整理好,返回给用户代码。哎呀,好麻烦呀。
协议
简单了解下redis的协议,知道redis的数据传输格式。
发送请求的协议:
*参数个数CRLF$参数1的字节数CRLF参数1的数据CRLF...$参数N的字节数CRLF参数N的数据CRLF
例如,SET name lixinjie,实际发送的数据是:
*3
$3
SET
$4
name
$8
lixinjie
接受响应的协议:
单行回复,第一个字节是+
错误消息,第一个字节是-
整型数字,第一个字节是:
批量回复,第一个字节是$
多个批量回复,第一个字节是*
例如,
+OK
-ERR Operation against
:1000
$6 foobar
*2
$3
foo
$3
bar
可见redis的协议设计的非常简单。
集群带来的问题与解决思路
集群带来的好处是显而易见的,比如容量增加、处理能力增强,还可以按需要进行动态的扩容、缩容。但同时也会引入一些新的问题,至少会有下面这两个。
一是数据分配:存数据时应该放到哪个节点上,取数据时应该去哪个节点上找。二是数据移动:集群扩容,新增加节点时,该节点上的数据从何处来;集群缩容,要剔除节点时,该节点上的数据往何处去。
上面这两个问题有一个共同点就是,如何去描述和存储数据与节点的映射关系。又因为数据的位置是由key决定的,所以问题就演变为如何建立起各个key和集群所有节点的关联关系。
集群的节点是相对固定和少数的,虽然有增加节点和剔除节点。但集群里存储的key,则是完全随机、没有规律、不可预测、数量庞多,还非常琐碎。
这就好比一所大学和它的所有学生之间的关系。如果大学和学生直接挂钩的话,一定会比较混乱。现实是它们之间又加入了好几层,首先有院系,其次有专业,再者有年级,最后还有班级。经过这四层映射之后,关系就清爽很多了。
这其实是一个非常重要的结论,这个世界上没有什么问题是不能通过加入一层来解决的。如果有,那就再加入一层。计算机里也是这样的。
redis在数据和节点之间又加入了一层,把这层称为槽(slot),因该槽主要和哈希有关,又叫哈希槽。
最后变成了,节点上放的是槽,槽里放的是数据。槽解决的是粒度问题,相当于把粒度变大了,这样便于数据移动。哈希解决的是映射问题,使用key的哈希值来计算所在的槽,便于数据分配。
可以这样来理解,你的学习桌子上堆满了书,乱的很,想找到某本书非常困难。于是你买了几个大的收纳箱,把这些书按照书名的长度放入不同的收纳箱,然后把这些收纳箱放到桌子上。
这样就变成了,桌子上是收纳箱,收纳箱里是书籍。这样书籍移动很方便,搬起一个箱子就走了。寻找书籍也很方便,只要数一数书名的长度,去对应的箱子里找就行了。
其实我们也没做什么,只是买了几个箱子,按照某种规则把书装入箱子。就这么简单的举动,就彻底改变了原来一盘散沙的状况。是不是有点小小的神奇呢。
一个集群只能有16384个槽,编号0-16383。这些槽会分配给集群中的所有主节点,分配策略没有要求。可以指定哪些编号的槽分配给哪个主节点。集群会记录节点和槽的对应关系。
接下来就需要对key求哈希值,然后对16384取余,余数是几key就落入对应的槽里。slot = CRC16(key) % 16384。
以槽为单位移动数据,因为槽的数目是固定的,处理起来比较容易,这样数据移动问题就解决了。
使用哈希函数计算出key的哈希值,这样就可以算出它对应的槽,然后利用集群存储的槽和节点的映射关系查询出槽所在的节点,于是数据和节点就映射起来了,这样数据分配问题就解决了。
我想说的是,一般的人只会去学习各种技术,高手更在乎如何跳出技术,寻求一种解决方案或思路方向,顺着这个方向走下去,八九不离十能找到你想要的答案。
集群对命令操作的取舍
客户端只要和集群中的一个节点建立链接后,就可以获取到整个集群的所有节点信息。此外还会获取所有哈希槽和节点的对应关系信息,这些信息数据都会在客户端缓存起来,因为这些信息相当有用。
客户端可以向任何节点发送请求,那么拿到一个key后到底该向哪个节点发请求呢?其实就是把集群里的那套key和节点的映射关系理论搬到客户端来就行了。
所以客户端需要实现一个和集群端一样的哈希函数,先计算出key的哈希值,然后再对16384取余,这样就找到了该key对应的哈希槽,利用客户端缓存的槽和节点的对应关系信息,就可以找到该key对应的节点了。
接下来发送请求就可以了。还可以把key和节点的映射关系缓存起来,下次再请求该key时,直接就拿到了它对应的节点,不用再计算一遍了。
理论和现实总是有差距的,集群已经发生了变化,客户端的缓存还没来得及更新。肯定会出现拿到一个key向对应的节点发请求,其实这个key已经不在那个节点上了。此时这个节点应该怎么办?
这个节点可以去key实际所在的节点上拿到数据再返回给客户端,也可以直接告诉客户端key已经不在我这里了,同时附上key现在所在的节点信息,让客户端再去请求一次,类似于HTTP的302重定向。
这其实是个选择问题,也是个哲学问题。结果就是redis集群选择了后者。因此,节点只处理自己拥有的key,对于不拥有的key将返回重定向错误,即-MOVED key 127.0.0.1:6381,客户端重新向这个新节点发送请求。
所以说选择是一种哲学,也是个智慧。稍后再谈这个问题。先来看看另一个情况,和这个问题有些相同点。
redis有一种命令可以一次带多个key,如MGET,我把这些称为多key命令。这个多key命令的请求被发送到一个节点上,这里有一个潜在的问题,不知道大家有没有想到,就是这个命令里的多个key一定都位于那同一个节点上吗?
就分为两种情况了,如果多个key不在同一个节点上,此时节点只能返回重定向错误了,但是多个key完全可能位于多个不同的节点上,此时返回的重定向错误就会非常乱,所以redis集群选择不支持此种情况。
如果多个key位于同一个节点上呢,理论上是没有问题的,redis集群是否支持就和redis的版本有关系了,具体使用时自己测试一下就行了。
在这个过程中我们发现了一件颇有意义的事情,就是让一组相关的key映射到同一个节点上是非常有必要的,这样可以提高效率,通过多key命令一次获取多个值。
那么问题来了,如何给这些key起名字才能让他们落到同一个节点上,难不成都要先计算个哈希值,再取个余数,太麻烦了吧。当然不是这样了,redis已经帮我们想好了。
可以来简单推理下,要想让两个key位于同一个节点上,它们的哈希值必须要一样。要想哈希值一样,传入哈希函数的字符串必须一样。那我们只能传进去两个一模一样的字符串了,那不就变成同一个key了,后面的会覆盖前面的数据。
这里的问题是我们都是拿整个key去计算哈希值,这就导致key和参与计算哈希值的字符串耦合了,需要将它们解耦才行,就是key和参与计算哈希值的字符串有关但是又不一样。
redis基于这个原理为我们提供了方案,叫做key哈希标签。先看例子,{user1000}.following,{user1000}.followers,相信你已经看出了门道,就是仅使用Key中的位于{和}间的字符串参与计算哈希值。
这样可以保证哈希值相同,落到相同的节点上。但是key又是不同的,不会互相覆盖。使用哈希标签把一组相关的key关联了起来,问题就这样被轻松愉快地解决了。
相信你已经发现了,要解决问题靠的是巧妙的奇思妙想,而不是非要用牛逼的技术牛逼的算法。这就是小强,小而强大。
最后再来谈选择的哲学。redis的核心就是以最快的速度进行常用数据结构的key/value存取,以及围绕这些数据结构的运算。对于与核心无关的或会拖累核心的都选择弱化处理或不处理,这样做是为了保证核心的简单、快速和稳定。
其实就是在广度和深度面前,redis选择了深度。所以节点不去处理自己不拥有的key,集群不去支持多key命令。这样一方面可以快速地响应客户端,另一方面可以避免在集群内部有大量的数据传输与合并。
https://zhuanlan.zhihu.com/p/78896005