Redis缓存时效问题

以下所提到的LevelDB与Redis均为公司内部二次封装后,所以部分功能与官方原生略有差别。

背景

针对用户每日的上传文件需要做一些简单的统计作为费用控制参考,如果数据结果相差太多,可能会导致资损。每日的QPS大概平峰读7W,写12W。高峰读15W,写24W。目前流程是用户如果有了上传或者删除图片操作后,先写关系型数据库,有中间件监控关系型数据库的binlog,发现更改后,发送消息。存储系统监听到消息后,将数据变动存储到LevelDB。数据结构为pkey=userId,skey=日期,value=上传总数or已上传总存储量。每个以skey维度,30天有效期做持久化。容错为每日定时定次的map-reduce来做离线计算,计算后结果若与LevelDB无法匹配,以MR结果为准,覆盖LevelDB中的数据。

问题

按照上述背景描述来看,整套实施方案没有明显纰漏,但是在实际运行中发现有3类问题

  1. 由于QPS较高,而LevelDB的限流阈值是9000的请求量,当达到阈值后,数据写入失败
  2. 当LevelDB发生宕机后,数据恢复平均时长在15分钟左右,而业务方能接受的时长在10分钟以内
  3. 在目前使用的数据结构中,value支持的类型是Integer,但若用户上传的是超大文件,需将文件大小存储量拆分为高低位(高位1GB以上,低位1GB以下)分别存储。这种方式无论在存取过程中,引入了原子性问题

迁移方案——Redis

优势

  1. 数据结构方面支持类Redis的复杂类型外,还提供比Redis更多的类型和功能
  2. 单机版的实例,最大可支撑64G,QPS大致在70~80k左右(热点数据,如果对应于Range操作会更低);集群版实例,可以支持到1T/2T的集群,大致3-7M TPS
  3. 对于持久化的实例,集群内份数为2份。主挂切备,备挂换备,数据不会丢失。如果在同城容灾集群,份数为2+2。
    Redis的集群内HA主要包括引擎的错误检测 和快速切换 ,这个检测过程,最长16s,平均只有7秒。集群间的HA包括同城容灾和跨地域复制,跨地域复制可以工作在多Master的多活模式

缺陷

官方文档描述

持久化类型的原理是内存全量数据+write behind log,对于OPS在绝大多数情况下几乎与缓存性相同,但是在峰值写入时,由于受到WBL的影响,性能会比cache这种log型低20%-50%(仍旧依赖磁盘类型,和操作系统file cache情况)。

线上环境情况需上线后观察

方案

确认数据结构

  • 存储量
    采用Hashmap存储,key为{userId}_modifiedDate,field为RealTimeFileQuota,value为存储量
  • 上传文件数
    采用Hashmap存储,key为{userId}_modifiedDate,field为RealTimeFileNum,value为文件数

注意:此处的key真实存储时也需要包含{},目的是将userId作为hashtag来保证同一个用户的所有数据不会被分散到不同的slot中。因为在对账时,需要批量获取数据,必须保证key都在同一个slot内。

API

  • 针对Hashmap需要使用到的api
    HINCRBY key field increment
    EXPIRE key seconds
    HGET key field
    EXIST key
    以及高级功能pipeline(适用批量获取用户某个时间段内所有存储量)

引发的问题

由于key是以userId+操作日期为维度创建,并且每个用户的每日数据有效期为30天,因此我们的set动作时包含了3个步骤:

  1. 获取当前key是否存在
  2. 真正的set操作
  3. 若key在set前已存在,结束流程,否则刷新key有效期30天

按照这种操作逻辑,一次set动作至少要有2次redis交互,多则3次。前者情况每日最多发生N次,后者情况每日最多发生1次。

无论怎样,都是不理想的设计。在这种高并发情况下,每多一次IO,都会造成性能损耗,引来为止风险。

最为理想的解决方案是,一次IO操作完成上述3步;退而求其次,也希望可以有一个类似redis中对String结构SETEX的操作。

求医问药

对于所有的缓存,都必然需要一个主键时效及淘汰机制,否则你的内存指定是要爆的。那么Redis的这两种机制是如何的呢?

主键失效机制

所谓主键失效机制,就是我们平时常常用的的EXPIRE、EXPIREAT、PEXPIRE、PEXPIREAT、SETEX和PSETEX命令。前4个都是设置key的有效期,区别无非就是传入的value不同,有日期的,有时间戳的,有秒的,还有毫秒的。后2个cmd都是特别针对String结构的,将set和expire合二为一,一次IO,做了两件事。和我们上文中所理想的方式差不多。

只要给某个key设定了有效期,这类key就是volatile key(不稳定key),就好像Java中的volatile字段,字面理解都不稳定,都是会随时发生变化的。而另外一类就是没有设置有效期的key,这类叫作persistent key(持久化key)。如果没有特殊需求,建议都使用VK,目的在于提升内存使用率和预防内存满了之后,数据丢失或写入失败,这个就是淘汰机制要说的内容,别着急,后面再说它。回到原本的话题——主键失效机制。

生存时间

生存时间就是KEY的有效期,当一个KEY的生存时间等于0时,也就是它生命周期结束的时候,这个KEY和其所对应的数据可能会被删除!(敲黑板:是可能!不是一定!具体解释看下文)在有效期开始至结束这个范围内,其实我们是可以改变影响它的,也就是说有些命令是可以影响生存时间,而有些命令是不会影响的。

  1. DEL 毋庸置疑,都把一个KEY删掉了,哪里还存在生存时间问题呢
  2. SET和GETSET 针对一个已经存在的KEY,覆盖了新的数据同时,生存时间也会连同被重置
  3. RENAME 若将一个VKA改名VKB,这时旧的VKB会被delete,新的VKB的生存时间与VKA相同(有点绕,得琢磨一会)
  4. PERSIST 没的说,把一个VK持久化了,VK->PK,自然生存时间已经没有意义了,所以生存时间是要被干掉的

既然有的命令可以影响生存时间,那就有其他的命令不会影响生存时间,比如:

  1. INCR
  2. PUSH类操作
  3. HSET
  4. HINCR
  5. RENAME 疑惑状···上面不是说RENAME可以影响吗?这个是要分情况的。假如对一个VK进行了RENAME操作,期望改的那个KEY NAME本身是在内存中不存在的,那么改名后的生存时间不会发生改变

等等~官方并没有针对各个CMD对生存时间的影响与否一一作出说明,所以这个还是要自己去试的。如果没有十足把握确认当前CMD是否会影响到KEY的生存时间,建议在写开发方案时,一定要去考证。

相比之下,影响生存时间是我们需要关注的重点,只有了解了有哪些命令会影响生存时间,使用时才不会出现一脸懵X,找不到生存时间出现差异的根源~

如何更新生存时间就比较简单明了了,注意几个细节即可:

  1. EXPIRE类操作对一个PK使用该命令,会直接设置我们所传入的值作为生存时间。对于一个VK使用该命令,会用新的值替代旧值。这就是上文背景中所提到的封装的业务set操作为何需要优先EXIST一下KEY了。这也是此次业务问题发生的根源,Redis不像LevelDB,设置了skey生存时间,后面再传入有效期,也不会刷新
  2. 主键失效操作的时间复杂度是O(1),所以无需担心性能
  3. 与EXPIRE类操作常见搭配的TTL,TTL是可以查看当前KEY的剩余生存时间的哦~

淘汰机制(策略)

最大内存

Redis默认指定最大内存是0,即没有指定最大内存。那就是有多大内存就用多大,如果使用量超过最大内存了,那服务指定就崩了。所以一定要设置~~~~既然内存终究有耗尽的时候,此时就需要淘汰机制来维持服务的稳定运行。

Redis支持用户自行配置可使用的最大内存,3种方式修改:

  • 动态修改(重启后失效) 服务运行期执行命令config set maxmemory value
  • 动态修改并持久化到配置文件中config set maxmemory valueconfig rewrite
  • 修改本地配置文件(重启后生效)server.maxmemory

复习一下名词:VK(volatile key——设置了生存时间的key),PK(persistent key——持久化key,无生存时间)。好了,接着说!

6种淘汰策略

  1. volatile-lru 顾名思义,从VK集中挑选最近最少使用(LRU)的数据淘汰
  2. volatile-ttl TTL上文中提到过它是查询剩余生存时间的,所以这种策略就是指从VK集中挑选即将要过期的数据淘汰
  3. volatile-random 不用多解释,从VK集中随机淘汰
  4. allkeys-lru 从所有数据中挑选最近最少使用的淘汰
  5. allkeys-random 从所有数据中随机淘汰
  6. no-envication 禁止淘汰,结果是怎么样的?那就是塞不进来数据了,抛出异常还是返回失败结果标识呢?自己去试试吧~

以上6种淘汰策略都是可以在Redis中自行配置的,配置方法在此不做阐述。既然有6种可选,哪一种又是比较合适的呢?这个就是仁者见仁智者见智的事情了,一切都要以当时的业务场景来做度量,选择最合适的淘汰策略。通常有2类常见场景:

  1. 幂等分布——通俗的说就是有热点数据,除去热点数据,其余访问率低,此时通常使用lru
  2. 均等分布——所有数据访问频率差距不大,通常使用random

内部实现

淘汰机制(策略)有2种实现方式:

  1. passive way 消极方法
    当访问key时候,发现失效(生存时间为0)则删除。在GET类操作或者RANGE此类凡是读取数据的操作时,都会调用一个叫做expireIfNeeded的方法,该方法的作用就是判断该key是否已经失效,如果失效则删除。同时,方法内部会调用另一个方法propagateExpire,它是负责在删除动作发生前,将失效信息广播给AOF文件以DEL命令形式存储,还会将该信息广播给所有的Slave,同样传播的是DEL命令,通知所有Slave执行此条命令,将数据删除。所以对于Slave来说,并不需要通过主动触发消极方法来删除失效KEY,只是需要同步执行Master发出的指令即可。
  2. active way 积极方法
    周期性的从VK集中选取一部分失效的KEY进行删除。消极方法有个缺点,若KEY一直没有被访问,那即使失效了,也不会删除。而积极方法正是为了弥补这个缺点而产生的。它利用Redis的时间事件(类似Java中的定时任务),每隔一段时间,就挂起Redis执行线程,去执行一些特殊操作,其中包括删除失效KEY。
    (敲黑板:常有人问Redis是单线程的吗?答案:是。扩展:为什么Redis单线程还能那么快?答案:1.基本都是内存操作,你说快不快;2.单线程不存在竞争条件和上下文切换;3.非阻塞IO)
    1. 时间事件
      时间事件有个关键的回调函数serverCron,它在Redis服务启动时创建,每秒的执行次数可配置REDIS_DEFAULT_HZ,默认每秒执行10次。该时间事件做了很多事,其中比较重要的是:
      1. 统计信息更新
      2. Client连接超时检测
      3. AOF触发
      4. BGSAVE触发
      5. 失效KEY检查及删除
    2. activeExpireCycle
      1. 原理:从所有数据库的expire字典中,随机抽样REDIS_EXPIRELOOKUPS_PER_CRON(默认10)个VK,检查是否已失效,并删除。如果失效个数占本次抽样个数的比例超过25%,则继续下一轮抽样检查,直到比例低于25%停止对当前数据库的操作,转向下一个数据库
      2. 该函数并不会一次处理所有数据库,最多只处理REDIS_DBCRON_DBS_PER_CALL(默认16)个库,若当前存在的库总量小于设置值,或者是该函数此次执行时间超过了执行时间的限制设置(此类情况说明失效KEY过多)才会一次处理所有库
      3. 该函数有执行时间限制(微秒计),有效避免该行为占用过多的系统资源。计算公式timelimit = 1000000*REDIS_EXPIRELOOKUPS_TIME_PERC/server.hz/100。其中REDIS_EXPIRELOOKUPS_TIME_PERC是单位时间内能够分配给activeExpireCycle函数执行的CPU时间比例(默认值为25),server.hz是一秒内activeExpireCycle的调用次数。

失效淘汰带来的性能问题

虽然定期检查淘汰的做法会因为要挂起Redis的执行线程,从而产生了一定的性能损耗。但是由于activeExpireCycle方法内部做了多处优化改进,进而大大降低了淘汰机制给系统性能带来的影响,但是如果在实际场景中发生了缓存穿透,那么也是会发生灾难性问题的。

何为缓存穿透

本人在之前的工作中有一次要做一个大型营销活动,由于营销业务涉及复杂的规则计算,需要从多个业务系统中获取用于规则计算的参数,这种IO开销是很重的。为了应对这个紧急需求,开发方案选择了预热缓存,提前1周开始灌数。灌数据是由各业务方来主动触发的行为,而我们自身也没有做好严格的把控。结果大家写入的数据的失效时间都集中在某一时间段内。最后发生了什么呢?所有数据集中在0点前后失效,而活动是0点开始。服务直接血崩了~~~各种查询阻塞在缓存,之后自己的数据库压力突增和各种RPC调用超时,遇到不堪一击的上游系统,直接击垮。
通过我以上的白话描述,相信大家基本明白何为缓存穿透了吧~

如何避免缓存穿透

  1. 缓存失效后,一定要采取限流手段或者提前做好数据库层面优化,防止数据库被击穿
  2. 不同的KEY设置不同的过期时间,均匀分布,缩短执行线程被挂起的时长
  3. 二级缓存(将一个可拆分的数据拆为多个小数据,分别存储)或者双缓存(A、B两套一样的缓存,A是短期失效,B是长期。这样A找不到数据了,还能去B找)

结论

理想状态并不存在。所以我们暂且只能使用目前方案先上线跑一段时间,看看效果如何了。为了防止出现不可控的问题,险中求稳~那就来个开关吧!开关3种状态,仅使用LevelDB、仅使用Redis和两者同时使用(以LevelDB结果为准,Redis异常全部吃掉打日志)。

期望

探索了Redis的主键失效机制后,依旧没有寻找的理想的解决方案,那为什么Redis不能针对Hase、List和Set这类集合结构增加一种可以一次set包含expire的操作呢?另外对于expire操作为什么不能包装一个exist?expire:don’t do expire的复合操作呢?毕竟现实场景中都是一些复合操作,如果把这些复合操作以单条命令的形式由客户端逐个发起,不但增加了IO开销,还会衍生出来一些原子性问题~

当然Redis自身也有其他方式可以解决,比如说用事务或者LUA脚本都可以一次IO开销,做一系列的操作动作,甚至还可以做很多简单的逻辑计算判断等等,但是目前所处的环境是不允许使用的。原因很简单,并发太大,采用这两种方式可能会引发严重的性能问题,同时使用脚本操作,会有数据安全隐患!

所以,凡事都是各有各的原因,各有各的目的,鱼和熊掌不可得兼!

白十 wechat
欢迎订阅我的微信公众号!