引言
根据【Redis教程0x02】中介绍的,Redis的数据类型可分为5种基本数据类型(String、Hash、List、Set、Zset)和4种高级数据类型(BitMap、HyperLogLog、GEO、Stream)。在本篇博客中,我们将详解这9种数据类型,分别从介绍、内部实现、常用指令、应用场景四个维度来说明。此外,还要埋个伏笔,Redis的9种数据类型的底层实现主要依赖了8种数据结构(SDS、LinkedList、Dict、SkipList、Intset、ZipList、QuickList),我们后面也会讨论。废话不多说,让我们从5种基本数据类型开始学习吧。
这里有个在线Redis环境,网页上就能使用:https://try.redis.io/
基本数据类型
字符串String
介绍
字符串String是Redis中最基本也是我们最常用的数据类型,它是基于key-value结构,key是唯一标识,value是具体的值。String是一种二进制安全的数据类型,其value可以用来存储任何类型的数据比如字符串、整数、浮点数、图片(图片的base64编码或者解码或者url)、序列化后的对象。value最多可以容纳的数据长度是512MB
。
底层实现
Redis的String类型的底层数据结构实现主要是int和SDS(Simple Dynamic String,简单动态字符串)。虽然Redis是用C语言写的,但是SDS跟C语言的原生字符串并不相同,具体表现如下:
- SDS不仅可以保存文本数据,还可以保存二进制数据。因为SDS使用
len
属性的值而不是空字符串来判断是否结束,并且SDS的所有API都会处理二进制的方式来处理SDS存放在buf[]
数组里的数据。这也是为什么SDS能存放文本、图片、音频、视频、压缩文件这样的二进制数据的原因。 - SDS获取字符串长度的时间复杂度为O(1)。C语言不记录字符串长度,所以获取复杂度为O(n)。而SDS结构里用
len
属性记录。 - Redis的SDS API是安全的,拼接字符串不会造成缓冲区溢出。因为SDS在拼接字符串之前会检查SDS空间是否满足要求,空间不够会自动扩容,所以不会产生缓冲区溢出的问题。
字符串对象的内部编码(encoding)有3种:int、raw和embstr。
如果一个字符串保存的是整数值,并且这个整数值可以用long
类型来表示,那么字符串对象会将整数值保存在字符串对象结构的ptr
属性里面(将void*
转换成long
),并将字符串对象的编码设置为int
。
如果字符串对象保存的是一个字符串,且字符串的长度<=32字节(Redis 2.+版本),那么字符串对象将使用一个简单动态字符串SDS来保存这个字符串,并将对象的编码设置为embstr
,embstr
编码是专门用于保存短字符串的一种优化编码方式:
如果字符串对象保存的是一个字符串,且字符串的长度>32字节(Redis 2.+版本),那么字符串对象将使用一个SDS来保存这个字符串,并将对象的编码设置为raw
:
注意,这里embstr编码和raw编码的边界在redis不同版本中是不一样的:
- redis 2.+是32字节;
- redis 3.0-4.0 是39字节;
- redis 5.0 是44字节;
embstr
和raw
都会用SDS
来保存值,但是不同之处在于embstr
会通过一次内存分配函数来分配一块连续的内存空间来保存redisObject
和SDS
,而raw
编码会通过调用两次分配函数来分别分配两块空间保存redisObject
和SDS
。
这样做的好处如下:
- embstr编码将创建字符串对象所需的内存分配次数从raw编码的两次降低为1次。
- 释放embstr编码的字符串对象同样只需调用一次内存释放函数。
- 数据都放在一块连续的内存空间,可以更好利用CPU缓存提升性能。
但肯定也是有缺点的:
- 如果字符串长度增加到需要重新分配内存时,整个
redisObject
和SDS
都需要重新分配空间,所以**embstr**
编码的字符串对象实际上是只读的,Redis没有为embstr
编码的字符串对象编写任何相应的修改程序。当我们对embstr编码的字符串对象进行任何修改时,程序会先将其转换为raw
,再进行修改。
常用命令
基本操作:
# 设置 key-value 类型的值
> SET name iq50
OK
# 根据 key 获得对应的 value
> GET name
"iq50"
# 判断某个 key 是否存在
> EXISTS name
(integer) 1
# 返回 key 所储存的字符串值的长度
> STRLEN name
(integer) 4
# 删除某个 key 对应的值
> DEL name
(integer) 1
批量设置:
# 批量设置 key-value 类型的值
> MSET key1 hello key2 world
OK
# 批量获取多个 key 对应的 value
> MGET key1 key2
1) "hello"
2) "world"
计数器(字符串的内容为整数时可用):
# 设置 key-value 类型的值
> SET number 0
OK
# 将 key 中储存的数字值增一
> INCR number
(integer) 1
# 将key中存储的数字值加 10
> INCRBY number 10
(integer) 11
# 将 key 中储存的数字值减一
> DECR number
(integer) 10
# 将key中存储的数字值键 10
> DECRBY number 10
(integer) 0
设置过期时间(默认为永不过期):
# 设置 key 在 60 秒后过期(该方法是针对已经存在的key设置过期时间)
> EXPIRE name 60
(integer) 1
# 查看数据还有多久过期
> TTL name
(integer) 51
#两种方式设置 key-value 类型的值,并设置该key的过期时间为 60 秒
> SET key value EX 60
OK
> SETEX key 60 value
OK
不存在就插入:
# 不存在就插入(not exists)
>SETNX key value
(integer) 1
应用场景
- 缓存对象
使用String来缓存对象有两种方式:
- 直接缓存对象的整个JSON,例:
SET user:1 '{"name":"iq50", "age":24}'
- 基于key将对象的属性分离,例:
MSET user:1:name iq50 user:1:age 24
- 进行计数
因为Redis处理命令是单线程,所以命令的执行过程是原子的。因此String数据类型适合计数的场景,比如计算访问次数、点赞、转发、库存数量等。
例:计算文章的阅读数量
# 初始化编号为108的文章的阅读量
> SET aritcle:No.108:readcount 0
OK
#阅读量+1
> INCR aritcle:No.108:readcount
(integer) 1
#阅读量+1
> INCR aritcle:No.108:readcount
(integer) 2
#阅读量+1
> INCR aritcle:No.108:readcount
(integer) 3
# 获取对应文章的阅读量
> GET aritcle:No.108:readcount
"3"
- 分布式锁
SET命令有个参数NX表示"key不存在才插入",可以借此实现分布式锁。
- 如果key不存在,则显示插入成功,可以表示加锁成功;
- 如果key存在,则显示插入失败,表示加锁失败;
一般而言,还会对分布式锁加上过期时间,命令如下:
SET lock_key unique_value NX PX 10000
# lock_key就是key键
# unique_value是客户端生成的唯一标识
# NX表示只有key不存在,才进行插入
# PX表示过期时间,10*10000ms=10s,这是为了避免客户端异常而无法正常释放锁
而解锁的过程就是将key键lock_key删除,但是要保证执行删除的客户端就是加锁的那个客户端,所以解锁的时候我们要借助unique_value判断是否就是加锁的那个客户端,是的话才允许删除lock_key。
- 共享Session信息
作为服务端,通常会使用Session来保存用户的会话状态,但这只适合用于单服务器应用,如果是分布式系统将不适用。比如用户A的Session信息被存储在服务器A,但用户A第二次访问时被负载均衡调度到服务器B,B没有存储用户A的Session,就会出现重复登陆的问题。
因此可以借助Redis对这些Session信息进行统一的存储和管理,保证无论请求发到哪个服务器,服务器都会从同一Redis获取相关信息,解决了分布式系统中Session存储的问题。
列表List
介绍
List列表是简单的字符串列表,按照插入顺序排序,可以从头部或尾部向List列表添加元素(所以这是个双向链表)。列表的最大长度为2**32-1,也即每个列表支持超过40亿个元素。
内部实现
List类型的底层数据结构是由双向列表或压缩列表实现的:
- 如果列表的元素个数<512个(默认值,可由
list-max-ziplist-entries
配置),列表每个元素的值都<64字节(默认值,可由list-max-ziplist-value
配置),Redis会使用压缩列表作为List类型的底层数据结构。 - 如果列表的元素不满足上面的条件,Redis会使用双向链表作为List底层的数据结构。
但是在Redis 3.2版本以后,List数据类型的底层数据结构只由quicklist实现了,替代了双向链表和压缩列表。
常用命令
# 将一个或多个值value插入到key列表的表头(最左边),最后的值在最前面
LPUSH key value [value ...]
# 将一个或多个值value插入到key列表的表尾(最右边)
RPUSH key value [value ...]
# 移除并返回key列表的头元素
LPOP key
# 移除并返回key列表的尾元素
RPOP key
# 返回列表key中指定区间内的元素,区间以偏移量start和stop指定,从0开始
LRANGE key start stop
# 从key列表表头弹出一个元素,没有就阻塞timeout秒,如果timeout=0则一直阻塞
BLPOP key [key ...] timeout
# 从key列表表尾弹出一个元素,没有就阻塞timeout秒,如果timeout=0则一直阻塞
BRPOP key [key ...] timeout
应用场景
- 消息队列(有缺陷的)
消息队列在存取消息时,必须满足三个需求,分别是消息保序、处理重复的消息、保证消息可靠性。
Redis的List和Stream两种数据类型,就可以满足消息队列的这三个需求。这里先介绍List,后面讲到Stream时会再详解。
Q1:如何满足消息保序需求?
List本身就是按照FIFO先进先出的顺序进行存取,所以本身就满足消息的保序需求。List可以使用LPUSH+RPOP命令实现消息队列。
不过,在消费者读取数据时,有一个潜在的性能风险点。
在生产者往List中写入数据时,List并不会主动通知消费者有新消息写入,如果消费者想要及时处理消息,就需要在程序中不停进行RPOP命令(比如使用一个while循环)。这必然会导致消费者不必要的性能损失。
为了解决这个问题,Redis提供了BRPOP命令。BRPOP命令也称为阻塞式读取(Blocking Right POP),客户端在没有读取到队列数据时,自动阻塞,直到有新的数据写入队列,再开始读取新数据。
Q2:如何处理重复的消息?
消费者要实现重复消息的判断,需要2个方面的需求:
- 每个消息都有一个全局的ID。
- 消费者要记录已经处理过的消息ID。当收到一条消息后,消费者程序就可以对比这个消息ID和已经处理过的消息ID。如果已经处理过,就不再处理了。
但是List并不会为每个消息生成ID号,所以我们需要自行为每个消息生成一个全局唯一ID。生成之后,再使用LPUSH把消息插入List,并在这个消息中包含全局唯一ID。
例如:
# 生成一条全局ID:111000102、库存量99的消息插入消息队列
> LPUSH mq "111000102:stock:99"
(integer) 1
Q3:如何保证消息可靠性?
当消费者程序从List中读取一条消息后,List就不会再留存这条消息了。所以如果消费者程序在处理消息的过程出现了故障或宕机,就会导致消息没有处理完成,那么消费者程序再次启动后,就没法再次从List中读取消息了。
为了留存消息,List类型提供了BRPOPPUSH命令,这个命令的作用是让消费者程序从一个List中读取消息,同时,Redis会把这个消息再插入到另一个List(备份List)留存。这样一来,如果消费者程序读了消息但没能正常处理,等它重启后,就可以从备份List中重新读取消息并处理了。
至此,我们解答了List作为消息队列的三大需求的问题。总结一下:
- 消息保序:使用LPUSH+RPOP;
- 重复消息处理:生产者自行实现全局唯一ID;
- 消息可靠性:使用BRPOPPUSH;
画个分割线,这一段的标题说用List作为消息队列是有缺陷的,具体是什么呢?
List不支持多个消费者消费同一条消息,因为一个消费者RPOP消息,List中就删除了此消息,无法被其他消费者再次消费。
要想实现多消费者消费同一条消息,就要将多个消费者构造成一个消费组,但遗憾的是List不支持这么做,不过在Redis 5.0以后,引入的Stream支持,后面我们会再详细讨论。
哈希Hash
介绍
Redis中的Hash是一个键值对field-value的映射表,特别适合用来存储对象。
Redis中String和Hash的区别如下:
可以看到,对于Redis而言,String是key–>value的,而Hash是key–>[{field1,value1}, {field2,value2},…{fieldN,valueN}]。
内部实现
Hash类型的底层数据结构是由压缩列表或哈希表实现的:
- 如果哈希类型元素个数小于512个(默认,可由hash-max-ziplist-entries设置),所有值小于64字节(默认,可由hash-max-ziplist-value设置)的话,Redis会使用压缩列表作为Hash底层数据结构。
- 否则,Redis使用哈希表作为Hash底层数据结构。
在Redis 7.0中,压缩列表数据结构已经废弃,交由listpack数据结构实现。
常用命令
# 存储一个哈希表key的键值
HSET key field value
# 获取哈希表key对应的field键值
HGET key field
# 在一个哈希表key中存储多个键值对
HMSET key field value [field value...]
# 批量获取哈希表key中多个field键值
HMGET key field [field ...]
# 删除哈希表key中的field键值
HDEL key field [field ...]
# 返回哈希表key中field的数量
HLEN key
# 返回哈希表key中所有的键值
HGETALL key
# 为哈希表key中field键的值加上增量n
HINCRBY key field n
应用场景
- 缓存对象
Hash类型的_(key、field、value)的结构与对象的(id、属性、值)_结构类似,因此很适合拿来存储对象。例:存储用户信息:
# 存储一个哈希表uid:1的键值
> HMSET uid:1 name Tom age 15
2
# 存储一个哈希表uid:2的键值
> HMSET uid:2 name Jerry age 13
2
# 获取哈希表用户id为1中所有的键值
> HGETALL uid:1
1) "name"
2) "Tom"
3) "age"
4) "15"
那么和String对比,一般的对象用String存JSON就行了,如果是某些属性频繁变化的对象,考虑用Hash来存储。
- 购物车
用户id为key,商品id为field,商品数量为value,恰好构成购物车的要素。
- 添加商品:HSET cart:用户id 商品id 1;
- 添加数量:HINCRBY cart:用户id 商品id 1;
- 商品总数:HLEN cart:用户id;
- 删除商品:HDEL cart:用户id 商品id;
- 获取购物车所有商品:HGETALL cart:用户id;
当然,这里只是拿到了商品的id,在回显商品具体信息的时候,还是要拿商品id去查数据库的。
集合Set
介绍
Set类型是一种无序集合,集合中的元素没有先后顺序(无序)但是都唯一,类似于Java中的HashSet。一个集合最多可以存储2**32-1个元素。我们可以基于Set轻易实现求交集、并集、差集的操作。
内部实现
Set类型的底层数据结构是哈希表或整数集合实现的:
- 如果集合中的元素都是整数且元素个数小于512,Redis会采用整数集合作为Set的底层数据结构;
- 否则,Redis会使用哈希表;
常用命令
Set的基本操作:
# 往集合key中存入元素,元素存在则忽略,若key不存在则新建
SADD key member [member ...]
# 从集合key中删除元素
SREM key member [member ...]
# 获取集合key中所有元素
SMEMBERS key
# 获取集合key中的元素个数
SCARD key
# 判断member元素是否存在于集合key中
SISMEMBER key member
# 从集合key中随机选出count个元素,元素不从key中删除
SRANDMEMBER key [count]
# 从集合key中随机选出count个元素,元素从key中删除
SPOP key [count]
Set运算操作:
# 交集运算
SINTER key [key ...]
# 将交集结果存入新集合destination中
SINTERSTORE destination key [key ...]
# 并集运算
SUNION key [key ...]
# 将并集结果存入新集合destination中
SUNIONSTORE destination key [key ...]
# 差集运算
SDIFF key [key ...]
# 将差集结果存入新集合destination中
SDIFFSTORE destination key [key ...]
应用场景
集合Set的主要特性就是:无序、不可重复、支持并交叉等。
但是有个注意点,Set求差集、并集、交集的计算复杂度较高,在数据量大的情况下,不建议做这些操作。在主从集群中,为了避免主库因为Set做这些操作导致阻塞,我们可以选择一个从库做这些运算,然后把结果返回主库。
- 点赞
Set可以保证一个用户只能点一个赞。例:文章的id作为key,用户的id作为value。
# uid:1 用户对文章 article:1 点赞
> SADD article:1 uid:1
(integer) 1
# uid:2 用户对文章 article:1 点赞
> SADD article:1 uid:2
(integer) 1
# uid:3 用户对文章 article:1 点赞
> SADD article:1 uid:3
(integer) 1
# uid:1取消了点赞
> SREM article:1 uid:1
(integer) 1
# 获取哪些人点赞了
> SMEMBERS article:1
1) "uid:3"
2) "uid:2"
# 统计点赞的数量
> SCARD article:1
(integer) 2
- 共同关注
Set可以做交集,因此可以用来计算共同关注的好友、公众号等。
用户id作为key,关注的公众号id作为value。
# uid:1 用户关注公众号 id 为 5、6、7、8、9
> SADD uid:1 5 6 7 8 9
(integer) 5
# uid:2 用户关注公众号 id 为 7、8、9、10、11
> SADD uid:2 7 8 9 10 11
(integer) 5
# 获取共同关注
> SINTER uid:1 uid:2
1) "7"
2) "8"
3) "9"
# 给uid:2推荐uid:1关注的公众号
> SDIFF uid:1 uid:2
1) "5"
2) "6"
- 抽奖活动
存储某活动中中奖的用户名 ,Set 类型因为有去重功能,可以保证同一个用户不会中奖两次。key为抽奖活动名,value为员工名称,把所有员工名称放入抽奖箱 :
>SADD lucky Tom Jerry John Sean Marry Lindy Sary Mark
(integer) 5
# 如果允许重复中奖(使用命令 SRANDMEMBER)
# 抽取 1 个一等奖:
> SRANDMEMBER lucky 1
1) "Tom"
# 抽取 2 个二等奖:
> SRANDMEMBER lucky 2
1) "Mark"
2) "Jerry"
# 抽取 3 个三等奖:
> SRANDMEMBER lucky 3
1) "Sary"
2) "Tom"
3) "Jerry"
# 如果不允许重复中奖(抽中后直接剔除,使用命令SPOP)
# 抽取一等奖1个
> SPOP lucky 1
1) "Sary"
# 抽取二等奖2个
> SPOP lucky 2
1) "Jerry"
2) "Mark"
# 抽取三等奖3个
> SPOP lucky 3
1) "John"
2) "Sean"
3) "Lindy"
有序集合Zset(也叫Sorted Set)
介绍
Zset类型相比于Set多了一个排序的属性score。Zset保留了Set不重复的特性(但是score可以重复)。
内部实现
Zset类型的底层数据结构是用压缩列表或跳表实现的:
- 如果Zset的元素个数小于128个,且每个元素值小于64字节,Redis使用压缩列表;
- 否则,使用跳表;
在Redis 7.0中,废弃了压缩列表,改用listpack数据结构。
常用命令
Zset常用操作:
# 往有序集合key中加入带分值元素
ZADD key score member [[score member]...]
# 往有序集合key中删除元素
ZREM key member [member...]
# 返回有序集合key中元素member的分值
ZSCORE key member
# 返回有序集合key中元素个数
ZCARD key
# 为有序集合key中元素member的分值加上increment
ZINCRBY key increment member
# 正序获取有序集合key从start下标到stop下标的元素
ZRANGE key start stop [WITHSCORES]
# 倒序获取有序集合key从start下标到stop下标的元素
ZREVRANGE key start stop [WITHSCORES]
# 返回有序集合中指定分数区间内的成员,分数由低到高排序。
ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]
# 返回指定成员区间内的成员,按字典正序排列, 分数必须相同。
ZRANGEBYLEX key min max [LIMIT offset count]
# 返回指定成员区间内的成员,按字典倒序排列, 分数必须相同
ZREVRANGEBYLEX key max min [LIMIT offset count]
Zset运算操作(相比Set,不支持差集):
# 并集计算(相同元素分值相加),numberkeys一共多少个key,WEIGHTS每个key对应的分值乘积
ZUNIONSTORE destkey numberkeys key [key...]
# 交集计算(相同元素分值相加),numberkeys一共多少个key,WEIGHTS每个key对应的分值乘积
ZINTERSTORE destkey numberkeys key [key...]
应用场景
- 排行榜
有序集合的典型场景就是排行榜,以博客点赞为例:
# arcticle:1 文章获得了200个赞
> ZADD user:iq50:ranking 200 arcticle:1
(integer) 1
# arcticle:2 文章获得了40个赞
> ZADD user:iq50:ranking 40 arcticle:2
(integer) 1
# arcticle:3 文章获得了100个赞
> ZADD user:iq50:ranking 100 arcticle:3
(integer) 1
# arcticle:4 文章获得了50个赞
> ZADD user:iq50:ranking 50 arcticle:4
(integer) 1
# arcticle:5 文章获得了150个赞
> ZADD user:iq50:ranking 150 arcticle:5
(integer) 1
文章新增一个赞,使用ZINCRBY命令:
> ZINCRBY user:iq50:ranking 1 arcticle:4
"51"
查看某篇文章的赞:
> ZSCORE user:iq50:ranking arcticle:4
"50"
获取文章赞数最多的 3 篇文章,可以使用 ZREVRANGE 命令(倒序获取有序集合 key 从start下标到stop下标的元素):
# WITHSCORES 表示把 score 也显示出来
> ZREVRANGE user:iq50:ranking 0 2 WITHSCORES
1) "arcticle:1"
2) "200"
3) "arcticle:5"
4) "150"
5) "arcticle:3"
6) "100"
获取 100 赞到 200 赞的文章,可以使用 ZRANGEBYSCORE 命令(返回有序集合中指定分数区间内的成员,分数由低到高排序):
> ZRANGEBYSCORE user:iq50:ranking 100 200 WITHSCORES
1) "arcticle:3"
2) "100"
3) "arcticle:5"
4) "150"
5) "arcticle:1"
6) "200"
- 电话、姓名排序
使用有序集合的 ZRANGEBYLEX
或 ZREVRANGEBYLEX
可以帮助我们实现电话号码或姓名的排序,我们以 ZRANGEBYLEX (返回指定成员区间内的成员,按 key 正序排列,分数必须相同)为例。
# 插入一些号码
> ZADD phone 0 13100111100 0 13110114300 0 13132110901
(integer) 3
> ZADD phone 0 13200111100 0 13210414300 0 13252110901
(integer) 3
> ZADD phone 0 13300111100 0 13310414300 0 13352110901
(integer) 3
# 获取所有号码
> ZRANGEBYLEX phone - +
1) "13100111100"
2) "13110114300"
3) "13132110901"
4) "13200111100"
5) "13210414300"
6) "13252110901"
7) "13300111100"
8) "13310414300"
9) "13352110901"
# 获取[132,133)开头的号码
> ZRANGEBYLEX phone [132 (133
1) "13200111100"
2) "13210414300"
3) "13252110901"
同理,姓名排序:
> zadd names 0 Toumas 0 Jake 0 Bluetuo 0 Gaodeng 0 Aimini 0 Aidehua
(integer) 6
> ZRANGEBYLEX names - +
1) "Aidehua"
2) "Aimini"
3) "Bluetuo"
4) "Gaodeng"
5) "Jake"
6) "Toumas"
> ZRANGEBYLEX names [A (B
1) "Aidehua"
2) "Aimini"
> ZRANGEBYLEX names [C [Z
1) "Gaodeng"
2) "Jake"
3) "Toumas"
总结
本篇博客我们详细介绍了Redis中的5种基本数据类型:String、List、Hash、Set、Zset,以及其相关的底层实现、相关命令和使用场景等细节。 下一篇将详细介绍剩下的4种高级数据类型,敬请期待。