Redis,一文带你领略它的方方面面
1. 基础数据结构及其应用场景
Redis的基础数据结构分别为:String、Hash、List、Set、Zset。
-
String: 字符串
-
Hash: 散列
-
List: 列表
-
Set: 集合
-
Sorted Set(Zset): 有序集合
1.1 String
key都是字符串,value可以是五种数据类型。
使用场景:
-
缓存;
-
计数器:
incr key
,对应键值自增1,如果key不存在,自增后get(key)=1,由于是单线程无竞争,为此不会出错。可以应用于网站记录每个用户个人主页的访问量,一定时间后再将访问量持久到数据库中,这样就不用每次多一个人访问就修改一次数据库中的访问量值,提高性能; -
分布式锁:
setnx key value
,key不存在时,才生效,设置成功返回1,失败则返回0。根据返回值确定当前线程是否获得锁; -
简单分布式id生成:利用incr的自增实现分布式应用id唯一。
1.2 Hash
key为字符串,值分为两部分field和value,视为属性和值。可以把key当作一张表的一行,Key就代表一个id,每个属性可以看作关系型数据库的一个字段。fields不能相同,value可以。
image.png1.3 List
key是字符串,value是一个有序的list。特点是有序、可以重复。Redis列表是简单的字符串列表,按照插入顺序排序。你可以添加一个元素到列表的头部(左边)或者尾部(右边)一个列表最多可以包含 2^32 - 1 个元素 (4294967295, 每个列表超过40亿个元素)。
为了说明其应用场景,我们先了解下主要的几个命令:
命令格式 | 描述 |
---|---|
LTRIM key start stop | 对一个列表进行修剪(trim),就是说,让列表只保留指定区间内的元素,不在指定区间之内的元素都将被删除。 |
LPOP key | 移出并获取列表的第一个元素 |
RPOP key | 移除列表的最后一个元素,返回值为移除的元素。 |
LPUSH key | 将一个或多个值插入到列表头部 |
RPUSH key | 将一个或多个值插入到列表尾部 |
RPOPLPUSH source des | 从源列表中弹出最后一个元素,将弹出的元素插入到目标列表头部并返回它; |
BRPOP key timeout | 移出并获取列表的最后一个元素, 如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止。前面加的B其实为Blocking的首字母。 |
应用场景:
-
LPUSH + LPOP = Stack (栈);
-
LPUSH + RPOP = Queue(队列);
-
LPUSH + LTRIM = Capped Collection(固定集合。对于大小固定,我们可以想象其就像一个环形队列,当集合空间用完后,再插入的元素就会覆盖最初始的头部的元素);
-
LPUSH + BRPOP = Message Quene(消息队列,利用BRPOP的阻塞性,实现阻塞消息队列);
-
RPOPLPUSH可应用于物流,假如某派送流程为:发货->中转A->中转B->送达目的地,当商家发货后,并送达了中转A,用户应该可以看到已发货(即完成了发货流程)。此时,派送列表流程下一步应该为:中转B->送达目的地,用户查看列表为发货-> 中转A。
1.4 Set
Redis 的 Set 是 String 类型的无序集合。集合成员是唯一的,这就意味着集合中不能出现重复的数据。Redis 中集合是通过哈希表实现的,所以添加,删除,查找的复杂度都是 O(1)。集合中最大的成员数为 2^32 - 1 (4294967295, 每个集合可存储40多亿个成员)。
为了说明应用场景,我们也先了解以下主要几个命令:
命令格式 | 描述 |
---|---|
SADD key member1 [member2] | 向集合添加一个或多个成员 |
SPOP key | 移除并返回集合中的一个随机元素 |
SRANDMEMBER key [count] | 返回集合中一个或多个随机数 |
SINTER key1 [key2] | 返回给定所有集合的交集 |
应用场景:
-
SADD = Tagging(给用户添加标签);
-
SPOP/SRANDMEMBER = Random item(随机元素,可用于抽奖平台抽奖等)
-
SADD+ SINTER= social graph (社交相关应用,如微博的共同关注,QQ的共同好友等)。
1.5 Zset
Redis 有序集合和集合一样也是string类型元素的集合,且不允许重复的成员。不同的是每个元素都会关联一个double类型的分数。redis正是通过分数来为集合中的成员进行从小到大的排序。有序集合的成员是唯一的,但分数(score)却可以重复。集合是通过哈希表实现的,所以添加,删除,查找的复杂度都是O(1)。 集合中最大的成员数为 2^32 - 1 (4294967295, 每个集合可存储40多亿个成员)。
image.png应用场景:
-
排行榜;
-
分数添加和更新;
2. 过期策略
2.1 定时删除
在设置key的过期时间的同时,为该key创建一个定时器,让定时器在key的过期时间来临时,对key进行删除;
优点:保证内存被尽快释放;
缺点:
-
若过期key很多,删除这些key会占用很多的CPU时间;
-
定时器的创建耗时,若为每一个设置过期时间的key创建一个定时器(将会有大量的定时器产生),性能影响严重。
2.2 惰性删除
key过期的时候不删除,每次从数据库获取key的时候去检查是否过期,若过期,则删除,返回null。
优点:删除操作只发生在从数据库取出key的时候发生,而且只删除当前key,所以对CPU时间的占用是比较少的,而且此时的删除是已经到了非做不可的地步(如果此时还不删除的话,我们就会获取到了已经过期的key了);
缺点:若大量的key在超出超时时间后,很久一段时间内,都没有被获取过,那么可能发生内存泄露(无用的垃圾占用了大量的内存)。
2.3 定期删除
每隔一段时间执行一次删除(在redis.conf配置文件设置hz,1s刷新的频率)过期key操作。需要合理设置删除操作的执行时长(每次删除执行多长时间)和执行频率(每隔多长时间做一次删除)。
优点:
-
通过限制删除操作的时长和频率,来减少删除操作对CPU时间的占用--处理"定时删除"的缺点;
-
定期删除过期key--处理"惰性删除"的缺点。
缺点:
-
在内存友好方面,不如"定时删除";
-
在CPU时间友好方面,不如"惰性删除"。
2.4 Redis采用的过期策略
redis采用的过期策略为惰性删除+定期删除,其两大流程如下:
-
惰性删除流程:
-
在进行get或setnx等操作时,先检查key是否过期;
-
若过期,则删除key,然后执行相应的操作;
-
若没过期,则直接执行相应操作。
-
-
定期删除流程:
-
对指定的n个数据库(redis默认的n为16),每一个库随机删除小于等于指定的m个过期key;
-
遍历每个数据库,并检查当前库中的m个key(默认m是20个,即每个库检查20个key,相当于循环执行20次);
-
如果当前库没有一个key设置了过期时间,则直接执行一下个库的遍历;
-
随机获取一个设置了过期时间的key,检查该key是否过期,如果过期,删除key;
-
判断定期删除操作是否已经达到指定时长,若已经达到,直接退出定期删除。
-
3. 持久化策略
由于redis是基于内存的数据库,其运行时数据都保存在内存中,在没有进行持久化数据的情况下,一旦redis服务器关闭,则会丢失内存中的数据。为此Redis为我们提供了两种持久化机制,分别是RDB(Redis DataBase)和AOF(Append Only File)。
3.1 RDB机制
RDB持久化是指在指定的时间间隔内将内存中的数据集快照写入磁盘。也是默认的持久化方式,这种方式是就是将内存中数据以快照的方式写入到二进制文件中,默认的文件名为dump.rdb。
既然RDB机制是通过把某个时刻的所有数据生成一个快照来保存,那么就应该有一种触发机制,是实现这个过程。对于RDB来说,提供了三种机制:save、bgsave、自动化。我们分别来看一下:
3.1.1 save触发方式
该命令会阻塞当前Redis服务器,执行save命令期间,Redis不能处理其他命令,直到RDB过程完成为止。具体流程如下:
image.png执行完成时候如果存在老的RDB文件,就用新的替代掉旧的。我们的客户端可能都是几万或者是几十万,这种方式显然不可取。
3.1.2 bgsave触发方式
执行该命令时,Redis会在后台异步进行快照操作,快照同时还可以响应客户端请求。具体流程如下:
image.png具体操作是Redis进程执行fork操作创建子进程,RDB持久化过程由子进程负责,完成后自动结束。阻塞只发生在fork阶段,一般时间很短。基本上 Redis 内部所有的RDB操作都是采用 bgsave 命令。
3.1.3 自动触发
自动触发是由我们的配置文件来完成的。在redis.conf配置文件中,里面有如下配置,我们可以去设置:
①save:这里是用来配置触发 Redis的 RDB 持久化条件,也就是什么时候将内存中的数据保存到硬盘。比如“save m n”。表示m秒内数据集存在n次修改时,自动触发bgsave。
默认如下配置:
#表示900 秒内如果至少有 1 个 key 的值变化,则保存
save 900 1
#表示300 秒内如果至少有 10 个 key 的值变化,则保存
save 300 10
#表示60 秒内如果至少有 10000 个 key 的值变化,则保存save 60 10000
不需要持久化,那么你可以注释掉所有的 save 行来停用保存功能。
②stop-writes-on-bgsave-error :默认值为yes。当启用了RDB且最后一次后台保存数据失败,Redis是否停止接收数据。这会让用户意识到数据没有正确持久化到磁盘上,否则没有人会注意到灾难(disaster)发生了。如果Redis重启了,那么又可以重新开始接收数据了
③rdbcompression ;默认值是yes。对于存储到磁盘中的快照,可以设置是否进行压缩存储。
④rdbchecksum :默认值是yes。在存储快照后,我们还可以让redis使用CRC64算法来进行数据校验,但是这样做会增加大约10%的性能消耗,如果希望获取到最大的性能提升,可以关闭此功能。
⑤dbfilename :设置快照的文件名,默认是 dump.rdb
⑥dir:设置快照文件的存放路径,这个配置项一定是个目录,而不能是文件名。
我们可以修改这些配置来实现我们想要的效果。因为第三种方式是配置的,所以我们对前两种进行一个对比:
image.png3.1.4 RDB的优劣
①优势
(1)RDB文件紧凑,全量备份,非常适合用于进行备份和灾难恢复。
(2)生成RDB文件的时候,redis主进程会fork()一个子进程来处理所有保存工作,主进程不需要进行任何磁盘IO操作。
(3)RDB 在恢复大数据集时的速度比 AOF 的恢复速度要快。
②劣势
RDB快照是一次全量备份,存储的是内存数据的二进制序列化形式,存储上非常紧凑。当进行快照持久化时,会开启一个子进程专门负责快照持久化,子进程会拥有父进程的内存数据,父进程修改内存子进程不会反应出来,所以在快照持久化期间修改的数据不会被保存,可能丢失数据。
3.2 AOF机制
全量备份总是耗时的,有时候我们提供一种更加高效的方式AOF,工作机制很简单,redis会将每一个收到的写命令都通过write函数追加到文件中。通俗的理解就是日志记录。
3.2.1 持久化原理
原理如下图:
image.png每当有一个写命令过来时,就直接保存在我们的AOF文件中。
3.2.2 文件重写原理
AOF的方式也同时带来了另一个问题。持久化文件会变的越来越大。为了压缩aof的持久化文件。redis提供了bgrewriteaof命令。将内存中的数据以命令的方式保存到临时文件中,同时会fork出一条新进程来将文件重写。
image.png重写aof文件的操作,并没有读取旧的aof文件,而是将整个内存中的数据库内容用命令的方式重写了一个新的aof文件,这点和快照有点类似。
3.2.3 AOF三种触发机制
(1)每修改同步(always):同步持久化,每次发生数据变更会被立即记录到磁盘 性能较差但数据完整性比较好;
(2)每秒同步(everysec):异步操作,每秒记录 如果一秒内宕机,有数据丢失;
(3)不同(no):从不同步。
[图片上传失败...(image-dabc24-1597293704284)]
3.2.4 AOF优劣
①优点
(1)AOF可以更好的保护数据不丢失,一般AOF会每隔1秒,通过一个后台线程执行一次fsync操作,最多丢失1秒钟的数据;
(2)AOF日志文件没有任何磁盘寻址的开销,写入性能非常高,文件不容易破损;
(3)AOF日志文件即使过大的时候,出现后台重写操作,也不会影响客户端的读写;
(4)AOF日志文件的命令通过非常可读的方式进行记录,这个特性非常适合做灾难性的误删除的紧急恢复。比如某人不小心用flushall命令清空了所有数据,只要这个时候后台rewrite还没有发生,那么就可以立即拷贝AOF文件,将最后一条flushall命令给删了,然后再将该AOF文件放回去,就可以通过恢复机制,自动恢复所有数据。
②劣势
(1)对于同一份数据来说,AOF日志文件通常比RDB数据快照文件更大;
(2)AOF开启后,支持的写QPS会比RDB支持的写QPS低,因为AOF一般会配置成每秒fsync一次日志文件,当然,每秒一次fsync,性能也还是很高的
(3)以前AOF发生过bug,就是通过AOF记录的日志,进行数据恢复的时候,没有恢复一模一样的数据出来。
3.3 两种机制对比以及两种机制对过期key的处理
image.png3.3.1 RDB对过期key的处理
过期key对RDB没有任何影响。
-
在从内存数据库持久化数据到RDB文件:持久化key之前,会检查是否过期,过期的key不进入RDB文件;
-
从RDB文件恢复数据到内存数据库:数据载入数据库之前,会对key先进行过期检查,如果过期,不导入数据库(主库情况)。
3.3.2 AOF对过期key的处理
过期key对AOF也没有任何影响。
-
从内存数据库持久化数据到AOF文件:若key过期后,还没有被删除,此时进行执行持久化操作(该key是不会进入aof文件的,因为没有发生修改命令)。若key过期后,在发生删除操作时,程序会向aof文件追加一条del命令(在将来的以aof文件恢复数据的时候该过期的键就会被删掉);
-
AOF重写:重写时,会先判断key是否过期,已过期的key不会重写到aof文件 。
4. Redis的缓存穿透、缓存击穿、缓存雪崩及其解决方案
4.1 概念
-
缓存穿透:key对应的数据在数据源并不存在,每次针对此key的请求从缓存获取不到,请求都会到数据源,从而可能压垮数据源。比如用一个不存在的用户id获取用户信息,不论缓存还是数据库都没有,若黑客利用此漏洞进行攻击可能压垮数据库。
-
缓存击穿:key对应的数据存在,但在redis中过期,此时若有大量并发请求过来,这些请求发现缓存过期一般都会从后端DB加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端DB压垮。
-
缓存雪崩:当缓存服务器重启或者大量缓存集中在某一个时间段失效,这样在失效的时候,也会给后端系统(比如DB)带来很大压力
4.2 缓存穿透解决方案
一个一定不存在缓存及查询不到的数据,由于缓存是不命中时被动写的,并且出于容错考虑,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。
有很多种方法可以有效地解决缓存穿透问题,最常见的则是采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被 这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。另外也有一个更为简单粗暴的方法(我们采用的就是这种),如果一个查询返回的数据为空(不管是数据不存在,还是系统故障),我们仍然把这个空结果进行缓存,但它的过期时间会很短,最长不超过五分钟。
4.2.1 布隆过滤器
布隆过滤器是一种数据结构,垃圾网站和正常网站加起来全世界据统计也有几十亿个。网警要过滤这些垃圾网站,总不能到数据库里面一个一个去比较吧,这就可以使用布隆过滤器。假设我们存储一亿个垃圾网站地址。
可以先有一亿个二进制比特,然后网警用八个不同的随机数产生器(F1,F2, …,F8) 产生八个信息指纹(f1, f2, …, f8)。接下来用一个随机数产生器 G 把这八个信息指纹映射到 1 到1亿中的八个自然数 g1, g2, …,g8。最后把这八个位置的二进制全部设置为一。过程如下:
image.png有一天网警查到了一个可疑的网站,想判断一下是否是XX网站,首先将可疑网站通过哈希映射到1亿个比特数组上的8个点。如果8个点的其中有一个点不为1,则可以判断该元素一定不存在集合中。
那这个布隆过滤器是如何解决redis中的缓存穿透呢?很简单首先也是对所有可能查询的参数以hash形式存储,当用户想要查询的时候,使用布隆过滤器发现不在集合中,就直接丢弃,不再对持久层查询。
image.png4.2.2 缓存空对象
当存储层不命中后,即使返回的空对象也将其缓存起来,同时会设置一个过期时间,之后再访问这个数据将会从缓存中获取,保护了后端数据源;
image.png但是这种方法会存在两个问题:
-
如果空值能够被缓存起来,这就意味着缓存需要更多的空间存储更多的键,因为这当中可能会有很多的空值的键;
-
即使对空值设置了过期时间,还是会存在缓存层和存储层的数据会有一段时间窗口的不一致,这对于需要保持一致性的业务会有影响。
4.3 缓存击穿解决方案
key可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。这个时候,需要考虑一个问题:缓存被“击穿”的问题。
使用互斥锁(mutex key)
业界比较常用的做法,是使用mutex。简单地来说,就是在缓存失效的时候(判断拿出来的值为空),不是立即去load db,而是先使用缓存工具的某些带成功操作返回值的操作(比如Redis的SETNX或者Memcache的ADD)去set一个mutex key,当操作返回成功时,再进行load db的操作并回设缓存;否则,就重试整个get缓存的方法。
4.4 缓存雪崩解决方案
与缓存击穿的区别在于这里针对很多key缓存,前者则是某一个key。缓存雪崩原因:
-
缓存层出现了错误,不能正常工作了。于是所有的请求都会达到存储层,存储层的调用量会暴增,造成存储层也会挂掉的情况;
-
大量缓存集中在某一个时间段失效。
这两种情况都会造成缓存雪崩。其解决方案主要有以下几种。我们选用哪种来解决需要我们针对我们具体的业务系统,具体分析,选择最合适的一种来使用。
4.4.1 redis高可用
部署redis集群,避免单机挂掉的风险。
4.4.2 限流降级
这个解决方案的思想是,在缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数量。比如对某个key只允许一个线程查询数据和写缓存,其他线程等待。某一条线程写缓存成功后,其余线程则可以直接在缓存中查询到数据。
-
这种思路减轻了数据库的压力,避免了数据源的崩溃,但是在高并发下,缓存重建期间key是锁着的,这是过来1000个请求999个都在阻塞的。同样会导致用户等待超时,这是个治标不治本的方法;
-
加锁排队的解决方式分布式环境的并发问题,有可能还要解决分布式锁的问题;线程还会被阻塞,用户体验很差!因此,在真正的高并发场景下很少使用。
4.4.3 缓存标记
伪代码如下:
//伪代码
public object GetProductListNew() {
int cacheTime = 30;
String cacheKey = "product_list";
//缓存标记
String cacheSign = cacheKey + "_sign";
String sign = CacheHelper.Get(cacheSign);
//获取缓存值
String cacheValue = CacheHelper.Get(cacheKey);
if (sign != null) {
return cacheValue; //未过期,直接返回
} else {
CacheHelper.Add(cacheSign, "1", cacheTime);
ThreadPool.QueueUserWorkItem((arg) -> {
//这里一般是 sql查询数据
cacheValue = GetProductListFromDB();
//日期设缓存时间的2倍,用于脏读
CacheHelper.Add(cacheKey, cacheValue, cacheTime * 2);
});
return cacheValue;
}
}
说明:利用缓存标记比实际缓存数据失效快,去提前更新缓存的方式去解决缓存雪崩。但这样也需要缓存的key为原来的两倍,即每个缓存都有缓存本身以及缓存标记
-
缓存标记:记录缓存数据是否过期,如果过期会触发通知另外的线程在后台去更新实际key的缓存;
-
缓存数据:它的过期时间比缓存标记的时间延长1倍,例:标记缓存时间30分钟,数据缓存设置为60分钟。这样,当缓存标记key过期后,实际缓存还能把旧数据返回给调用端,直到另外的线程在后台更新完成后,才会返回新缓存。
4.4.4 为key设置不同的缓存失效时间
将缓存失效时间分散开,比如我们可以在原有的失效时间基础上增加一个随机值,比如1-5分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
4.4.5 数据预热
数据加热的含义就是在高流量点到达之前,我先把可能的数据先预先访问一遍,这样部分可能大量访问的数据就会加载到缓存中。在即将发生大并发访问前手动触发加载缓存不同的key,设置不同的过期时间,让缓存失效的时间点尽量均匀。
5.总结
以上针对Redis的基本数据结构及其应用场景、过期策略、持久策略以及存在的问题进行了详细介绍与讲解,由于内容过多,若有错误之处望指出。