分布式缓存redis常见问题及解决方案

  Seves

    一、redis和memcache有什么区别?

    redis是现在的企业使用最广泛缓存技术,而在redis以前memcache是一些公司最常用的缓存技术,它们比较相似,但有如下一些区别:

    (1)redis相对于memcache来说拥有更丰富的数据类型,可以适用更多复杂场景。

    (2)redis原生就是支持cluster集群模式的,但memcache没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据。

    (3)redis使用的是单核,memcache使用的是多核,所以redis在存储小数据的时候性能比较高,memcache在存储大一点的数据时候性能更好。

    (4)memcache在使用简单的key-value存储的时候内存利用率更高,但redis如果采用hash的结构来做存储,内存使用率会较好。

    二、redis的单线程模型

    redis基于reactor模式开发了网络事件处理器,这个处理器叫做文件事件处理器,它的组成结构为4部分:多个套接字(socket)、IO多路复用程序、文件事件分派器、事件处理器。因为这个文件处理器是单线程的,所以redis才叫单线程模型。

    在redis启动初始化的时候,redis会将连接应答处理器跟AE_READABLE事件关联起来,接着如果一个客户端跟redis发起连接,此时会产生一个AE_READABLE事件,然后由连接应答处理器来处理跟客户端建立连接,创建客户端对应的套接字(socket),同时将这个socket的AE_READABLE事件跟命令请求处理器关联起来。

    当客户端向redis发起请求的时候,首先就会在socket产生一个AE_READABLE事件,然后由对应的命令请求处理器来处理。这个命令请求处理器就会从socket中读取请求相关数据,然后进行执行和处理。

    接着redis这边准备好了给客户端的响应数据之后,就会将socket的AE_WRITABLE事件跟命令回复处理器关联起来,当客户端这边准备好读取响应数据时,就会在socket上产生一个AE_WRITABLE事件,会由对应的命令回复处理器来处理,就是将准备好的响应数据写入socket,供客户端来读取。

    命令回复处理器写完之后,就会删除这个socket的AE_WRITABLE事件和命令回复处理器的关联关系。

    三、为什么redis单线程模型的效率很高

    首先redis里的数据是存储在内存中,对redis里的数据操作的时候实际上是纯内存操作;其次,他的文件事件处理器的核心机制是非阻塞的IO多路复用程序;最重要的是单线程反而避免了多线程频繁上下文切换带来的损耗。

    四、redis的过期策略和内存淘汰机制

    在我们平常使用redis做缓存的时候,我们经常会给这个缓存设置一个过期时间,那么大家都知道如果我们在查过了过期时间的key时是不会有数据的,那么所有过期key数据已经被删除了吗?是如何删除的?

    其实如果一个key过期了,但是数据不一定已经被删除了,因为redis采用的是定期删除和惰性删除。定期删除是指redis默认会每隔100ms会随机抽取一些设置了过期时间的key检查是否过期了,如果过期了就删除。那么为什么不遍历删除所有的而是随机抽取一些呢?是因为可能redis中放置了大量的key,如果你每隔100ms都遍历,那么CPU肯定爆炸,redis也就GG了。

    那么这样的话,为什么去查过期的key的话会查不到?

    其实这就是redis的惰性删除,在你去查key的时候,redis会检查一下这个key是否设置了过期时间和是否已经过期了,如果是redis会删除这个key,并且返回空。

    那么这样的话岂不是出大问题了,如果过期了又没有去查这个key,垃圾数据大量堆积,把redis的内存耗尽了怎么办?

    其实当内存占用过多的时候,此时会进行内存淘汰,redis提供了如下策略:

    (1)noeviction:当内存不足以容纳新写入数据时,新写入数据会报错。

    (2)allkeys-lru:当内存不足以容纳新写入数据时,会移除最近最少使用的key。

    (3)allkeys-random:当内存不足以容纳新写入数据时,会随机移除某个key。

    (4)volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的key。

    (5)volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个key。

    (6)volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的key优先移除。

    以上几个策略最常用的应该是allkeys-lru,其实这个也要根据业务场景去选择。

    五、redis的持久化机制

    大家都知道redis的数据都是存储在内容中,那么redis实例挂了或者重启了怎么办?那是不是我们的数据就全没了?其实redis提供了持久化来解决这类问题。

    (1)RDB和AOF机制

    RDB持久化机制原理是对redis中的数据执行者周期性的持久化,是redis默认的持久化机制,他的默认持久化策略是如果在900S之内执行了一次数据变更操作,或者在300S内执行了10次,再或者在60s执行了10000次,那么在这个900S、300S、60S将会生成一个redis数据快照文件。

    AOF持久化机制对每条redis的写入指令都会做一条日志,以append-only的模式写入一个日志文件中,在redis重启的时候会回放日志文件中的指令来重新构建数据,它默认我没记错的话会每秒保存一个文件。

    如果同时启用了RDB和AOF的话,redis会使用AOF来恢复数据,因为AOF的数据会更加完整。

    在我们实际生产使用中,可以定期把RDB和AOF做备份到云服务器来做灾难恢复,数据恢复。

    (2)RDB的优缺点

    RDB机制有几个优点,首先它会生成多个数据文件,每个文件都代表某个时刻redis中的数据快照,这种文件非常适合我们做冷备,可以定期把这种文件推送到云服务做存储来应对灾难恢复;其次,RDB对redis对外提供的读写服务影响比较小,可以让redis保持高性能,因为redis只需要fork一个子进程,让子进程执行磁盘IO操作来进行RDB持久化;而且由于RDB是基于数据文件恢复的,所以说相对于AOF来说更加快速。

    RDB的缺点在于2个方面,首先在redis故障的时候,RDB由于它是定期去数据快照,所以说可能会丢失一部分数据;其次RDB每次在fork子进程来生成快照文件的时候,如果数据文件特别大,可能会导致对客户端提供服务暂停数毫秒,甚至数秒。

    (3)AOF机制的优缺点

    AOF机制的优点:1.AOF可以更好的避免数据丢失问题,默认AOF每隔1S,通过后台线程执行一次fsync操作,它最多丢失1S的数据。2.AOF日志文件以append-only模式写入,所以没有任何磁盘寻址的开销,写入性能比较高,而且文件不容易受损坏,redis也提供了修复受损坏AOF文件的方式,很容易修复。3.AOF日志文件可读性比较高,可以追踪误操作记录(如flush all),来删除这个日志记录进行紧急恢复。

    AOF机制的缺点:1.对于同一份数据来说,AOF日志文件通常比RDB数据快照文件更大。2.AOF开启之后,支持的写QPS会比RDB支持的写QPS低,因为默认AOF会每秒去fsync一次日志。

    (4)RDB和AOF该如何选择?

    其实我们应该两者一起使用的,用AOF来保证尽可能的不丢失数据,作为数据恢复的第一选择,利用RDB来做冷备,当AOF丢失或者不可用的时候来使用RDB快照来快速回复。如果单一选择RDB的话对比AOF可能会丢失部分数据,选择AOF的换又没有RDB回复的速度快而且AOF的备份很复杂。

    六、redis的cluster模式

    (1)redis模式简介

    redis的cluster模式自动将数据分片,每个master上放部分数据,每个master可以挂slave节点。

    redis的cluster模式提供了一定程度的可用性,当某些节点发生故障或者无法通信时,集群能够继续正常运行。

    (2)redis-cluster的数据存储

    redis-cluster采用的分布式存储算法为hash slot,redis-cluster有固定的16384个hash slot,对每个key计算CRC16值,然后对16384取模,就可以获取key对应的hash slot。

    redis-cluster会把hash-slot分散到各个master上,这样每一个master都会持有部分数据,当增加或者删除一个master,只需要移动hash slot则可,hash slot的移动成本很低。

    如果客户端想要把某些数据存储到同一个hash slot,可以通过api的hash tag来实现。

    (3)redis-cluster的基础通信原理

    redis-cluster节点间采用gossip协议进行通信,每一个节点都会有一个专门用于节点间通信的端口,默认为自己提供服务的端口号加10000,每个节点通过发送消息来交换信息。

    (4)gossip协议

    gossip协议包含多种消息,包括ping,pong,meet,fail,等等。

    meet: 某个节点发送meet给新加入的节点,让新节点加入集群中,然后新节点就会开始与其他节点进行通信,其实内部就是发送了一个gossip meet消息,给新加入的节点,通知那个节点去加入我们的集群。

    ping: 每个节点都会频繁给其他节点发送ping,其中包含自己的状态还有自己维护的集群元数据,互相通过ping交换元数据,每个节点每秒都会频繁发送ping给其他的集群,ping,频繁的互相之间交换数据,互相进行元数据的更新。

    pong: 返回ping和meet,包含自己的状态和其他信息,也可以用于信息广播和更新。

    fail: 某个节点判断另一个节点fail之后,就发送fail给其他节点,通知其他节点,指定的节点宕机了。

    (5)高可用性和主备切换原理

    一个节点在cluster-node-timeout内没有收到某一个节点的pong,那么它就会认为这个节点pfail了,它会在ping给其他节点,当超过半数节点都认为某个节点pfail了,那么那个节点就fail了。

    对宕机的master node,redis cluster会从其所有的slave node中,选择一个切换成master node。它首先会检查每个slave node和master node断开连接的时间,如果超过了cluster-node-timeout * cluster-slave-validity-factor,那么这个slave node就没有资格成为master node。每个从节点,都根据自己对master复制数据的offset来设置一个选举时间,offset越大代表从master节点上复制的数据越多,选举时间越靠前,优先进行选举。当选举开始时,所有的master node给要进行选举的slave node进行投票,如果大部分都投票给了某个节点,那么选择通过,这个从节点或切换成master。

    (6)JedisCluster的工作原理

    在JedisCluster初始化的时候,就会随机选择一个node,初始化hashslot -> node映射表,同时为每个节点创建一个JedisPool连接池,每次基于JedisCluster执行操作,首先JedisCluster都会在本地计算key的hashslot,然后在本地映射表找到对应的节点,如果那个node正好还是持有那个hashslot,那么就ok; 如果说进行了reshard这样的操作,可能hashslot已经不在那个node上了,就会返回moved,如果JedisCluter API发现对应的节点返回moved,那么利用该节点的元数据,更新本地的hashslot -> node映射表缓存,重复上面几个步骤,直到找到对应的节点,如果重试超过5次,那么就报错JedisClusterMaxRedirectionException。

    七、缓存雪崩和穿透

    缓存雪崩是发生在高并发的情况下,如果redis宕机不可用时大量的请求涌入数据库,导致数据库崩溃以至于整个系统不可用,在这种情况下数据库会直接无法重启,因为起来会被再次打挂。所以要想避免缓存雪崩可以考虑采用以下措施,首先尽量保证redis的高可用(可以通过主从+哨兵或者redis cluster来提供高可用),其次可以使用一些限流降级组件(例如hystrix)来避免mysql被打挂,最后如果不是分布式系统可以考虑本地缓存一些数据。

    缓存穿透发生在一些恶意攻击下,在有些恶意攻击中会伪造请求来访问服务器,由于伪造的错误的数据在redis中不能找到,所以请求直接打到了数据库,导致了高并发直接把mysql打挂,同缓存雪崩一样,在这种情况下即使重启数据库也不能解决问题。想避免这种问题可以考虑采用以下措施,首先对请求过来的参数做合法性校验,例如用户id不是负数;其次,可以考虑如果有参数频繁的访问数据库而不能查询到,可以在redis给它搞个空值,避免请求直接打在数据库。

    八、缓存与数据库双写一致性解决方案

    (1)Cache Aside Pattern原则

    读的时候,先读缓存,缓存没有的话,那么就读数据库,然后取出数据后放入缓存,同时返回响应。更新的时候,先删除缓存,然后再更新数据库。

    (2)解决方案

    方案一:队列串行化,当更新数据的时候,根据数据的唯一标识可以经过hash分发后搞到一个jvm内部队列,读取数据的时候,如果发现数据不在缓存中,那么将重新读取+ 更新缓存的操作也根据唯一标识发送到同一个jvm内部的队列,可以判断一下队列中是否已经有查询更新缓存的操作,如果有直接把更新缓存操作取消掉,然后每个队列单线程消费。但是这种方案有几个问题需要根据业务或者测试去完善优化,首先多实例服务怎么把请求根据数据的唯一标识路由到同一个实例;其次,读请求长阻塞、请求吞吐量、热点问题这些可能需要大量的压力测试和业务处理。

    方案二:分布式锁,当读数据的时候如果缓存miss,可以去尝试根据唯一标识(例如userId)获取锁,如果获取不到直接从数据库查询数据返回即可,不更新缓存,反之则更新缓存,之后释放锁。当写请求过来时要保证获取到公平锁或者获取锁失败可以直接拒绝(公平性获取锁可以通过zookeeper的临时顺序节点来实现),在更新完数据库可以同时更新缓存(也可以不更新)。

    九、redis的并发竞争问题以及解决方案

    当并发的去set某个值时可能结果与我们期望不同,这时我们就要去采取一些措施来避免。

    首先可以使用分布式锁来保证,这一块我在上面的双写一致性里面提到过,此处就不在赘述了。

    其次我们可以采用redis的CAS事务。Redis使用WATCH命令实现事务的“检查再设置”(CAS)行为。作为WATCH命令的参数的键会受到Redis的监控,Redis能够检测到它们的变化。在执行EXEC命令之前,如果Redis检测到至少有一个键被修改了,那么整个事务便会中止运行,然后EXEC命令会返回一个Null值,提醒用户事务运行失败。

    十、热点key

    (1)缓存热点key重建

    由于一个key是热点key必然会有大量的并发访问量,在这个key过期时,这是会有大量的线程去构建缓存,而构建缓存可能会很复杂,这样就导致了后端负载过大,这可能会导致系统崩溃。解决方式可以考虑以下几种之一,首先可以使用互斥锁,可以只让一个线程去构建,其他线程等待即可;其次可以考虑不设置redis key的过期时间,我们可以把过期时间放在key的value里,通过后台获取到value自己去判断是否已过期。

    (2)热点key所在实例负载过大被打挂

    当某一热点 Key 的请求在某一主机上超过该主机网卡上限时,由于流量的过度集中,会导致服务器中其它服务无法进行。如果热点过于集中,热点 Key 的缓存过多,超过目前的缓存容量时,就会导致缓存分片服务被打垮现象的产生。当缓存服务崩溃后,此时再有请求产生,会直接打在数据库,由于热点数据并发量非常大,很容易打挂数据库。这种问题的解决首先是提前准备好降低负载的手段和架构,例如读写分离、本地缓存等还有一些措施,然后提前预估一些热点key和定时去统计和发现是否有成为热点key趋势的key,如果发现可以提前扩容机器或者采用一些手段来降低负载。

    手在键盘敲很轻,我写的代码很小心。
    70