image.png
image.png
image.png

1.redis 是单线程的吗?为什么这么快?

1、完全基于内存,绝大部分请求是纯粹的内存操作,非常快速。数据存在内存中,类似于HashMap,HashMap的优势就是查找和操作的时间复杂度都是O(1);
2、数据结构简单,对数据操作也简单,Redis中的数据结构是专门进行设计的;
3、采用单线程,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗 CPU,不用去考虑各种锁的问题,不存在加锁释放锁操作,没有因为可能出现死锁而导致的性能消耗;
4、使用多路I/O复用模型,非阻塞IO;这里“多路”指的是多个网络连接,“复用”指的是复用同一个线程。采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络 IO 的时间消耗),且 Redis 在内存中操作数据的速度非常快,也就是说内存内的操作不会成为影响Redis性能的瓶颈,主要由以上几点造就了 Redis 具有很高的吞吐量。

2.Redis 中有哪几种数据结构?使用场景是怎样的?

  • String——字符串String数据结构是简单的key-value类型,value其实不仅可以是String,也可以是数字。这种一般自己用缓存都会用得到,比如记录某个业务字段存不存在。常规key-value缓存应用;常规计数:微博数,粉丝数等。
  • Hash——字典Hash 是一个 string 类型的 field 和 value 的映射表,hash 特别适合用于存储对象,后续操作的时候,你可以直接仅仅修改这个对象中的某个字段的值。比如我们可以Hash数据结构来存储用户信息,商品信息等等。
  • List——列表list就是链表,Redis list的应用场景非常多,也是Redis最重要的数据结构之一,比如微博的关注列表,粉丝列表,消息列表等功能都可以用Redis的 list 结构来实现。Redis list 的实现为一个双向链表,即可以支持反向查找和遍历,更方便操作,不过带来了部分额外的内存开销。另外可以通过 lrange 命令,就是从某个元素开始读取多少个元素,可以基于 list 实现分页查询,这个很棒的一个功能,基于 redis 实现简单的高性能分页,可以做类似微博那种下拉不断分页的东西(一页一页的往下走),性能高。
  • Set——集合set对外提供的功能与list类似是一个列表的功能,特殊之处在于set是可以自动排重的。当你需要存储一个列表数据,又不希望出现重复数据时,set是一个很好的选择,并且set提供了判断某个成员是否在一个set集合内的重要接口,这个也是list所不能提供的。可以基于 set 轻易实现交集、并集、差集的操作。比如:在微博应用中,可以将一个用户所有的关注人存在一个集合中,将其所有粉丝存在一个集合。Redis可以非常方便的实现如共同关注、共同粉丝、共同喜好等功能。
  • Sorted Set——有序集合和set相比,sorted set增加了一个权重参数score,使得集合中的元素能够按score进行有序排列。

    3.Redis常见问题


    PART1——基础摸底

    Q:先说说你最常用的Redis命令吧?
    A:常用的读操作是get a,表示获取a对应的数据;最常用的写操作是setex a t b,表示将a的数据设置为b,并且在t秒后过期。

Q:Redis的过期键清除策略?
A:过期键清除策略有三种,分别是定时删除、定期删除和惰性删除。
定时删除,是在设置键的过期时间的同时,创建一个定时器,让定时器在键的过期时间来临时,立即执行对键的删除操作;
定期删除,每隔一段时间,程序就对数据库进行一次检查,删除里面的过期键;
惰性删除,是指使用的时候,发现Key过期了,此时再进行删除。
Redis过期键采用的是定期删除+惰性删除二者结合的方式进行删除的。
redis2.png
如果定期+惰性都没有删除过期的key,这时就会走到redis的内存淘汰机制
Redis支持内存淘汰,配置参数maxmemory_policy决定了内存淘汰策略的策略。这个参数一共有8个枚举值。
内存淘汰:Redis用的是近似LRU算法,LRU算法需要一个双向链表来记录数据的最近被访问顺序,但是出于节省内存的考虑,Redis的LRU算法并非完整的实现。如果按照HashMap和双向链表实现,需要额外的存储存放 next 和 prev 指针,牺牲比较大的存储空间,显然是不划算的。所以Redis采用了一个近似的做法,就是随机取出若干个key,然后按照访问时间排序后,淘汰掉最不经常使用的,具体分析如下:

  • Redis 使用的是一种近似 LRU 算法,之所以不用传统的 LRU 是因为它引入了链表,会占用较多的内存。
  • 近似 LRU 算法在现有数据结构的基础上采用随机采样的方式来淘汰元素它为每个 key 增加了一个最后一次被访问的时间戳,当内存不足时,就执行一次近似 LRU 算法,具体步骤是随机采样 5 个 key,这个采样个数默认为 5,然后根据时间戳淘汰掉最旧的那个 key,如果淘汰后内存还是不足,就继续随机采样来淘汰。这里的淘汰策略如果设置的是 allkeys,就从所有 key 中随机采样,如果设置的是 volatile,就从设有过期时间的 key 中随机采样,采样值越大,效果就越接近传统的 LRU 算法。
  • redis 3.0 在算法中增加了淘汰池,进一步提升了近似 LRU 算法的效果。具体原理是构建一个淘汰池数组,在每一次淘汰循环中,新随机采样的 key 会和淘汰池中的 key 进行融合,淘汰掉最旧的那个 key 后,保留剩余的 key 放入淘汰池中等待下一次循环。

[

](https://blog.csdn.net/dl674756321/article/details/105612735)

PART2——数据结构

Q:Redis数据结构?
对外暴露5种Redis对象,分别是String、List、Hash、Set、Zset。底层实现依托于sds、ziplist、skiplist、dict等更基础数据结构。

redis3.png

Redis的字符串如果保存的对象是整数类型,那么就用int存储。如果不能用整数表示,就用SDS来表示,SDS通过记录长度,和预分配空间,可以高效计算长度,进行append操作。

Q:Hash扩容过程是怎样的?
两张Hash表,平常起作用的都是0号表,当装载因子超过阈值时就会进行Rehash,将0号每上每一个bucket慢慢移动到1号表,所以叫渐进式Rehash,这种方式可以减少迁移系统的影响
首先,生成新Hash表ht[1],为 ht[1] 分配空间。此时字典同时持有ht[0]和ht[1]两个哈希表。字典的偏移索引从静默状态-1,设置为0,表示Rehash 工作正式开始。
然后,迁移ht[0]数据到ht[1]。在 Rehash进行期间,每次对字典执行增删查改操作,程序会顺带迁移一个ht[0]上的数据,并更新偏移索引。与此同时,周期函数也会定时迁移一批。
最后,ht[1]和ht[0]指针对象交换。随着字典操作的不断执行, 最终在某个时间点上,ht[0]的所有键值对都会被Rehash至 ht[1],此时再将ht[1]和ht[0]指针对象互换,同时把偏移索引的值设为-1,表示Rehash操作已完成。
Q2:如果字典正在Rehash,此时有请求过来,Redis会怎么处理
针对新增Key,是往ht[1]里面插入。针对读请求,先从ht[0]读,没找到再去ht[1]找。至于删除和更新,其实本质是先找到位置,再进行操作,所以和读请求一样,先找ht[0],再找ht[1],找到之后再进行操作。

Q:接下来讲讲跳表的实现吧?
Redis的跳表由zskiplist和zskiplistNode两个结构组成,前者保存跳表信息,后者表示跳表节点
跳表本质上是对链表的一种优化,通过逐层跳步采样的方式构建索引,以加快查找速度。如果只用普通链表,只能一个一个往后找。跳表就不一样了,可以高层索引,一次跳跃多个节点,如果找过头了,就用更下层的索引。
Q2:那每个节点有多少层呢
使用概率均衡的思路,确定新插入节点的层数。Redis使用随机函数决定层数。直观上来说,默认1层,和丢硬币一样,如果是正面就继续往上,这样持续迭代,最大层数32层。可以看到,50%的概率被分配到第一层,25%的概率被分配到第二层,12.5%的概率被分配到第三层。这种方式保证了越上层数量越少,自然跨越起来越方便。

Q:Redis的Zset为什么同时需要字典和跳表来实现?
Zset是一个有序集合,字典和跳表分别对应两种查询场景,字典用来支持按成员查询数据,跳表则用以实现高效的范围查询,这样两个场景,性能都做到了极致。

PART3——容灾和集群

Q:Redis是基于内存的存储,如果服务重启,数据不就丢失了吗?
可以通过持久化机制,备份数据。有两种方式
一种是开启RDB,RDB是Redis的二进制快照文件,优点是文件紧凑,占用空间小,恢复速度比较快。同时,由于是子进程Fork的模式,对Redis本身读写性能的影响很小。
另一种方式是AOF,AOF中记录了Redis的操作命令,可以重放请求恢复现场,AOF的文件会比RDB大很多。
出于性能考虑,如果开启了AOF,会将命令先记录在AOF缓冲,之后再刷入磁盘。数据刷入磁盘的时机根据参数决定,有三种模式:1.关闭时刷入;2.每秒定期刷入;3.执行命令后立刻触发。
AOF的优点是故障情况下,丢失的数据会比RDB更少。如果是执行命令后立马刷入,AOF会拖累执行速度,所以一般都是配置为每秒定期刷入,这样对性能影响不会很大。

Q:这样看起来,AOF文件会越来越大,最后磁盘都装不下?
不会的,Redis可以在AOF文件体积变得过大时,自动地在后台Fork一个子进程,专门对AOF进行重写。说白了,就是针对相同Key的操作,进行合并,比如同一个Key的set操作,那就是后面覆盖前面。
在重写过程中,Redis不但将新的操作记录在原有的AOF缓冲区,而且还会记录在AOF重写缓冲区。一旦新AOF文件创建完毕,Redis 就会将重写缓冲区内容,追加到新的AOF文件,再用新AOF文件替换原来的AOF文件。

Q:Redis集群

  1. 主从模式

可以用主从模式部署,即有一个或多个备用机器,备用机器作为Slave同步Master的数据,在Redis出现问题的时候,把Slave升级为Master。
redis4.png

完整同步:用于初次复制的情况,主服务器向从服务器发送RDB文件,以及缓冲区的数据
部分同步:处理断线后重新复制的情况,主要包括
1.偏移量
2.缓冲区
3.服务器运行id

2.哨兵模式
Redis已经有了现成的解决方案:哨兵模式。哨兵来监测Redis服务是否正常,异常情况下,由哨兵代理切换。为避免哨兵成为单点,哨兵也需要多机部署。
当哨兵集群选举出哨兵Leader后,由哨兵Leader从Redis从节点中选择一个Redis节点作为主节点:
1.过滤故障的节点;
2.选择优先级slave-priority最大的从节点作为主节点,如不存在,则继续;
3.选择复制偏移量最大的从节点作为主节点,如果都一样,则继续。这里解释下,数据偏移量记录写了多少数据,主服务器会把偏移量同步给从服务器,当主从的偏移量一致,则数据是完全同步;
4.选择runid最小的从节点作为主节点。Redis每次启动的时候生成随机的runid作为Redis的标识

每一个哨兵节点都可以成为Leader,当一个哨兵节点确认Redis集群的主节点主观下线后,会请求其他哨兵节点要求将自己选举为Leader。被请求的哨兵节点如果没有同意过其他哨兵节点的选举请求,则同意该请求,也就是选举票数+1,否则不同意。
如果一个哨兵节点获得的选举票数超过节点数的一半,且大于quorum配置的值,则该哨兵节点选举为Leader;否则重新进行选举。

3.Cluster模式
集群模式实现了Redis的分布式存储。对数据进行分片,也就是说每台Redis节点上存储不同的内容,来解决在线扩容的问题。并且,它也提供复制和故障转移的功能。

Cluster集群节点的通讯

一个Redis集群由多个节点组成,各个节点之间是怎么通信的呢?通过Gossip协议
Redis Cluster集群通过Gossip协议进行通信,节点之前不断交换信息,交换的信息内容包括节点出现故障、新节点加入、主从节点变更信息、slot信息等等
常用的Gossip消息分为4种,分别是:ping、pong、meet、fail。

  • meet消息:通知新节点加入。消息发送者通知接收者加入到当前集群,meet消息通信正常完成后,接收节点会加入到集群中并进行周期性的ping、pong消息交换。
  • ping消息:集群内交换最频繁的消息,集群内每个节点每秒向多个其他节点发送ping消息,用于检测节点是否在线和交换彼此状态信息。
  • pong消息:当接收到ping、meet消息时,作为响应消息回复给发送方确认消息正常通信。pong消息内部封装了自身状态数据。节点也可以向集群内广播自身的pong消息来通知整个集群对自身状态进行更新。
  • fail消息:当节点判定集群内另一个节点下线时,会向集群内广播一个fail消息,其他节点接收到fail消息之后把对应节点更新为下线状态。

Hash Slot插槽算法

Redis集群会有16384个哈希槽,每个key通过CRC16校验后对16384取模来决定放置哪个槽,而集群的每个节点负责一部分hash槽。

🏃‍ Redis - 图7
1.每个节点通过通信都会共享Redis Cluster中槽和集群中对应节点的关系
2.客户端向Redis Cluster的任意节点发送命令,接收命令的节点会根据CRC16规则进行hash运算与16383取余,计算自己的槽和对应节点
3.如果保存数据的槽被分配给当前节点,则去槽中执行命令,并把命令执行结果返回给客户端
4.如果保存数据的槽不在当前节点的管理范围内,则向客户端返回moved重定向异常
5.客户端接收到节点返回的结果,如果是moved异常,则从moved异常中获取目标节点的信息
6.客户端向目标节点发送命令,获取命令执行结果

image.png

PART4——性能优化

Q:Redis性能怎么样?
Redis是单线程Reactor模型,通过高效的IO复用以及内存处理实现高性能。如果是6.0之前我会毫不犹豫说是单线程,6.0之后,我还是会说单线程,但会补充一句,IO解包通过多线程进行了优化,而处理逻辑,还是单线程。
redis5.png
多线程功能,主要用于提高解包的效率。和传统的Multi Reactor多线程模型不同,Redis的多线程只负责处理网络IO的解包和协议转换,一方面是因为Redis的单线程处理足够快,另一方面也是为了兼容性做考虑。

Q:如果数据太大,Redis存不下了怎么办?
使用集群模式。也就是将数据分片,不同的Key根据Hash路由到不同的节点。
集群索引是通过一致性Hash算法来完成,这种算法可以解决服务器数量发生改变时,所有的服务器缓存在同一时间失效的问题。
同时,基于Gossip协议,集群状态变化时,如新节点加入、节点宕机、Slave提升为新Master,这些变化都能传播到整个集群的所有节点并达成一致。

Q:一致性Hash能详细讲一下吗?
好的,传统的Hash分片,可以将某个Key,落到某个节点。但有一个问题,当节点扩容或者缩容,路由会被完全打乱。如果是缓存场景,很容易造成缓存雪崩问题。
为了优化这种情况,一致性Hash就应运而生了。一致性Hash是说将数据和服务器,以相同的Hash函数,映射到同一个Hash环上,针对一个对象,在哈希环上顺时针查找距其最近的机器,这个机器就负责处理该对象的相关请求。
这种情况下,增加节点,只会分流后面一个节点的数据。减少节点,那么请求会由后一个节点继承。也就是说,节点变化操作,最多只会影响后面一个节点的数据。
redis6.png

如果缓存节点分布不均匀,会引起负载不均衡,在空闲的地方加入 虚拟节点,这些节点的数据映射到真实节点上,让整个集群的负载能够均衡。

PART5——场景应用

缓存雪崩表示在某一时间段,缓存集中失效,导致请求全部走数据库,有可能搞垮数据库,使整个服务瘫痪。雪崩原因一般是由于缓存过期时间设置得相同造成的。
针对这种情况,可以借鉴ETCD中Raft选举的优化,让过期时间随机化,避免同一批请求,在同一时间过期。另一方面,还可以业务层面容灾,为热点数据使用双缓存。


缓存穿透指请求Redis里面根本没有的数据,高频请求不存在的Key,有可能是正常的业务逻辑,但更可能的,是黑客的攻击。
可以用布隆过滤器来应对,布隆过滤器是一种比较巧妙的概率型数据结构,特点是高效地插入和查询,可以用来告诉我们 “某样东西一定不存在或者可能存在”
redis7.png

布隆过滤器底层是一个64位的整型,将字符串用多个Hash函数映射不同的二进制位置,将整型中对应位置设置为1。
在查询的时候,如果一个字符串所有Hash函数映射的值都存在,那么数据可能存在。为什么说可能呢,就是因为其他字符可能占据该值,提前点亮。可以看到,布隆过滤器优缺点都很明显,优点是空间、时间消耗都很小,缺点是结果不是完全准确。
redis8.png

缓存击穿,是指某一热键,被超高的并发访问,在失效的一瞬间,还没来得及重新产生,就有海量数据,直达数据库。这种情况和缓存雪崩的不同之处,在于雪崩是大量缓存赶巧儿一起过期,击穿只是单个超热键失效。
这种超高频Key,我们要提高待遇,可以让它不过期,再单独实现数据同步逻辑,来维护数据的一致性。当然,无论如何,对后端肯定是需要限频的,不然如果Redis数据丢失,数据库还是会被打崩。限频方式可以是分布式锁或分布式令牌桶。

Q:Redis在限流场景下的应用吗?
在微服务架构下,限频器也需要分布式化。无论是哪种算法,都可以结合Redis来实现。这里我比较熟悉的是基于Redis的分布式令牌桶。
很显然,Redis负责管理令牌,微服务需要进行函数操作,就向Redis申请令牌,如果Redis当前还有令牌,就发放给它。拿到令牌,才能进行下一步操作。
另一方面,令牌不光要消耗,还需要补充,出于性能考虑,可以使用懒生成的方式:使用令牌时,顺便生成令牌。这样子还有个好处:令牌的获取,和令牌的生成,都可以在一个Lua脚本中,保证了原子性。

漏桶算法的主要概念如下:

  • 一个固定容量的漏桶,按照常量固定速率流出水滴;
  • 如果桶是空的,则不需流出水滴;
  • 可以以任意速率流入水滴到漏桶;
  • 如果流入水滴超出了桶的容量,则流入的水滴溢出了(被丢弃),而漏桶容量是不变的。

漏桶算法比较好实现,在单机系统中可以使用队列来实现,在分布式环境中消息中间件或者Redis都是可选的方案。

  1. public class LeakyBucket {
  2. private int capacity;// 桶的容量
  3. private int rate;//水漏出的速度
  4. private int water;//当前水量
  5. private long refreshTime;//最后更新时间
  6. public LeakyBucket(int capacity, int rate) {
  7. this.capacity = capacity;
  8. this.rate = rate;
  9. }
  10. //刷新桶的水量
  11. private void refreshWater() {
  12. long now = getNowTime();
  13. //先执行漏水,因为rate是固定的,所以可以认为“时间间隔*rate”即为漏出的水量
  14. water = (int) Math.max(0, water - (now - refresh) * rate);
  15. refresh = now;
  16. }
  17. //加水
  18. public synchronized boolean tryAcquire() {
  19. refreshWater();
  20. if (water < capacity) {
  21. water++;
  22. return true;
  23. } else {
  24. return false;
  25. }
  26. }
  27. }

**令牌桶算法
令牌桶算法是一个存放固定容量令牌(token)的桶,按照固定速率往桶里添加令牌。令牌桶算法基本可以用下面的几个概念来描述:

  • 令牌将按照固定的速率被放入令牌桶中。比如每秒放10个。
  • 桶中最多存放b个令牌,当桶满时,新添加的令牌被丢弃或拒绝。
  • 当一个n个字节大小的数据包到达,将从桶中删除n个令牌,接着数据包被发送到网络上。
  • 如果桶中的令牌不足n个,则不会删除令牌,且该数据包将被限流(要么丢弃,要么缓冲区等待)。

image.png
漏桶和令牌桶的比较
令牌桶可以在运行时控制和调整数据处理的速率,处理某时的突发流量。放令牌的频率增加可以提升整体数据处理的速度,而通过每次获取令牌的个数增加或者放慢令牌的发放速度和降低整体数据处理速度。而漏桶不行,因为它的流出速率是固定的,程序处理速度也是固定的。整体而言,令牌桶算法更优.

  1. public class TokenLimmiter {
  2. private ArrayBlockingQueue<String> blockingQueue;
  3. private int limit;
  4. private TimeUnit timeUnit;
  5. private int period;
  6. public TokenLimmiter(int limit, int period, TimeUnit timeUnit) {
  7. this.limit = limit;
  8. this.timeUnit = timeUnit;
  9. this.period = period;
  10. blockingQueue = new ArrayBlockingQueue<>(limit);
  11. init();
  12. start();
  13. }
  14. //初始化
  15. private void init() {
  16. for (int i = 0; i < limit; i++) {
  17. blockingQueue.add("1");
  18. }
  19. }
  20. //获取令牌
  21. private boolean tryAcquire() {
  22. return blockingQueue.poll() == null ? false : true;
  23. }
  24. private void addToken() {
  25. blockingQueue.offer("1");
  26. }
  27. //开启一个定时线程执行添加令牌
  28. private void start() {
  29. Executors.newScheduledThreadPool(1).scheduledAtFixedRate(() -> {
  30. addToken();
  31. }, 10, period, timeUnit);
  32. }
  33. }

PART6——Redis分布式锁

现在的业务应用通常都是微服务架构,这也意味着一个应用会部署多个进程,那这多个进程如果需要修改MySQL中的同一行记录时,为了避免操作乱序导致数据错误,此时,我们就需要引入「分布式锁」来解决这个问题了。
image.png
想要实现分布式锁,必须要求+Redis+有「互斥」的能力,我们可以使用SETNX命令,这个命令表示SET if Not eXists,即如果key不存在,才会设置它的值,否则什么也不做。
操作完成后,还要及时释放锁,给后来者让出操作共享资源的机会。如何释放锁呢?也很简单,直接使用DEL命令删除这个key即可。
image.png

但是,它存在一个很大的问题,当客户端1拿到锁后,如果发生下面的场景,就会造成「死锁」

  • 程序处理业务逻辑异常,没及时释放锁
  • 进程挂了,没机会释放锁

这时,这个客户端就会一直占用这个锁,而其它客户端就「永远」拿不到这把锁了

如何避免死锁?

设置过期时间

  1. //一条命令保证原子性执行
  2. 127.0.0.1:6379>SET lock 1 EX 10 NX
  3. OK
  • 客户端1加锁成功,开始操作共享资源
  • 客户端1操作共享资源的时间,「超过」了锁的过期时间,锁被「自动释放」
  • 客户端2加锁成功,开始操作共享资源
  • 客户端1操作共享资源完成,释放锁(但释放的是客户端2的锁)

这里存在两个严重的问题:

  1. 锁过期:客户端1操作共享资源耗时太久,导致锁被自动释放,之后被客户端2持有
  2. 释放别人的锁:客户端1操作共享资源完成后,却又释放了客户端2的锁

    防止误删和保证原子性?

    setnx获取锁时,设置一个指定的唯一值(例如:uuid);释放前获取这个值,判断是否自己的锁
    删除操作缺乏原子性。
    删除LUA脚本保证原子性(判断是自己的锁才对其进行删除):
    1. if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end

锁过期时间不好评估怎么办?

锁的过期时间如果评估不好,这个锁就会有「提前」过期的风险。
是否可以设计这样的方案:加锁时,先设置一个过期时间,然后我们开启一个「守护线程」,定时去检测这个锁的失效时间,如果锁快要过期了,操作共享资源还未完成,那么就自动对锁进行「续期」,重新设置过期时间。
image.png
到这里我们再小结一下,基于Redis的实现分布式锁,前面遇到的问题,以及对应的解决方案:

  • 死锁:设置过期时间
  • 过期时间评估不好,锁提前过期:守护线程,自动续期
  • 锁被别人释放:锁写入唯一标识,释放锁先检查标识,再释放

PART7——Zookeeper分布式锁

Zookeeper的优点:

  • 不需要考虑锁的过期时间
  • watch机制,加锁失败,可以watch等待锁释放,实现乐观锁

但它的劣势是:

  • 性能不如 Redis
  • 部署和运维成本高
  • 客户端与 Zookeeper 的长时间失联,锁被释放问题