redis的数据过期 vs 数据淘汰

redis的数数据过期(expire)机制

这跟redis的key过期删除清理策略有关,redis提供了两种删除方式;

  1. 操作key的时候检查是否过期,如果过期返回null的同时删除key对应的数据。也就是所谓的惰性删除
  2. 定期删除。redis每隔一段时间(默认100ms)会检查过期key并进行删除
  3. 如果是全量扫描所有的key,那么每次会占用大量的cpu资源。可能会造成cpu资源的争抢,影响redis主线程
  4. 如果全量撒扫描所有的key,然后一次性删除所有的过期key,那么就会回收原本key占用的内存。这就有可能造成内存碎片,这可能会反过来影响新的写入请求。另外redis的IO及高性能中也说到redis的内存碎片整理,这个整理过过程是可能影响到主线程的。

鉴于这个原因,redis的定期删除就不是扫描所有可以了,而是随机选择一部分key(maxmemory-samples参数控制一次采样的key的个数),然后看这些key里哪些过期了,然后删除。

另外就是过期删除在主从节点上的机制:不管是惰性删除、还是定期删除,都是主节点上的行为,从节点永远都只是同步主节点数据,不会主动操作。所以从节点上数据的数据过期也是靠主节点删除后同步过来的。

虽然从节点不会删除、那如果去从节点读取数据的时候,是否会判断过期呢?

  1. 3.2版本以前,从节点是不判断过期时间的。有就返回;没有就不返回。所以过期安全靠主节点删除后同步。
  2. 3.2版本后,从节点会判断过期时间了。如果过期就返回null;但是还是不会主动删除数据,数据的删除还是需要等主节点同步。

导致在从库读取到过期key的原因,首先需要说明一点,就当前来说,主从节点是个子使用自己的本地时钟来计算过期的,并没有一个强一致的全局时钟(其实这个全局强一致时钟在分布式系统中是比较难做到的,参考下newsql的实现就知道了,花费了非常大的力气去实现这个全局时钟)。

  1. 3.2版本前,因为从节点不判断过期时间,只是依靠同步主节点的删除来实现。
  2. 主节点对过期key的删除本来就是有滞后性的,要么有人访问过期key、要么定时任务正好扫描到了这个过期key
  3. 主从延迟。主节点删除后,到从节点同步到命令并删除,中间是有时间差的。
  4. 3.2以后主要就是 因为主从节点上相同key的过期时间实际上不完全一致。
  5. 主从延迟导致。设置key过期时间的命令,主节点执行后,从节点同步到并执行成功,本身是有时间差的。如果过期命令是指令的一个相对时间,那么主从节点各自计算过期时间点,因为主从延迟就会导致从节点的过期时间是略长于主节点的。

为了减少这个问题,不要使用使用相对时间设置key的过期时间,而是使用exporeAt()使用绝对时间。这样主从上key的过期时间点就一定是一样的。

  1. 主从节点可能存在时钟不一致

这个其实是硬伤,为了缓解这个问题导致的不一致,可以让主从节点和相同的 NTP 服务器(时间服务器)进行时钟同步。

缓存淘汰(eviction)

这里需要明确一下,缓存过期和缓存淘汰完全不是一回事,千万别混淆了。

  • 缓存过期:解决的是如果缓存污染了,能够将污染数据在一定时间后自动删除的问题。
  • 缓存淘汰:解决的是因缓存数据占用过多内存,当内存的使用量达到了允许的最大值的时候,该怎么办的问题

淘汰策略的介绍可参考:缓存Caffeine之W-TinyLFU淘汰策略

redis7.0之前,只是提供了一个参数maxmemory来控制redis实例占用的内存总和,而触发redis淘汰key-value数据的依据,也是根据这个参数来的。

所以,这就有个问题:可能不是因为key-value占用太多内存而触发了淘汰key-value数据。

  • maxmemory参数控制redis实例的最大内存。注意是所有内存,包括key-value数据的内存,以及客户端的缓冲区等
  • maxmemory-policy:淘汰策略。即当redis使用内存总和达到了maxmemory指定值的时候,就会触发这个参数指定的策略去淘汰key-value数据。

整体上redis支持4类淘汰策略:

  • noEviction:不进行淘汰。当内存使用达到maxmemory后,就不提供写入服务了
  • LRU:按lru算法淘汰。但是需要注意的是,redis实现的lru算法做了简化,并不会真的去维护所有的key-value数据最新访问队列,而是在RedisObject(全局hash桶的一个结构,参考:redis的常用数据结构)中有个lru字段记录了这个key的最新访问时间,然后在启动淘汰的时候,是随机从所有key(或者设置了过期时间的key)中选择参数maxmemory-samples指定个数的key来进行最新访问时间的比较,将访问时间最远的给淘汰掉。

这样的好处是,不用维护lru队列,从而也是提高了性能。

  • LFU:4.0版本后,提供了按lfu算法淘汰

但需要注意的是,redis使用lfu淘汰的时候,应该是说优先使用访问次数淘汰、当访问次数一样的时候,就会使用lru来淘汰。

4.0版本后,RedisObject中的lru字段作了扩展,分为了两部分

  • 前16bit的ldt 值:记录数据的访问时间戳,即原来的lru
  • 后8bit的counter值:记录数据的访问次数,用于lfu。
  • 由于8bit的counter最大也就可以存储255,而Caffine使用的tiny-lfu用于存储访问次数的只有4bit,最大也就只能存储15,tiny-lfu采用的是满了过后衰减缓存Caffeine之W-TinyLFU淘汰策略;而redis采用的时候在加1的时候采用减缓增加的策略,将+1的速度放缓

p=1/(counter + lfu_log_factor+1)

random = (0,1)的随机数。

如果 p > random 则counter++;否则不做任何事情。这个规则的目的其实就是为了让counter不要线性增长,减缓counter增长的速度。

有了这个规则:当lfu_log_factor=10的时候,访问次数要达到10M(几十万级别),counter才会到达255。

  • 为了防止热极一时的数据常驻内存不能淘汰,redis同样也设置了计数器的衰减。

counter = count -(now() - 最近访问时间)转换成分钟/lfu_decay_time

即先取当前时间内和最近访问时间的差值,并转换成分钟单位,然后这分钟差值除以配置项lfu_decay_time,得到的值就是计数器要衰减的值

  • 为了防止一个数据刚刚进入缓存因为访问次数低而被优先淘汰的问题,redis将counter的初始设置成5,而不是0;而tiny-lfu虽然初始值不是0,但是它有candinate访问次数小于5的判断。
  • random:随机淘汰

redis对key数据存储,其实是分为了两类,且分开存储的,即代过期时间和不带过期事假的。将带过期时间的key是单独进行了存储,即放在了db.expires空间中,而db.dict里放的是所有的key。所以按照淘汰key的范围又进行了区分

  • 如果在所有的key中按照lru、lfu、random来淘汰。即:allkeys-lru、allkeys-lfu、allkeys-random
  • 如果只是在设置了过期时间的key中进行淘汰。即:volatile-lru、volatile-lft、volatile-random。如果只是在设置了过期时间中淘汰,还多了一种volatile-ttl,即优先淘汰设置过期时间短的(即优先将快要过期的给淘汰出去)

redis的对内存的使用,主要包括两部分(ps:其实还有一些其他元数据也会占用内存,但是大头主要是一下两部分)

  1. 存储key-value数据的。
  2. 为每个客户端设置的缓冲区。参考redis的IO及高性能之缓冲区

在redis7.0之前,只提供了限制redis进程总内存的使用,没有办法限制客户端缓冲区的内存使用量。

  • maxmemory参数限制的是redis进程使用的总的内存量。
  • client-output-buffer-limit限制的是每个客户端使用的输出缓存区的大小,而输入缓冲区不支持用户设置,但是也是有最大限制的,即最大1G。但是没有限制最大客户端连接数,所以总的客户端端缓冲区的内存是没有限制的。

这就有个坑爹问题:如果客户端比较多,且流量比较大,那就有可能因为客户端连接占用太多缓冲区导致去淘汰key-value数据。但这个时候,去淘汰key-value数据并不一定合适。

在redis7.0之后终于提供了一个限制客户端最大连接数的参数了。

  • maxmemory-clients:这个参数加上client-output-buffer-limit参数,其实就可以限制redis给客户端分配的缓冲区内存总和的使用了。当超过过后,就会触发自动关闭连接了。

所以在7.0之后,提供了一个client命令的选项:no-evict,可以将其设置为开或者管。如果将客户端连接no-evict设置成开,那么在redis服务端触发连接关闭的时候,只会去关闭no-evict=false的链接,对于no-evict=true的那些连接,redis服务端不会主动去关闭他。

这就有个好处,对于一些管理员使用的客户端,就可以设置成no-evict=true,避免真的出现了链接爆炸,管理员都登不上啥也做不了。

redis集群机制简介

redis主从同步

redis通过在slave上执行replicaof 主节点ip 主节点port来建立主从关系,当执行了这个命令时:

  1. 建立主从关系
  2. 会执行一次全量数据同步
  3. 从节点发送psync请求给主节点
  4. 主节点收到请求后,会生成一个全量的rdb文件,然后将rdb文件传输给从节点
  5. 从节点收到rdb文件后,会先清空自己本地的数据,然后加载rdb文件。

ps:这个过程是消耗比较大的。

  1. 后续增量的修改,都会将修改命令异步同步给从节点。

主从关系建立后,主节点所有对数据修改的命令都会同步给从节点,以此保证主从数据的一致。

redis的主从方案参考:https://zhuanlan.zhihu.com/p/429397090,这篇文章写的是比较详细了。

为了让主从数据不一致不可控,redis也提供了两个参数,让用户可以修改主从同步的一些策略,min-slaves-to-write和min-slaves-max-lag,这两个参数的含义:主库连接的从库中至少有min-slaves-to-write 个从库,和主库进行数据复制时的 ACK 消息延迟不能超过 min-slaves-max-lag 秒,否则,主库就不会再接收客户端的请求了

ps:这两个参数其实其实就是CAP种一致性和可用性的权衡了,交给了用户自己根据自己的应用场景去优化配置。几乎所有的分布式系统基本上都会有类似的参数交给用户来配置,其本质就是CAP理论。虽然redis有这种参数,其实会发现其目的是让主从不一致不至于不可控,所以redis其实是一个偏AP系统。

所以说,如果再有人直接问你哪个系统是AP、还是CP,这个其实问法就不准确。CAP理论证明了在分布式系统中,当有故障发生的时候,这三者不能兼得,只能求其二。而分布式系统强依赖于网络,所以一定会解决网络分区(脑裂)问题,否则只要网络抖动就可能系统运行的不正常,所以一个分布式系统的实现,就会在A和C中做”选择“(更准确的说法是权衡)。但是我们可以从系统的默认行为中,看出一些端倪,到底是偏向于AP还是CP。

比如redis,主从同步默认就是异步的,好处就是主从同步不会阻塞新的写入操作,可用性更高了,但是会有一定主从不一致空间。但是给用户提供了两个参数来影响主从同步过程,所以完全可以把他变成一个CP系统:slaves-to-write=副本个数,slaves-max-lag=0,只要有节点同步进度没有跟上主节点,那就不允许写入了,所以写入的可用性就大打折扣了,实际上也不会建议这么去做。所以从这个参数的默认行为以及实践建议上看,reids是一个偏AP系统,但并不表示它完全抛弃了C了。

再来看zookeeper,默认就是一个大多数机制:主从同步的时候,大多数副本同步成功了,才会返回成功。这个默认行为其实就已经是一个AC的权衡了:

  1. 一致性上看,少数节点肯定是存在和主节点不一致的时间窗口的,所以不是一个绝对的C系统。从节点的读取时,只要请求到了还没有来得及同步的少数节点上,那就会读到不一致的数据
  2. 可用性上看,它也只能容忍少数节点的失败。如果超过半数节点失败了,写入操作也是不可用的。

但是,为什么说zk是一个偏CP的系统呢?

  1. 数据更新和master选举都要求大多数,可以保证主从切换数据不丢失。对于用户来说,无论如何只要写入成功了,后续就一定能读取到最新数据(虽然读从节点可能有短暂的不一致),对用户来说数据的一致性最终得到保证(其实这个case更像是持久性)
  2. 配合读写策略,其实就能够保证用户读到的是最新数据,比如读大多数节点、读主节点等

ps:具有集群分片能力的分布式系统,其实默认都是读主节点,靠分片来分散读写压力的。

redis集群的主从延迟

因为redis除了支持普通的key-value数据以外,还支持给指定key-value指定过期时间,过期后将自动删除。所以主从延迟包含两个方便的含义:

  1. 同一key-value数据本身在主从节点上存储的内容存在一定时间的延迟不一致。
  2. redis同步主库是异步的,master写成功了就返回了。而主从同步本身就有延迟,且可能失败,造成主从的不一致
  3. 同一key-value的过期时间在主从节点上不一致。导致主库认为过期了不返给客户端端数据、但如果请求到从库却返回了数据。
  4. 主从延迟导致
  5. 主从时间的不一致

集群脑裂问题脑裂问题

在redis官方推出分布式集群部署方案之前,都是开源社区的大佬们基于redis二次开发提供的分布式集群能力,所以redis的方案就有好几种,比如曾经被广泛使用的民间分布式集群方案Codis,官方的RedisCluster。

如下的脑裂会出现数据丢失的问题,以及解决脑裂问题,redis提供的用户参数,都是redis官方集群版本RedisCluster。

脑裂丢失数据的原因:

脑裂恢复后,原来的主节点,接收到新主节点心跳广播后,会清空自己本地的数据,然后请求一次rdb的全量同步。因为这个机制,就需要特别小心,如果使用不当,就会导致在脑裂期间,原主节点如果是假死状态,那么脑裂期间原主节点就可能接收到客户端数据的写入,当脑裂恢复时,因为原主节点会清空本地数据,然后去新主节点同步,这样脑裂期间,在原主节点上写入的数据就丢失了

解决这个问题的业界通用办法就是:选主和写入的时候判断大多数成功才算写入成功。如果脑裂发生了,那么如果原master所在的网络分区只要少数节点,那么脑裂期间,原主节点一定写入不成功的;如果原老主节点所在分区拥有大多数节点,那么势必另外的网络分区里一定只有少数节点,所以主都选不出来,所以 也是没问题。

会不会存在一个节点既在原主节点网络分区、又在另外一个分区呢? 如果真有这种情况,说明这个节点和两个分区都能正常通信,那不就是没有发生脑裂么。

redis关于写入的时候判断大多数的相关参数:

  • min-slaves-to-write和min-slaves-max-lag 参数

主库连接的从库中至少有min-slaves-to-write 个从库,和主库进行数据复制时的 ACK 消息延迟不能超过 min-slaves-max-lag 秒,否则,主库就不会再接收客户端的请求了

min-slaves-to-write:这只时满足大多数要求。比如3副本(含master)则设置为3、5副本则设置成2

min-slaves-max-lag:这个就需要是个经验值了,需要设置成比选主过程更长一点的时间。

  • down-after-milliseconds:表示的是,哨兵节点超过down-after-milliseconds指定时间后还没有收到主节点的心跳,就会发起新的master选举流程

其实这个值的设置也是需要根据集群当前的网络、master节点压力等情况设置,太短了容易频繁触发master选举;太长了,master真的挂了,长时间选举不出来新的master,会导致更长时间的写入不可用。