欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

分布式缓存 --Redis

程序员文章站 2024-01-31 11:06:16
...

高分布式缓存—redis

大家好,我是一只奔跑的蜗牛。今天开始,我准备开始写一个redis的专题。

缓存

​ 什么是缓存。很多人把内存跟缓存划为等号,其实这是一种外行人的看法。你要知道很多场景下,我们经常使用SSD来作为冷数据的缓存。那我认为, 凡是位于速度相差较大的两种硬件之间,用于协调两者数据传输速度差异的结构,均可称之为缓存。 既然说到这里了,我们经常提到的两种存储数据的两种存储介质就是磁盘和内存。经过相关的科学数据显示,内存的传输速度比磁盘的传输速度高几个数量级,所以这也是为什么人们钟爱内存来存储数据的原因了。

天下武功唯快不破,在这互联网的时代,时间比空间更稀缺。在这高流量的网络环境,需要一个高性能,高可用的系统来支撑。这也是为什么需要缓存的原因了。

那么,我们普遍把缓存又分为三类静态缓存(ngnix),分布式缓存(redis),热点本地缓存(Guava cache)。这三种缓存分别在整个系统的不同层,用来支撑整个系统的高并发的流量,保护后方脆弱的关系型数据库。

静态缓存经常在ngnix部署一些静态资源,比如html,图片,用来显示静态的页面。对于动态的数据就无能为力了,所以分布式缓存就登场了。今天我们的主角redis就是分布式缓存。redis可以阻挡大流量的冲击。就像岸边的堤坝用来阻挡潮汐来袭。对于一些极其热点的数据,热点本地缓存可以解决。为了满足系统的高性能,你可以增加多级缓存,为了缓存的高可用,你也可以部署集群。

redis

redis作为常见的分布式缓存的组件,拥有着极高读写性能。那么你是否真正了解redis,了解底层的数据结构,了解工作原理,如果你对自己很有信心不妨试着回答以下面试题,如果你回答不了,不如带着你的疑问,我们一起来回顾下redis相关的知识点。

面试题:

  • Redis支持的数据类型?
  • 什么是Redis持久化?Redis有哪几种持久化方式?优缺点是什么?
  • 使用过Redis分布式锁么,它是怎么实现的?
  • 如果在setnx之后执行expire之前进程意外crash或者要重启维护了,那会怎么样?
  • 什么是缓存穿透?如何避免?什么是缓存雪崩?何如避免?什么是缓存击穿?何如避免?
  • 单线程的redis为什么这么快 ?
  • Redis 为什么是单线程的?
  • 讲解下Redis线程模型?
  • 为什么Redis的操作是原子性的,怎么保证原子性的?
  • Redis实现分布式锁?
  • Redis事务?
  • Redis 常见性能问题和解决方案?
  • 有没有尝试进行多机redis 的部署?如何保证数据一致的?
  • Redis 集群方案应该怎么做?都有哪些方案?
  • 对于大量的请求怎么样处理?

一,常见的数据结构

  1. String

  2. List

  3. Hash

  4. set

  5. zset

    底层的数据结构

    1. RedisObject
    2. 字符串SDS
    3. 跳表
    4. 字典
    5. 列表&集合
    6. 快速列表

二,Redis的过期和淘汰策略

​ redis的过期淘汰策略是非常值得去深入了解以及考究的一个问题。很多使用者往往不能深得其意,往往停留在人云亦云的程度,若生产不出事故便划水就划过去了,但是当生产数据莫名其妙的消失,或者reids服务崩溃的时候,却又束手无策。本文尝试着从浅入深的将redis的过期策略剖析开来,期望帮助作者以及读者站在一个更加系统化的角度去看待过期策略。

redis作为缓存的数据库,其底层的数据结构是由dict和expires组成,dict是保存key-value的字典数据的,expires是来保存了对dict设置的过期时间。我们选择缓存的原因也是因为访问磁盘的成本远高于内存的访问的成本,但是内存的容量却不比磁盘。所以redis必须对自己的内存的容量进行监控,当内存容量有限或者固定的情况下,存储数据已经达到内存存储的阈值,新的数据该和老的数据该何去何从,redis也必须有兜底的策略和机制。

内存的策略

redis可以设置 maxmemory 来设置内存的最大容量(阈值)。当超过所设置的最大阈值,redis就触发相对应的可配置的淘汰策略。

淘汰策略

简单来说就是五种策略。驱逐策略,使用最少原则策略(LRU),使用频率最少策略(LFU),过期时间最早策略(TTL)。随机策略(RANDOM),细分如下所列举的:

  • no-enviction :驱逐策略,这也是默认的配置项,当数据达到阈值以后,redis再写入新的数据,就会报错。也就是说redis不提供写数据功能,读不影响。它可以保证redis原来存储的数据的不丢失。
  • allkeys-lru :从所有的数据集中挑选最近最少使用的值进行淘汰。
  • allkeys-lfu :从所有的数据集中挑选使用频率最少的值进行淘汰。
  • allkeys-random:从所有的数据集中随机挑选值进行淘汰。
  • volatile-lru :从已经设置过期时间的数据集中,挑选最近使用最少的值进行淘汰。
  • volatile-lfu :从已经设置过期时间的数据集中,挑选使用频率最少的值进行淘汰。
  • volatile-ttl:从已经设置过期时间的数据集中,挑选过期时间最早的值进行淘汰。
  • volatile-random:从已经设置过期时间的数据集中,随机挑选值进行淘汰。

既然已经确定了淘汰的策略,那么何时进行淘汰呢。如果redis只存储几个key-value,自然不必说,循环遍历,判断,过滤,删除符合淘汰策略的值即可。但是实际的情况,redis的存储当超过阈值的时候,存储的数据量可能相当大。显然,redis为了保持它的读写的高性能,不会这么做。那么它又是何种机制呢?redis的设计者们设计了 一个配置项maxmemory-samples,称为过期检测样本。那么这个过期检测样本又是如何工作的呢?

首先说明一点,redis在内存的大小超过阈值以后,新写入的请求会阻塞,直到删除过程的结束。但是,redis设计者也考虑到了这一点,因此对这个淘汰的过程进行了优化,redis只会在它的样本池里面的数据进行淘汰,并不会全量扫描进行淘汰策略。当然,这也是可以配置的。

  • 一般来说,为了避免触发 maxmemory,最好在 mem_used 超过 maxmemory一定的比例以后,调大赫兹进行加速淘汰或者,或者采用扩容集群,分片的方法。
  • 如果能够控制住内存,则可以不用修改maxmemory-samples配置。如果redis本身就作为LRU缓存服务(这种服务一般长时间处于maxmemory状态,由redis自动做LRU淘汰),可以适当调大maxmemory样本。

接下来,我用流程图方式,看下底层是如何处理的。

分布式缓存 --Redis

看到这里,大家是否有这样的思路,这是一种亡羊补牢的做法。就是redis的淘汰删除整个过程还是相当繁琐的,而且还阻塞请求,直到内存释放并且释放的内存必须满足写入数据所需要的内存,要不然就一直循环。那既然这样,有没有未雨绸缪的做法,来尽量少的触发maxmemory,答案是有的,那就是过期自动删除策略。

过期自动删除策略

**定时删除:**通过一个定时器,对已经过期的数据进行删除。这种方法能保证已经过期的数据尽快的被删除,并释放内存

优点:对内存是友好的。内存尽快释放,避免触发maxmemory。

缺点:对CPU不友好。当删除的键非常多的时候,会占用CPU大量的时间,并且还可能是在CPU比较繁忙的时候。将CPU应用于删除与本次任务无关的键上,无疑对响应时间和吞吐量造成相当大的影响。

**惰性删除:**当设置了过期时间的键已经过期,并不会马上删除。而是当再次get的时候删除。

优点:对CPU友好

缺点:对内存不友好。内存中存放着大量已经过期的键,鸠占鹊巢的节奏。

**定期删除:**通过设置一定的周期时间来删除。可以通过配置,控制操作的时长和频率来删除键,主动限制对CPU地影响。这是一种可主动配置化地定时删除。可以删除长期驻留在内存中地数据,有效防止内存地泄露。

好处:可以控制删除地频率。

坏处:服务器必须有效地控制删除地执行时长和删除地频率。

最后,一般通过定期删除和惰性删除相结合,优势互补的方法,尽可能地合理化。

三,持久化的机制

​ 持久化的意义在于,当redis因为异常情况宕机,或者服务器掉电的情况,必须依赖持久化的机制来重启恢复数据。像Mysql中的redulog和binlog,最大的作用就是为了当Mysql异常重启后,用来恢复数据。这不是本次课题的重点,这里不做展开。

RDB

RDB持久化是指在指定的时间间隔内,将内存中的数据以快照的形式写入到二进制文件中,默认的文件名是dump.rdb。

​ 可以通过配置自动进行快照保存,比如redis在n秒内超过m个key的值被修改了,就自动开始快照的持久化。一下配置就是配置快照的触发的条件:

   save 900 1     #900秒内如果超过1个key被修改,则发起快照保存
   save 300 10    #300秒内容如超过10个key被修改,则发起快照保存
   save 60 10000
RDB的保存过程

​ 当触发RDB快照保存的时候,redis会fork一个子进程。子进程会把内存的时候写入一个临时文件,当子进程写完结束以后,会把临时文件替换原来的快照文件。

优势:

  • 方便备份,因为我们可以把一个个RDB文件复制到其他服务器上
  • RDB在恢复数据时候比AOF快
  • RDB是fork一个子进程进行快照持久化的,所以最大化利用了redis的性能。

劣势:

  • 当数据集相当大的时候,fork一个子进程来进行快照持久化,肯定也相当耗时。如果CPU此刻非常繁忙,无疑对服务器的响应和吞吐量有严重的影响。

  • 如果你的业务对数据丢失有很低的容忍度,那么RDB也不适合你。如果服务器宕机,RDB已经执行了n分钟,那么在n分钟内,客户端向服务器写入的所有数据都将丢失。这里可以秀一下我的底层水平。这就要谈到os(操作系统)写时复制copy-on-write机制了。我用流程图来说明更加易懂。

分布式缓存 --Redis

当主进程开始写入数据,CPU会复制一个副本给主进程,这时候子进程和主进程就不是同一个物理页。所以子进程不会把最新的写入的数据,持久化到RDB文件中。一旦redis宕机,写入的数据就会丢失。

AOF

AOF会将每一条的写的命令用wirte的方法写入到 文件中(默认是 appendonly.aof)。

redis重启,会利用AOF文件的内容重建整个数据库的内容。不过,在操作系统层面,os会把AOF的写命令先缓存在内核中,并不会马上落盘。所以AOF还是存在丢失数据的机会。但是redis也提供了配置项,调用fsync函数来强制OS落盘。

appendonly yes              //启用aof持久化方式
# appendfsync always      //每次收到写命令就立即强制写入磁盘,最慢的,但是保证完全的持久化,不推荐使用
appendfsync everysec     //每秒钟强制写入磁盘一次,在性能和持久化方面做了很好的折中,推荐
# appendfsync no    //完全依赖os,性能最好,持久化没保证

AOF也带来了另外一个问题。每次写入数据,把命令写入AOF文件中,会导致AOF文件会越来越大。而且还存在一些冗余的命令,如果用incr对一个变量相加1,执行1000次,那么就会写入1000条命令。其实对于数据恢复来说,只需要一个命令。所以redis的设计者考虑到这一点,设计对AOF进行重写,来压缩AOF的文件大小。

总结来说,AOF的持久化的过程,对写入的每一条命令先缓存在内核中,并每秒对内核中的数据进行落盘,写入AOF文件中。当AOF大小超过阈值的时候,对AOF进行重写,fork一个子进程进行重写AOF,父进程对新的写命令缓存在内核中。当子进程完成AOF的重写,并用新的替换老的以后,通知父进程,父进程就把缓存中的新的写命令追加到新的AOF文件中。

优势:

  • AOF持久化会让redis显得非常的耐久。即使redis异常宕机了,也只会丢失一秒的数据。
  • redis对AOF可以进行自动重写。使得以最小的命令写入AOF文件,使得AOF的体积变小。
  • AOF按照一定的协议写入文件,使得简单易懂,并且可以修改。

劣势:

  • 对于相同的数据集,AOF的体积还是大于RDB文件。
  • AOF写入载入的性能还是劣于RDB
  • 可能因为个别命令无法将数据库恢复到原来的样子。RDB从来不会产生这样的BUG。

RDB和AOF的区别

  • RDB文件比AOF文件的体积小。

  • RDB丢失的数据更多,AOF最多只会丢失一秒的数据。

  • RDB比AOF能更快的恢复数据。

  • RDB写入文件的速度快于AOF。

四,通讯协议和事件处理机制

​ 面试中经常会问到这样的一个问题。redis是单线程的为什么还能这么快?这就要来好好讲讲redis的IO模型了。为了照顾下萌新,我们一起来回忆下最常见的IO的三种模型:BIO,NIO,AIO。

BIO:同步阻塞IO模型

一个连接,就会创建一个线程。并且在连接以后,没有返回响应数据之前,线程一直阻塞。

NIO:同步非阻塞IO模型

多个连接,一个线程。建立一个线程,所有的请求连接会注册到一个多路复用器上,遍历socket请求,发现有一个socket连接,就把它交给线程处理并返回数据。与BIO相比,能处理多个socket连接,并不会阻塞任何一个socket连接。

AIO:异步非阻塞IO模型

一个连接,一个线程。客户端请求,并不会等待,立即返回。客户端的IO请求都是由OS完成后再通知服务端创建线程处理。

​ redis就是采用了同步非阻塞的IO模型。为什么不采用BIO,AIO呢?BIO会因为很多高并发的请求,导致创建的线程很多,CPU有创建线程数量的上限,所以很显然BIO不合适。AIO又是什么问题呢?异步非阻塞会导致线程切换经常切换,导致性能的下降。redis封装了自己的事件处理机制。我来讲下具体的处理流程。IO多路复用就是利用select,pllo,epllo同时监查多个IO流的能力,同时阻塞一个线程,让线程处于睡眠等待状态。当发现一个流处于就绪可用的时候,就唤醒线程,并轮询所有的流(epllo只会轮询真正有事件的流),依次顺序处理可用的流。多路的意思就是可以同时处理客户端多个请求,复用就是共用一个线程,单线程可以保证操作的原子性。redis的同时处理多个请求,redis的事件分离器会把开,关,读,写的事件分发给事件处理器,这两种机制大大提高了redis的吞吐量,并且redis的单线程,避免了线程间的切换,也提高了redis的性能。

redis的应用篇

一,如何选择缓存的读写策略

​ 提问:如果一个请求要求更新数据库某一个字段的值,你是先更新缓存还是更新数据库?

  • 更新缓存,再更新数据库,先更新缓存有什么问题? 举例:有两个线程,分别是A,B。A线程更新缓存a的值1->2,B更新完缓存了,去更新数据库a的值1->3。然后C来了,查询缓存a的值为2。发现没有,数据库和缓存的值不一样了。

  • 先更新数据库,再更新缓存,先更新数据库有什么问题?举例:还是A,B两个线程。A线程更新数据库a的值1->2,B查询a的值,读取缓存中的旧值为1,发现没有数据又不一致了。

    所以,不管更新缓存和数据库,顺序前后,都有导致数据不一致的情况。根本原因是因为,数据库和缓存的操作两个操作不是独立的,或者说不是原子的。

    **那么我们应该怎么去解决这个问题呢?**如果我们把更新缓存,改成删除缓存,可不可以。试试看。问题又来了,是先删除缓存,更新数据库,还是先更新数据库,删除缓存呢?前者有个很严重的弊端,如果A线程先删除缓存,然后update的,未commit的时候。线程B来了,发现缓存中没数据,读取数据库中老数据,然后放入缓存,然后A提交了。B线程还是读取了老的数据。所以选择后者。这也是经常使用到的 Cache Aside 策略(旁路缓存策略)。

    读策略

    1. 读取缓存中的数据。
    2. 如果缓存中有数据,则直接返回数据
    3. 如果缓存中没有数据,读取数据库数据
    4. 数据写入缓存

    写策略:

    1. 先更新数据库
    2. 删除缓存

功力深厚的童鞋也发现了,其实这种策略也有一定的风险。在线程A中,在update数据中,缓存数据未删除,如果一个线程来读取,发现缓存中有数据,则读取了旧数据。线程A提交,然后数据库更新。所以如果A线程不早于其他缓存去删除缓存,那么数据不一致还是有可能发生。

如果你的业务对数据一致性的要求很高的时候,还是有几种方法来保证的。

  1. 加分布式锁。在线程A来更新a值的时候,加分布式锁,保证只有一个线程来更新数据库,更新缓存(或者删除缓存)。这样对写入的性能有影响。
  2. 设置短的过期时间。给缓存中的值设置较短的过期时间,这样对业务的影响虽然有,但是也是能够接受的。
  3. 延迟双删,在更新数据库之前删除缓存,更新完数据库,再删除缓存。延迟是为了等待其他线程执行完,删除缓存。

二,分布式锁和redis事务

三,redis的三大问题

缓存穿透

缓存穿透:请求查询缓存中没有找到,不得不查询数据库的情况。

​ 少量的缓存穿透对系统或者数据库是可以允许的,无害的。主要有以下几点原因:

  1. 互联网大厂,面临着海量的数据。因为缓存的容量有限,并不可能把所有的数据都放入缓存中。所以少量的缓存穿透是不可避免的,也是无害的。
  2. 另外一方面,互联网访问数据的模型一般遵从这样的法则:“80/20”法则。意大利经济学家帕累托提出的一个经济学的理论。在一个事务中,百分之80是不重要的数据,另外百分之20是重要的数据。在我们软件行业中,一个系统内。百分之80是非热点数据,百分之20是热点数据。所以我们只要保证那百分之20的数据在缓存中即可。

那么,什么时候发生缓存穿透呢?那就是高并发,高流量情况下。如果把少量请求比作毛毛细雨,那么大流量就是洪水猛兽。脆弱的后端系统无法承受,最终导致崩溃宕机。

举例:当我们查询用户A的时候,发现缓存中没有,就去查询数据库,发现数据库中也没有。大量的查询不存在的数据,导致大量穿透请求到数据库,导致数据库性能急剧下降,最近崩溃宕机。那么缓存作为堤坝也无法阻挡洪水,也就失去了它的意义和作用。

当然办法总比困难多。我们也有常用的两种方法来解决。

返回空值:还是举例,当查询A用户,发现缓存中没有,穿透缓存查询数据库也没有。那么在缓存中存入一个key为A用户,value为null。当同样的请求再次查询缓存,直接返回null。

这种方法,虽然可以解决,但是还是有一个弊端。当大量请求不存在的数据的时候,缓存中也存有大量的null的值,而且在缓存内存快满了的时候,还会剔除那些有用的缓存数据。所以在使用这种解决方案的时候,必须评估下自己的缓存内存大小。当然这种情况也有对应的解决策略。那就是布隆过滤器。

布隆过滤器: 布隆过滤器能存放那些不存在的数据。布隆过滤器的特点就是,通过布隆过滤器判断,如果一个值通过判断,存在,那么可能存在在布隆过滤器中。如果一个值通过判断,不存在,那么肯定不存在在布隆过滤器中。你看,这非常适合用来做缓存穿透的解决方案。

任何方案都不是银弹。布隆过滤器有一定的误判,那是因为底层是通过hash算法来实现的,我们知道无论多么优秀的hash算法都有一定几率的hash冲突。那是因为hash算法,输入值无限,输出值有限。但是这种误判是可以优化和调整的。即使几万次的请求,有几次请求成为落网之鱼,那也是不痛不痒。另外的缺陷是,引入布隆过滤器无疑增加了系统的复杂性。

缓存击穿

缓存击穿:当一个key承担的极高的流量,当key失效的瞬间。大量的请求击穿缓存,访问数据库的情况。

好像大冬天,冷风呼啸的晚上,你坐在车里,突然汽车的玻璃被砸了一个洞。呼呼的大风吹进车里。

解决方案不言而喻:设置极高热点数据,永不过期就好了呀。

缓存雪崩

缓存雪崩:当很多节点的热点数据集中过期失效。这还是能接受的。当一个节点崩溃宕机,导致其他节点承受平时几倍的流量,最终所有的节点接连宕机,这种情况才是最可怕的情况。

设置很多数据的过期时间分散,不要在同一时刻。比如热门商品设置过期时间长一点,冷门商品设置过期时间短一点。就不会出现大量数据在同一时刻过期了。

对于大量节点宕机了,可以用一致性hash算法。