是数据结构而非类型
很多文章都会说,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 了。
集群带来的问题与解决思路
集群带来的好处是显而易见的,比如容量增加、处理能力增强,还可以按需要进行动态的扩容、缩容。但同时也会引入一些新的问题,至少会有下面这两个。
一是数据分配:存数据时应该放到哪个节点上,取数据时应该去哪个节点上找。二是数据移动:集群扩容,新增加节点时,该节点上的数据从何处来;集群缩容,要剔除节点时,该节点上的数据往何处去。
上面这两个问题有一个共同点就是,如何去描述和存储数据与节点的映射关系。又因为数据的位置是由 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 命令。这样一方面可以快速地响应客户端,另一方面可以避免在集群内部有大量的数据传输与合并。
单线程模型
redis 集群的每个节点里只有一个线程负责接受和执行所有客户端发送的请求。技术上使用多路复用 I /O,使用 Linux 的 epoll 函数,这样一个线程就可以管理很多 socket 连接。
除此之外,选择单线程还有以下这些原因:
1、redis 都是对内存的操作,速度极快(10W+QPS)
2、整体的时间主要都是消耗在了网络的传输上
3、如果使用了多线程,则需要多线程同步,这样实现起来会变的复杂
4、线程的加锁时间甚至都超过了对内存操作的时间
5、多线程上下文频繁的切换需要消耗更多的 CPU 时间
6、还有就是单线程天然支持原子操作,而且单线程的代码写起来更简单
事务
事务大家都知道,就是把多个操作捆绑在一起,要么都执行(成功了),要么一个也不执行(回滚了)。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,实际发送的数据是:
*3rn$3\r\nSET\r\n$4rnnamern$8rnlixinjiern
接受响应的协议:
单行回复,第一个字节是 +
错误消息,第一个字节是 -
整型数字,第一个字节是:
批量回复,第一个字节是 $
多个批量回复,第一个字节是 *
例如,
+OKrn
-ERR Operation againstrn
:1000rn
$6rnfoobarrn
*2rn$3\r\nfoo\r\n$3rnbarrn
可见 redis 的协议设计的非常简单。