是数据结构而非类型
很多文章都会说,redis支持5种常用的数据类型,这其实是存在很大的歧义。redis里存的都是二进制数据,其实就是字节数组(byte[]),这些字节数据是没有数据类型的,只有把它们按照合理的格式解码后,可以变成一个字符串,整数或对象,此时才具有数据类型。
这一点必须要记住。所以任何东西只要能转化成字节数组(byte[])的,都可以存到redis里。只要转换成字节数组,无论是字符串、数字、对象、图片、声音、视频还是文件,都可以处理。
因此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了。
集群带来的问题与解决思路
集群带来的优点明显,包括容量增加、处理能力提升,同时还可以根据需要实现动态扩缩容。但同时也会引入一些新的问题,至少会有下面这两个。
数据分配包括了在存储时确定数据所存储的节点,和在检索时确定数据所查询的节点。二是数据移动:集群扩容,新增加节点时,该节点上的数据从何处来;集群缩容,要剔除节点时,该节点上的数据往何处去。
上面这两个问题有一个共同点就是,如何去描述和存储数据与节点的映射关系。问题的演变在于需要建立各个key和集群所有节点之间的关联关系,因为数据位置是由key决定的。
集群的节点是相对固定和少数的,虽然有增加节点和剔除节点。在集群中,存储的key是数量庞大、完全随机、没有规律可言、不可预测且多为琐碎的。
这就好比一所大学和它的所有学生之间的关系。如果大学和学生直接挂钩的话,一定会比较混乱。现实是它们之间又加入了好几层,首先有院系,其次有专业,再者有年级,***还有班级。经过这四层映射之后,关系就清爽很多了。
没有什么问题是加入一层不能解决的,这是一个十分重要的结论。如果有,那就再加入一层。计算机里也是这样的。
redis在数据和节点之间又加入了一层,把这层称为槽(slot),因该槽主要和哈希有关,又叫哈希槽。
***变成了,节点上放的是槽,槽里放的是数据。槽解决的是粒度问题,相当于把粒度变大了,这样便于数据移动。哈希技术用于解决映射问题,它利用键的哈希值计算其所在的槽,以便于数据的分配。
你的学习桌子上书本堆积如山,极为凌乱,要想找到其中一本十分艰难。你购买了一些大的收纳箱,根据书名长度将书籍分类后放入不同的收纳箱,并将它们放置在桌子上。
这样就变成了,桌子上是收纳箱,收纳箱里是书籍。这样书籍移动很方便,搬起一个箱子就走了。只需测量书名的长度,然后前往相应的箱子,就可以轻松地找到所需的书籍。
其实我们也没做什么,只是买了几个箱子,按照某种规则把书装入箱子。就这么简单的举动,就彻底改变了原来一盘散沙的状况。是不是有点小小的神奇呢。
一个集群只能有16384个槽,编号0-16383。这些槽会分配给集群中的所有主节点,分配策略没有要求。可以指定哪些编号的槽分配给哪个主节点。集群会记录节点和槽的对应关系。
接下来需要对键进行哈希计算,将结果除以16384并取余,得到的余数将确定键落入哪个槽中。slot = CRC16(key) % 16384。
以槽为单位移动数据,因为槽的数目是固定的,处理起来比较容易,这样数据移动问题就解决了。
使用哈希函数计算出key的哈希值,这样就可以算出它对应的槽,然后利用集群存储的槽和节点的映射关系查询出槽所在的节点,于是数据和节点就映射起来了,这样数据分配问题就解决了。
我想说的是,一般的人只会去学习各种技术,高手更在乎如何跳出技术,寻求一种解决方案或思路方向,顺着这个方向走下去,八九不离十能找到你想要的答案。
集群对命令操作的取舍
客户端只要和集群中的一个节点建立链接后,就可以获取到整个集群的所有节点信息。此外还会获取所有哈希槽和节点的对应关系信息,这些信息数据都会在客户端缓存起来,因为这些信息相当有用。
客户端可以向任何节点发送请求,那么拿到一个key后到底该向哪个节点发请求呢?其实就是把集群里的那套key和节点的映射关系理论搬到客户端来就行了。
所以客户端需要实现一个和集群端一样的哈希函数,先计算出key的哈希值,然后再对16384取余,这样就找到了该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和参与计算哈希值的字符串有关但是又不一样。
redis基于这个原理为我们提供了方案,叫做key哈希标签。先看例子,{user1000}.following,{user1000}.followers,相信你已经看出了门道,就是仅使用Key中的位于{和}间的字符串参与计算哈希值。
这样可以保证哈希值相同,落到相同的节点上。但是key又是不同的,不会互相覆盖。通过使用哈希标签将一组相关的键关联起来,问题轻松愉快地得到解决。
解决问题所依赖的是巧妙的创意和想法,而非必须采用高超的技术和算法。这就是小强,小而强大。
最后再来谈选择的哲学。Redis的主要特点是在最短的时间内实现常用数据结构的键值存储和访问,以及在这些数据结构上执行相关运算。对于与核心无关的或会拖累核心的都选择弱化处理或不处理,这样做是为了保证核心的简单、快速和稳定。
其实就是在广度和深度面前,redis选择了深度。因此,节点不会处理其未拥有的键,集群也不支持多个键的命令。这样一方面可以快速地响应客户端,另一方面可以避免在集群内部有大量的数据传输与合并。
单线程模型
redis集群的每个节点里只有一个线程负责接受和执行所有客户端发送的请求。技术上使用多路复用I/O,使用Linux的epoll函数,这样一个线程就可以管理很多socket连接。
除此之外,选择单线程还有以下这些原因:
1、redis都是对内存的操作,速度极快(10W+QPS)
2、整体的时间主要都是消耗在了网络的传输上
3、如果使用了多线程,则需要多线程同步,这样实现起来会变的复杂
4、线程的加锁时间甚至都超过了对内存操作的时间
5、多线程上下文频繁的切换需要消耗更多的CPU时间
6、还有就是单线程天然支持原子操作,而且单线程的代码写起来更简单
事务
事务大家都知道,就是把多个操作捆绑在一起,要么都执行(成功了),要么一个也不执行(回滚了)。redis也是支持事务的,但可能和你想要的不太一样,一起来看看吧。
redis的事务可以分为两步,定义事务和执行事务。在开启一个事务后,按照顺序添加所有待执行的命令。这就定义好了一个事务。You can execute the transaction using the exec command at this point, or abandon it with the discard command.。
你可能希望在你的事务开始前,你关心的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的协议设计的非常简单。