引言
本文作为笔者研究学习Redis的开篇之作,主要是对redis做一个简单系统的介绍,日常开发中都只是集成使用其缓存的功能,没有更深入的学习了解它的特性。笔者作为一个有五年Java开发经验的程序员,把大量时间都花在了编码上,各种框架组件的集成使用上,没有花时间去研究这些设计精妙的组件,从原理上理解吃透。说来惭愧,这也是自己还没有迈向架构那一步的主要原因。
Redis相信从事开发工作的朋友没有没听说过的,是一个非常强大的内存数据库,性能非常强劲,支持的数据类型也很丰富,有着很广泛的使用场景,学习掌握它的关键技术,了解它的设计思想对我们开发会有很大的帮助。废话不多说了,直接进入主题。
一、Redis的来历
先来一张"Redis之父"的照片压压惊,保佑我学习顺利。
"Redis之父"本名为 Salvatore Sanfilippo,网名Antirez,一位来自意大利西西里岛(Sicily)的70后程序员。这让我想起了年少时看过的一部启蒙电影《西西里的美丽传说》,我看了很多遍(懂得都懂)。
Redis是怎么诞生的
Redis诞生于2009年3月,据"Redis之父"自己的描述,他当时是遇到了这样一个需求场景:多个网站会通过一个小型的JavaScript追踪器(tracker)不断的向他的服务器发送页面访问记录,他的服务器需要为每个网站保存一定数量的最新页面访问记录,并通过网页把这些记录实时地展示给用户观看。
最大负载达到每秒几千条页面记录的情况下,常规的关系数据库都无法在一个受限的小虚拟机上处理如此大的负载。Salvatore Sanfilippo决定开发一个内存数据库,使用列表作为基本数据类型,并且能够对列表两端执行常数时间复杂度的弹出(pop)和推入(push),这个初始版本帮助他解决了问题。后来他又用C语言重写了最初的内存数据库原型,加上了基于子进程实现的持久化特性,Redis诞生了。
Redis始于实用主义——它是一个程序员因为找不到合适的工具来解决手头上的问题而发明的。说到这一点笔者感概良多,很多伟大的软件系统都是这么诞生的,不过在国内很难有环境支持国内程序员去创新。一个程序员的一生能有一个这么世界级的作品留下,可以说是无上的荣耀,我等只能膜拜大佬。
程序员想要提升到更高层次必须意识到的一个方面,不要过多花时间关注在编码上,而应该是花时间在思考需求和问题、找到好的设计这些事情上。
Redis默认端口为什么是6379
这个端口号不是随便取得,是由手机键盘字母"MERZ"的位置决定的,如下图所示
“MERZ"这个在"Redis之父"的朋友圈的意思是"愚蠢”,据说是意大利广告女明星"Alessia Merz"在电视节目上说了一堆愚蠢的话让人熟知,Redis之父使用6379作为默认端口估计也有点嘲讽的意思吧。
二、Redis数据库介绍
笔者在windows环境安装了redis,这个请读者朋友们自行网上搜索安装,比较简单,这里不再叙述。使用navicat工具连接redis数据库:
可以看到redis默认有16个数据库,索引范围从0-15,这个是可以修改的,windows下安装的redis,在配置文件redis.windows.conf文件中修改,其他系统如linux系统,可能在redis.linux.conf中或者redis.conf等类似的配置文件中修改。
默认情况下,使用的是索引为0数据库
修改成其他数值的数据库后,重启redis即可。
三、Redis基本数据结构
String
Redis 的 String 类型虽然是以“字符串”命名,但实际上它可以存储任意的二进制数据,而不仅仅是文本字符串。这意味着你可以在 Redis 的 String 键中存储任何形式的字节序列,包括文本、图片、音频文件、视频文件、序列化的对象等,只要总的大小不超过单个键值对的最大限制(目前 Redis 支持的最大值大小为 512 MB,但在某些版本中这个限制可能会更高,例如 1GB)。
String类型是Redis中最简单数据结构,常用来缓存用户信息。Redis中所有的数据结构都以唯一的key字符串作为名称,然后通过唯一的key获取对应的value数据。
不同类型数据结构的差异主要在于value结构不一样。
redis提供了很多命令来操作字符串,这里只简单介绍三个最常用的:
命令 | 行为 |
---|---|
GET | 获取存储在给定键中的值 |
SET | 设置存储在给定键中的值 |
DEL | 删除存储在给定键中的值(这个命令可以用于所有数据类型) |
设置一个简单的键值对,key为hello,value为word
set hello word
查询key为hello的value值
get hello
删除键为key的
del hello
可以使用redis客户端工具,执行试下,语句结尾注意不要加分号";",不然会作为value的一部分,保存到redis中。
List
Redis的列表相当于java中的LinkedList,注意是链表而不是数组,而且是双向链表。双向链表这种数据结构插入和删除速度很快,时间复杂度为O(1)。
Redis的列表常用来做异步队列使用。把需要延后处理的任务结构体序列化为字符串,塞入Redis的列表,另一个线程从这个列表中轮询数据进行处理。
下面是关于List列表的常见命令:
命令 | 行为 |
---|---|
RPUSH | 将给定值推入列表右端 |
LRANGE | 获取列表在给定范围上的所有值 |
LINDEX | 获取列表在给定位置上的单个元素 |
LPOP | 从列表的左端弹出一个值,并返回被弹出的值 |
以下操作命令,读者朋友可以自行试下:
rpush list-key item 从左边推入元素item1
rpush list-key item2 从左边推入元素item2
rpush list-key item3 从左边推入元素item3
LRANGE list-key 0 -1 查询所有元素,起始位置为0,结束位置索引为-1,可以取出列表包含的所有元素
lindex list-key 2 查询索引位置2的元素
lpop list-key 从左边弹出元素,并返回弹出的元素
关于List列表,再深入底层一点,我们会发现它不是一个简单的LinkedList,而是一个称之为"快速链表"(quicklist)的结构。
拓展
Quicklist这种数据结构是由ziplist(压缩列表)和双向链表两种结构组合而成。
从大的范围看快速列表本质还是一个双向链表,只是每个节点又是另外一种数据结构ziplist。
ziplist不是单一的元素,它的内部是几个在物理内存上连续的,紧挨在一起的元素。即ziplist这种数据结构占有一块连续的内存区域,里面的元素紧挨在一起存储。你可以理解ziplist类似数组,但和数组有一定的区别。
上面的图很直观的展示了普通双向链表和quicklist快速列表之间的区别。
quicklist节省了内存空间,因为它不像普通链表那样需要维护大量指针,多个ziplist之间使用双向链表方式串起来,又保证了快速插入删除的性能,是一中折中的数据结构。
Hash
Redis中的Hash类型相当于java中的HashMap,它是一个无序字典,内部存储了很多的键值对。散列存储的值既可以是字符产(注意这里不仅仅指人类可读的字符产,还包括各种二进制字符串,实际可以存储各种数据类型),也可以是数值。
下面是一些常用的散列命令:
命令 | 行为 |
---|---|
HSET | 在散列中关联起给定的键值对 |
HGET | 获取指定散列键的值 |
HGETALL | 获取散列包含的所有键值对 |
HDEL | 如果给定键存在于散列里面,那么移除这个键 |
以下命令语句,感兴趣的可以自己尝试下:
hset hash-key sub-key1 value1 添加键值对到散列 hash-key, 返回一个值1,表示给定的键不存在于散列中
hset hash-key sub-key2 value2 添加键值对到散列 hash-key, 返回一个值1,表示给定的键不存在于散列中
hset hash-key sub-key1 value1 添加键值对到散列 hash-key, 返回一个值0,表示给定的键已存在于散列中
hgetall hash-key 获取散列hash-key的所有键值对信息
hdel hash-key sub-key1 从散列 hash-key 中删除键为sub-key1的值
Set
Redis中的Set集合类型相当于java里面的HashSet,它内部的键值对是无序的、唯一的。
说到这里解释下HashSet和HashMap,方便理解Redis中的Set集合:
HashSet 在概念上是一个集合,它用于存储不重复的元素。从用户的角度来看,HashSet 只涉及元素(或者说值),并没有显式的键的概念。但是,从实现的角度讲,HashSet 是通过 HashMap 来实现的,也就是说,HashSet 的底层实际上是一个 HashMap。
在 HashSet 的实现中,元素本身充当了 HashMap 的键,而值则是一个静态的私有对象 PRESENT。这个对象在 HashSet 类中定义,是一个 Object 类型的私有静态实例。这意味着在 HashSet 中的每个元素都会在 HashMap 中有一个相应的条目,其中键是集合中的元素,值是同一个 PRESENT 对象。
这样做的原因是 HashMap 要求键是唯一的,而 HashSet 需要保证元素的唯一性。通过将元素作为键,HashSet 可以利用 HashMap 的哈希表结构来高效地存储和检索元素,同时自动处理元素的去重问题。
下面是一些常用的操作集合Set的命令:
命令 | 行为 |
---|---|
SADD | 将给定元素添加到集合 |
SMEMBERS | 返回集合包含的所有元素 |
SISMEMBERS | 检查给定元素是否存在于集合中 |
SREM | 如果给定的元素存在于集合中,那么移除这个元素 |
同样的,以下是一些测试命令:
sadd set-key item 添加元素item到集合set-key中,返回1,表示成功添加
sadd set-key item2 添加元素item2到集合set-key中,返回1,表示成功添加
sadd set-key item3 添加元素item3到集合set-key中,返回1,表示成功添加
sadd set-key item 添加元素item到集合set-key中,返回0,表示元素已存在
smembers set-key 获取集合set-key的所有元素
sismember set-key item4 检查集合set-key是否存在元素item4,返回0表示不存在
sismember set-key item 检查集合set-key是否存在元素item,返回1表示存在
srem set-key item2 从集合set-key中移除元素item2,返回1表示移除一个元素
srem set-key item2 再次从集合set-key中移除元素item2,返回0表示没有移除到元素
smembers set-key 获取集合set-key的所有元素
ZSet
ZSet有序集合算是Redis中最有特色的数据结构,类似于java中SortedSet和HashMap的结合体,一方面它是一个Set保证内部value的唯一性,另一方面它可以给每一个value赋予一个score,代表这个value的排序权重。
一个使用场景是:zset可以用来存储粉丝列表,value值是用户的id,score是关注时间,可以对粉丝列表按照关注时间来排序。
另一个使用场景是:zset可以用来存储学生成绩,value值是学生的id,score是学生成绩,对成绩按照分数排名可以得到学生的名次。
ZSet内部的排序功能是通过"跳跃列表"来实现的。skiplist跳跃列表是一种高阶数据结构,较为复杂。
下面的是一个有序链表,首尾的节点分别为头节点和尾节点
在这样一个链表中,如果我们要查找某个数据,那么需要从头开始逐个进行比较,直到找到包含数据的那个节点,或者找到第一个比给定数据大的节点为止(没找到)。也就是说,时间复杂度为O(n)。同样,当我们要插入新数据的时候,也要经历同样的查找过程,从而确定插入位置。
假如我们每相邻两个节点增加一个指针,让指针指向下下个节点,如下图:
这样所有新增加的指针连成了一个新的链表,但它包含的节点个数只有原来的一半(上图中是7, 19, 26)。现在当我们想查找数据的时候,可以先沿着这个新链表进行查找。当碰到比待查数据大的节点时,再回到原来的链表中进行查找。比如,我们想查找23,查找的路径是沿着下图中标红的指针所指向的方向进行的:
-
23首先和7比较,再和19比较,比它们都大,继续向后比较。
-
但23和26比较的时候,比26要小,因此回到下面的链表(原链表),与22比较。
-
23比22要大,沿下面的指针继续向后和26比较。23比26小,说明待查数据23在原链表中不存在,而且它的插入位置应该在22和26之间。
在这个查找过程中,由于新增加的指针,我们不再需要与链表中每个节点逐个进行比较了。需要比较的节点数大概只有原来的一半。
利用同样的方式,我们可以在上层新产生的链表基础上,继续为每相邻的两个节点增加一个指针,从而产生第三层链表。如下图:
在这个新的三层链表结构上,如果我们还是查找23,那么沿着最上层链表首先要比较的是19,发现23比19大,接下来我们就知道只需要到19的后面去继续查找,从而一下子跳过了19前面的所有节点。可以想象,当链表足够长的时候,这种多层链表的查找方式能让我们跳过很多下层节点,大大加快查找的速度,类似于二分查找。
上面的方式确实大大加快了查找速度,但是缺点也很明显,新插入一个节点之后,就会打乱上下相邻两层链表上节点个数严格的2:1的对应关系。如果要维持这种对应关系,就必须把新插入的节点后面的所有节点(也包括新插入的节点)重新进行调整,这会让时间复杂度重新蜕化成O(n)。删除数据也有同样的问题。
skiplist为了避免这一问题,它不要求上下相邻两层链表之间的节点个数有严格的对应关系,而是为每个节点随机出一个层数(level)。比如,一个节点随机出的层数是3,那么就把它链入到第1层到第3层这三层链表中。为了表达清楚,下图展示了如何通过一步步的插入操作从而形成一个skiplist的过程:
从上面skiplist的创建和插入过程可以看出,每一个节点的层数(level)是随机出来的,而且新插入一个节点不会影响其它节点的层数。因此,插入操作只需要修改插入节点前后的指针,而不需要对很多节点都进行调整。这就降低了插入操作的复杂度。实际上,这是skiplist的一个很重要的特性,这让它在插入性能上明显优于平衡树的方案。
刚刚创建的这个skiplist总共包含4层链表,现在假设我们在它里面依然查找23,下图给出了查找路径:
以上就是跳表的原理解释,需要详细理解的请自行搜索相关资料。
有序集合的常用命令如下:
命令 | 行为 |
---|---|
ZAAD | 将一个带有给定分值的成员添加到有序集合中 |
ZRANGE | 根据元素在有序排列中所处的位置,从有序集合中获取多个元素 |
ZRANGEBYSCORE | 获取有序集合在给定分值范围内的所有元素 |
ZREM | 如果给定成员存在于有序集合,那么移除这个元素 |
zadd zset-key 728 member1 向有序集 合zset-key 添加新元素member1,分值为728,返回1 ,添加成功
zadd zset-key 982 member0 向有序集 合zset-key 添加新元素member0,分值为982,返回1 ,添加成功
zadd zset-key 982 member0 向有序集 合zset-key 添加新元素member0,分值为982,返回0,添加失败
zrange zset-key 0 -1 withscores 获取有序集合所有元素,并按照分值大小排序
zrangebyscore zset-key 0 800 withscores 获取0-800分值的所有元素
四、总结
通用规则
五个常用的Redis数据结构前面已经介绍完毕,其中list、hash、set、zset这四种数据结构都是容器型数据结构,它们共享下面两条规则:
- create if not exists:如果容器不存在,那就创建一个,然后进行操作。比如hset刚开始没有集合的,Redis会自动创建,然后往其中添加元素。
- drop if no elements:如果容器中的元素没有了,那么立即删除容器,释放内存。
过期时间
Redis中的所有数据结构都可以设置过期时间,时间到了Redis会自动删除相应对象。注意的是过期是以对象为单位,比如一个hash结构的过期是整个hash对象的过期,不是其中的某个子key过期。
对于字符串若已经设置了过期时间,调用set方法修改它,它的过期时间会消失。