本文使用Redis的版本是6.0.9。另外,本文的重点不是告诉读者去记住每种数据类型的全部命令,而是理解哪种数据类型适合的业务场景。
Redis不仅仅是简单的键值(key-value)存储,而是一个数据结构服务器,能否支持不同类型的值。该值不仅限于简单的字符串,还可以是更丰富的数据结构,而操作这些数据类型的命令也十分丰富。下面讲解不同数据类型的同时列举使用场景。
Redis的字符串数据类型是基本的数据类型,也是Memcached中唯一的数据类型。因此,对于新手来说,可能要经常使用String类型。
1、设置与获取键的内容
#为键设置指定的内容
set name zhangsan
#获取键的内容
get name
运行结果如图:
2、获取子字符串
#设置长字符串
set name "my name is zhangsan"
#获取长字符串的子字符串,下标从0开始
getrange name 3 7
运行结果如图:
3、返回旧值
#为键设置新的内容,并返回键的旧值
getset name zhangsan
getset name lisi
运行结果如图:
上面主要列举了Redis中String数据类型的命令操作,本小结将主要列举能否使用String数据类型处理的业务场景。
单个值的缓存就是单个字符串的存储。在分布式项目部署的业务中需要一个统一的鉴权和授权中心将相对应的算法生成token,而token本身就是单个字符串,这样完全可以利用Redis中的String数据类型来实现。
下图是一个比较简单的分布式架构中的部分环节,网关服务提供了鉴权中心需要判断当前访问的客户端所提供的token是否合法,所以当客户端在第一次请求服务时会带着一些信息,然后由服务根据信息生成一个token,这其中存储的就是经过压缩后的客户端信息和这个信息的过期时间。因为网关服务本身就是分布式部署,所以需要一个分布式的缓存服务器,而Redis刚好可以满足这个要求。
执行步骤:
(1)第一次请求的服务节点生成一个用户token,并存储到Redis服务器,然后可以根据需要设置对应的过期时间,从而保证token过期后的信息从Redis服务器移除。如果有需要,也可以不设置token的过期时间。
(2)之后请求的节点只需要通过get命令从Redis中获取token验证合法性。
如下:
用户信息既可能是一个简单的字符串,也可能是一个对象或者高频率访问但是很少修改的数据,这些数据可以作为一个整体对象,如果想要减轻数据库访问的压力,可以将其对象进行json序列化后缓存起来。这样的数据可以采用String数据类型进行存储。
针对这种需求,有两种不同命令的操作方式。
【1.2.2.1】将对象一次性转换成json序列化进行存储:
set userid "{'name':'zhangsan','age':1,'address':'china'}"
get userid
结果如图:
【例1.2.2.2】利用mset、mget同时设置多个键、获取多个键的值:
mset userid:name zhangsan userid:age 1 userid:address china
mget userid:name userid:age userid:address
如果存储的对象会用到的只是获取某个属性的值,则可以这样操作。如果存储的对象将作为整个对象操作,则不建议这样使用,因为需要把多个键的值序列化,这是需要我们自己去实现的。
在高并发的业务场景中,可能会遇到各种各种的业务场景,比如接口限流。在双十一或其他大型节目中,业务服务器的资源有限,但是在高并发的场景下,过多的请求可能会导致服务器宕机,所以会对接口请求做一些限制,比如限制每秒请求总数为200次,超过200就等待下一秒在次请求,可以使用Redis作为计数器的模式来实现。
实现流程如下:
(1)首先在接到请求之后设置一个键,然后自增1,如果不存在,则值会初始化为0。
#第一次执行返回的结果是1
incr mykey
这里的mykey可能需要特殊处理,因为是根据秒来的,所以当接口得到一个请求之后,应该获取当前时间生成动态的键,如202309102020:mykey。
(2)当接口得到请求后,执行incr yyyyMMddHH-mmss:mykey,如果返回结果小于200则进行处理,超过200将等待下一秒处理。
(3)因为每秒会生成一个键,为了节省内存空间,可能需要一个定时任务,定时删除这些已经使用过的键。
以上只是一个简单的限流案例,读者可以在此案例基础上去实现自己业务的限流操作。
在某些业务中,为了提高数据库的读写能力,可能会将一个库根据业务拆分成多个小库、将一张表拆分成几个小标,比如user01表,user02表。但是又要保证两个表中的ID是全局唯一自增的,所以数据库表自带自增属性无法使用。这时也可以利用Redis的String数据类型实现自增,在新增数据时,通过调用Redis服务自增功能生成一个自增的数值来实现多张表的唯一自增。
假设目前有5台单独的Redis服务器,那么生成的方式就是这样的:
第1台的生成方式:
第2台的生成方式:
第3台的生成方式:
第4台的生成方式:
第5台的生成方式:
上面5个图所示,每一台服务器从初始值1~5开始,然后每次累加数值5,则多个节点生成自增且不重复的序列号。
以上只是利用Redis的自增API处理多个表的唯一自增ID而额外提供的一种方案。如果读者了解雪花算法或其他更简单的方式,则完全没有必要使用Redis的这种操作。
当我们访问大部分网页时,比如技术博客或新闻博客,会充斥着各种统计数量,比如用户的总点赞数、关注数、粉丝数、热度等等。实现这些需求并想要减轻数据库压力,而且对数据的实效性和写文章的频率要求高时,可以使用Redis。
【1.2.5.1】实现文章阅读数量统计需要执行如下命令:
#文档每次被阅读后,加1
Incr mypageurl
#查看文章的阅读数量
get mypageurl
#再次被阅读后,加1
Incr mypageurl
在分布式架构中,多线程访问共享数据时,可以借助Redis中的setnx命令来完成。
具体操作流程如下:如果键没有值就能新增成功,就表示抢到锁了,否则失败。直到操作成功时释放锁,也就是删除键。
下面是一段秒杀商品的伪代码:
#给键seckill001赋值,如果之前不存在,则新增成功,返回1
#表示抢到了锁
setnx seckill001 true
#当其他客户端进来之后执行相同的命令,返回0则表示锁被占用。
#可以选择等待锁释放或直接返回
setnx seckill001 true
#当第一个抢到锁的线程执行完成后,可以删除键,让其他线程继续抢锁
del seckill001
#当其他线程执行在执行setnx命令时,如果返回1,表示已经抢到了锁
setnx seckill001 true
执行结果如图:
以上会有一个问题:如果第一个线程拿到锁后还没有执行删除键,这个线程就宕机了,就会导致这个锁永久被占用,其他线程则无法执行。对于这个问题,可以通过给键设置一个过期时间来解决,到达过期时间后让Redis服务自动把这个键删除。
代码如下:
#抢到锁
setnx seckill001 true
#设置过期时间为10秒,到期之后会被删除
expire seckill001 10
#查询键还有多久过期,如果过期就返回负数,再次获取键的值则返回nil
ttl seckill001
get seckill001
结果如图:
上面的例子是赋值和设置过期时间是分两步的。如果要保证操作的原子性,则可以在设置值的同时设置过期时间:
set seckill001 true ex 10 nx
ttl seckill001
get seckill001
本案例介绍的只是一种简单分布式锁的实现,后面会解释和说明分布式锁的原理以及如何更好的在项目中使用分布式锁。
在Redis中,哈希数据类型是指Redis键值对中的值本身又是一个键值对结构,如下图:
既然使用String可以实现相同的功能,为何还要使用Hash呢?在Redis官网中会看到优先使用Hash的字眼,主要基于以下三个因素:内存占有率、时间复杂度和使用的简便性。
假如我们想要存储的一些用户信息如表:
使用Redis的String和Hash来存储与处理如上信息的优缺点对比如下:
(1)利用String存储用户信息的命令如下:
set userid:1:name zhangsan
set userid:1:age 18
优点:简单直观,每个属性都支持更新的操作。
缺点:占用更多的键,内存占用量大,同时用户信息分散,一般不会在实际的生产环境中使用。
(2)利用String来序列化字符串后的命令如下:
set user:1 serialize(userInfo)
优点:简化编程,合理使用序列化可以提高内存的使用效率。
缺点:序列化和反序列化有一定的开销,同时每次更新属性都需要把全部数据取出来进行发序列化,更新后在序列化到Redis中,操作比较麻烦。
(3)利用Hash存储用户信息:
hmset user:2 name clay age 18
优点:简单直观,使用合理可以减少内存空间的使用。
缺点:要控制Hash在ZipList和HashTable两种内部编码之间的转换,HashTable会消耗更多内存,在后面会进一步分析底层数据原理。
以下是Hash数据类型基本的操作命令:
#给myhash键设置name=clay的键值对
hset myhash name "clay"
#获取myhash键中的name的值
hget myhash name
根据Hash特性,可以Redis作为一个实时数据库来处理。比如在硬件相关的系统中,通常关于硬件的相关参数配置或一些设备的实时状态都会放在内存中,但是参数过多时维护非常麻烦,而且稍微改动一个参数就会重新发布程序。对于高效访问和修改,比如多个主机中的每个主机具有不同的属性,每个属性又有对应的值,Redis是比较好的选择。
如下是记录设备的实时状态的命令:
#设置设备001的当前异常码是1
hset device:001 code 1
#设置设备001的温度是10
hset device:001 temperature 10
#设置设备的两种状态
hmset device:001 s1 start s2 stop
#获取设备001的所有属性
hgetall device:001
结果如下:
有些电商网站可能会使用cookie来实现购物车。这种做法的一个好处就是无须对数据库进行写入就可以实现购物车功能,而且大大提高了购物车的性能;缺点是程序需要重新解析和验证cookie,还要保证cookie的格式正确,并且包含的商品都是可购买的商品。用cookie实现购物车的缺点是因为浏览器每次发送请求都会联通cookie一起发送,如果购物车cookie的体积比较大,那么请求发送和处理的速度可能会所有降低,对性能有影响。
购物车的定义非常简单:以每个用户的userid作为Redis的键(key),每个用户的购物车都是一个哈希表,它存储了商品ID与商品订购数量之间的映射关系。在商品的订购数量出现变化时,操作Redis哈希对购物车进行更新。
如果用户订购某件商品的数量大于0,那么程序会将这件商品的ID以及用户订购该商品的数量添加到Hash中。
购物车操作命令如下:
#用户1 商品1 数量1
hset userid:1 pid:1 1
#获取userid:1
hgetall userid:1
如果用户购买的商品已经存在于哈希表中,那么新的订购数量会覆盖已有的订购数量。
hset userid:1 pid:1 5
hgetall userid:1
hdel userid:1 pid:1
hgetall userid:1
学习String数据类型时,知道String可以用作计数器,实际上Redis的Hash作为计数器的使用也非常广泛,它常被用于记录网站的一天、一月、一年的访问数量。每次访问,在对应的field上自增1即可。
如下是记录博客文章每月访问量的命令:
hincrby myblob 202309 1
hincrby myblob 202309 1
hincrby myblob 202309 1
hincrby myblob 202309 1
hincrby myblob 202309 1
从常规角度来看,列表(List)只是一系列有序元素,比如(1,2,3,4,5)。不过,使用Array实现的List属性与使用链表(Linked List)实现的List属性是非常不同的。
Redis的List数据类型是通过链表实现的。这意味着即使用户在列表中有数百万个元素,在列表的开头或者结尾添加新元素的操作也会在固定时间内执行。使用LPUSH命令将新元素添加到具有100个元素的开头的速度与具有1000万个元素开头的速度相同,而且一个列表最多可以包含4294967295(42亿)个元素。
由于Redis的List数据类型通过链表实现的,因为对于数据库而言,重要的是能否以非常快的方式将元素添加到很长的列表中。可以用两种不同的方式来操作列表:第一种是队列,按照先进先出的顺序操作;第二种是栈,按照先进后出的顺序操作。
List数据类型有以下特点:
(1)List中的元素是有序的,可以通过下标(或称为索引)来获取某个元素或者某个范围内的元素列表。
(2)List中的元素是可以重复的。
(3)可以实现顺序排队和插队的操作。
下图是List数据类型如何存储数据的。
1 先进后出操作
用Redis的List数据类型模拟栈的数据结构实现先进后出的操作:
#向栈的左侧插入数据a
lpush mystack a
#向栈的左侧插入数据b
lpush mystack b
#向栈的左侧插入多条数据 dd ee ff
lpush mystack dd ee ff
#获取栈中从下标0开始到2结束的数据
lrange mystack 0 2
#获取栈中所有的元素
lrange mystack 0 -1
#从栈中取出一个值(删除),按照先进后出的顺序
lpop mystack
#再次获取栈中的所有元素
lrange mystack 0 -1
结果如下:
2 先进先出操作
用Redis的List数据类型模拟队列的数据结构,实现先进先出的操作。
#向队列中插入数据a
lpush myqueue a
#向队列中插入数据b
lpush myqueue b
#向队列中插入多条数据 cc dd ee
lpush myqueue cc dd ee
#获取队列的值
lrange myqueue 0 -1
#按照先进先出的顺序,弹出(删除)数据
rpop myqueue
#再次获取队列的值
lrange myqueue 0 -1
结果如图:
3 阻塞度列
Redis中的列表还具有一项特殊功能呢,使其适用于实现队列,通常用作进程间通信系统的构建模块:阻止操作。比如,用户想通过一个流程将数据项推入列表,然后使用不同的流程来对这些数据项进行某种加工或处理。这是通常的生产者/消费者模式,可以通过以下的简单方式来实现:
(1)将数据推入列表,生产者使用lpush命令。
(2)从列表中删除数据,消费者使用rpop命令。有时列表可能为空,没有任何要处理的数据项,此时rpop就返回null。在这种情况下,消费者被迫等待一段时间,然后使用rpop重试,这种方式称为轮询。不过会有一个缺点:强制Redis和客户端处理无用的命令。因为消费处理端在收到null之后会等待一段时间,所以会增加数据项处理的延迟。为了减小延迟,我们可以在两次调用rpop之间等待更少的时间,即对Redis的调用变得越来越无用。
按照先进先出的顺序,如果列表没有元素就等待,执行命令如下:
#客户端1
lpush blockmq a b c
#客户端2 通过阻塞的方式获取元素,如果没有元素就等待10秒
brpop blockmq 10
brpop blockmq 20
brpop blockmq 20
brpop blockmq 20
#客户端1,再次推入数据
lpush blockmq a b c
执行结果:
客户端1第一次推入数据:
客户端2 分别获取,最后一次获取没有数据返回null
此时客户端2在输入获取命令后,马上在客户端1推入数据,客户端2获取数据,注意秒:
使用brpop要注意以下几点:
(1)客户端以有序的方式服务:第一个被阻塞而等待列表的客户端在某个元素被其他客户端推送到列表中时,首先被服务,以此类推。
(2)返回值与rpop相比有所不同:它是一个包含两个元素的数组(还包含键的名称),brpop和blpop通过阻塞方式等待来自多个列表的元素,如果超时,则返回null。
4 模拟消息推送
将上面的阻塞队列扩展后,就可以实现消息的推送和功能消费,其架构图如下:
以博客站点为例,当用户和文章都越来越多时,为了加快程序的响应速度,我们可以把用户自己的文章存入列表中,因为列表是有序的结构,所以这样又可以实现分页功能,从而加快程序的影响速度。
每篇文章使用Hash结构存储,如果每篇文章有3个属性:title,timestamp,content。使用Hash存储文章内容:
hmset bbs:1 title title1 timestamp 1234566789 content 'content1'
hmset bbs:2 title title2 timestamp 1234566789 content 'content2'
hmset bbs:3 title title3 timestamp 1234566789 content 'content3'
执行结果:
上面的命令仅存储文章的详细信息,如果要实现分页,就必须把每篇文章存在hash表中的key存储在列表中:
lpush user:1:bbs bbs:1
lpush user:1:bbs bbs:2
lpush user:1:bbs bbs:3
结果如下:
分页获取用户文章列表的命令如下:
lrange user:1:bbs 0 9
如果每次分页获取的文章数较多,就需要执行多次hgetall命令,此时可以考虑使用Pipeline批量获取,或者考虑将文章数据序列化为字符串类型,然后使用mget批量获取。
在我们访问任何一个网站时,并发最高的可能就是网站的首页,如果首页是列表类信息,那么完全可以使用List来实现。把首页中的所有信息都存储在List中,即放在内存中,提高用户访问首页的影响效率。
如果数据量特别大,可以把标题和当前文章的ID存在Redis的列表中,当用户点击当前文章并需要查看详情的时候,在结合ID去数据库查询,也是一种方案。
集合(Set)数据类型是其元素无序且唯一的一种键值对(key-value)集合。因为是无序的,所以不会按照元素插入的先后顺序进行存储。
集合类型和列表类型的区别如下:
(1)列表是可以存储重复元素,集合默认去重。
(2)列表顺序存储,集合无序存储。
(3)列表和集合多支持增删改查,同时集合还支持区多个集合的交集、并集、差集。
当网上出现某明星和某时间的新闻时,可能会造成服务器压力过大,导致服务器宕机。宕机的大多数原因是很多人同时去关注和查看某条热点新闻,而对于热点的过多点赞或者评论会导致并发问题。如果将这些点赞和评论利用缓存来解决,然后定时把缓存数据保存到数据库中,那么会提高性能。
在我们的生活中,无论公司年会抽奖还是公众号抽奖,大部分都是通过程序把手机号码或者其他信息放在一起,然后随机抽取。这种场景中,很适合使用set数据类型。
【例4.2.1】重复在抽奖案例。
#首先存入所有参与抽奖的用户信息
sadd luckers user1
#当存入重复的用户信息时,存入操作会失败并返回0
sadd luckers user1
#批量存储多个用户信息
sadd luckers user1 user2 user3 user4 user5
#查看所有参与抽奖人的信息,不会有重复的数据
#smembers luckers
#随机抽取一个人,抽完不删除信息可以再次抽奖
#srandmember luckers
#随机抽取三个人,抽完不删除信息可以再次抽奖
#sranmember luckers 3
#验证抽完之后人员信息是否被删除
#smembers luckers
结果:
【例4.2.2】对于一个用户只能抽取一次奖品的活动, 命令如下:
#清理所有数据
#flushdb
#批量存储多个用户
sadd luckers user1 user2 user3 user4 user5
#随机抽取一个用户,抽取后从集合中删除
spop luckers 1
#随机抽取两个用户,抽取后从集合中删除
spop luckers 2
#验证抽完之后,获奖的用户是否被删除
smembers luckers
结果:
就点赞和投票而言,如果根据用户ID或者IP地址来进行限制,那么一个IP地址或者一个用户ID只能针对一个信息投票一次。集合数据类型能够实现这个需求,自动去重。
【例4.3.1】点赞或者投票命令:
#用户根据ip点赞或者投票
sadd like:id1 ip1
#用户根据ip点赞或者投票
sadd like:id1 ip2
#用户根据ip点赞或者投票
sadd like:id1 ip3
#重复投票或点赞会失败,并返回0
sadd like:id1 ip3
#取消投票或点赞
srem like:id1 ip3
#查看所有点赞或者投票人的信息
smembers like:id1
QQ好友推介界面如图:
【例4.4.1】利用Redis中的set类型模拟共同好友功能。
#给张三添加frienda friendb friendc friendd
sadd zhangsan frienda friendb friendc friendd
#查看张三所有的朋友
smembers zhangsan
#给李四添加朋友frienda friendb friende friendf
sadd lisi frienda friendb friende friendf
#查看李四所有的朋友
smembers lisi
#zhangsan和lisi共同的好友
sinter zhangsan lisi
#zhangsan可能认识lisi的朋友
sdiff lisi zhangsan
#lisi可能认识张三的朋友
sdiff zhangsan lisi
#zhangsan和lisi的好友全集
sunion zhangsan lisi
有序集(Zset)类型是Redis中一个非常重要的数据类型,类似于集合类型和哈希类型之间的 混合类型。像Set集合一样,Zset有序集由唯一、非重复的一组元素组成,从某种意义上也是一个集合。Zset类型数据结构如下图:
虽然Zset内的元素没有排序,但是排序后集合中的所有元素都与一个成为得分的浮点值相关联(这就是Zset类型类似于哈希类型的原因,因为每个元素都映射一个值)。
微服务日益流行,缓存、降级和限流是保护微服务运行稳定性的三大利器。缓存的目的是提升系统访问速度和增大系统处理的容量,而降级是当服务出现问题或者影响到核心流程的性能时需要暂时被屏蔽掉,待高峰或者问题解决后再打开。有些场景并不能用缓存和降级来解决,比如稀缺资源、数据库的写操作、频繁的复杂查询,因此需要有一种手段来限制这些场景的请求量,这就是限流。Redis的Zset类型可以实现限流功能。
限流是对系统的出入流量进行控制,防止大流量出入,从而导致资源不足、系统不稳定。限流的目的应当是通过对并发访问和请求进行限速或者对一段时间内的请求进行限速来保护系统。一旦达到限制速率就可以拒绝服务或者让请求的服务等待。
假设我们上线一个服务,而这个服务提供的最大出力能力是1秒2000个QPS,一旦出现高于2000QPS时,就要对其请求进行限流。
下面了解一下两种基本的限流算法
1、滑动窗口限流方式
固定窗口限流的效果如上图。前两个窗口和后两个窗口分别表示的是第一秒和第二秒的请求,它们的长度相同,表示每一秒接受的请求数量相同,达到了限流的效果。这种固定窗口限流会出现一些问题。假如限流设置为1秒2000AQPS,而在第一秒的最后100毫秒以及第二秒开始的100毫秒都收到2000次请求,就等于在这200毫秒的周期中收到了4000次请求,并且限流通过。这样就出现了两倍的配置速率问题。
而滑动窗口为固定窗口的改进版,如下图:
在每个时间片段接收到的请求数量都是相等的,或者不会超过限流数量。接下里看一下如何使用Zset处理。
(1)利用Zset类型在value出存储随机字符,而在score部分存储时间戳。
(2) 每一次请求进来的时候获取限流key数量,根据当前时间错和当前时间减去前一秒的时间戳。这样每次获取的都是一秒内的总请求数。
(3)如果没有超过限制,继续处理;如果超过,则等待或者直接返回。
使用Zset实现限流的数据结构图如下:
令牌桶算法是和漏桶算法效果一样但方向相反的算法,且更加容易理解。随着时间的增加,系统会按恒定1/QPS时间间隔(如果QPS是100,则间隔是10毫秒)往桶里假入令牌,如果桶已经满了就不在加了。当新请求来时,会各自拿走一块令牌,如果没有令牌可拿就阻塞或者拒绝服务。令牌桶操作流程如下:
新闻排行榜效果图如下:
新闻热榜是我们经常看到的,针对这样数量大且实时性高的排名,用传统的数据库实现性能太差。可以使用Redis中的Zset数据类型来模拟。
#清空当前数据库
flushdb
#以关注度分值讲新闻id是001的数据存储到集合中
zadd news 601 newid001
#以关注度分值讲新闻id是002的数据存储到集合中
zadd news 605 newid002
#以关注度分值讲新闻id是003的数据存储到集合中
zadd news 505 newid003
#根据关注度的递增获取热榜新闻
zrange news 0 10 withscores
#对新闻001点赞加3
zincrby news newid001
当下直播平台都会给粉丝主播提供打赏功能,并且打赏金额越高,粉丝排名越靠前。这样的功能也是在数据实时性高、大数据和高并发的场景中实现的,存在一个时间段内大量粉丝打赏和打赏排名更新的情况。为了提高性能和实时性,可以使用Redis的Zset实现。
【例4.5.3.1】模拟直播打赏排名
#以粉丝打赏的金额作为元素分支存入集合中
zadd anchor 10 fans1
zadd anchor 20 fans2
zadd anchor 200 fans3
#按照粉丝的打算金额排名顺序输入粉丝信息
zrange anchor 0 -1
#按照粉丝的打算金额排名倒序输出粉丝信息
zrevrange anchor 0 -1
#粉丝再次打赏,金额叠加
zincrby anchor 100 fans1
#粉丝信息和金额信息全部输出
zrange anchor 0 -1 withscores