本文为Redis相关的读书笔记

Redis基础数据结构

Redis中有五种基础数据结构,分别为:string(字符串)、list(列表)、set(集合)、hash(哈希)和zset(有序集合)。

string(字符串)

字符串string是Redis最简单的数据结构。Redis中所有的数据结构都是以唯一的key字符串作为名称,然后通过这个唯一key值来获取对应的value数据。不同类型的数据结构的差异就在value的结构不一样。value可以是字符串类型、整数或者浮点型。字符串实际的值可以是字符串、xml、json、数字,甚至是二进制(图片、视频、音频),最大不能超过512MB。

  1. 设置值
set key value [ex seconds]	[px milliseconds]	[nx|xx]
一些选项
-ex seconds:为键设置秒级过期时间
-px milliseconds:为键设置毫秒级过期时间
-nx:键必须不存在,才可以设置成功,用于添加
-xx:与nx相反,键必须存在,才可以设置成功,用于更新。

除了set选项,Redis还提供了setex和setnx两个命令,它们的作用和ex nx选项是一样的。

setnx和setxx在实际使用中有什么应用场景呢?以setnx命令为例子,由于Redis的单线程命令处理机制,如果多个客户端同时执行setnx key value,根据setnx特性,只有一个客户端能够设置成功,setnx可以作为分布式锁的一种实现方案。

  1. 获取值
// 如果要获取的键不存在,则返回空
get key
  1. 批量设置值
// 通过mset 一次设置4个键值对
mset key value [key value ...]
  1. 批量获取值
mget key [key ...]

批量的获取和设置命令,可以有效提高开发效率,加入没有mget这样的命令,需要发送多命令,返回多次结果,而使用mget命令,只有一次发送命令,返回一次结果。针对客户端来说,一次命令除了命令时间还有网络时间,多次命令会增加多次网络时间。

  1. 计数
// 三种结果
// 值不是整数,返回错误
// 值是整数,返回自增后的结果
// 键不存在,按照值为0自增,返回1
incr key

decr key				// 自减
incrby key increment	// 自增指定数字
decrby key decrement 	// 自减指定数字
decrbyfloat key decrement 	// 自减指定浮点数

很多的存储系统和变成语言内部使用CAS机制来实现计数功能,会有一定的cpu开销,但是在Redis中完全不存在这个问题,因为是单线程架构。

  1. 不常用命令
// 追加值
append key value 
// 字符串长度
strlen key (如果有中文,会占两个字节)
// 设置并返回原值   getset和set一样会设置值,但是不同的是,它同时会返回键原来的值
getset key value 
// 设置指定位置的字符
setrange key offset value
// 获取部分字符串
getrange key start end 

内部编码

字符串类型的内部编码有三种:

  1. int 8个字节的长整型
  2. embstr 小于等于39个字节的字符串
  3. raw 大于39个字节的字符串

典型使用场景

缓存功能

将Redis作为缓存层,MySql作为存储层,绝大部分的请求数据都是从Redis中获取,可以加速读写、降低后端压力。以获取用户的基础信息为例,大体思路为:

  1. 首先从Redis中获取用户信息。
  2. 如果Redis中没有用户信息,需要从MySQL中获取,并将结果写到Redis中,添加一定的过期时间(1小时)

开发提示:与MySQL等关系型数据库不同的是,Redis没有命名空间,而且对键名没有强制要求,因此需要对键名进行合理设计,有利于防止键冲突和项目的可维护性。

​ 比如采用: “业务名:对象名:id:属性”作为键名,{uid}:friend:messages:mid 。可以在能够描述键含义的前提下适当减少键的长度,例如:“u:uid:fr : m : mid”,从而减少由于键过长的内存浪费。

list(列表)

list可以有序的存储多个字符串。一个列表最多可以存储2 32次方 -1个元素。在Redis中,可以对列表两端插入或者弹出,还可以获取指定范围的元素列表,获取指定索引下的元素等。列表是一种比较灵活的数据结构,它可以充当栈和队列的角色。

列表类型有两个特点:

  1. 列表中的元素是有序的,这意味着可以通过索引下标获取某个元素或者某个范围内的元素列表。
  2. 列表的元素可以是重复的。

  3. RPUSH 将给定值推入列表的右端
  4. LRANGE 获取列表在给定范围上的所有值
  5. LINDEX 获取列表在给定位置上的单个元素
  6. LPOP 从列表的左端弹出一个值,并且返回被弹出的值

set(结合)

集合存储多个字符串,其key不相同。通过使用散列列表来保证自己存储的每个字符串都是不一样的。(只针对键的散列)

1. SADD				将给定元素添加到集合中
2. SMEMBERS	  		返回集合包含的所有元素
3. SISMEMBER	 	检查给定元素是否存在于集合中
4. SREM				如果给定元素存在于集合中,那么移除这个元素

散列(hash)

散列可以存储多个键值对之间的映射。和字符串一样,散列存储的值既可以是字符串又可以是数字值,并且同样可以对散列存储的数字值进行自增操作或者自减操作。

  1. 设置值
// 如果设置成功  返回1   反之返回0
hset key field value
// 键必须不存在,否则不成功
hsetnx    
  1. 获取值
hget key field
  1. 删除field
// hdel会删除一个或者多个field,返回结果为成功删除field的个数
hdel key field [field ...]
  1. 计算field个数
hlen key
  1. 批量设置或设置 field-value
hmget key field [field ...]
hmset key field value [field value ...]
  1. 判断field是否存在
hexists key field
  1. 获取所有的field
hkeys key
  1. 获取所有的value
hvals key
  1. 获取所有的field-value
hgetall key  // 时间复杂度  O(n)
  1. 其他的命令
hincrby key field	// 加上指定增量
hincrbyfloat key field   
hstrlen key field   // 计算value的字符串长度

内部编码

哈希类型的内部编码有两种:

  1. ziplist(压缩列表)

    当哈希类型元素个数小512(默认值 可以修改),同时所有值都小于64字节(默认值 可以修改),会使用ziplist作为内部实现,更节省内存。

  2. hashtable(哈希表)

    当哈希类型无法满足ziplist的条件,Redis会使用hashtable作为哈希的内部实现,因为此时ziplist的读写效率会下降。

关于rehash

Redis中字典的rehash并不是一次性完成的,为了高性能,不堵塞服务,采用了渐进式rehash策略。

image-20200705105301006

渐进式rehash会再rehash的同时,保留新旧连个hash结构,查询时会同时查询两个hash结构,然后再后续的定时任务以及hash的子指令中,循序渐进的将旧hash的内容一点点的迁移到新的hash结构中。

使用场景

相比于使用字符串序列化缓存用户信息,哈希类型变得更加只管,并且在操作上会更加便捷。

image-20200630153113325

但是,需要注意的是哈希类型和关系型数据库有两点不同之处:

  1. 哈希类型是稀疏的,而关系型数据库是完全结构化的,哈希类型每个键可以有不同的field,而关系型数据库一旦添加新的列,所有行为都要为其设置值。
  2. 关系型数据库可以做复杂的关系查询,而Redis没法做。

当前有三种方法缓存用户信息:

  1. 原生字符串类型:每个属性一个键

    ​ 优点:简单直观,每个属性都支持更新操作。

    ​ 缺点:占用过多的键,内存占用量较大,同时用户信息内聚性比较差。

  2. 序列化字符串类型:将用户信息序列化用一个键保存。

    ​ 优点:简化编程,如果合理的使用序列哈可以提高内存的使用效率。

    ​ 缺点:序列化和反序列化有一定的开销,每次更新属性都需要将全部数据取出进行反序列化,更新后再序列化到Redis中。

  3. 哈希类型:每个用户属性使用一对field-value,但是只用一个键保存。

    ​ 优点:简单直观,如果合理使用可以减少内存空间的使用

    ​ 缺点:要控制哈希在ziplist和hashtable两种内部编码的转换,hashtable会消耗更多的内存。

使用场景

  1. 消息队列

Redis的lpush + brpop命令组合即可实现阻塞队列,生产者客户端使用lrpush从列表左侧插入元素,多个消费者客户端使用brpop命令阻塞式的抢列表尾部的元素,多个客户端保证消费的负载均衡和高可用性。

  1. 文章列表

有序集合(zset)

有序集合同样用于存储键值对,有序集合的键被称为成员(member),每个成员都是各不相同的。有序集合的值称之为分值(score 必须为浮点型)。有序集合是Redis中唯一一个既可以根据成员访问元素,又可以根据分值以及分值排序来访问元素的结构。

1. ZADD			将一个带有给定分值的成员添加到有序集合中
2. ZRANGE		根据元素在有序排列中所处位置,从有序集合中获取多个元素
3. ZRANGEBYSCORE	获取有序集合在给定分值范围内的所有元素
4. ZREM				如果给定成员存在于有序集合,那么移除这个成员

内部编码

zset内部的排序功能时通过跳跃列表数据结构来实现的。

如何使用这些数据结构呢?

对文章进行投票

构建一个文章投票网站,我们首先要做的就是为了这个网站设置一些数值和限制条件:

  1. 一篇文章得到了至少200张支持票,那么网站就认为其有趣

  2. 网站需要将支持票排名前50篇的文章放到文章列表前显示

  3. 需要产生一个随着时间流逝文章评分不断减少的评分。

    具体方法为:文章支持票数*常量 +文章发布时间

    文章投票使用两个有序集合来有序的存储文章:

    1. key-文章ID value-文章发布时间

    2. key-文章ID value-文章的评分

      通过这两个集合,网站既可以根据文章发布的先后顺序来展示文章,又可以根据文章评分的高低来展示文章。

      为了防止用户对同一篇文章进行多次投票,同时需要针对每篇文章记录一个已投票用户名单。

      同时,为了节省内存,将对发表了一周以上的文章,用户不能再对其进行投票,文章评分将会被固定下来。

当用户尝试对一篇文章进行投票的时候,

  1. 程序需要使用ZSCORE命令检查记录文章发布时间,是否超过一周

  2. 如果没有超过一周,则需要将用户使用SADD命令添加到投票用户列表中。

  3. 如果添加成功,则说明为第一次投票,程序将更新文章对应评分。

    注意:这三条操作需要放到事务中执行,原子性操作。

发布并获取文章

  1. 创建一个新的文章ID,通过计数器(counter)执行INCR命令来完成

  2. 将发布者ID添加(SADD)到记录文章已投票用户名单的集合中,并设置过期时间为一周(EXPIRE)。

  3. 使用HMSET命令来存储文章的相关信息。

  4. 执行两个ZADD命令,初始化两个有序集合(发布时间和评分)

如何获取评分最高的文章以及如何获取最新发布的文章

  1. 使用ZREVRANGE命令 ,然后针对每个文章ID执行一次HGETALL命令来取出文章详细信息,

对文章进行分组

​ 群组有两部分组成,一个部分负责记录文章属于哪个群组,另外一个部分负责取出群组中的文章。

​ 网站为每个群组创建一个集合,将所有同属于一个群组的文章ID都记录到一个集合中。

使用Redis构建Web应用

关于背景:Fake Web Retailer这个网上商店,每天大约有500万不同的用户,给网站带来1亿次点击,并且从网站购买超过10万件商品。

登录和cookie缓存

每次我们登录互联网服务的时候,服务都会使用cookie来记录我们的身份。cookie由少量数据组成,网站会要求我们浏览器存储这些数据,同时每次服务发送请求的时候将这些数据传回给服务。对于用来登录的cookie,有两种常见的方法将登录信息存储再cookie中:签名cookie\令牌cookie。

签名cookie通常会存储用户名、用户ID、用户最后一次成功登录的时间以及王章觉得有用的其他信息。服务器会使用签名来验证该信息是否被改动过。

令牌cookie会再cookie中存储一串随机字节作为令牌,服务器可以根据令牌再数据库中查找令牌的拥有者。

图-抄的呢

FWR采取令牌cookie。除去登录信息外,FWR还可以将用户的访问时长、浏览记录等存储到数据库中,这样便于将来对用户行为进行分析。

用户在决定购买某些商品前,通常会先浏览多个不同的商品,而记录用户浏览过的所有商品以及用户最后一次访问页面的时间等信息,会导致相关的数据库写操作。针对FWR当前的负载,大体每秒1200次写入,高峰6000次写入。

使用Redis来实现登录cookie功能

  1. 使用一个散列来存储登录cookie令牌与登录用户之间映射。要检查一个用户是否已经登录,需要根据给定令牌来查找对应的用户。再用户登录情况下,返回该用户ID。
  2. 用户每次浏览页面的时候,会对用户存储再散列中的信息进行更新,并将用户的令牌和当前时间戳天机道到记录最近用户登录的有序结合中。

  3. 如果用户正在浏览一个商品,则会将该商品添加到记录用户最近浏览过的商品的有序集合中,并且再记录商品的数量超过25个时候,对有序集合进行修剪
  4. 由于存储会话数量所需内存会随着时间的推移不断增加,所以我们需要定期清理旧的会话数据。比如只保存最新的1000万个会话。清理会话程序由一个循环构成,这个循环每次执行时候,都会检查存储最近登录令牌的有序集合的大小。
  5. 如果超出限制,程序就会从有序集合中移除最多100个最旧的令牌,并从记录用户登录信息的散列中,移除被删除令牌对应的用户信息,并对这些用户浏览商品记录的有序集合进行清理。
  6. 如果没有超出限制,则会休眠一段时间,然后重新检查。

使用Redis实现购物车

使用cookie实现购物车—也就是将购物车都存储到cookie里的做法非常常见,

​ 优点:无须对数据库进行写入就可以实现购物车功能,

​ 缺点:是程序需要重新解析和验证cookie,确保cookie的格式正确,并且确保所包含的商品是真正可购买的商品。浏览器每次发送请求的时候都会连cookie一起发送,所以如果购物车的cookie体积过大,那么请求发送和处理的速度可能会有所降低。

那么如何使用Redis来实现会话cookie和记录用户最近浏览过的商品这两个特性呢?

所以我们决定将购物车信息也存储到Redis中,并且使用与会话cookie相同的cookieID来引用购物车。

购物车的定义非常简单:每个用户的购物车都是一个散列,这个散列存储了商品ID和商品订购数量之间的映射。我们需要做的是再商品订购数量出现变化的时候,对购物车进行更新。

​ 1:如果订购数量大于0,程序会将商品ID以及订购数量添加到散列中,

​ 2:如果商品ID已经存在,则更新数量。

​ 3:如果数量为0,程序将从散列中移除该条目。

​ 4:那么之前的清理会话函数需要稍微修改,再清理旧的会话的同时,将旧的会话对应的用户购物车也一并删除。

网页缓存

在动态生成网页的时候,通常会使用模板语言来简化网页的生成操作。

尽管FWR也能够动态的生成内容,但是这个网站上多数页面实际上并不会经常发生大的变化。一般情况下,网站中只有账号设置、以往订单、购物车以及其他的少数页面才包含需要每次载入都要动态生成的内容。

因此,我们可以想办法不再生成这些页面,减少网站在动态生成内容上面所花费的时间,可以减少网站处理相同负载所需要的服务器数量,让网站速度更快。

数据行缓存

FWR的商品页面通常只会从数据库中载入一两行数据,包括用户信息以及商品本身信息,程序可以通过缓存页面载入时候所需要的数据库行来减少页面所需时间。

但是,在促销或者秒杀的时候,网站是不能对促销或者秒杀商品页面进行缓存的,因为这样可能会导致用户看到错误的特价商品的价格错误或者剩余数量不对。但是每次载入页面都从数据库中取出特价商品的剩余数量,又会给数据库带来巨大的压力。

为了应对促销活动带来的大量负载,我们需要对数据进行缓存。具体做法:

  1. 编写一个持续运行的守护进程函数,让这个函数将指定的数据行缓存到Redis中,并不定期对这些缓存进行更新。缓存函数会将数据行编码为JSON并存储到Redis中。

  2. 程序使用两个有序集合来记录应该在何时对缓存进行更新:

    第一个集合为调度有序集合,成员为数据行的ID,分值则为时间戳。时间戳记录应该在何时将指定的数据行缓存到Redis里面

    第二个有序集合为延时有序集合,它的成员为数据行的ID,分值为数据行缓存需要多久更新一次。

  3. 为了让缓存函数定期的缓存数据行,程序手续需要将行ID和给定的延迟值添加到延迟有序集合里,然后再将行ID和当前时间的时间戳添加到调度有序集合里面。实际执行缓存操作的函数需要用到数据行的延迟值,如果延迟值不存在,则取消对数据行的调度。如果想移除某个数据的缓存,并且让缓存函数不再缓存那个数据行,那么只需要将数据行的延迟值设置为小于或者等于0即可。

网页分析

。。。。。。。。

核心概念

这一部分将深入探讨标准的Redis命令,其中包括数据操作命令和配置命令。

Redis命令

一些全局命令

  1. keys * :查看所有键 。它会遍历所有键,时间复杂度O(n),线上环境禁止使用。
  2. dbsize:返回当前数据库中键的总数。不会遍历所有键,时间复杂度为O(1)。
  3. exists key:检查键是否存在
  4. del key :删除键
  5. expire key:设置键的过期时间
  6. type a:返回键的类型

数据结构和内部编码

Redis中包含多种数据类型,而每种数据类型都有两种以上的内部编码实现,针对的不同的场景。如下图所示:

image-20200630084714067

Redis这样设计的好处是:

  1. 可以改进内部编码,而对外的数据结构和命令没有影响
  2. 多种内部编码实现可以在不同的场景下发挥各自的优势,例如ziplist比较节省内存,而在元素较多的情况下,性能会有所下降,这时候Redis会根据配置选项将列表类型的内部实现转换为linkedList。

单线程架构

Redis使用了单线程架构和I/O多路复用模型来实现高性能的内存数据服务,那么Redis单线程模型吸能为什么如此之高呢?

首先,来了解下Redis在面对多个客户端是如何工作的呢?

  1. 客户端发送命令到服务端
  2. 服务端接收到客户端发送过来的命令,将命令放到队列中
  3. 服务端依次处理队列中的命令

Redis速度快的原因大体有三点:

  1. 纯内存访问,Redis将所有数据都放到内存中。这是Redis达到每秒万级别的重要基础。
  2. 非阻塞I/O,Redis使用epoll作为I/O多路复用技术的实现,再加上Redis自身的事件处理模型将epoll的连接、读写、关闭都转换为事件,不在网络I/O上浪费过多时间。
  3. 单线程避免了线程切换和竞态产生的消耗。单线程模型简化了数据结构和算法的实现。

但是,单线程也有一个问题:对于每个命令的执行时间是有要求的。如果某个命令执行过长,会造成其他命令的阻塞。所以Redis是面向快速执行场景的数据库。

字符串对应命令

再Redis中,字符串可以存储以下三种类型的值:

  • 字节串
  • 整数
  • 浮点数
  1. 用户可以同给给定一个任意数组 , 对整数或者浮点数进行自增(increment)或者自减(decrement)操作。

image-20200629110842184

  1. Redis还拥有最字节串的其中一部分内容进行读取或者写入的操作

image-20200629111148561

列表对应命令

image-20200629111717447

阻塞式的列表弹出命令以及列表间移动元素的命令

image-20200629111914158

集合对应命令

image-20200629203842253

image-20200629204328322

散列对应命令

image-20200629210008181

有序集合对应命令

image-20200629210220037

image-20200629210300492

发布与订阅

发布与订阅(pub/sub)的特点是订阅者负责订阅频道,发送者负责向频道发送二进制字符串消息。每当又消息发送给指定频道时,频道的所有订阅者都会收到消息。

关于 发布于订阅的命令

image-20200629211240226

Redis的一些问题

缓存雪崩

缓存雪崩我们可以简单理解为:有缘原有缓存失效,新的缓存未到期间(例如:我们设置缓存时候采用了相同的过期时间,在同一时刻出现大面积的缓存过期),所有的原本应该访问缓存的请求都去查询数据库了,进而对数据库cpu和内存造成巨大压力,严重的会造成数据库宕机,从而形成一系列的连锁反应。

缓存正常的示意图是:

img

缓存失效瞬间的示意图为:

img

缓存失效时候的雪崩效应对底层系统的冲击非常可怕!大多数系统设计者考虑加锁或者队列的方式来保证不会有大量的线程对数据库一次性的进行读写。从而避免失效时大量的并发请求落到底层存储系统上。还有一个比较简单方法就是将缓存的过期时间分散开来,比如可以在原有失效时间基础上增加一个随机值。这样缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。

缓存穿透

缓存穿透是指用户查询数据,在数据库中没有,自然缓存中也不会有。这样就会导致用户查询的时候,在缓存中找不到,每次都要取数据库中再查询一边,然后返回为空。

有很多方法可以有效的解决缓存穿透问题,最常见的时是采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个bitmap拦截掉,从而避免对底层存储系统的查询压力。

还有另外一个更为简单粗暴的方法,如果一个查询返回结果为空,我们任然将空结果进行缓存,并且设置一个比较短的过期时间(5分钟),通过这个直接设置的默认值存放到缓存中,那么第二次到缓存中就有值了,而不会继续访问数据库,最简单粗暴。

缓存预热

缓存预热就是系统上线后,会将相关的缓存数据直接加载到缓存系统中。这样可以避免用户在请求的时候,先查询数据库,然后再将数据缓存到redis中的问题。

解决思路:

  1. 编写一个缓存刷新页面,上线的时候手动操作下。
  2. 数据量不大,可以再项目启动的时候自动进行加载
  3. 定时刷新缓存

缓存降级

当访问量剧增,服务出现问题(如响应时间慢或者不响应)或非核心服务影响到核心流程的性能时,任然需要保证服务还是可用的,即使时有损服务。系统可以根据一些关键数据进行自动降级,也可以配置开关实现人工降级。而降级的最终目的时保证核心服务可用。

再进行降级之前要多系统进行梳理,看看系统是不是可以丢举报帅; 从而梳理出哪些必须誓死保护,哪些可以降级。

Redis一些应用

分布式锁

分布式应用进行逻辑处理时候经常会遇到并发问题。比如一个操作要修改用户的状态,修改状态需要先读出用户的状态,在内存中进行修改,然后再存回去。如果这样的操作同时进行了,就会出现并发问题。因为读取和保存状态这两个操作不是原子的。这个时候就需要使用到分布式锁来限制程序的并发执行。

所谓原子操作是指不会被线程调度机制打断的操作。这种操作一旦开始,就一直运行到结束,中间不会有任何context switch 线程切换。

分布式锁本质上要实现的目标就是在Redis中占一个位置,当别的进程要进来时,发现已经有人了,就只好放弃或者稍后重试。

占位置一般使用setnx指令,它只允许一个客户端使用成功,用完再调用del释放位置。

问题一:

如果逻辑执行到中间出现异常了,可能会导致del指令没有调用,这样就会陷入死锁。

我们可以再拿到锁之后,再给锁加上一个过期时间,比如5S,这样即使中间出现异常也可以保证5秒后锁会自动释放。

问题二:如果再setnx和expire之间服务器进程突然挂掉了,会导致expire得不到执行,也会造成死锁。

这个问题的根源在于setnx和expire是两条指令而不是原子指令。如果两条指令可以一起执行就没有问题。2.8之后的版本,加入了set指令的扩展参数,使得setnx和expire可以一起执行,彻底解决了分布式锁的乱象。

set lock:codehole true ex 5 nx OK ... do something critical ...

超时问题

Redis的分布式锁不要用于处理较长时间的任务。

因为如果在加锁和释放锁之间的逻辑执行的太长,以至于超出了锁的超时限制,就会出现问题。因为这个时候锁过期了,第二个线程会重新持有这把锁,但是紧接着第一个线程执行完了业务逻辑,就把锁给释放了。

如果出现这种情况,那就需要人工介入解决。

集群版本的分布式锁

在集群环境下,之前提到的分布式锁的方式时有缺陷的。

比如在哨兵集群中,主节点挂掉了,从节点会取而代之,客户端却并没有明显感知。但是之前第一个客户端在主节点中申请成功了一把锁,但是这把锁还没有来得及同步到从节点,主节点突然挂掉了。然后从节点变成了主节点,而新的节点中并没有这把锁,而当其他客户端过来申请加锁时,会立即批准。这样会导致系统中同样一把锁被两个客户端同时持有。

不过这种不安全也仅仅时在主从发生failover的情况下才会产生,而且持续时间极短,业务系统多数情况下可以容忍。

Redlock算法

同很多分布式算法一样,redlock也使用大多数机制。加锁时,它会向过半节点发送set(key , value ,nx = true ,ex =xxx)指令,只要过半节点set成功,就认为加锁成功。释放锁的时候,需要向所有节点发送del指令,不过redlock算法还需要考虑出错重试、时钟漂移等细节问题,同时因为redlock需要向多个节点进行读写,意味着相比单实例redis性能会下降一些。

延时队列

专业的消息中间件,功能强大,但是使用起来也更复杂。对于那些只有一组消费者的消息队列,使用Redis可以非常轻松的搞定。Redis的消息队列不是专业的消息队列,它没有非常多的高级特性,没有ack保证,如果对消息的可靠性有着极致的追求,那么它就不适合使用。

异步消息队列

Redis的list数据结构常用来作为异步消息队列使用,使用rpush/lpush操作入队列,使用lpop/rpop来出队列。

image-20200705165629915

客户端可以在for循环中通过队列的pop操作来获取消息,然后进程处理。

可是如果队列空了,客户端就会陷入pop的死循环中。通常,我们可以使用sleep来解决这个问题。

队列延迟

sleep解决方法有个小问题,睡眠会导致消息的延迟增大。如果只有一个消费者,那么这个延迟就是1s。如果有多个消费者,延迟会有所下降,因为每个消费者的睡眠时间时岔开的。

这种情况下,可以使用blpop/brpop。这两个指令时阻塞读。阻塞读在队列没有数据的情况下,会立即进入休眠状态,一旦数据到来,则立刻醒过来。

空闲连接问题

如果线程一直阻塞在那,Redis的客户端连接就变成了闲置连接。闲置过久,服务器一般会主动断开连接,减少限制资源占用,这个使用blpop/brpop会抛出异常。

所以在编写客户端消费者的时候需要小心,注意捕获异常,并进行重试。

锁冲突处理之延时队列

当客户端在处理请求时加锁没加成怎么办?

一般有三种策略来处理加锁失败:

  1. 直接抛出异常,通知用户稍后再试

    这种方式比较适合由用户直接发起的请求,用户看到错误对话框后,会先阅读对话框内容,然后点击重试,这样会起到人工延时的效果。

  2. sleep 一会再试

    sleep会阻塞当前的消息处理线程,会导致队列的后续处理出现延迟,如果出现的比例比较频繁挥着队列中消息比较多,sleep并不合适。

    如果个别的死锁的key导致加锁不成功,线程会彻底堵死。

  3. 将请求转移至延时队列,过一会再试。

    这种方式比较适合异步消息处理,将当前冲突的请求扔到另外一个队列延后处理并避开冲突。

延时队列的实现

延时队列可以通过Redis的zset(有序列表)来实现。我们将消息序列化成一个字符串作为zset的value,这个消息的到期处理时间为score,然后多个线程轮询zset获取到期的任务进行处理,多个线程时为了保障可用性,万一挂了一个线程还有其他线程可以继续处理。

位图的使用

在我们平时开发过程中,会有一些bool型数据需要存取,比如用户一年的签到记录,需要记录365天。如果使用普通的key value ,每个用户需要记录365个,当用户越来越多,需要的存储空间时惊人的。

Redis提供了位图数据结构,这样每天的签到记录就只占据一个位。这样大大节省了存储空间。

位图并不是特殊数据结构,它的内容其实时普通的字符串,也就是byte数组。我们可以通过普通的get set操作进行操作,也可以使用位图操作getbit setbit 进行处理。

基本使用

零存整取、零存零取、整存零取。

零存-》setbit w 1 1

整存-》set w h

整取-》get w

零取-》getbit w 2

统计和查找

Redis提供了位图统计指令和位图查找指令

bitcount 统计指定位置范围内1的个数
bitpos	 查找指定范围内出现的第一个0或者1

HyperLogLog

业务场景:当对大型网站进行维护,产品经理要求你来实现每个网页每天的UV数据的统计功能,如何实现呢?

如果时统计PV非常好办,给每个网页一个独立的Redis计数器就可以了,这个计数器的key后缀加上当天的日期。这样来一次请求,incrby一次,最终可以统计出所有的PV数据。

而UV数据不一样,它需要去重,同一个用户一天之内的请求只能计数一次,这就要求每个网页请求都需要带上用户的ID,无论时登录用户还是未登录用户都需要一个唯一的ID来标识。

也许想到一个简单的方案,每个页面一个独立的set集合来存储所有当天用户访问次页面的用户ID。当请求过来,使用sadd将用户ID塞进去即可。通过scard可以取出这个集合的大小。而这个值就是这个页面的UV数据。

但是,如果你的页面访问量非常大,比如一个爆款页面几千万的UV,你需要一个很大的set集合来统计,这非常浪费空间。如果这样的页面很多,那它所需要的存储空间时惊人的。而且老板需要的数据又不需要态精确,105w和106w对老板来说没有很大区别。那么,有更好的方案吗?

使用HyperLogLog数据结构可以用来解决这种统计问题。HyperLogLog提供不精确的去重计数方案,虽然不精确但是也不是非常不精确,标准误差为0.81%,这样的精确度已经可以满足上面的UV统计需求。

HyperLogLog使用方法

HyperLogLog提供了两个指令pfadd和pfcount,一个是增加计数,一个是获取计数值。同时,还有一个pfmerge命令,用于将多个pf计数值累加在一起形成一个新的pf值。

HyperLogLog这个数据结构需要占据一定12K的存储空间,相比多用户的情况下,针对set的存储方案,HyperLogLog所使用的空间还是很少的。

布隆过滤器

实际场景:我们在使用新闻客户端看新闻的时候,它会给我们不断的推荐新的内容,每次推荐的时候都要去重,去掉那些已经看过的内容。那么新闻客户端推荐系统被如何实现推送去重的呢?

你会想到服务器记录用户看过的所有历史记录,当推荐系统推荐新闻的时候会从每个用户的历史记录中进行筛选,过滤掉那些已经存在的记录。问题是当用户量很大,每个用户看过新闻又很多的时候,这种方式,推荐系统的去重工作在性能上跟的上么?

实际上,如果历史记录存储在关系数据库中,去重就需要频繁的对数据库进行exists查询,当系统并发量很高,数据库是很难抗住压力的。

而如此多的历史记录全部缓存起来,那得浪费很多存储空间。而且这个存储空间是随着时间线性增长的。

布隆过滤器是什么

布隆过滤器可以理解为一个不怎么精确的set结构,当你使用contains方法判断某个对象是否存在时,它可能会误判。但是布隆过滤器也不是特别精确,只要参数设置的合理,只会有小小的误判概率。基本上只会误判没有见过的元素,见过的元素不会误判。

Redis中的布隆过滤器

Redis官方的布隆过滤器在Redis4.0提供了插件功能之后才正式登场。

bf.add   添加元素    一次只能添加一个元素
bf.exists 查询元素是否存在
bf.madd   可以一次添加多个元素
bf.mexists	查询多个元素是否存在
bf.reserve  显式创建布隆过滤器,分别时key   error_rate 和initial_size(标识预计放入的元素数量) ,错误率越低,需要的空间越大

注意事项

布隆过滤器的initial_size估计的过大,会浪费存储空间,估计的过小,就会影响准确率。在使用之前一定要尽可能精确的估计好元素数量,再加上一定的冗余空间。

布隆过滤器的error_rate越小,需要的存储空间就越大,对于不需要过于精确的场合,error_rate设置的稍大一点也无伤大雅。

再爬虫系统中,需要对URL进行去重,已经爬过的网页就可以不用爬了。但是URL太多了,上亿,用集合取装这些URL时非常浪费的。而考虑使用布隆过滤器,可以大幅度降低存储消耗。

Redis实现详解

Redis过期策略

Redis所有数据都时可以设置过期时间,时间一到,就会自动删除。那么,有个问题,会不会因为同一时间太多的key过期,以至于忙不过来,同时因为Redis是单线程的,删除过期key也会占用线程的处理时间,如果删除太过的繁忙,会不会导致线上读写指令出现卡顿呢?

  1. Redis会将每个设置了过期时间的key放到一个独立的字典中,以后会定时遍历这个字典来删除到期的key。
  2. 除了定期遍历之外,它还会使用惰性策略来删除过期的key,所谓惰性删除就是客户端在访问这个key的时候,Redis会对key的过期时间进行检查,如果过期就立即删除

定时扫描策略

Redis默认会每秒进行10次过期扫描,过期扫描不会遍历字典中所有的key,而是采用一种简单的贪心策略。

  1. 从过期字典中随机20个key。
  2. 删除这20个key中已经过期的key。
  3. 如果过期的key比例超过四分之一,那就重复步骤1。

为了保证过期扫描不会出现循环过度,导致线程卡死现象,算法还增加了扫描时间上限,默认不会超过25ms。

问题:

设想以下,当一个Redis实例中有大量的key在同一时间过期,会出现什么样的结果?

Redis会持续性的扫描过期字典(循环多次),直到过期字典中的key变得稀疏,才会停止。在这个过程中,内存管理器需要频繁的回收内存,会产生一定的CPU消耗。这会导致线上读写请求出现明显的卡顿现象。

解决方法:

这种情况多出现在一些活动系统中,因为活动是一期一会,下一期活动举办时,前面几期的很多数据都会丢失,所以需要给相关的活动数据设置一个过期时间,以减少不必要的Redis内存占用。如果将过期时间设置为活动结束再加一个常量的冗余时间,如果活动数据太多,出现大量key同时过期的情况。

可以采用过期时间后再加一个随机时间,将key的过期时间打散,随机化,总是能很好的解决这个问题。

从库的过期策略

从库不会进行过期扫描,从库对过期的处理时被动的。从库再key到期时,会在AOF文件中增加一条del指令,同步到所有的从库。

由于指令同步时异步进行的,所以从库过期key的del指令如果没有及时同步到从库的话,会出现数据不一致的情况。