1. 配置
- Redis的备份策略
RDB每隔指定时间将内存数据集快照写入磁盘。由于只有一个文件方便容灾备份。同时RDB会fork一个子线程去备份不会影响Redis主线程提供服务,性能较好。但是它实时性不高,可能会导致数据的丢失。而AOF可以解决RDB备份的不足,以日志的形式记录每一个增删操作并追加到AOF日志中,实时性高。但是根据同步策略的不同,AOF的运行效率会低于RDB。
备份策略导致redis接口变慢
但如果你发现,操作 Redis 延迟变大,都发生在 Redis 后台 RDB 和 AOF rewrite 期间,那你就需要排查,在这期间有可能导致变慢的情况。
当 Redis 开启了后台 RDB 和 AOF rewrite 后,在执行时,它们都需要主进程创建出一个子进程进行数据的持久化。
主进程创建子进程,会调用操作系统提供的 fork 函数。
而fork 在执行过程中,主进程需要拷贝自己的内存页表给子进程,如果这个实例很大,那么这个拷贝的过程也会比较耗时。
而且这个 fork 过程会消耗大量的 CPU 资源,在完成 fork 之前,整个 Redis 实例会被阻塞住,无法处理任何客户端请求。
如果此时你的 CPU 资源本来就很紧张,那么 fork 的耗时会更长,甚至达到秒级,这会严重影响 Redis 的性能。
那如何确认确实是因为 fork 耗时导致的 Redis 延迟变大呢?
你可以在 Redis 上执行 INFO 命令,查看 latest_fork_usec 项,单位微秒。
# 上一次 fork 耗时,单位微秒
latest_fork_usec:59477
这个时间就是主进程在 fork 子进程期间,整个实例阻塞无法处理客户端请求的时间。
如果你发现这个耗时很久,就要警惕起来了,这意味在这期间,你的整个 Redis 实例都处于不可用的状态。
除了数据持久化会生成 RDB 之外,当主从节点第一次建立数据同步时,主节点也创建子进程生成 RDB,然后发给从节点进行一次全量同步,所以,这个过程也会对 Redis 产生性能影响。
要想避免这种情况,你可以采取以下方案进行优化:
- 控制 Redis 实例的内存:尽量在 10G 以下,执行 fork 的耗时与实例大小有关,实例越大,耗时越久;
- 合理配置数据持久化策略:在 slave 节点执行 RDB 备份,推荐在低峰期执行,而对于丢失数据不敏感的业务(例如把 Redis 当做纯缓存使用),可以关闭 AOF 和 AOF rewrite;
- Redis 实例不要部署在虚拟机上:fork 的耗时也与系统也有关,虚拟机比物理机耗时更久;
- 降低主从库全量同步的概率:适当调大 repl-backlog-size 参数,避免主从全量同步。
Redis的key的过期策略
Redis 的过期数据采用惰性删除 + 定期删除两种策略:
被动过期(惰性删除):只有当访问某个 key 时,才判断这个 key 是否已过期,如果已过期,则从实例中删除;
主动过期(定期删除):Redis 内部维护了一个定时任务,默认每隔 100 毫秒(1 秒 10 次)就会从全局的过期哈希表中随机取出 20 个 key,然后删除其中过期的 key,如果过期 key 的比例超过了 25%,则继续重复此过程,直到过期 key 的比例下降到 25% 以下,或者这次任务的执行耗时超过了 25 毫秒,才会退出循环。
注意,这个主动过期 key 的定时任务,是在 Redis 主线程中执行的。
也就是说如果在执行主动过期的过程中,出现了需要大量删除过期 key 的情况,那么此时应用程序在访问 Redis 时,必须要等待这个过期任务执行结束,Redis 才可以服务这个客户端请求。
此时就会出现,应用访问 Redis 延时变大。
- Redis的淘汰策略
- volatile-lru:从设置过期时间的数据集中,移除最近最久未使用的元素。
- allkeys-lru:从redis所有数据中,移除最近最久未使用的元素。
- volatile-random:从设置过期时间的数据集中,随机选择一个数据进行释放。
- allkeys-random:从redis所有数据中,随机选择一个数据进行释放。
- volatile-ttl:从设置过期时间的数据集中,选择一个将要过期的数据释放。
- noeviction:不删除任何数据,直接返回异常。
默认的淘汰策略是noeviction。一般推荐的淘汰策略是volatile-lru。
- 针对不变的,重要的数据(例如配置数据),不应该设置有效期,这样Redis永远不会淘汰这些数据;
- 针对那些不是很重要,并且可能发生改变的数据,应当设置有效期,这样在内存不足的情况下,可以淘汰这部分数据。
当Redis节点内存满了会发生什么?
一般最常使用的是 allkeys-lru / volatile-lru 淘汰策略,它们的处理逻辑是,每次从实例中随机取出一批 key(这个数量可配置),然后淘汰一个最少访问的 key,之后把剩下的 key 暂存到一个池子中,继续随机取一批 key,并与之前池子中的 key 比较,再淘汰一个最少访问的 key。以此往复,直到实例内存降到 maxmemory 之下。
需要注意的是,Redis 的淘汰数据的逻辑与删除过期 key 的一样,也是在命令真正执行之前执行的,也就是说它也会增加我们操作 Redis 的延迟,而且,写 OPS 越高,延迟也会越明显。
- 如何实现lru算法
LRU算法实际上是:最近操作的数据位于数据结构的队首,并且put和get操作的时间复杂度均是O(1)。
需要使用“双端链表+HashMap”来实现LRU算法。
HashMap用于定位Node节点:解决链表的查找缓慢的问题;
双向链表存储Value,解决Hash表无序的问题;
- Redis速度快的原因
- 纯内存操作;
- 单线程操作,避免了频繁的上下文切换;
- 采用了非阻塞的IO多路复用模型;
IO多路复用模型不是使得单个连接更加快速,而是为了处理更多的连接。采用IO复用技术可以让单个线程高效处理多个连接请求(尽量减少网络IO消耗),且Redis在内存中操作速度是非常快的(内存的操作不是成为性能的瓶颈);
- 对于大量请求怎么处理
Redis是一个单线程程序,也就是说它同一时刻只能处理一个客户端请求。redis是通过IO多路复用来处理多个客户端请求。
- redis并发竞争key的解决方案
Redis是一种单线程机制的nosql数据库,由于单线程所以Redis本身并没有锁的概念,多个客户端连接并不存在竞争关系,但是利用jedis等客户端对Redis并发访问时会出现问题。
可以准备一个分布式锁,大家去争抢锁,抢到锁就做set操作。加锁的目的实际上就是将并行读写改成串行读写的方式,从而避免资源竞争。
- 一致性hash算法
一致性hash算法解决的是普通hash算法,增加或者减少节点时,所有节点均失效的问题。
对节点和数据,都做一次哈希运算,然后比较节点和数据的哈希值,数据存放在下一个的节点中,这样就会保证节点增加或者减少的时候,影响的数据最少。
当节点数量过少时。会引起数据倾斜问题。一致性哈希算法引入了虚拟节点机制:即对每一个服务节点计算多个哈希,每个计算得到的Hash位置都放置一个此服务节点,称虚拟节点。具体可以在服务器ip或主机名的后面增加编号来实现。
底层题
1. Redis常用命令的复杂度以及Redis底层数据结构
- String:底层是字符数组+指针实现(SDS动态字符串),时间复杂度O(1);
- list:底层是双向链表(quicklist)实现,查询效率O(n),但是访问两端元素时间复杂度O(1)
- lRange:时间复杂度O(n);
- lpop:时间复杂度O(1);
- lpush key v1 v2:时间复杂度O(k);
- hash:底层结构hashtable/ziplist,时间复杂度O(1)。
- hget:O(1);
- hmget:O(k),k为元素个数;
- set:底层结构hashtable/intset,时间复杂度O(1)。
- sadd:O(k);
- sdiff:O(k);
- zset:底层结构ziplist/skiplist(跳表),平均时间复杂度O(log(n))
- zadd:O(k*log(n));
- zscore:O(1);
- zrange:O(k*log(n)),k为要删除成员个数,n为当前成员个数;
- 什么叫做ziplist
请说下redis命令的时间复杂度??(实际问的是redis底层结构)
使用ziplist的前提:
- 列表对象保存的所有字符串元素长度都小于64字节;
- 列表对象保存的元素数量小于512个;
ziplist是一个经过特殊编码的双向链表,它设计的目的是为了提高存储的效率。一个普通的双向链表,链表中每一项都占用独立的一块内存,各项之间用地址指针(或引用)连接起来,但是这种方式会造成大量的内存碎片,而且地址指针也会占用额外的内存。
ziplist却是将表中每一项存放在前后连续的地址空间内,一个ziplist整体占用一大块内存。它是一个表(list),但其实不是一个链表(linked list)
- ziplist作用:节省内存;
- ziplist特点:数据存放在前后连续的地址空间内;
(易考)Redis的String和Hash结构哪个更节省内存?
Hash在使用zipList使用更加节省内存,zipList底层结构不是hashtable结构,而是一个比较长的字符串,将key-value都按照顺序依次摆放到一个长长的字符串里来存储。如果要找某个key,就需要遍历整个长字符串。
Hash使用hashTable结构时,其类似于Java的HashMap的结构,为了避免Hash冲突也会存在负载因子,会导致最少25%的空间被浪费。
而使用String存储数据时,每一个记录都是一个SDS都需要存在len、free来标识。但是使用hash的ziplist时,只需要标头的几个标识位外,接着都是紧凑的数据。这就是为什么hash(ziplist)比string更节省内存的原因。
- 什么叫做跳表
请说下redis命令的时间复杂度??(实际问的是redis底层结构)
跳表是一种有序的数据结构,通过每个节点维护多个指向其他节点的指针,从而达到快速访问节点的目的。跳表中每一个节点元素均是一个短链表:
蚂蚁面试:B+树和skipList的时间复杂度都是O(log n),为什么数据库底层要用B+树而不用skipList?
一切都是以减少磁盘IO耗时为目的。
skipList是通过多路分治的方式来实现O(log n)的时间复杂度。但是skipList的logN是这样实现的:第二层~最高层的节点总数是通过上一层节点总数/2得到的。所以在相同层数的节点,在logN的查找实现上,时间复杂度优于二分查找。
- 当数据比较多的时候,skiplist的层级比B+Tree层级要高;
- skipList每一个节点只存储一条记录,对于一条记录的查询是比较节省io。而B+Tree叶子节点可以存储一条或者多条记录,所以叶子节点构成了一个双向循环列表,B+Tree 对于一条记录和多条记录的查询都能节省IO,对于多条记录的查询,skipList磁盘IO次数要比B+Tree多。
Redis为什么要使用skipList而不使用B+Tree
首先,内存对IO次数没磁盘那么敏感,反而redis使用B+Tree树会导致内存碎片。
B+Tree每个节点大小是固定的,节点删除数据后,B+Tree节点不会变小,所以容易产生内存碎皮。内存的访问速度比硬盘快,所以Redis的zset只要不是特别长。检索效率还是比较高的。
Redis为什么使用skipList而使用红黑树
为啥 redis 使用跳表(skiplist)而不是使用 red-black?
- skipList时间复杂度和红黑树相同,均是logN,而且skipList实现更简单;
- 在并发环境下,红黑树在插入和删除的时候需要做一些rebalance(平衡)操作,这样的操作可能会涉及到整个树的其他部分,而skiplist操作显然更加局部性一些,锁需要盯住的节点更少,因此skiplist在这样情况下性能更好一些。
问题排查
- 缓存穿透?如何预防?
当请求的数据在缓存中不可能存在,即为缓存穿透;
预防措施:
- 约定:若数据在DB中不存在,依旧进行缓存。
- 过滤:使用bitMap或者布隆过滤器制定过滤规则去过滤一些不存在的问题。
- 缓存击穿?如何预防?
当热点数据失效后,造成大量请求未命中缓存转而去数据库请求。
预防措施:
- 加内存锁:同一台机器,只有一个线程去维护缓存,其他线程阻塞;
- 异步加载:缓存击穿是热点数据才会出现的问题,可以对这部分数据采用到期自动刷新的策略,而不是到期自动淘汰。
- 缓存雪崩?如何预防?
缓存不可用或者同一时间大量缓存失效。
预防措施:
- 增加缓存监控,关注缓存健康程度,根据业务适当扩容缓存;
- 采用多级缓存,不同级别的缓存设置不同的超时时间;
- 缓存的key失效时间设置为随机数;
- redis大key和大value存在的安全隐患
无论是大key还是大value均存在如下隐患:
- 阻塞请求:redis是单线程,单value较大时,读写需要占用较长的处理时间,会阻塞后续的请求;
- 占用带宽:单value较大时会占用服务器网卡较多的带宽;
- 内存不均:导致redis集群的访问倾斜和数据倾斜;
- 如何解决大key的安全隐患
拆分:将大key拆分成多个key-value,使用multiGet获取值,拆分的目的主要是为了减少单台操作的压力,而将压力平摊到集群的各个实例中,降低单台机器的IO操作。
监控:在proxy层,对每一个redis请求进行收集上报。
- 什么是数据量倾斜和访问量倾斜
数据量倾斜:典型场景,存在bigkey。对于bigkey造成的数据倾斜问题,一个根本的解决方案是:避免bigkey的产生。通常的做法是对bigkey进行“化整为零”,在业务层面生成数据的时候,尽量避免把过多的数据保存到同一个键中。
访问量倾斜:对于热点数据数据问题,通常采用的解决方案是多副本冗余。对于热点数据问题,通常采用的解决方案是多副本冗余。具体做法:我们把热点数据复制多份,在每一个数据副本的 key 中增加一个随机前缀,让它和其它副本数据不会被映射到同一个 Slot 中。这样一来,热点数据既有多个副本可以同时服务请求,同时,这些副本数据的 key 又不一样,会被映射到不同的 Slot 中。在给这些 Slot 分配实例时,我们也要注意把它们分配到不同的实例上,那么,热点数据的访问压力就被分散到不同的实例上了。
这样可以有效解决数据访问倾斜问题。但是这种多副本的方式,仅仅适用于对热点数据的读操作,而对于存在写操作的热点数据,就不太适用了,因为对于写操作需要保证多个副本的数据一致性,而保证数据一致性,是一个比较复杂的问题,业务上需要较大的额外开销。
- Redis常见性能问题和解决方案
- Master最好不要做任何持久化工作,如RDB和AOF持久化;
- 如果数据比较重要,某个Slave开启AOF备份数据,策略设置每秒同步一次;
- 为了主从复制的速度和连接的稳定性,Master和Slave最好在一个局域网内;
- 尽量避免在压力很大的主库上增加从库;
- 主从复制不要用图状结构,使用单链表结构更加稳定。
场景题
- 使用Redis设计一个分布式锁
1. 完成锁最基本的互斥功能
根据Redis是否存在key,判断锁是否被获取;
2. 选择一个合适的数据结构
锁应该是一个对象,记录持有锁的线程信息、当前重入次数。所以应该使用Redis的Hash结构来存储锁对象。
3. 需要考虑异常情况
3.1 网络波动造成释放锁失败怎么解决?
需要为锁加上超时时间;
3.2 任务未执行完毕时,锁由于超时时间被释放?
线程一旦加锁成功,可以启动一个后台线程,每隔多少秒检查一次,如果线程还持有锁,可以不断延长锁的生存时间。
4. 可能存在的缺陷?
主从切换时,从服务器上没有加锁信息,导致多个客户端同时加锁。
实现限流?
计数器固定窗口限流:
对一段固定时间窗口内的请求进行计数,如果请求数超过了阈值,则舍弃该请求;如果没有达到设定的阈值,则接受该请求,且计数加1。当窗口时间结束,重置计数器为0。
使用Redis的incr命令可以实现。
优点:实现简单,容易理解;
缺点:流量曲线可能不够平滑,有“突刺现象”。
1. 一段时间内(不超过时间窗口)系统服务不可用。比如窗口大小1s,限流为100,恰好某个窗口第1ms来了100个请求,然后2ms-999ms请求都会被拒绝。这段时间用户会感觉系统服务不可用(即不够平滑)。
2. 窗口切换时可能会出现两倍于阈值流量的请求。比如窗口大小1s,限流大小100,然后在某个窗口的第999ms有100个请求,窗口前期没有请求。所以这100个请求都会通过。然后下一个窗口的第1ms又来100个请求,然后全部通过。其实也是1ms内通过的200个请求。
2. 滑动窗口限流:
滑动窗口解决的是:计数器固定窗口切换时可能会产生两倍于阈值流量请求的缺点。
- 将一个计时窗口分成若干个小窗口,小窗口维护独立的计数器。窗口划分的多,则限流越精确;
- 当请求时间大于当前窗口的最大时间时,则将计时窗口向前平移一个小窗口(平移的维度是小窗口维度)。
- 平移时将头部小窗口数据丢弃,尾部新增一个小窗口,将新的请求放入新增的小窗口中。
- 保证整个窗口中所有小窗口请求数目之和不能超过设定的阈值。
比如1s中限流100个请求。
滑动窗口时间,0-100ms(10个请求)100-200ms(10个请求)...900-999ms(10个请求)
当时间为1001ms时,在这个小的固定窗口中(1000-1100ms),可以允许10笔请求。
滑动窗口本质上是更细粒度的固定窗口。
优点:
- 解决固定窗口切换窗口2倍流量的问题;
- 和漏桶算法相比,新到的请求也能被处理,避免了漏斗算法的饥饿问题;
3. 漏桶算法限流
请求先进入桶中,漏桶以一定速度消费请求,当请求速度过大直接溢出,不能处理短时间内大并发请求;
优点:
漏桶起到整流效果:请求流量可能具有随机性,但是经过漏桶算法后,变成了固定频率的稳定流量,从而对下游的系统起到保护作用。
缺点:
流量突发问题:漏桶速率是2个/s,漏桶大小5个,但是突然来了10个请求,受限于漏桶容量,只有5个请求被接受,另外5个被拒绝。但是5个被接受的请求,只有两个能被立刻执行。其他的依旧被等待。故并不能处理突发流量。而令牌桶算法可以解决这个问题
令牌桶限流算法
维护一个固定大小令牌桶,请求进入时,根据(当前时间-上次生成令牌时间)*令牌生成速率 生成令牌。将生的令牌放入桶中(若超过桶流量则直接溢出)。然后取出令牌,放行请求。
令牌桶限流算法是对漏桶算法的一种改进,能够限制调用的平均速率外同时还能允许一定程度的流量突发。
Redis如何实现令牌桶限流
数据结构选择hash。
hash里面维护:最后放入令牌时间、当前桶内令牌量、桶内最大数量、令牌放置速度(元数据)。
被动式维护:
- hash维护令牌桶元数据;
- 当用户请求到达时,第一步是根据(当前时间-最后最后一次令牌生成时间)/1000* 每秒生成令牌速度—生成令牌;
- 将令牌放入桶中,若满了则溢出来;
- 请求取出令牌;
- 若请求获取到令牌,则放行用户请求;
- Lua脚本必须是纯函数的原因
Lua脚本必须是纯函数的原因是受到持久化和主从复制的约束。给定一段Lua脚本和输入参数却得到不同的结果,会造成重启前后和主备之间数据的不一致。
- 使用Redis实现延迟队列
- hash维护消息和数据结构的关系;
- zset完成消息排序,其中score为执行时间戳
- 定时器调用zset的
zrangebyscore()
获取比当前时间戳小的消息; - 解析消息的topic信息,将其放入对应的list队列中;
- 线程池的work线程轮询调用list的
brpop
方法,去拉取延迟的消息;
- 使用Redis实现布隆过滤器
数据结构选择bitmap。算法解决guava的BF算法。
- 存入:通过算法,将一个value计算出多个偏移量,调用setbit将多组偏移量置为1;
- 取出:通过算法,将一个value计算出多个偏移量,调用getbit判断这些偏移量是否均为1;
为什么计算出多组偏移量?
如果只将value经过hash计算得到16位的偏移量,那么bitmap在内存中会占用大量空间,不推荐。
布隆过滤器为什么不能保证精确?
将不存在集合的元素误判存在集合中。
- 对value进行hash计算时,hash冲突。
- 计算出多组偏移量的方式,比如A计算出1 2 3 ;B 计算出 3 4 5,均存储到bitmap中,C计算出 2 3 4,集合不存在,但是会误判存在集合中。
6 使用bitmap的注意事项
网上的文章,均是简单的介绍bitmap的用法,但是都存在一个很大的风险的,导致bitmap占用大内存。
例如判断活跃用户量,使用bitmap实现:网上介绍很简单,bitset 将偏移量设置为1,但是id如何转换为偏移量并没有一篇文章进行介绍。
bitmap推荐的偏移量是从1一直累加的,但是计算出的hash值为10位(10亿级别),那么占用的内存大小为239MB,若是计算出的hash值为7位(百万级别)占用的内存大小为124KB。
所以计算偏移量的时候不能无脑的进行hash得到,而是要根据系统情况(百万级别的日活、十万级别日活),进行取余计算,得到合适的偏移量。
另一种解决方案:对数据进行分组在分片。SpringBoot2.x中使用Redis的bitmap结构(工具类)
- 使用redis实现静默报警(n秒内实现m次报警)
借助zset结构,score为异常时的时间戳。通过zrangebyscore方法取出单位时间内的异常数量,判断异常个数是否达到阈值,来选择报警。
- 线上Redis慢查询如何排查
1. 考虑是不是bigkey
跨链路线程追踪找到慢的命令,组装出Redis的key,发现查询出来的结果并不是大key
2. 是一个key有问题还是一组key有问题
若是一组key有问题,对key进行hash计算,看看是不是同一个节点。那么考虑到是不是集群节点性能出现问题,导致的问题。
3. 若是一个节点出现问题
判断是否存在hotkey的情况。即出现访问量偏斜;
判断是否该节点是否存在bigkey的情况,造成数量量偏斜;
4. 判断是否存在key*等耗时操作
查询Redis的调用日志(腾讯云)提供,并没有发现key*等耗时操作。
5. 命令是否集中过期
平时是否没有问题,变慢的时间点很有规律,每隔一段时间就会发生一波延迟。
主动过期的key定时任务,是Redis主线程中执行的。若出现大量删除过期key的情况,那么应用程序在访问Redis时,必须要等待这个过期任务执行结束,Redis才可以服务客户端请求。
6. 实例内存是否达到上限
因为Redis具有淘汰策略,当Redis实例到达内存上限后,写入新数据后势必会淘汰一些老数据。然后将新数据写进来。
(bitmap因为没有注意偏移量,导致占用内存过大,也是一个风险点)
7. 备份策略影响
RDB和AOF rewrite期间,fork耗时导致延时变大,也会导致性能问题。
8 是不是突发流量,导致redis性能瓶颈
查询流量曲线和redis命令执行指标,判断是否达到性能瓶颈。
9. 判断是否proxy出现问题
服务器->proxy->redis集群。当上面排查没有问题的话,需要考虑是否是proxy出现的问题。
- 如何保证Redis缓存一致性?
SpringBoot2.x—SpringCache(4)集成SpringCache保证Redis的数据一致性
SpringCache采用的是先更新数据库在更新缓存,但是存在一种场景:
- 线程A更新数据库,在更新缓存时被阻塞;
- 线程B更新数据库,然后将缓存更新为B;
- 线程A去更新数据库,将缓存更新为A;
这就会导致数据库和缓存不一致的现象。
改进版:采用的是:先更新数据库,在删除缓存。
一般写操作比读操作更耗时,故这种方法出现不一致的几率小。
- 缓存失效,线程A查询数据库查询到A,线程A被阻塞;
- 线程B更新数据库,线程B删除缓存;
- 线程A更新缓存为A;
造成了缓存不一致的情况;
为了彻底解决这种情况,可以使用延迟双删策略。
推荐阅读
滑动窗口限流 java_一文搞懂高频面试题之限流算法,从算法原理到实现,再到对比分析...
历史文章
Redis使用bitmap、zset、hash、list等结构完成骚操作?