一、Redis介绍

  Redis是一个开源的,基于内存的结构化数据存储媒介,可以作为数据库、缓存服务或消息服务使用。Redis支持多种数据结构,包括字符串、哈希表、链表、集合、有序集合、位图、Hyperloglogs等。Redis具备LRU淘汰、事务实现、以及不同级别的硬盘持久化等能力,并且支持副本集和通过Redis Sentinel(哨兵)实现的高可用方案,同时还支持通过Redis Cluster(集群)实现的数据自动分片能力。

  Redis的主要功能都基于单线程模型实现,也就是说Redis使用一个线程来服务所有的客户端请求,同时Redis采用了非阻塞式IO,并精细地优化各种命令的算法时间复杂度,这些信息意味着:

    • Redis是线程安全的(因为只有一个线程),其所有操作都是原子的,不会因并发产生数据异常

    • Redis的速度非常快(因为使用非阻塞式IO,且大部分命令的算法时间复杂度都是O(1))

    • 使用高耗时的Redis命令是很危险的,会占用唯一的一个线程的大量处理时间,导致所有的请求都被拖慢。(例如时间复杂度为O(N)的KEYS命令,严格禁止在生产环境中使用)

二、Redis数据结构及常用的命令

1  key设置注意事项

  Redis采用Key-Value型的基本数据结构,任何二进制序列都可以作为Redis的Key使用(例如普通的字符串或一张JPEG图片)
  关于Key的一些注意事项:

  (1)不要使用过长的Key。例如使用一个1024字节的key就不是一个好主意,不仅会消耗更多的内存,还会导致查找的效率降低

  (2)Key短到缺失了可读性也是不好的,例如"u1000flw"比起"user:1000:followers"来说,节省了寥寥的存储空间,却引发了可读性和可维护性上的麻烦

  (3)最好使用统一的规范来设计Key,比如"object-type:id:attr",以这一规范设计出的Key可能是"user:1000"或"comment:1234:reply-to"

  (4)Redis允许的最大Key长度是512MB(对Value的长度限制也是512MB)

2  String

  String是Redis的基础数据类型,Redis没有int、float、boolean等数据类型的概念,所有的基本类型在Redis中都以String体现。

  与String相关的常用命令: 

SET key value [EX seconds] [PX milliseconds] [NX|XX]:为一个key设置value,可以配合EX/PX参数指定key的有效期,通过NX/XX参数针对key是否存在的情况进行区别操作,时间复杂度O(1),例如:set name zhangsan ex 10。

GET:获取某个key对应的value,时间复杂度O(1)

GETSET:为一个key设置value,并返回该key的原value,时间复杂度O(1)

MSET:为多个key设置value,时间复杂度O(N)

MSETNX:同MSET,如果指定的key中有任意一个已存在,则不进行任何操作,时间复杂度O(N)

MGET:获取多个key对应的value,时间复杂度O(N)

  Redis也可以把String作为整型或浮点型数字来使用,主要体现在INCR、DECR类的命令上:

INCR:将key对应的value值自增1,并返回自增后的值。只对可以转换为整型的String数据起作用。时间复杂度O(1)
INCRBY:将key对应的value值自增指定的整型数值,并返回自增后的值。只对可以转换为整型的String数据起作用。时间复杂度O(
1)
DECR
/DECRBY:同INCR/INCRBY,自增改为自减。

  INCR/DECR系列命令要求操作的value类型为String,并可以转换为64位带符号的整型数字,否则会返回错误。

  也就是说,进行INCR/DECR系列命令的value,必须在[-2^63 ~ 2^63 - 1]范围内。

  前文提到过,Redis采用单线程模型,天然是线程安全的,这使得INCR/DECR命令可以非常便利的实现高并发场景下的精确控制。

使用场景:

  例1:库存控制

  在高并发场景下实现库存余量的精准校验,确保不出现超卖的情况。

  设置库存总量:

  SET inv:remain "100"

  库存扣减+余量校验:

  DECR inv:remain

  当DECR命令返回值大于等于0时,说明库存余量校验通过,如果返回小于0的值,则说明库存已耗尽。

  假设同时有300个并发请求进行库存扣减,Redis能够确保这300个请求分别得到99到-200的返回值,每个请求得到的返回值都是唯一的,绝对不会找出现两个请求得到一样的返回值的情况。

  例2:自增序列生成

  实现类似于RDBMS的Sequence功能,生成一系列唯一的序列号

  设置序列起始值:

  SET sequence "10000"

  获取一个序列值: 

  INCR sequence

  直接将返回值作为序列使用即可。

  获取一批(如100个)序列值:

  INCRBY sequence 100

  假设返回值为N,那么[N - 99 ~ N]的数值都是可用的序列值。

  当多个客户端同时向Redis申请自增序列时,Redis能够确保每个客户端得到的序列值或序列范围都是全局唯一的,绝对不会出现不同客户端得到了重复的序列值的情况。

3、List

  Redis的List是链表型的数据结构,可以使用LPUSH/RPUSH/LPOP/RPOP等命令在List的两端执行插入元素和弹出元素的操作。虽然List也支持在特定index上插入和读取元素的功能,但其时间复杂度较高(O(N)),应小心使用。

  与List相关的常用命令: 

LPUSH:向指定List的左侧(即头部)插入1个或多个元素,返回插入后的List长度。时间复杂度O(N),N为插入元素的数量
RPUSH:同LPUSH,向指定List的右侧(即尾部)插入1或多个元素
LPOP:从指定List的左侧(即头部)移除一个元素并返回,时间复杂度O(1)
RPOP:同LPOP,从指定List的右侧(即尾部)移除1个元素并返回
LPUSHX/RPUSHX:与LPUSH/RPUSH类似,区别在于,LPUSHX/RPUSHX操作的key如果不存在,则不会进行任何操作
LLEN:返回指定List的长度,时间复杂度O(1)
LRANGE:返回指定List中指定范围的元素(双端包含,即LRANGE key 0 10会返回11个元素),时间复杂度O(N)。
应尽可能控制一次获取的元素数量,一次获取过大范围的List元素会导致延迟,同时对长度不可预知的List,避免使用LRANGE key 0 -1这样的完整遍历操作。

应谨慎使用的List相关命令

LINDEX:返回指定List指定index上的元素,如果index越界,返回nil。index数值是回环的,即-1代表List最后一个位置,-2代表List倒数第二个位置。时间复杂度O(N)
LSET:将指定List指定index上的元素设置为value,如果index越界则返回错误,时间复杂度O(N),如果操作的是头/尾部的元素,则时间复杂度为O(1)
LINSERT:向指定List中指定元素之前/之后插入一个新元素,并返回操作后的List长度。如果指定的元素不存在,返回-1。如果指定key不存在,不会进行任何操作,时间复杂度O(N)

  由于Redis的List是链表结构的,上述的三个命令的算法效率较低,需要对List进行遍历,命令的耗时无法预估,在List长度大的情况下耗时会明显增加,应谨慎使用。

  换句话说,Redis的List实际是设计来用于实现队列,而不是用于实现类似ArrayList这样的列表的。如果你不是想要实现一个双端出入的队列,那么请尽量不要使用Redis的List数据结构。

  为了更好支持队列的特性,Redis还提供了一系列阻塞式的操作命令,如BLPOP/BRPOP等,能够实现类似于BlockingQueue的能力,即在List为空时,阻塞该连接,直到List中有对象可以出队时再返回。

4、Hash

  Hash即哈希表,Redis的Hash和传统的哈希表一样,是一种field-value型的数据结构,可以理解成将HashMap搬入Redis。

  Hash非常适合用于表现对象类型的数据,用Hash中的field对应对象的field即可。

  Hash的优点包括:

    可以实现二元查找,如"查找ID为1000的用户的年龄"

    比起将整个对象序列化后作为String存储的方法,Hash能够有效地减少网络传输的消耗

    当使用Hash维护一个集合时,提供了比List效率高得多的随机访问命令

  与Hash相关的常用命令:

HSET:将key对应的Hash中的field设置为value。如果该Hash不存在,会自动创建一个。时间复杂度O(1)
HGET:返回指定Hash中field字段的值,时间复杂度O(1)
HMSET/HMGET:同HSET和HGET,可以批量操作同一个key下的多个field,时间复杂度:O(N),N为一次操作的field数量
HSETNX:同HSET,但如field已经存在,HSETNX不会进行任何操作,时间复杂度O(1)
HEXISTS:判断指定Hash中field是否存在,存在返回1,不存在返回0,时间复杂度O(1)
HDEL:删除指定Hash中的field(1个或多个),时间复杂度:O(N),N为操作的field数量
HINCRBY:同INCRBY命令,对指定Hash中的一个field进行INCRBY,时间复杂度O(1)

应谨慎使用的Hash相关命令

HGETALL:返回指定Hash中所有的field-value对。返回结果为数组,数组中field和value交替出现。时间复杂度O(N)
HKEYS/HVALS:返回指定Hash中所有的field/value,时间复杂度O(N)

上述三个命令都会对Hash进行完整遍历,Hash中的field数量与命令的耗时线性相关

5、Set

  Redis Set是无序的,不可重复的String集合。

  与Set相关的常用命令:

SADD:向指定Set中添加1个或多个member,如果指定Set不存在,会自动创建一个。时间复杂度O(N),N为添加的member个数
SREM:从指定Set中移除1个或多个member,时间复杂度O(N),N为移除的member个数
SRANDMEMBER:从指定Set中随机返回1个或多个member,时间复杂度O(N),N为返回的member个数
SPOP:从指定Set中随机移除并返回count个member,时间复杂度O(N),N为移除的member个数
SCARD:返回指定Set中的member个数,时间复杂度O(1)
SISMEMBER:判断指定的value是否存在于指定Set中,时间复杂度O(1)
SMOVE:将指定member从一个Set移至另一个Set

慎用的Set相关命令

SMEMBERS:返回指定Hash中所有的member,时间复杂度O(N)
SUNION/SUNIONSTORE:计算多个Set的并集并返回/存储至另一个Set中,时间复杂度O(N),N为参与计算的所有集合的总member数
SINTER/SINTERSTORE:计算多个Set的交集并返回/存储至另一个Set中,时间复杂度O(N),N为参与计算的所有集合的总member数
SDIFF/SDIFFSTORE:计算1个Set与1或多个Set的差集并返回/存储至另一个Set中,时间复杂度O(N),N为参与计算的所有集合的总member数

  上述几个命令涉及的计算量大,应谨慎使用,特别是在参与计算的Set尺寸不可知的情况下,应严格避免使用。可以考虑通过SSCAN命令遍历获取相关Set的全部member,如果需要做并集/交集/差集计算,可以在客户端进行,或在不服务实时查询请求的Slave上进行。

6、Sorted Set

  Redis Sorted Set是有序的、不可重复的String集合。Sorted Set中的每个元素都需要指派一个分数(score),Sorted Set会根据score对元素进行升序排序。如果多个member拥有相同的score,则以字典序进行升序排序。

  Sorted Set非常适合用于实现排名。

  Sorted Set的主要命令:

ZADD:向指定Sorted Set中添加1个或多个member,时间复杂度O(Mlog(N)),M为添加的member数量,N为Sorted Set中的member数量
ZREM:从指定Sorted Set中删除1个或多个member,时间复杂度O(Mlog(N)),M为删除的member数量,N为Sorted Set中的member数量
ZCOUNT:返回指定Sorted Set中指定score范围内的member数量,时间复杂度:O(log(N))
ZCARD:返回指定Sorted Set中的member数量,时间复杂度O(1)
ZSCORE:返回指定Sorted Set中指定member的score,时间复杂度O(1)
ZRANK/ZREVRANK:返回指定member在Sorted Set中的排名,ZRANK返回按升序排序的排名,ZREVRANK则返回按降序排序的排名。时间复杂度O(log(N))
ZINCRBY:同INCRBY,对指定Sorted Set中的指定member的score进行自增,时间复杂度O(log(N))

慎用的Sorted Set相关命令

ZRANGE/ZREVRANGE:返回指定Sorted Set中指定排名范围内的所有member,ZRANGE为按score升序排序,ZREVRANGE为按score降序排序,时间复杂度O(log(N)+M),M为本次返回的member数
ZRANGEBYSCORE/ZREVRANGEBYSCORE:返回指定Sorted Set中指定score范围内的所有member,返回结果以升序/降序排序,min和max可以指定为-inf和+inf,代表返回所有的member。时间复杂度O(log(N)+M)
ZREMRANGEBYRANK/ZREMRANGEBYSCORE:移除Sorted Set中指定排名范围/指定score范围内的所有member。时间复杂度O(log(N)+M)

  上述几个命令,应尽量避免传递[0 -1]或[-inf +inf]这样的参数,来对Sorted Set做一次性的完整遍历,特别是在Sorted Set的尺寸不可预知的情况下。可以通过ZSCAN命令来进行游标式的遍历

7、其他常用命令

EXISTS:判断指定的key是否存在,返回1代表存在,0代表不存在,时间复杂度O(1)
DEL:删除指定的key及其对应的value,时间复杂度O(N),N为删除的key数量
EXPIRE/PEXPIRE:为一个key设置有效期,单位为秒或毫秒,时间复杂度O(1)
TTL/PTTL:返回一个key剩余的有效时间,单位为秒或毫秒,时间复杂度O(1)
RENAME/RENAMENX:将key重命名为newkey。使用RENAME时,如果newkey已经存在,其值会被覆盖;使用RENAMENX时,如果newkey已经存在,则不会进行任何操作,时间复杂度O(1)
TYPE:返回指定key的类型,string, list, set, zset, hash。时间复杂度O(1)
CONFIG GET:获得Redis某配置项的当前值,可以使用*通配符,时间复杂度O(1)
CONFIG SET:为Redis某个配置项设置新值,时间复杂度O(1)
CONFIG REWRITE:让Redis重新加载redis.conf中的配置

三、Redis持久化策略选择

1、RDB和AOF的优缺点

  RDB和AOF各有优缺点:

  RDB持久化

    优点:RDB文件紧凑,体积小,网络传输快,适合全量复制;恢复速度比AOF快很多。当然,与AOF相比,RDB最重要的优点之一是对性能的影响相对较小。

    缺点:RDB文件的致命缺点在于其数据快照的持久化方式决定了必然做不到实时持久化,而在数据越来越重要的今天,数据的大量丢失很多时候是无法接受的,因此AOF持久化成为主流。此外,RDB文件需要满足特定格式,兼容性差(如老版本的Redis不兼容新版本的RDB文件)。 

  

  AOF的优点:

    最安全,在启用appendfsync always时,任何已写入的数据都不会丢失,使用在启用appendfsync everysec也至多只会丢失1秒的数据。

    AOF文件在发生断电等问题时也不会损坏,即使出现了某条日志只写入了一半的情况,也可以使用redis-check-aof工具轻松修复。

    AOF文件易读,可修改,在进行了某些错误的数据清除操作后,只要AOF文件没有rewrite,就可以把AOF文件备份出来,把错误的命令删除,然后恢复数据。

  AOF的缺点:

    AOF文件通常比RDB文件更大

    性能消耗比RDB高

    数据恢复速度比RDB慢

2、持久化策略选择

  在介绍持久化策略之前,首先要明白无论是RDB还是AOF,持久化的开启都是要付出性能方面代价的:

    对于RDB持久化,一方面是bgsave在进行fork操作时Redis主进程会阻塞,另一方面,子进程向硬盘写数据也会带来IO压力;

    对于AOF持久化,向硬盘写数据的频率大大提高(everysec策略下为秒级),IO压力更大,甚至可能造成AOF追加阻塞问题(后面会详细介绍这种阻塞)。

    此外,AOF文件的重写与RDB的bgsave类似,会有fork时的阻塞和子进程的IO压力问题。相对来说,由于AOF向硬盘中写数据的频率更高,因此对Redis主进程性能的影响会更大。

  在实际生产环境中,根据数据量、应用对数据的安全要求、预算限制等不同情况,会有各种各样的持久化策略;如完全不使用任何持久化、使用RDB或AOF的一种,或同时开启RDB和AOF持久化等。此外,持久化的选择必须与Redis的主从策略一起考虑,因为主从复制与持久化同样具有数据备份的功能,而且主机master和从机slave可以独立的选择持久化方案。

下面分场景来讨论持久化策略的选择,下面的讨论也只是作为参考,实际方案可能更复杂更具多样性。

  (1)如果Redis中的数据完全丢弃也没有关系(如Redis完全用作DB层数据的cache),那么无论是单机,还是主从架构,都可以不进行任何持久化。

  (2)在单机环境下,如果可以接受十几分钟或更多的数据丢失,选择RDB对Redis的性能更加有利;如果只能接受秒级别的数据丢失,应该选择AOF。

  (3)但在多数情况下,我们都会配置主从环境,slave的存在既可以实现数据的热备,也可以进行读写分离分担Redis读请求,以及在master宕掉后继续提供服务。

      在这种情况下,一种可行的做法是:

      master:完全关闭持久化(包括RDB和AOF),这样可以让master的性能达到最好

      slave:关闭RDB,开启AOF(如果对数据安全要求不高,开启RDB关闭AOF也可以),并定时对持久化文件进行备份(如备份到其他文件夹,并标记好备份的时间);然后关闭AOF的自动重写,然后添加定时任务,在每天Redis闲时(如凌晨12点)调用bgrewriteaof。

  为什么开启了主从复制,可以实现数据的热备份,还需要设置持久化呢?因为在一些特殊情况下,主从复制仍然不足以保证数据的安全,例如:

    1.master和slave进程同时停止:考虑这样一种场景,如果master和slave在同一栋大楼或同一个机房,则一次停电事故就可能导致master和slave机器同时关机,Redis进程停止;如果没有持久化,则面临的是数据的完全丢失。

    2.master误重启:考虑这样一种场景,master服务因为故障宕掉了,如果系统中有自动拉起机制(即检测到服务停止后重启该服务)将master自动重启,由于没有持久化文件,那么master重启后数据是空的,slave同步数据也变成了空的;如果master和slave都没有持久化,同样会面临数据的完全丢失。需要注意的是,即便是使用了哨兵进行自动的主从切换,也有可能在哨兵轮询到master之前,便被自动拉起机制重启了。因此,应尽量避免“自动拉起机制”和“不做持久化”同时出现。

  (4)异地灾备:上述讨论的几种持久化策略,针对的都是一般的系统故障,如进程异常退出、宕机、断电等,这些故障不会损坏硬盘。但是对于一些可能导致硬盘损坏的灾难情况,如火灾地震,就需要进行异地灾备。

  例如对于单机的情形,可以定时将RDB文件或重写后的AOF文件,通过scp拷贝到远程机器,如阿里云、AWS等;对于主从的情形,可以定时在master上执行bgsave,然后将RDB文件拷贝到远程机器,或者在slave上执行bgrewriteaof重写AOF文件后,将AOF文件拷贝到远程机器上。

  一般来说,由于RDB文件文件小、恢复快,因此灾难恢复常用RDB文件;异地备份的频率根据数据安全性的需要及其他条件来确定,但最好不要低于一天一次。

四、内存管理与数据淘汰机制

1、最大内存设置

  默认情况下,在32位OS中,Redis最大使用3GB的内存,在64位OS中则没有限制。

  在使用Redis时,应该对数据占用的最大空间有一个基本准确的预估,并为Redis设定最大使用的内存。否则在64位OS中Redis会无限制地占用内存(当物理内存被占满后会使用swap空间),容易引发各种各样的问题。

  通过如下配置控制Redis使用的最大内存:

  maxmemory 100mb

  在内存占用达到了maxmemory后,再向Redis写入数据时,Redis会:

  (1)根据配置的数据淘汰策略尝试淘汰数据,释放空间

  (2)如果没有数据可以淘汰,或者没有配置数据淘汰策略,那么Redis会对所有写请求返回错误,但读请求仍然可以正常执行

  在为Redis设置maxmemory时,需要注意:

    如果采用了Redis的主从同步,主节点向从节点同步数据时,会占用掉一部分内存空间,如果maxmemory过于接近主机的可用内存,导致数据同步时内存不足。所以设置的maxmemory不要过于接近主机可用的内存,留出一部分预留用作主从同步。

2、数据淘汰机制

  Redis提供了5种数据淘汰策略:

    (1)volatile-lru:从已设置过期时间的数据集(server.db[i].expires)中使用LRU算法进行数据淘汰(淘汰上次使用时间最早的,且使用次数最少的key),只淘汰设定了有效期的key

    (2)allkeys-lru:从数据集(server.db[i].dict)中使用LRU算法进行数据淘汰,所有的key都可以被淘汰

    (3)volatile-random:从已设置过期时间的数据集(server.db[i].expires)中随机淘汰数据,只淘汰设定了有效期的key

    (4)allkeys-random:从数据集(server.db[i].dict)中随机淘汰数据,所有的key都可以被淘汰

    (5)volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中淘汰剩余有效期最短的key

    (6)noeviction:不会继续服务写请求 (DEL 请求可以继续服务),读请求可以继续进行。这样可以保证不会丢失数据,但是会让线上的业务不能持续进行。这是默认的淘汰策略。

  最好为Redis指定一种有效的数据淘汰策略以配合maxmemory设置,避免在内存使用满后发生写入失败的情况。

  一般来说,推荐使用的策略是volatile-lru,并辨识Redis中保存的数据的重要性。对于那些重要的,绝对不能丢弃的数据(如配置类数据等),应不设置有效期,这样Redis就永远不会淘汰这些数据。对于那些相对不是那么重要的,并且能够热加载的数据(比如缓存最近登录的用户信息,当在Redis中找不到时,程序会去DB中读取),可以设置上有效期,这样在内存不够时Redis就会淘汰这部分数据。

  配置方法:

  maxmemory-policy volatile-lru   #默认是noeviction,即不进行数据淘汰

  从redisObject 中可以发现,每一个 Redis 对象都会设置相应的 lru,即最近访问的时间。可以想象的是,每一次访问数据的时候,会更新 redisObject.lru。

  LRU 数据淘汰机制是这样的:在数据集中随机挑选几个键值对,取出其中 lru 最大的键值对淘汰。所以,你会发现,Redis 并不是保证取得所有数据集中最近最少使用(LRU)的键值对,而只是随机挑选的几个键值对中的。

  TTL 数据淘汰机制是这样的:从过期时间 redisDB.expires 表中随机挑选几个键值对,取出其中 ttl 最大的键值对淘汰。同样你会发现,Redis 并不是保证取得所有过期时间的表中最快过期的键值对,而只是随机挑选的几个键值对中的。

  Redis 每服务客户端执行一个命令的时候,会检测使用的内存是否超额。如果超额,即进行数据淘汰。

3  近似LRU算法

  LRU(Least recently used,最近最少使用)算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。最常见的实现是使用一个链表保存缓存数据,详细算法实现如下: 

  

  在LRU算法中,当空间满的时候,会踢掉链表尾部的元素。当字典的某个元素被访问时,它在链表中的位置会被移动到表头。所以链表的元素排列顺序就是元素最近被访问的时间顺序。

  Redis 使用的是一种近似 LRU 算法,它跟 LRU 算法还不太一样。之所以不使用 LRU 算法,是因为需要消耗大量的额外的内存,需要对现有的数据结构进行较大的改造。近似 LRU 算法则很简单,在现有数据结构的基础上使用随机采样法来淘汰元素,能达到和 LRU 算法非常近似的效果。

  Redis 为实现近似 LRU 算法,它给每个 key 增加了一个额外的小字段,这个字段的长度是 24 个 bit,也就是最后一次被访问的时间戳。

  Redis的过期方式分为定期删除和懒惰删除,LRU 淘汰不一样,它的处理方式只有懒惰删除。当 Redis 执行写操作时,发现内存超出 maxmemory,就会执行一次 LRU 淘汰算法:

  随机采样(可以通过maxmemory-policy配置)出 5个 key,然后淘汰掉最旧的 key,如果淘汰后内存还是超出 maxmemory,那就继续随机采样淘汰,直到内存低于 maxmemory为止。

  自己实现一个LRU代码如下:

public class LRUQueue<E> {

    private final int MAX_CACHE_SIZE;
    private static final int DEFAULT_CACHE_SIZE = 30;
    private final float DEFAULT_LOAD_FACTOR = 0.75f;
    private transient LinkedHashMap<E, Object> map;
    private static final Object PRESENT = new Object();
    private final Lock lock = new ReentrantLock();

    public LRUQueue() {
        MAX_CACHE_SIZE = DEFAULT_CACHE_SIZE;
        init();
    }

    public LRUQueue(int cacheSize) {
        MAX_CACHE_SIZE = cacheSize;
        init();
    }

    public LRUQueue(LinkedHashSet<E> set, int cacheSize) {
        this(cacheSize);
        Stack<E> stack = new Stack<>();
        set.forEach(l -> stack.push(l));
        final int len = stack.size();
        for (int i = 0; i < len; i++) {
            map.put(stack.pop(), PRESENT);
        }
    }

    void init() {
        // +1 确保当达到 cacheSize 上限时不会 rehash,
        int capacity = (int) Math.ceil(MAX_CACHE_SIZE / DEFAULT_LOAD_FACTOR) + 1;
        map = new LinkedHashMap(capacity, DEFAULT_LOAD_FACTOR, true) {
            @Override
            protected boolean removeEldestEntry(Map.Entry eldest) {
                return size() > MAX_CACHE_SIZE;
            }
        };
    }

    public Iterator<E> iterator() {
        return map.keySet().iterator();
    }

    public int size() {
        try {
            lock.lock();
            return map.size();
        } finally {
            lock.unlock();
        }
    }

    public boolean add(E e) {
        try {
            lock.lock();
            return map.put(e, PRESENT) == null;
        } finally {
            lock.unlock();
        }
    }

    public boolean contains(E e) {
        try {
            lock.lock();
            return map.containsKey(e);
        } finally {
            lock.unlock();
        }
    }

    public boolean remove(E e) {
        try {
            lock.lock();
            return map.remove(e) == PRESENT;
        } finally {
            lock.unlock();
        }
    }

    public LinkedHashSet<E> getAll() {

        try {
            lock.lock();
            LinkedHashSet<E> set = new LinkedHashSet<>();
            Stack<E> stack = new Stack<>();
            map.entrySet().forEach(l -> stack.push(l.getKey()));
            final int len = stack.size();
            for (int i = 0; i < len; i++) {
                set.add(stack.pop());
            }
            return set;
        } finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        LRUQueue<Long> queue = new LRUQueue<>(10);
        queue.add(10L);queue.add(1L);queue.add(2L);queue.add(2L);queue.add(1L);
        queue.add(3L);queue.add(4L);queue.add(4L);queue.add(5L);queue.add(1L);
        queue.add(10L);queue.add(4L);queue.add(7L);queue.add(5L);queue.add(1L);
        queue.add(10L);queue.add(8L);queue.add(9L);queue.add(5L);queue.add(1L);
        queue.add(9L);queue.add(8L);queue.add(9L);queue.add(5L);queue.add(1L);
        queue.add(100L);queue.add(6L);queue.add(22L);queue.add(99L);

        queue.getAll().forEach(System.out :: println);
        System.out.println("---------------------------");

        LRUQueue<Long> queue2 = new LRUQueue(queue.getAll(), 10);
        //queue2.getAll().forEach(System.out :: println);

        queue2.remove(1L);
        queue2.getAll().forEach(System.out :: println);
    }
}
View Code

4  LFU(Least Frequently Used)

  它淘汰策略配置参数maxmemory-policy增加了 2 个选项,分别是 volatile-lfu allkeys-lfu,分别是对带过期时间的 key 进行 lfu 淘汰以及对所有的 key 执行 lfu 淘汰算法。

  如果一个 key 长时间不被访问,只是刚刚偶然被用户访问了一下,那么在使用 LRU 算法下它是不容易被淘汰的,因为 LRU 算法认为当前这个 key 是很热的。而 LFU 是需要追踪最近一段时间的访问频率,如果某个 key 只是偶然被访问一次是不足以变得很热的,它需要在近期一段时间内被访问很多次才有机会被认为很热。

五、Redis过期策略及实现原理

1  说明

  我们在使用redis时,一般会设置一个过期时间,当然也有不设置过期时间的,也就是永久不过期。当我们设置了过期时间,redis是如何判断是否过期,以及根据什么策略来进行删除的。

2  设置过期时间

  expire key time(以秒为单位)--这是最常用的方式

  setex(String key, int seconds, String value)--字符串独有的方式

注:除了字符串自己独有设置过期时间的方法外,其他方法都需要依靠expire方法来设置时间,如果没有设置时间,那缓存就是永不过期,如果设置了过期时间,之后又想让缓存永不过期,使用persist key

3  三种过期策略

(1)定时删除

  含义:在设置key的过期时间的同时,为该key创建一个定时器,让定时器在key的过期时间来临时,对key进行删除

  优点:保证内存被尽快释放

  缺点:

    若过期key很多,删除这些key会占用很多的CPU时间,在CPU时间紧张的情况下,CPU不能把所有的时间用来做要紧的事儿,还需要去花时间删除这些key

    定时器的创建耗时,若为每一个设置过期时间的key创建一个定时器(将会有大量的定时器产生),性能影响严重

(2)懒汉式式删除

  含义:key过期的时候不删除,每次通过key获取值的时候去检查是否过期,若过期,则删除,返回null。

  优点:删除操作只发生在通过key取值的时候发生,而且只删除当前key,所以对CPU时间的占用是比较少的,而且此时的删除是已经到了非做不可的地步(如果此时还不删除的话,我们就会获取到了已经过期的key了)

  缺点:若大量的key在超出超时时间后,很久一段时间内,都没有被获取过,那么可能发生内存泄露(无用的垃圾占用了大量的内存)

(3)定期删除

  含义:每隔一段时间执行一次删除过期key操作

  优点:

    通过限制删除操作的时长和频率,来减少删除操作对CPU时间的占用--处理"定时删除"的缺点

    定期删除过期key--处理"懒汉式删除"的缺点

  缺点:

    在内存友好方面,不如"定时删除"(会造成一定的内存占用,但是没有懒汉式那么占用内存)
    在CPU时间友好方面,不如"懒汉式删除"(会定期的去进行比较和删除操作,cpu方面不如懒汉式,但是比定时好)

  难点:合理设置删除操作的执行时长(每次删除执行多长时间)和执行频率(每隔多长时间做一次删除)(这个要根据服务器运行情况来定了),每次执行时间太长,或者执行频率太高对cpu都是一种压力。

  每次进行定期删除操作执行之后,需要记录遍历循环到了哪个标志位,以便下一次定期时间来时,从上次位置开始进行循环遍历

(4)说明

  memcached只是用了惰性删除,而redis同时使用了惰性删除与定期删除,这也是二者的一个不同点(可以看做是redis优于memcached的一点);

  对于懒汉式删除而言,并不是只有获取key的时候才会检查key是否过期,在某些设置key的方法上也会检查。

  例如:setnx key2 value2:如果设置的key2已经存在,那么该方法返回false,什么都不做;如果设置的key2不存在,那么该方法设置缓存key2-value2。假设调用此方法的时候,发现redis中已经存在了key2,但是该key2已经过期了,如果此时不执行删除操作的话,setnx方法将会直接返回false,也就是说此时并没有重新设置key2-value2成功,所以对于一定要在setnx执行之前,对key2进行过期检查。

4  Redis采用的过期策略

Redis采用了懒汉式删除+定期删除的过期策略

(1)懒汉式删除流程:

    在进行get或setnx等操作时,先检查key是否过期;若过期,删除key,然后执行相应操作;若没过期,直接执行相应操作;

  删除指令 del 会直接释放对象的内存,大部分情况下,这个指令非常快,没有明显延迟。

  不过如果删除的 key 是一个非常大的对象,那么删除操作就会导致单线程卡顿,怎么破?

  Redis 4.0 版本引入了 unlink 指令(为了解决这个卡顿问题),它能对删除操作进行懒处理,丢给后台线程来异步回收内存。Redis内部实际上并不是只有一个主线程,它还有几个异步线程专门用来处理一些耗时的操作。

> unlink key
OK

(2)定期删除流程(简单而言,对指定个数个库的每一个库随机删除小于等于指定个数个过期key):

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

    (1)遍历每个数据库(就是redis.conf中配置的"database"数量,默认为16)

    (2)检查当前库中的指定个数个key(默认是每个库检查20个key,注意相当于该循环执行20次,循环体是下边的描述)

    (3)如果当前库中没有一个key设置了过期时间,直接执行下一个库的遍历

    (4)删除这 20 个 key 中已经过期的 key;如果过期的 key 比率超过 1/4,那就重复步骤(2);

    (5)判断定期删除操作是否已经达到指定时长,若已经达到,直接退出定期删除。为了保证过期扫描不会出现循环过度,导致线程卡死现象,默认不会超过 25ms

  对于定期删除,在程序中有一个全局变量current_db来记录下一个将要遍历的库,假设有16个库,我们这一次定期删除遍历了10个,那此时的current_db就是11,下一次定期删除就从第11个库开始遍历,假设current_db等于15了,那么之后遍历就再从0号库开始(此时current_db==0)

5  如果Redis 实例中所有的 key (几十万个)在同一时间过期会怎样?

  过期的 key 比率超过 1/4的概率较大,因此Redis 会持续扫描过期字典 (循环多次),直到过期字典中过期的 key 变得稀疏,才会停止 (循环次数明显下降)。内存管理器需要频繁回收内存页,此时会产生一定的 CPU 消耗,必然导致线上读写请求出现明显的卡顿现象。

  当客户端请求到来时(服务器如果正好进入过期扫描状态),客户端的请求将会等待至少 25ms 后才会进行处理,如果客户端将超时时间设置的比较短,比如 10ms,那么就会出现大量的链接因为超时而关闭,业务端就会出现很多异常。而且这时你还无法从 Redis 的 slowlog 中看到慢查询记录,因为慢查询指的是逻辑处理过程慢,不包含等待时间。

  其实这个故障在社区中时常爆出 ,业务开发人员一定要注意不宜全部在同一时间过期,可以给目标过期时间的基础上再加一个随机范围(redis.expire_at(key, random.randint(86400) + expire_ts)),分散过期处理的压力。

6  从库的过期策略

  从库不会进行过期扫描,从库对过期的处理是被动的。主库在 key 到期时,会在 AOF 文件里增加一条 del 指令,同步到所有的从库,从库通过执行这条 del 指令来删除过期的 key。

  因为指令同步是异步进行的,所以主库过期的 key 的 del 指令没有及时同步到从库的话,会出现主从数据的不一致,主库没有的数据在从库里还存在,分布式锁的算法漏洞就是因为这个同步延迟产生的。

参考:https://blog.csdn.net/alex_xfboy/article/details/88959647

六  Redis事务实现

1  redis事务的错误

  使用事务时可能会遇上以下两种错误:    

    a)入队错误:事务在执行 EXEC 之前,入队的命令可能会出错。比如说,命令可能会产生语法错误(参数数量错误,参数名错误,等等),或者其他更严重的错误,比如内存不足(如果服务器使用 maxmemory 设置了最大内存限制的话)。    

    b)执行错误:命令可能在 EXEC 调用之后失败。举个例子,事务中的命令可能处理了错误类型的键,比如将列表命令用在了字符串键上面,诸如此类。    

    c)第三种错误,redis进程终结。  

2  Redis事务实现

  Redis 通过 MULTI 、 DISCARD 、 EXEC 和 WATCH 四个命令来实现事务功能, 本章首先讨论使用 MULTI 、 DISCARD 和 EXEC 三个命令实现的一般事务, 然后再来讨论带有 WATCH 的事务的实现。

  MULTI命令用于开启一个事务,它总是返回OKMULTI执行之后,客户端可以继续向服务器发送任意多条命令,这些命令不会立即被执行,而是被放到一个队列中。

  当EXEC命令被调用时,所有队列中的命令才会被执行。  

  通过调用DISCARD,客户端可以清空事务队列,并放弃执行事务。  

(1)正常执行

127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET key1 1
QUEUED
127.0.0.1:6379> HSET key2 field1 1
QUEUED
127.0.0.1:6379> SADD key3 1
QUEUED
127.0.0.1:6379> EXEC
1) OK
2) (integer) 1
3) (integer) 1

   EXEC 命令的回复是一个数组,数组中的每个元素都是执行事务中的命令所产生的回复。 其中,回复元素的先后顺序和命令发送的先后顺序一致。  

  当客户端处于事务状态时,所有传入的命令都会返回一个内容为 QUEUED 的状态回复(status reply),这些被入队的命令将在 EXEC命令被调用时执行。

 (2)放弃事务

  当执行 DISCARD 命令时,事务会被放弃,事务队列会被清空,并且客户端会从事务状态中退出:

127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET key1 1
QUEUED
127.0.0.1:6379> DISCARD
OK
127.0.0.1:6379> EXEC
(error) ERR EXEC without MULTI

 (3)入队列错误回滚

127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> set key1 1
QUEUED
127.0.0.1:6379> HSET key2 1
(error) ERR wrong number of arguments for 'hset' command
127.0.0.1:6379> SADD key3 1
QUEUED
127.0.0.1:6379> EXEC
(error) EXECABORT Transaction discarded because of previous errors.

  对于入队错误,redis 2.6.5版本后,会记录这种错误,并且在执行EXEC的时候,报错并回滚事务中所有的命令,并且终止事务

 (4)执行错误放过
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> HSET key1 field1 1
QUEUED
127.0.0.1:6379> HSET key2 field1 1
QUEUED
127.0.0.1:6379> EXEC
1) (error) WRONGTYPE Operation against a key holding the wrong kind of value
2) (integer) 1

  当遇到执行错误时,redis放过这种错误,保证事务执行完成。

  这里要注意此问题,与mysql中事务不同,在redis事务遇到执行错误的时候,不会进行回滚,而是简单的放过了,并保证其他的命令正常执行。

3  使用WATCH

  WATCH 命令可以为 Redis 事务提供 check-and-set (CAS)乐观锁行为。

  被 WATCH 的键会被监视,并会发觉这些键是否被改动过了。如果有至少一个被监视的键在 EXEC 执行之前被修改了, 那么整个事务都会被取消, EXEC 返回空多条批量回复(null multi-bulk reply)来表示事务已经失败。

127.0.0.1:6379> WATCH key1
OK
127.0.0.1:6379> set key1 2
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> set key1 3
QUEUED
127.0.0.1:6379> set key2 3
QUEUED
127.0.0.1:6379> EXEC
(nil)

   使用上面的代码, 如果在 WATCH 执行之后, EXEC 执行之前, 有其他客户端修改了 key1 的值, 那么当前客户端的事务就会失败。 程序需要做的, 就是不断重试这个操作, 直到没有发生碰撞为止。这种形式的锁被称作乐观锁。

七  Redis分布式锁实现

  为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下四个条件:

  1. 互斥性。在任意时刻,只有一个客户端能持有锁。
  2. 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
  3. 具有容错性。只要大部分的Redis节点正常运行,客户端就可以加锁和解锁。
  4. 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。
正确实现代码如下:
/**
 * Redis分布式锁实现
 * 不能使用 spring-boot 提供的 redisTemplate.opsForValue().set() 命令是因为 spring-boot 对 jedis 的封装中没有返回 set 命令的返回值,
 * 这就导致上层没有办法判断 set 执行的结果,因此需要通过 execute 方法调用 RedisCallback 去拿到底层的 Jedis 对象,来直接调用 set 命令。
 *
 */
public class RedisLockUtils {
    private static final String SET_IF_NOT_EXIST = "NX";
    private static final String SET_WITH_EXPIRE_TIME = "PX";
    private static final String LOCK_SUCCESS = "OK";
    private static final Long RELEASE_SUCCESS = 1L;

    /**
     * 为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下四个条件:
     *
     * 互斥性。在任意时刻,只有一个客户端能持有锁。
     * 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
     * 具有容错性。只要大部分的Redis节点正常运行,客户端就可以加锁和解锁。
     * 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。
     *
     * @param jedis redis客户端
     * @param lockName 使用key来当锁,因为key是唯一的
     * @param resourceKey 请求标识
     * 分布式锁要满足第四个条件解铃还须系铃人,通过给value赋值为resourceKey,我们就知道这把锁是哪个请求加的了,在解锁的时候就可以有依据。resourceKey可以使用UUID.randomUUID().toString()方法生成。
     * @param expireTime 过期时间
     * @return 加锁结果
     * 执行上面的set()方法就只会导致两种结果:
     *  1. 当前没有锁(key不存在),那么就进行加锁操作,并对锁设置个有效期,同时value表示加锁的客户端。
     *  2. 已有锁存在,不做任何操作
     */
    public static boolean tryLock(Jedis jedis, String lockName, String resourceKey, int expireTime) {
        /*
          nxxx,这个参数我们填的是NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作;
          expx,这个参数我们传的是PX,意思是我们要给这个key加一个过期的设置,具体时间由第五个参数决定。
         */
        String result = jedis.set(lockName, resourcePath, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
        return LOCK_SUCCESS.equals(result);
    }

    /**
     * 释放分布式锁
     * @param jedis Redis客户端
     * @param lockName 锁
     * @param resourceKey 请求标识
     * @return 是否释放成功
     */
    public static boolean release(Jedis jedis, String lockName, String resourcePath) {
        //lua表达式,Redis执行是原子操作
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        Object result = jedis.eval(script, Collections.singletonList(lockName), Collections.singletonList(resourceKey));
        return RELEASE_SUCCESS.equals(result);
    }
}

解锁错误实例: 

public static void wrongReleaseLock2(Jedis jedis, String lockKey, String requestId) {     
    // 判断加锁与解锁是不是同一个客户端
    if (requestId.equals(jedis.get(lockKey))) {
        // 若在此时,这把锁突然不是这个客户端的,则会误解锁
        jedis.del(lockKey);
    }
}

  如果调用jedis.del()方法的时候,这把锁已经不属于当前客户端的时候会解除他人加的锁。那么是否真的有这种场景?答案是肯定的,比如客户端A加锁,一段时间之后客户端A解锁,在执行jedis.del()之前,锁突然过期了,此时客户端B尝试加锁成功,然后客户端A再执行del()方法,则将客户端B的锁给解除了。 

八  缓存穿透、缓存击穿、缓存雪崩、热点数据失效

  在我们的平常的项目中多多少少都会使用到缓存,因为一些数据我们没有必要每次查询的时候都去查询到数据库。特别是高 QPS 的系统,每次都去查询数据库,对于你的数据库来说将是灾难。我们使用缓存时,我们的业务系统大概的调用流程如下图:

    

  当我们查询一条数据时,先去查询缓存,如果缓存有就直接返回,如果没有就去查询数据库,然后返回。这种情况下就可能会出现一些现象。

1、缓存穿透

(1)什么是缓存穿透

  正常情况下,我们去查询数据都是存在。那么如果去查询一条数据库中根本就不存在的数据,也就是缓存和数据库都查询不到这条数据,那么请求每次都会打到数据库上面去。这种查询不存在数据的现象我们称为缓存穿透。这时如果有黑客会对你的系统进行攻击,拿一个不存在的id 去查询数据,会产生大量的请求到数据库去查询。可能会导致你的数据库由于压力过大而宕掉。

(2)解决方案

  1、缓存空值

  之所以会发生穿透,就是因为缓存中没有存储这些空数据的key,从而导致每次查询都到数据库去了。那么我们就可以为这些key对应的值设置为null 丢到缓存里面去。后面再出现查询这个key 的请求的时候,直接返回null 。这样,就不用在到数据库中去走一圈了,这种解决方案需要给每一个不存在的key设置一个相对较短的过期时间

   2、布隆过滤器

  将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。在缓存之前在加一层 BloomFilter ,在查询的时候先去 BloomFilter 去查询 key 是否存在,如果不存在就直接返回,存在再走查缓存 -> 查 DB。流程如下:

  

  3、如何选择

  针对于一些恶意攻击,攻击带过来的大量key 是不存在的,那么我们采用第一种方案就会缓存大量不存在key的数据。此时我们采用第一种方案就不合适了,我们完全可以先对使用第二种方案进行过滤掉这些key。针对这种key异常多、请求重复率比较低的数据,我们就没有必要进行缓存,使用布隆过滤器直接过滤掉。

  而对于空数据的key有限的,重复率比较高的,我们则可以采用缓存空值方式进行缓存。

2、缓存击穿

(1)什么是缓存击穿  

  在平常高并发的系统中,大量的请求同时查询一个 key 时,此时这个key正好失效了,就会导致大量的请求都打到数据库上面去。这种现象我们称为缓存击穿会造成某一时刻数据库请求量过大,压力剧增。

   与缓存穿透的区别是:

    缓存穿透是数据在缓存和数据库中都不存在。

    缓存击穿是数据存在,只是刚好数据在缓存中超时失效。

(2)解决方案  

  上面的现象是多个线程同时去查询数据库的这条数据,那么我们可以在第一个查询数据的请求上使用一个 互斥锁 来锁住它。其他的线程走到这一步拿不到锁就等着,等第一个线程查询到了数据,然后做缓存。后面的线程进来发现已经有缓存了,就直接走缓存。

3、缓存雪崩

(1)什么是缓存雪崩

  缓存雪崩的情况是说,当某一时刻发生大规模的缓存失效的情况,比如你的缓存服务宕机了或者有多个key同时过期,会有大量的请求进来直接打到DB上面。结果就是DB 撑不住,挂掉。

 (2)解决办法

  1、事前:

  1)使用集群缓存,保证缓存服务的高可用

    这种方案就是在发生雪崩前对缓存集群实现高可用,如果是使用 Redis,可以使用 主从+哨兵或者Redis Cluster 来避免 Redis 全盘崩溃的情况。

  2)为key的过期时间加一个随机数

    这种方案就是使每个key的过期时间尽量不一样,防止大量的key同时过期的情况。

  3)使用缓存标记

    给每一个缓存的key增加一个缓存标记,例如缓存key = "hotel_name",缓存标记的signKey = "hotel_name_sign",并且使缓存标记的过期时间小于缓存数据的过期时间。

    在根据缓存key获取数据时,先获取该key的缓存标记是否存在,如果存在,则直接查询缓存并返回,如果缓存标记不存在,则表示缓存标记已经过期并且缓存即将过期,这时通过起个线程等异步方式去更新缓存key,并返回数据。

   2、事中:

  ehcache本地缓存 + Hystrix限流&降级,避免MySQL被打死

    使用 ehcache 本地缓存的目的也是考虑在 Redis Cluster 完全不可用的时候,ehcache 本地缓存还能够支撑一阵。

    使用 Hystrix进行限流 & 降级 ,比如一秒来了5000个请求,我们可以设置假设只能有一秒 2000个请求能通过这个组件,那么其他剩余的 3000 请求就会走限流逻辑。

    然后去调用我们自己开发的降级组件(降级),比如设置的一些默认值呀之类的。以此来保护最后的 MySQL 不会被大量的请求给打死。

   3、事后:

  开启Redis持久化机制,尽快恢复缓存集群

  一旦重启,就能从磁盘上自动加载数据恢复内存中的数据。

   防止缓存雪崩方案如下图:

  

4、解决热点数据集中失效问题

  (1)我们在设置缓存的时候,一般会给缓存设置一个失效时间,过了这个时间,缓存就失效了。对于一些热点的数据来说,当缓存失效以后会存在大量的请求过来,然后打到数据库去,从而可能导致数据库崩溃的情况。

  (2) 解决办法

  1 设置不同的失效时间

  为了避免这些热点的数据集中失效,那么我们在设置缓存过期时间的时候,我们让他们失效的时间错开。比如在一个基础的时间上加上或者减去一个范围内的随机值。

  2 互斥锁

  结合上面的击穿的情况,在第一个请求去查询数据库的时候对他加一个互斥锁,其余的查询请求都会被阻塞住,直到锁被释放,从而保护数据库。但是也是由于它会阻塞其他的线程,此时系统吞吐量会下降。需要结合实际的业务去考虑是否要这么做。

九   Redis如何快速删除1.2亿+指定前缀的key

  此问题可以延伸为Redis如何访问海量的数据。有时候我们需要知道线上的Redis的使用情况,尤其需要知道一些前缀的key值,让我们怎么去查看呢?并且通常情况下Redis里的数据都是海量的,那么我们访问Redis中的海量数据?

1  事故产生原因

  因为我们的用户token缓存是采用了【user_token:userid】格式的key,保存用户的token的值。我们运维为了帮助开发小伙伴们查一下线上现在有多少登录用户。

  直接用了 keys user_token* 方式进行查询,事故就此发生了。导致Redis不可用,假死。

2  原因分析

  我们线上的登录用户有几百万,数据量比较多;keys算法是遍历算法,复杂度是O(n),也就是数据越多,时间越高。

  数据量达到几百万,keys这个指令就会导致 Redis 服务卡顿,因为 Redis 是单线程程序,顺序执行所有指令,其它指令必须等到当前的 keys 指令执行完了才可以继续

3  解决方案

  那我们如何去遍历大数据量呢?这个也是面试经常问的。我们可以采用Redis的另一个命令scan。我们看一下scan的特点:

    1. 复杂度虽然也是 O(n),但是它是通过游标分步进行的,不会阻塞线程。

    2. 提供 count 参数,不是结果数量,而是Redis单次遍历字典槽位数量(约等于)。

    3. 同 keys 一样,它也提供模式匹配功能;

    4. 服务器不需要为游标保存状态,游标的唯一状态就是 scan 返回给客户端的游标整数;

    5. 返回的结果可能会有重复,需要客户端去重复,这点非常重要;

    6. 单次返回的结果是空的并不意味着遍历结束,而要看返回的游标值是否为零

  也就是说:SCAN 命令是一个基于游标的迭代器(cursor based iterator): SCAN 命令每次被调用之后, 都会向用户返回一个新的游标, 用户在下次迭代时需要使用这个新游标作为 SCAN 命令的游标参数, 以此来延续之前的迭代过程。当 SCAN 命令的游标参数被设置为 0 时, 服务器将开始一次新的迭代, 而当服务器向用户返回值为 0 的游标时, 表示迭代已结束。 SCAN的语法如下 :

SCAN cursor [MATCH pattern] [COUNT count]

 cousor 是游标,MATCH 则支持正则匹配,我们正好可以利用此功能,比如匹配 前缀为"dba_"的key, COUNT 是每次获取多少个key

  

 

  从0开始遍历,返回了游标6,又返回了数据,继续scan遍历,就要从6开始

  

  从上面的示例可以看到, SCAN 命令的回复是一个包含两个元素的数组, 第一个数组元素是用于进行下一次迭代的新游标, 而第二个数组元素则是一个数组, 这个数组中包含了所有被迭代的元素。注意:以 0 作为游标开始一次新的迭代, 一直调用 SCAN 命令, 直到命令返回游标 0 , 我们称这个过程为一次完整遍历(full iteration)。

4  如何执行删除

  Redis本身是基于Request/Response协议的,客户端发送一个命令,等待Redis应答,Redis在接收到命令,处理后应答。其中发送命令加上返回结果的时间称为(Round Time Trip)RRT-往返时间。

  如果客户端发送大量的命令给Redis,那就是等待上一条命令应答后再执行再执行下一条命令,这中间不仅仅多了RTT,而且还频繁的调用系统IO,发送网络请求。 

  

  Pipeline(流水线)功能极大的改善了上面的缺点。Pipeline能将一组Redis命令进行组装,然后一次性传输给Redis,再将Redis执行这组命令的结果按照顺序返回给客户端。

  

  需要注意的是Pipeline 虽然好用,但是Pipline组装的命令个数不能没有限制,否则一次组装数据量过大,一方面增加客户端的等待时间,另一方面会造成网络阻塞,需要批量组装。

 

 

 

 

参考

  1、Redis 基础、高级特性与性能调优  https://mp.weixin.qq.com/s/aCbRr5QFVuQJgFhkOaiZcA

  2、关于【缓存穿透、缓存击穿、缓存雪崩、热点数据失效】问题的解决方案  https://mp.weixin.qq.com/s/5MloHIa5zKvYYsVVEWZjQA

  3、深入学习Redis(2):持久化  https://www.cnblogs.com/kismetv/p/9137897.html#t5

  4、Redis事务  https://redisbook.readthedocs.io/en/latest/feature/transaction.html  https://www.cnblogs.com/kangoroo/p/7535405.html

posted on 2019-06-21 16:08  Vagrant。  阅读(884)  评论(0编辑  收藏  举报