【Redis】04.常用变量类型
本文是对Redis的变量类型以及和特定数据类型相关命令的介绍。
1 string
1.1 介绍
Redis中,所有key都是字符串类型,作为value的字符串有几个特性
- 无编码转换(存的是什么取出来就是什么),所以你可以用string来存放二进制文件;
- 限制大小为512MB(避免过长的string操作耗时);
在MySQL中默认的字符集是拉丁文,此时插入中文内容会直接报错编码无法识别而失败,需要修改MySQL数据库的字符集。而在Redis中(不配置的情况下)插入中文,可以正常存放。
1 | 127.0.0.1:6379> set key1 你好 |
这里get查看的时候显示的是中文编码。此时Redis客户端没有配置字符集转码,所以没能显示中文,但实际上中文就是这么存放的(这就好比英文存放的是对应ASCII码一样)。
因为Redis服务端没有对编码进行转换,所以它遇到乱码问题(比如烫烫烫)的概率更小。但依旧不建议使用非英文+数字
的组合来做Redis的key。
如果想让redis-cli的终端中正常显示中文,可以在启动的时候添加--raw
选项。如下所示,添加了该选项后,正常显示出了value的中文值。
1 | ❯ sudo redis-cli --raw |
注意:使用该选项会导致Redis中显示的
(nil)
变成空字符串,极易产生误导!如果不是硬性需要在控制台中显示中文,请不要使用该选项!
另外,在Redis基础命令博客中提到,Redis会对不同的string采取不同的存储方式,其中对于纯整数的string采用的是int来存放。
1 | 127.0.0.1:6379> set key1 100 |
所以在Redis中如果存放一个整数数字(包括负数),虽然对应变量类型是string,但实际上就是一个int数字来存放的,Redis还提供了一系列原子性命令来对整数进行加减操作!
1.2 相关命令
set/get以及mset/mget命令已经在Redis基础命令博客中讲解了,这里不再重复。
1.2.1 setnx/msetnx
setnx等价于set中添加nx选项,只有在key不存在的时候才能正常设置
1 | setnx key value |
- 当key值已经存在的时候,setnx不会执行任何操作,返回0;
- 当key值不存在的时候,setnx等价于set,返回1;
如下所示,key1存在,设置失败返回0;key2不存在,设置成功返回1。
1 | 127.0.0.1:6379> setnx key1 value2 |
还有一个命令是msetnx(和mset一样),可以原子性地同时setnx多个key。
1 | mset key value [key value ...] |
- 只要给定的多个key中有一个key存在,msetnx就会失败且什么都不做,返回0;
- 只有给定的多个key全部都不存在,msetnx才会成功,返回1;
如下所示,此时key1和key2已经存在,设置失败返回0;key3和key4都不存在, 设置成功返回1。
1 | 127.0.0.1:6379> msetnx key1 value1 key2 value2 key3 value3 |
1.2.2 setex/psetex
setex/psetex是在设置key的时候指定过期时间,只是时间单位不一样。
1 | setex key value second |
虽然set命令本身已经提供了关于这些功能的选项,但直接使用特定命令来设置而不是用命令的参数会更加方便且符合人的直觉,使用门槛也更低!
对于学习过Linux系统使用的开发人员而言,命令参数已经见怪不怪了。但对于初次学习Redis使用而没有接触过Linux系统的萌新而言,使用一个简单的命令来传递单个参数,会比在set后面添加一大堆选项更加简单且不容易犯错。
1.2.3 数字加减命令
因为Redis中的整数是用int来存放的,所以它提供了一些命令来原子性的操作数字。这些命令中如果给定的key不存在,则会将其视作0,新建一个key再加上目标值。
命令 | 作用 | 备注 |
---|---|---|
incr key | value + 1 | value必须是整数 |
incrby key n | value + n | value必须是整数,给定的n可以是负数 |
decr key | value - 1 | value必须是整数 |
decrby key n | value - n | value必须是整数,给定的n可以是负数 |
incrbyfloat key n | value +/- 小数 | 给定的n可以是负数来实现减法 |
注意:decrbyfloat
命令是不存在的!
前四个命令的操作数value必须是一个整数,否则会失败。
1 | 127.0.0.1:6379> get key1 |
使用incrbyfloat命令操作一个整数后,它的存放方式就不再是int,而变成embstr了。此时我们依旧可以用incrbyfloat命令来继续添加小数(包括减去小数)。
1 | 127.0.0.1:6379> set key1 100 |
1.2.4 append
append命令用于在字符串之后追加字符串。如果给定key不存在,则和set命令等价。
1 | append key value |
该命令的时间复杂度是O(1)
,返回值是追加后的字符串长度。
1 | 127.0.0.1:6379> set k 123 |
如果尝试给k追加一个中文,字符串长度会是多少呢?
1 | 127.0.0.1:6379> append k 你好 |
可以看到,最终长度是11,因为Redis不会对字符串进行转码,中文在UTF8环境下是用三个字节存放的,所以两个中文就是6字节,加上原本的5个字节,最终长度就是11字节。
不使用--raw
启动redis-cli,就能看到这两个中文的原始编码值。
1 | 127.0.0.1:6379> get k |
1.2.5 getrange
这个命令用于获取字符串的子串,start/end指定一个区间(采用下标方式且为闭区间)。该命令的时间复杂度是O(N)
;
1 | getrange key start end |
测试如下,2代表从第三个字符开始,4代表第五个字符结束。
1 | 127.0.0.1:6379> set key 123456789 |
这里的start/end还可以给负数。当end为负数时,代表直接取到末尾。
1 | 127.0.0.1:6379> getrange key -1 4 |
当start小于end时,返回空字符串
1 | 127.0.0.1:6379> getrange key 2 0 |
注意,getrange的切分是严格按照字节切分的,如果是中文,则难以拆分出一个正常的中文字符(要按3个字节的间隙才能拆出一个正常的中文字符)。
1 | ❯ sudo redis-cli |
1.2.6 setrange
从指定下标开始替换字符串,返回值是替换后字符串的长度。
1 | setrange key offset value |
示例如下
1 | 127.0.0.1:6379> get key |
1.2.7 strlen
返回字符串的长度,单位是字节
1 | strlen key |
1.3 编码介绍
string有三种编码方式
- embstr:为短字符串优化;
- int:为非负整数优化;
- raw:原始字符串;
注意,不管是什么编码方式,都不会影响对string的数字加减命令的使用(只要value是一个数字就行,即便小数的编码方式是embstr,也依旧可以使用数字加减命令)
1.4 应用场景
1.4.1 用户信息缓存
绝大部分情况下,我们需要获取的信息都是字符串类型的。比如用户个人信息的JSON字符串。
此时可以在Redis中用user:平台:用户ID
的方式做key,来保存用户基本个人信息的JSON字符串。如果使用Redis+MySQL的组合的话,整个用户信息的请求步骤如下:
- 用户浏览器/APP中点击个人信息页面,客户端发起请求,假设用户ID为100;
- 应用服务器收到请求,先去请求Redis服务器:
get user:平台:100
; - Redis中成功查询到用户信息,将Json字符串返回给用户;
- 没有在Redis中查询到,使用SQL请求MySQL服务器
select 用户个人信息字段 from user where user_id = '100';
- 将MySQL返回的相关键值按预定格式制作成JSON字符串,返回给用户;
- 将该JSON字符串写入Redis服务器:
set user:平台:100 JSON字符串
;
在set的时候还可以设定一定的过期时间,在保证缓存的实时性的基础上,避免Redis中的数据始终增长而导致内存爆满。(当然,内存快满的时候Redis有淘汰策略可供选择,那是后面要学习的内容了)。
1.4.2 视频播放量计数
对于视频点赞、播放量这种经常变动的数据,可以使用Redis来做计数。比如使用vedio:hit:视频ID
作为key,代表视频的播放量。
当用户点击一个视频的时候,发起请求给应用服务器,服务器在返回视频相关信息的同时,将播放量加一的信息,使用incr命令传递给Redis服务器。
但只用Redis肯定是不够的,还会有一个MySQL数据表来存放视频对应的点赞、播放、收藏等全量信息。此时我们可以令起一个服务,异步地同步视频播放量、点赞等相关信息到MySQL表中。
异步同步:并非来一个视频请求就同步到MySQL一次,而是以一定频率(时间间隔)将Redis中的数据同步到MySQL表中。这样能保证Redis中的数据能有备份。
实际场景中,要想开发一个稳定的真实计数系统,还需要考虑防作弊、不同维度计数、避免单点问题以及数据持久化等等方面。这些都需要根据具体的业务逻辑来特殊处理。
1.4.3 cookie+seesion
在HTTP网络服务中,cookie是最常用的用于标定客户端信息的方式。有些网站并非每次打卡都需要登录,而是登录了之后能维持一段时间不需要用户每次都重新登录。在这个过程中,就是通过浏览器端的cookie和服务器端的session来实现的。
- cookie:存放在用户的浏览器中,其值是通过HTTP的
Set-Cookie
响应头由服务器告知用户浏览器的; - session:存放在服务器端(实际上服务器端应该存放的是cookie-session的键值对),用于标定用户的基本信息;
当用户登录后,服务器会生成一个session_id
,将其和登录的用户信息绑定(键值对),并发送包含Set-Cookie
头的HTTP报文给客户端,将session_id
告知用户。浏览器在检测到这个响应头后,会将它对应的值保存在本地,下一次向这个网站发起HTTP请求的时候就会带上这个设置的cookie值里面的session_id
。
服务器收到HTTP请求后,检查请求头中的Cookie
字段,并与服务器中存放的session_id
键值对进行对比,得到对应的用户,则返回用户相关信息,即当前用户已登录。
实现网页登录在一定时间后过期的功能,只需要在Redis中给这个键值对设置一个过期时间就行了。
使用cookie+session的方式也更加方便多个应用服务器之间的消息共享。因为负载均衡的存在,用户的请求可能会被发送到不同的应用服务器。只要这些应用服务器使用相同的Redis,它们就都能检索到用户的cookie对应的session信息,也就知道了当前用户的个人信息,可以正常进行服务(其实就是信息在多个应用服务器之中进行共享)。整个过程中用户完全不会发现自己的请求并非是同一个服务器来处理的。
1.4.4 验证码
这个场景就很常见了,手机验证码/邮箱验证码都是如此,在Redis中设置一个验证码的key,value是对应用户的id,并给这个key设置一定的过期时间,就能实现验证码的功能。
当用户输入验证码后,检查Redis中的key,当value中的id和用户的id相同,则代表验证成功。用户id不同或验证码的key不存在,则验证失败。
这个过程中可能还会涉及到间隔60s秒才能发送一次验证码(这种限制大概率是前端做的处理)后端自然也可以通过一些缓存的时间值来做检查,避免给同一个用户在较短时间内发送多个验证码(会影响性能)。
2 Hash
2.1 介绍
哈希是比较常见的一种数据结构,Redis本身的key-value结构其实就是通过哈希来实现的。同时,Redis也提供了hash作为value的数据类型,为了和Redis本身的kv进行区分,hash类型内的键值对被称为field-value
。
比如存放一个用户信息,原本我们可以采用如下方式,在key中用冒号作为分隔来保存个人信息。
1 | user:1:name 李四 |
现在我们可以直接将value设置为hash类型,然后在其中再设置对应的field和value,看上去就更加明了。
key | field | value |
---|---|---|
user:1 | name | 李四 |
user:1 | ag | 20 |
user:1 | sex | 男 |
2.2 相关命令
2.2.1 HSET/HGET/HGETALL
https://redis.io/commands/hget/
1 | HSET key field value [field value ...] |
经过之前的命令学习,现在这里的命令就很好理解了。
- HSET用于设置hash类型内部的field,可以同时设置多个field;
- HGET用于获取hash类型内部的field。
HSET命令的返回值是设置成功的field的个数。当HGET命令指定的key或者field不存在时,会返回nil
。
1 | 127.0.0.1:6379> HSET user:1 name 李四 age 20 sex 男 |
与此相关的还有HSETNX和HGETALL两个命令
- HSETNX:当hash中的field不存在时才会设置成功(返回值为1),如果field已经存在则不会做任何操作(返回值为0);
- HGETALL:返回hash中的所有field-value值
1 | 127.0.0.1:6379> HGETALL user:1 |
2.2.2 HEXISTS
该命令用于查询hash中的某个field是否存在。存在返回1,key或者field不存在返回0;
1 | HEXISTS key field |
测试如下
1 | 127.0.0.1:6379> HEXISTS user:1 name |
2.2.3 HDEL
删除hash中指定的field,可以一次性给定多个field来删除。返回值是本次成功删除的field个数。
1 | HDEL key field [field ...] |
示例,info字段是不存在的,实际上只成功删除了name字段,所以返回值为1。
1 | 127.0.0.1:6379> HDEL user:1 name info |
如果你需要删除整个hash,直接使用Redis的del命令将key给删除就行了。比如del user:1
;
2.2.4 HKEYS
获取哈希中的所有field(仅获取字段);该命令的时间复杂度是O(N)
,N是hash中的field个数。
1 | HKEYS key |
这个命令和HGETALL命令有所不同,HGETALL命令会获取field和value,但HKEYS只会获取field。
1 | 127.0.0.1:6379> HGETALL user:1 |
当然,这个命令和HGETALL命令都需要谨慎使用,它们就和keys *
一样,需要遍历整个hash对象,而我们在执行命令之前并不知道一个hash里面到底有多少个field。如果查询的hash中field过多,那就会阻塞Redis。
2.2.5 HVALS
获取hash中的所有value,和HKEYS的功能对应。
1 | HVALS key |
测试如下
1 | 127.0.0.1:6379> HVALS user:1 |
2.2.6 HMGET
一次性获取hash中的多个field(一条命令查询优于多条命令查询)
1 | HMGET key field [field ...] |
测试如下
1 | 127.0.0.1:6379> HMGET user:1 age sex |
2.2.7 HSCAN(仅作介绍)
和HGETALL/HKEYS/HVALS这些一次性遍历完毕所有hash内元素的命令不同,HSCAN命令是“渐进式遍历”(就好比过程化SQL和编程中常用的for循环)。
所谓渐进式遍历,就是敲一次命令遍历一次,这样遍历的过程和速度都是可控的,不会阻塞Redis。当你需要获取一个hash中的所有field/value,使用HSCAN会更加安全。
官网文档:https://redis.io/commands/hscan/
1 | HSCAN key cursor [MATCH pattern] [COUNT count] |
2.2.8 HLEN
获取一个hash中键值对的个数,该命令时间复杂度是O(1)
,因为Redis有使用额外变量来存放hash中元素的个数,无需遍历。
1 | HLEN key |
使用该命令遍历一个不存在的key时,返回值为0
1 | 127.0.0.1:6379> HLEN user:1 |
2.2.9 HINCRBY/HINCRYBYFLOAT
这些命令和string中的数字操作命令一致,因为hash中的value也是字符串,也能当作数字来处理。参数可以是负数来左减法。
1 | HINCRBY key field num |
测试如下,两个命令的返回值都是操作之后的变量值。
1 | 127.0.0.1:6379> hincrby user:1 age 10 |
2.2.10 HSTRLEN
计算hash中value的字符串长度
1 | HSTRLEN key field |
测试如下
1 | 127.0.0.1:6379> hget user:1 age |
2.3 编码介绍
之前在Redis基础命令博客的object encoding
中提到,hash有两种编码方式,一个是ziplist,一个是hashtable。
其中ziplist是在hash中元素较少的情况下使用的,如下所示,刚开始hash中的f1只有3个字节的字符串长度,使用的是ziplist来存放;当我买尝试设置一个非常长的字符串f2,就会切换成hashtable来存放。
1 | 127.0.0.1:6379> hset key f1 111 |
如果你了解hashtable的数据结构,以拉链法为例,它会有一个数组,内部存放链表指针。存放数据时,通过哈希函数计算出key所在下标位置,将value链接到数组下标位置的对应指针上,即为存放完毕。当hash表中的元素较少时,数组可能会空出几个下标的位置没有value链接,这几个下标的空间就算是浪费了。
而使用ziplist就可以节省这部分空间的浪费,对应的代价是ziplist的读写速度会慢于原生hashtable。
在Redis中可以通过下面两个配置项来设置hash什么时候使用ziplist,写入/etc/redis/redis.conf
即可。
配置 | 功能 | 备注 |
---|---|---|
hash-max-ziplist-entries | 设置field个数为多少以下时使用ziplist | 默认512个 |
hash-max-ziplist-value | 设置hash中value字符串的最大长度 | 默认为64字节 |
2.4 应用场景
2.4.1 关系数据库缓存
正如介绍阶段时提到的,hash非常适合用于存放一些结构化的数据。以用户数据为例,可以用uid作为key的标识,内部存放对应的个人信息。有的时候为了方便,还会在hash中再存放一次uid。
key | field | value |
---|---|---|
user:1 | uid | 1 |
name | 李四 | |
ag | 20 | |
sex | 男 | |
user:2 | uid | 2 |
name | 王五 | |
age | 23 | |
sex | 男 |
这样其实就好比一个MySQL数据库中的表
uid | name | age | sex |
---|---|---|---|
1 | 李四 | 20 | 男 |
2 | 王五 | 23 | 男 |
用这种方式,我们可以将MySQL中的表直接缓存在Redis中,提供更加快速的查询。需要修改数据的时候,也可以采用先修改Redis中的数据,再异步同步到MySQL中的方式来提高效率。
当然,使用string+json的方式也可以存放结构化数据,但在使用的时候就涉及到了json的序列化和反序列化,效率会低于直接使用Redis里面的hash来存放,不过使用hash会有更大的空间消耗。
这里还涉及到了高内聚/低内聚的概念:
- 高内聚:把有关联的数据存放在一起;
- 低内聚:有关联的东西散开存放了;
使用hash来存放用户数据,就属于高内聚。如果使用user:1:name
、user:1:age
的key/value来存放用户数据,就是低内聚,因为用户信息被拆开存放在了不同的key中。
同理,上文string中提到的视频播放量信息统计,也可以使用hash来存放一个视频的点击量/点赞量,将一个视频的数据存放在一个hash中,而不用string来存放。
我们在设计的时候都强调高内聚、低耦合,就是为了整个系统能有更好的整洁度,维护更加方便。
2.4.2 hash和关系数据库的区别
- 哈希类型是稀疏的,关系数据库是结构化的。比如不同的hash里面的field完全没有关系,可以随意设置,但MySQL中一个表想插入一个数据,就必须依照表的要求设置所有数据;
- 关系数据库可以进行复杂的关系查询(比如多表查询),而Redis很难模拟关系查询,维护的复杂度很高且没有必要。
3 List
3.1 介绍
Redis中的list列表类型提供了头插头删/尾插尾删的命令,我们可以将它当作顺序表、栈、队列来使用。
列表中的元素是按序存放的,所以我们可以通过下标来访问列表中的元素或获取一个范围中的元素。列表中的元素允许重复。
3.2 相关命令
3.2.1 LINDEX
根据下标查看list中的数据,当下标超出范围时返回nil
;
1 | LINDEX key index |
index支持负数下标,从后往前数。比如-1
代表从后往前第一个数据(即list末尾数据)。
注意,该命令的时间复杂度是O(N)
,因为Redis中的list并非时刻采用顺序表来实现(会有不同编码方式),不能保证顺序表下标访问那样的快速!
3.2.2 LPUSH/RPUSH
头插命令,支持一次插入多个数据。如果指定的key不是list类型则报错。
1 | LPUSH key element [element ...] |
注意,当一次插入多个数据时,最后一个数据会在list的头部(按命令中出现的顺序,从左往右插入)
1 | 127.0.0.1:6379> clear |
尾插也是相同的效果
1 | RPUSH key element [element ...] |
一次性插入多个数据的时候,也是最后一个数据在list的末尾。可以用lindex命令指定-1
下标来获取末尾的数据(从后往前数第一个值)
1 | 127.0.0.1:6379> rpush key 5 6 7 8 |
3.2.3 LPOP/RPOP
从list的头部或者尾部去除数据
1 | LPOP key [count] |
注意,高版本Redis才有可选的count选项,当前我使用的Redis仅可一次pop一个元素,返回值是被删除的元素。
Starting with Redis version 6.2.0: Added the
count
argument.
测试如下
1 | 127.0.0.1:6379> lpop key |
如果是高版本,指定count参数后,会返回一个被删除元素的array,效果参考下面这个官网给出的examples。
1 | redis> RPUSH mylist "one" "two" "three" "four" "five" |
如果尝试操作一个空的list或者不存在的key,返回值是nil
;如下所示,key1的list中只有1个元素,第一次成功删除元素,但是第二次操作的时候key1是一个空list,操作失败返回nil
;
1 | 127.0.0.1:6379> lpush key1 1 |
当一个list中不存在元素的时候,Redis会自动将该list的key删除!如下所示,当我们把test键值中的元素全部删除时,这个test键值就直接不存在了。
1 | 127.0.0.1:6379> keys * |
3.2.4 LRANGE
查看list中指定范围的元素,这里的区间是闭区间(最终结果包含start和stop下标的数据),当start小于stop时返回empty array
;
1 | LRANGE key start stop |
示例如下,指定了0
和-1
等同于获取list中的全部元素。
1 | 127.0.0.1:6379> lrange key 0 -1 |
注意,此处Redis是用array返回的一个结果集,序号是从1开始的(和list中的下标不一样且无关)
3.2.4.1 超出下标范围
另外,使用lrange命令指定下标的时候,如果下标超出范围,也会得到尽可能符合下标结果的数据,这点和lindex不同!
1 | 127.0.0.1:6379> lrange key 3 10 |
如上所示,我尝试访问3到10的数据,但实际上list中的数据下标到7就结束了(一共八个元素),但Redis并没有报错或返回empty array
,而是获取了从下标3开始一直到list末尾的数据。
同理,当你访问超出范围的负数下标,也能得到类似的结果。
1 | 127.0.0.1:6379> lrange key -10 -1 |
当然,实际业务时正确使用合法肯定是更好的!
3.2.5 LPUSHX/RPUSHX
和LPUSH/RPUSH作用相同,多了一个对key是否存在的检测,只有key存在时才能插入成功。
1 | LPUSHX key element [element ...] |
返回值是插入成功的元素个数,如果key不存在则返回0;
3.2.6 LINSERT
这个命令的作用是在指定pivot元素位置插入一个元素,可以通过参数选择是在指定pivot元素之前插入,还是在指定pivot元素之后插入。
1 | LINSERT key <BEFORE | AFTER> pivot element |
该命令时间复杂度是O(N)
,N是pivot元素和list开头的距离,返回值如下:
- 成功时返回插入元素后list的元素个数;
- key不存在时返回0;
- pivot元素不存在时返回-1,且什么都不会发生;
测试如下,这里选择的已有元素是4,before会在4之前插入一个元素,after会在4之后插入一个元素。
1 | 127.0.0.1:6379> lpush key 1 2 3 4 5 6 |
清空key后重新测试,如果list中有多个相同的pivot元素,会在哪里操作呢?可以看到它会在第一个5的位置操作,即pivot会采用第一个找到的元素。
1 | 127.0.0.1:6379> lpush key 1 2 5 3 4 5 6 |
3.2.7 LLEN
获取list的长度,如果key不存在返回0;
1 | LLEN key |
3.2.8 LREM
删除list中指定的值,返回值是被删除元素的个数
1 | LREM key count element |
count参数的可选项如下
- count大于0:从前往后删除count个等于element的元素;
- count小于0:从后往前删除
|count|
个等于element的元素; - count等于0:删除所有等于element的元素;
测试一下,使用rpush可以让list和我们输入的顺序一致。这里指定的count大于0,删除的元素是1,最终删除了从前往后数的两个1。
1 | 127.0.0.1:6379> rpush key 1 2 3 4 1 2 3 4 1 2 3 4 |
再指定count为-1,删除元素是3,会删除从后往前数的第一个3;
1 | 127.0.0.1:6379> lrem key -1 3 |
指定count为0,删除元素是4,list中的所有4都会被删除
1 | 127.0.0.1:6379> lrem key 0 4 |
3.2.9 LTRIM
删除list中指定区间外的元素(即保留指定的闭区间,其他都删除)
1 | LTRIM key start stop |
示例如下
1 | 127.0.0.1:6379> rpush key 1 2 3 4 5 6 |
3.2.10 LSET
该命令可以设置某个下标的元素(替换)。
1 | LSET key index element |
该命令的时间复杂度是O(N)
,这就和LINDEX的原因一样,因为Redis不总是用顺序表来存放list的数据,所以无法保证O(1)
下标访问一样的时间复杂度。
3.2.11 BLPOP/BRPOP(阻塞版本)
3.2.11.1 阻塞命令说明
BLPOP和BRPOP是LPOP/RPOP的阻塞版本,命令里面的B就是block阻塞的缩写。这也是我们第一次接触Redis里面的“阻塞命令”。
使用这两个命令的时候,list就可以当作一个阻塞队列(和Linux的管道也有点相似)了:
- 如果队列为空,尝试出队列时会阻塞;
- 如果队列已满,尝试入队列时会阻塞;
对于Redis而言,list一般不存在“满”的情况,我们大多考虑队列为空的情况。而Redis的单线程模型也保证了这个“阻塞队列”是线程安全的。
另外,Redis提供的阻塞命令并不会把自己给阻塞,而类似于阻塞了客户端,使用BLPOP/BRPOP的时候需要给定一个timeout参数,在阻塞等待的期间,Redis可以正常响应其他的命令和请求。
而被阻塞的客户端实际上是在等待其他客户端往对应list中插入新元素。
3.2.11.2 命令参数
https://redis.io/commands/blpop/
1 | BLPOP key [key ...] timeout |
注意:timeout单位为秒,Redis 6中可以设置为小数,设置为0时代表永久阻塞。
这两个命令都可以指定多个key,当指定多个key的时候,Redis会进行从左往右的遍历,只要其中一个key对应的list有元素,就会立刻返回。相当于一次等多个list。
如果有多个客户端都需要使用这两个命令来等待同一个key,那么先执行命令的客户端会得到弹出的元素。
因为blpop/brlpop都支持多个key值等待,为了标定弹出的键属于哪一个key,这两个命令在返回的时候会返回array类型的key+value;
1 | 127.0.0.1:6379> blpop key 0 |
当超时时间结束时,给定的几个key的list还是为空,则会返回nil;
3.2.11.3 命令预期行为
当list不为空时,lpop和blpop的命令行为完全一致。
当list为空,且blpop指定的timeout时间中没有新元素插入list时:
- lpop会立马得到nil;
- blpop会在等待timeout时间后得到nil;
- 此时lpop和brlpop的命令行为就不一致了。
当list为空,且timeout时间内有新元素插入
- 因为原本list是为空的,所以lpop还是会立马得到nil;
- blpop会在新元素插入后返回key和新元素;
3.2.11.4 阻塞测试
下面是一个阻塞时的测试,使用0来指定永久阻塞,当右侧终端回车提交插入操作时,左侧的阻塞会立马返回数据。
尝试等待多个key,只要有一个key返回了数据,阻塞的终端就会立马返回。虽然说这个等待时遍历的顺序是从左往右的,但由于Redis是单线程模型,不会出现两个key同时新增数据的情况,总会有个先后顺序,所以最终还是等待列表中,哪一个key先有数据插入,哪一个key就会被blpop返回。
如果开始遍历的时候,key没有数据,key1和key2有数据,那么Redis就会按从左往右的原则,返回key1的数据。
3.3 编码方式
旧版本Redis中list的编码方式有两个
- ziplist:为短list优化
- linklist:正常的双向链表
但在新版本(Redis 3.2以后)对list的数据结构进行了改造,使用quicklist替代了ziplist/linklist,更多信息可以参考:redis数据结构-快速列表;
3.4 应用场景
3.4.1 栈和队列
使用list来模拟栈或者队列的功能
- 队列:只使用rpush和lpop命令
- 栈:只使用lpush和lpop命令
3.4.2 班级中有那些学生/部门中有那些员工
可以使用list将一个班级id作为key,学生id作为list里面的元素。
1 | class:1 [1,3,4] |
这样我们就可以通过list来得知每个班级对应的学生id编号。部门和员工的关系也是一样的;
3.4.3 消息队列
使用BLPOP/BRPOP这两个命令就可以让list作为一个简单的消息队列来使用。
以“生产-消费者模型”为例,生产者往list中插入数据,消费者使用BLPOP等待新数据的插入并进行消费。此时可以有多个生产者进行push,也可以有多个消费者同时使用BLPOP命令等待list中的新数据插入。
因为Redis不存在多线程竞争的问题,所以新数据插入后只会有一个消费者能拿到数据进行消费。并且多个消费者执行BLPOP命令时也存在一个先后顺序,按123的顺序来说的话,这一次消费者1拿到了数据,下一次就是消费者2,再下一次就是消费者3,不会出现某个消费者饥饿的情况。
3.4.4 视频信息传递
以一个视频网站为例,使用list作为消息队列时,可以采用一个视频对应多个key的方式来处理
- 视频数据
- 视频评论
- 视频新弹幕
- …
对应的消费者可以通过BLPOP命令一次性等待多个key,这样不管是新的评论来了,还是新的弹幕来了,都能在第一时间被处理。
3.4.5 用户的timeline
因为list里面的元素是有序的,先插入的始终是在list的头部。我们就可以通过list来实现一个时间轴的功能。
当用户新建一个微博的时候,就将这个微博的id插入用户相关的list,这样用户和他上传的微博就有了一个时间的关系,还可以用lrange命令很方便的获取到用户的前n个微博,或某个区间的微博。
下面是一个分页获取用户微博的伪代码
1 | ## 先获取用户前10个微博 |
这里就会出现一个问题,假设将单个分页需要显示的数据设置为100,那么每次循环中就会多次调用hash类型的HGETALL命令,导致Redis可能被阻塞。
这个问题可以使用pipeline来解决,相当于将多个Redis命令合并成一个网络请求来执行,可以减少网络传输多次导致的延迟。后续将学习相关内容。
另外一个问题是,lrange针对list两头的查询效率还不错(因为可以直接从头或从尾部开始遍历)但对中间的分页获取的效率就有点低了。这个问题可以通过list来解决(有点类似分库分表)。
4 Set
set是一个集合,集合中的每个元素都是string类型。它和list的区别主要在于:
- set的元素不可以重复;
- set的元素是无序的;
所谓无需,是相对于list的有序而言的(注意,list的有序指的是顺序表中元素的顺序,并不是说list会按大小排序)
1 | [1,2,3] 和 [1,3,2] 是两个不同的list |
4.1 相关命令
4.1.1 SADD
SADD命令用于给set中添加元素,为了和list中的元素作区分,set中的元素被称为member。
1 | SADD key member [member ...] |
返回值表示本次操作添加成功了几个元素,重复的元素只会被添加一次。
1 | 127.0.0.1:6379> sadd key 1 2 3 4 |
4.1.2 SMEMBERS
查看set的所有成员
1 | SMEMBERS key |
1 | 127.0.0.1:6379> smembers key |
4.1.3 SISMEMBER
查看某个元素是否在集合中
1 | SISMEMBER key member |
返回值为1代表存在,返回值为0代表不存在。
1 | 127.0.0.1:6379> sismember key 1 |
4.1.4 SPOP
该命令可以弹出set中的元素。但因为set是无序的,所以我们只能指定删除元素的个数,Redis会随机删除(弹出)set中的元素,这一点在官方文档中有说明。
1 | SPOP key [count] |
返回值是被删除的元素
1 | 127.0.0.1:6379> spop key 2 |
这个命令也能体现出set中元素无序的概念。我们按1234构架两个set,尝试进行spop,能发现每次删除的元素的顺序是不一样的,完全随机。
1 | 127.0.0.1:6379> sadd key1 1 2 3 4 |
4.1.5 SRANDMEMBER
这个命令和SPOP功能类似,返回set中的一个或多个随机数据,但不会删除该数据。
1 | SRANDMEMBER key [count] |
4.1.6 SMOVE
将某个元素从set1移动到set2,或者说是从source中删除,在destination中新增。
如果destination中已经存在该元素,则只会删除source中的元素。
1 | SMOVE source destination member |
测试如下,该命令成功时返回1,不成功返回0(source中不存在该元素时失败);
1 | 127.0.0.1:6379> sadd key1 1 2 3 |
4.1.7 SREM
删除set中的指定元素
1 | SREM key member [member ...] |
4.1.8 SINTER/SINTERSTORE
求两个集合的交集,即获取同时出现在两个set中的元素。
1 | SINTER key [key ...] |
这个命令的时间复杂度是O(M*N)
,其中M是最小的集合元素个数,N是最大的集合元素个数。
1 | 127.0.0.1:6379> sadd key1 1 2 3 4 |
另外一个命令是SINTERSTORE,它多了一个存储功能,求了交集后,存储到destination中。
1 | SINTERSTORE destination key [key ...] |
这个命令的返回值是最终交集的元素个数。
1 | 127.0.0.1:6379> sinterstore key3 key1 key2 |
4.1.9 SUNION/SUNIONSTORE
求两个集合的并集
1 | SUNION key [key ...] |
功能和上面的命令类似,这里就不做演示了
4.1.10 SDIFF/SDIFFSTORE
求集合的差集,即存在于第一个key,但是不在第二个key中的值
1 | SDIFF key [key ...] |
实测如下
1 | 127.0.0.1:6379> sadd key1 1 2 3 4 |
如果key的数量不止两个,你可以理解为Redis会进行依次计算。即先计算key1和key2的差集,再将结果和key3进行计算。如下所示,key1和key2的差集包含1和2,但是key3中有1,所以最终的差集结果就只有2了。
1 | 127.0.0.1:6379> sadd key3 1 3 7 8 |
4.1.11 SCARD
获取set中元素的个数
1 | SCARD key |
测试如下
1 | 127.0.0.1:6379> sadd key1 1 2 3 4 |
4.2 编码方式
set有两种编码方式
- intset:如果set中全都是整数,采用这种方式,可以通过
set-max-intset-entries
来配置元素个数,超过这个数量的会采用hashtable。 - hashtable:当set中有其他非数字类型时,采用这种方式。
1 | 127.0.0.1:6379> sadd key 1 2 3 4 |
4.3 应用场景
4.3.1 保存标签
一个视频会有相关标签,一个用户也会有相关标签,set类型有元素不能重复的特性,比较适合用于保存某个对象的标签。
比如某个用户的兴趣爱好,这些爱好能帮助刻画一个用户画像,以便更加精确的推送用户喜欢的东西(或则广告)
4.3.2 公共好友
通过集合求交集,能很容易得到两个用户的公共好友,或者共同关注了xxx。
4.3.3 记录站点PV/UV
站点PV(Page View)指的是站点的访问量,每次点击一个页面,就会产生一个PV。
站点UV(User View)指的是站点的用户访问量,每个用户只会记录一次。
因为PV和UV的记录内容不同,在记录的时候需要进行一定的去重。比如UV需要按用户去重,一个用户只能记录一次。这时候用set来去重就比较方便了,当有新用户访问页面的时候,就将这个用户的id插入到对应key的set中,最终记录UV就获取这个set的长度就行。
5.Zset
Zset是有序的集合,在集合的基础上,对内容会进行升序排序。
Zset中的member同时引入了一个score
分数的属性,每个member都会有一个自己的分数,排序的时候就是按分数的大小来进行升序排序的。Zset中的member必须唯一,但score分数可以重复。
注意,member/score之间的关系并非键值对,score只是member的一个用于排序的属性值!在Zset中既可以通过member查询score,也可以通过score来查询member。
Zset中的分数可以是小数。如果多个元素有相同的分数,则按元素的字典序排序。
5.1 相关命令
5.1.1 ZADD
1 | ZADD key [NX | XX] [GT | LT] [CH] [INCR] score member [score member |
ZADD命令有很多选项,其中一些选项之前已经见过了
- XX:只有member存在的时候,才会更新分数;
- NX:插入新的member,如果已存在则不做任何处理;
- LT:只有新的score小于当前score时才会更新, 不会阻止添加新元素(不存在的member正常添加)
- GT:同上,但只有新的score大于当前score时才会更新;
- CH:一般情况下,ZADD返回新元素被添加的个数。添加CH选项后,会返回新元素被添加的个数+被修改score的元素个数。
- INCR:当指定此选项时,ZADD的作用类似于ZINCRBY。在此模式下只能指定一个score-element对。
这个命令的时间复杂度O(log(N))
,N是Zset中元素的个数。因为zset作为有序的结构,需要将新插入的元素放到正确的位置上,这个操作会有耗时。
插入数据后查看,可以看到数据是按我们预先设置是分数进行升序排序的。
1 | 127.0.0.1:6379> zadd key 98 lisi 97 zhangsan 99 wangwu |
测试NX/XX选项,如果不带任何选项,则会更新已有member的分数(原本lisi的分数是98),带了NX选项后,lisi的分数没有被修改。
1 | 127.0.0.1:6379> zadd key 99 lisi |
带xx选项,lisi的分数被修改成功
1 | 127.0.0.1:6379> zadd key xx 100 lisi |
带xx选项操作一个不存在的member,没有被新增
1 | 127.0.0.1:6379> zadd key xx 100 kk |
ch会在返回值中带上被修改分数的个数,如下所示,kk已经存在,将分数修改为110,带ch选项,返回值为1;再次修改kk的分数,不带ch选项,返回值为0,但kk的分数已经被修改为112了。
1 | 127.0.0.1:6379> zadd key xx ch 110 kk |
1 | 9) "kk" |
5.1.2 ZRANGE
ZRANGE类似于LRANGE,可以用下标的方式来查看Zset中的元素个数。因为Zset中的元素有序,所以它存在“下标”的概念。
1 | ZRANGE key start stop [BYSCORE | BYLEX] [REV] [LIMIT offset count] |
这个命令的时间复杂度是O(log(N)+M)
,其中N是ZSET中的元素个数,M是需要返回的元素个数。
1 | 127.0.0.1:6379> zadd key 98 lisi 97 zhangsan 99 wangwu |
使用WITHSCORES选项,可以让元素和分数一起返回
1 | 127.0.0.1:6379> zrange key 0 -1 withscores |
5.1.3 ZCARD
获取Zset中元素的个数(被称为zset的基数)
1 | ZCARD key |
测试如下
1 | 127.0.0.1:6379> zrange key 0 -1 |
5.1.4 ZCOUNT
返回分数在min/max之间的元素个数,默认是闭区间,包含min和max的值。该命令的时间复杂度是O(log(N))
,N是Zset中元素个数。
zset在存放一个member的时候会保存它的次序(可以简单理解为下标)所以这个命令不是通过min到max的遍历来获取元素的,而是先找到min和max这两个边界值的member,再获取到它们的元素次序,最终将次序相减,就得到了元素个数。
1 | ZCOUNT key min max |
可以使用括号来表示开区间,注意括号都是加在前面的。
1 | ZCOUNT key (min (max |
测试如下
1 | 127.0.0.1:6379> zrange key 0 -1 withscores |
因为zset中的score可以使用浮点数,在Redis中有两个特殊的浮点数,用于表示正无穷大inf
和负无穷大-inf
,所以在ZCOUNT的min/max中也可以用这两个特殊的浮点数来筛选
1 | 127.0.0.1:6379> zcount key -inf 100 |
5.1.5 ZREVRANCE
这个命令的作用和ZRANGE类似,但返回的数据是降序的
1 | ZREVRANGE key start stop [WITHSCORES] |
测试如下
1 | 127.0.0.1:6379> ZREVRANGE key 0 -1 WITHSCORES |
注意,该命令在Redis6.2中已经**弃用(deprecated)**,在ZRANGE中使用REV选项能实现它的功能。
As of Redis version 6.2.0, this command is regarded as deprecated.
It can be replaced by
ZRANGE
with theREV
argument when migrating or writing new code.
5.1.6 ZPOPMAX/ZPOPMIN
删除并返回Zset中分数最高的count个元素
1 | ZPOPMAX key [count] |
这个命令可以用来解决TopK问题,假设有个10元素的zset,想获得score在前3的元素的一个set,可以使用两种方式
- ZPOPMAX将高三位弹出并存放到另外一个zset中;
- ZPOPMIN将低七位弹出,此时剩下的就是高三位;
测试如下,pop的时候会将member和score一起弹出。
1 | 127.0.0.1:6379> zpopmax key 2 |
5.1.7 BZPOPMAX
这个命令是ZPOPMAX的阻塞版本,当key中没有元素时会阻塞
1 | BZPOPMAX key [key ...] timeout |
这个和BLPOP/BRPOP的效果一样,不做演示了。
5.1.8 ZRANK
获取一个Zset中某个成员的排名(返回的是排名序号,以0开始)时间复杂度O(log(N))
,N是Zset中元素个数。
1 | ZRANK key member [WITHSCORE] |
测试如下
1 | 127.0.0.1:6379> zrange key 0 -1 |
redis7.2后,添加withscore选项,会同时返回这个成员的分数
5.1.9 ZREVRANK
返回某个成员的排名,降序排序。时间复杂度O(log(N))
,N是Zset中元素个数。
1 | ZREVRANK key member [WITHSCORE] |
redis7.2后,添加withscore选项,会同时返回这个成员的分数
1 | 127.0.0.1:6379> zrange key 0 -1 |
5.1.10 ZSCORE
返回zset中某个成员的分数,时间复杂度为O(1)
;
1 | ZSCORE key member |
测试如下
1 | 127.0.0.1:6379> zrange key 0 -1 withscores |
5.1.11 ZREM
删除某个zset中的member,可以一次性传入多个。
1 | ZREM key member [member ...] |
时间复杂度O(M*log(N))
,N是zset中元素个数,M是需要删除的元素个数。
5.1.12 ZREMRANGEBYRANK
删除某个区间的元素,和ZRANGE的start/stop相同
1 | ZREMRANGEBYRANK key start stop |
时间复杂度是O(log(N)+M)
,N是zset中元素个数,M是区间内的元素个数。
1 | 127.0.0.1:6379> zadd key 1 one 2 two 3 three |
5.1.13 ZREMRANGEBYSCORE
删除某个区间的元素,使用的是min/max分数区间,默认闭区间,也可以用左括号表示开区间。
1 | ZREMRANGEBYSCORE key min max |
时间复杂度是O(log(N)+M)
,N是zset中元素个数,M是区间内的元素个数。
5.1.14 ZINCRBY
给某个zset中的元素增加分数,如果元素不存在,则和zadd的作用相同。
1 | ZINCRBY key increment member |
时间复杂度O(log(N))
,N为zset中元素个数。
5.1.15 ZUNIONSTORE
这个命令会求两个zset的并集并存放到的destination中。
时间复杂度:O(N)+O(M log(M))
,N是所有input参数zset中元素的个数总和,M是结果集中的元素个数。
1 | ZUNIONSTORE destination numkeys key [key ...] [WEIGHTS weight |
命令参数
- destination:目标zset的key,如果这个key已经存在,则会被覆盖。
- numkeys:输入的key的个数
- WEIGHTS:给每个入参的key设定的乘法系数,当求交集的时候,会将最终结果的分值乘以这个乘法系数再交付。不提供的时候默认为1;
- AGGREGATE:该选项可以指定并集运算结果的聚合方式。该选项默认值为 SUM,即将输入中所有存在该元素的集合中对应的分值全部加一起。当选项被设置为 MIN 或 MAX 任意值时,结果集合将保存输入中所有存在该元素的集合中对应的分值的最小或最大值。
测试如下,先不使用weights,可以看到两个zset中,相同的元素的分数会相加,one的分值变成了2,two的分值变成了4;如果只有一个zset存在的元素则保持不变。
1 | 127.0.0.1:6379> zadd zset1 1 "one" 2 "two" |
使用weight提供乘法系数,zset1中的分值被乘以2,zset2中的分值被乘以3,然后二者再相加起来。
1 | 127.0.0.1:6379> zunionstore out 2 zset1 zset2 weights 2 3 |
如果修改AGGREGATE策略,结果又不同,默认sum是相加;指定min是当两个zset都有某个参数的时候,选用分数较小的哪一个。比如zset1中乘法系数是2,所以one/two的分数小于zset2中的分数,最终的集合out中存放的就是zset1中的元素分数。
1 | 127.0.0.1:6379> zunionstore out 2 zset1 zset2 weights 2 3 aggregate min |
使用AGGREGATE MAX
存放的就会是zset2中的分数了。
1 | 127.0.0.1:6379> zunionstore out 2 zset1 zset2 weights 2 3 aggregate max |
5.1.16 ZINTERSTORE
这个命令存放并集到destination中,相关的命令选项和ZUNIONSTORE
一致。
1 | ZINTERSTORE destination numkeys key [key ...] [WEIGHTS weight |
时间复杂度:最坏情况是O(N*K)+O(M*log(M))
,N是最小的输入zset中的元素个数,K是输入参数中zset的个数,M是结果集中元素个数。
1 | 127.0.0.1:6379> zadd zset1 1 "one" 2 "two" |
5.2 编码方式
当zset中元素个数少的时候,会使用ziplist;
当元素个数较多,或者单个元素的体积较大(字符串长),使用skiplist来存储。
1 | zset-max-ziplist-entries 元素少于这个数量的时候使用ziplist |
测试
1 | 127.0.0.1:6379> zadd zset1 1 "one" 2 "two" |
5.3 使用场景
zset比较适合建立排行榜。诸如微博热搜、B站热搜、游戏排行榜这些,都是一个“排行榜”的应用场景。
游戏玩家的排位会有一个分数,排行榜按这个分数来降序排列的,并展示给用户。且可以使用zrange来进行分页查看。
微博热搜也可以应用zset,每个话题肯定会有一个热搜的指数,如果简单来处理,那么就是用户搜索一次这个话题,它在zset中的分数就加一(zincrby命令),这样被搜索的次数越多的话题,在热搜榜中就越靠前。
当然,微博实际上用的是一个综合的数值,而不是只看搜索量这个单一指标。这时候可以用zinterstore/zunionstore中的weight来实现多个维度数值的计算。比如浏览量、点赞率、转发量、评论量这些数据,在最终结果集合的时候都给他们赋予一个权值再进行计算,最终得到一个热榜的指数。
6.Steams
steams数据类型类似一个append-only log
,可以让我们把一个事件投放给多个目标。你可以理解为它就是一个队列,比list更加适合作为消息中间件。
在Redis官网上提到了steams类型的几个应用场景
- 事件来源监看
- 事件通知
- 消息监看(消费者进行阻塞等待)
所谓事件,就是某个东西满足了某种状态的场景。比如linux多路转接中epoll/select就是通过事件来通知进程来处理io请求的,我们在进程中调用接口进行等待的时候,就相当于是在执行“消息监看”这一步骤。
7.Geospatial
这个类型就是用来存储坐标(经纬度)的,代表一个地理位置。它存储点了之后,可以进行地理半径进行查找,在导航软件中就很有用。
基本命令是添加和查询:
- GEOADD将位置添加到给定的地理空间索引(请注意,使用此命令,经度先于纬度)。
- GEOSEARCH返回具有给定半径或地理边界框的位置。
平时肯定用不上这个类型,只有接触了具体的业务才需要了解
8.HyperLogLog
这个数据类型的应用场景主要是用于计算(估算)集合中的元素个数。
比如用set来存放站点的UV的时候,假设set中存放用户id(8字节)一个1亿UV的站点大约会占用800MB的内存。看上去好像不多?毕竟一亿UV的网站哪里有那么多啊?
但HyperLogLog可以使用最多12KB的空间就实现上述的效果!
The Redis HyperLogLog implementation uses up to 12 KB and provides a standard error of 0.81%.
set占用那么多内存是因为它完整存储了用户的id,但HyperLogLog并不存放元素内容,但可以记录“元素的特征”,新增元素的时候可以判断当前元素是新增的元素还是已经存在的元素。这时候就可以用HyperLogLog来进行计数,但不能用于真正存放元素。
这里的底层肯定很复杂,且HyperLogLog并不能保证百分百精确。官方文档上也提到了,HyperLogLog大概会有0.81%
的误差。
注意:HyperLogLog是一个算法思想,并非Redis专有的。其他工具中也可能提供这个数据结构来实现此类需求。
9.Bitmaps
bitmaps是位图结构,本质上还是一个集合,它使用某个比特位来存放某些特定的数据,以此来节省空间。
比如存放数字10,我们不是直接存放整形,而是将位图中的从右往左数第10位从0改成1,这样就代表10已经存放了。
和HyperLogLog不存放元素相比,bitmaps虽然是用比特来表示是否存在某个元素,但实际上它是存放了这个元素的,因为我们可以通过位图中的数据还原出某个元素是否存在!比如第3个比特位是1,那么集合中就有3。
- 不需要知道元素内容,可以使用HyperLogLog;
- 想节省空间的同时需要记录整形元素,使用bitmaps
10.Bitfields
位域Bitfields,在C语言中位段其实就是位域;
1 | struct TestBit { |
Redis中的bitfield和C语言中的位域相似,可以理解位一串二进制序列,并给某个位设置特定的哈衣,并进行读取/修改/算术运算等相关操作。
相比于string类型,它的核心目标还是节省空间。比如一把moba游戏中玩家的金币、KDA、补刀等信息,并不需要用大量空间来存储,使用位域可以节省空间。在Redis官网上也有相关的示例命令
https://redis.io/docs/data-types/bitfields/
The end
Redis中最主要使用的数据类型和命令就是这些了,后续相关知识点会继续补充。