前提介绍:
电商网站系统为了追求高并发性能,离不开缓存的设计,本文主要介绍系统中Redis的组件能力设计,介绍Redis缓存部分热点问题和解决方案。
Redis组件功能模块涉及Redis查询加载、逻辑过期时间设计、快速清理缓存、缓存数据库最终一致性、缓存预热等。
缓存热点问题有缓存穿透、缓存击穿、缓存雪崩、缓存热key问题、缓存快速扩缩容等方案。
某些业务场景会应用到全量缓存,Redis全量缓存组件设计含以下功能模块,避免章节过长,后续单独整理文章。
目录
一、Redis组件逻辑架构简单介绍
二、Redis缓存查询加载、有数据更新的处理流程。
三、Redis缓存结构设计注意点
四、Redis逻辑过期时间的设计思路及解决的问题
4.1 举例线上实际发生业务场景
4.2 缓存逻辑过期设计方案
4.3 缓存逻辑过期应用场景
五、Redis缓存大数据快速清理工具设计。
六、Redis热点缓存预热
七、缓存数据库最终一致性
7.1 单系统处理方案
7.2 多活系统处理方案
八、缓存穿透、缓存击穿、缓存雪崩解决方案
8.1 缓存穿透
8.2 缓存击穿
8.3 缓存雪崩
九、缓存热key问题
9.1 监控热key
9.2 处理热点key
十、Redis快速扩缩容
在介绍缓存功能之前,先简单介绍下Redis高可用架构设计,下图逻辑架构是结合Sentinel一起使用,实现Redis高可用,同时可支持多组Redis数据分片、同组Redis读写分离的功能。
服务高可用:高可用方案采用“主从+Sentinel”模式。 框架则利用Redis中成熟的订阅功能去监听Sentinel集群中的消息,一旦出现主从切换,框架就会自动做出配置调整,保障服务的高可用性。
框架通过Sentinel来实现故障转移的整体方案说明:
应用的Redis服务器会以主-从(master-slave)为一组分为多组(shard),每组通过shardName注册到Sentinel集群系统中加以监控。框架初始化时,会通过配置文件中Sentinel的IP/Port信息与Sentinel系统建立连接,通过shardName从Sentinel中取到Redis的IP、端口、主从关系等信息,完成连接池的创建。当Sentinel 检测到某个主(master)停止服务后会启用从(slave)为新的主 (master),框架会通过与Sentinel的连接监听到主(master)的变化接着对连接池做一些处理,保证缓存服务高可用。
备注:虽然提供了高可用方案,但在故障转移时应用可能会抛异常,不可避免。此时写数据会出异常,但是数据不会丢失。由于在连接池主从信息重新初始化完成之前,写数据还是向原来的主机写入,但是此时Sentinel上这个机器已经是从机的角色。
1、缓存查询加载通用的处理流程,是先查询Redis缓存,缓存没有从db查询并加载到缓存中,如果数据库查询的数据为空,避免频繁透库,也需要在Redis设置一个空标识(缓存穿透解决方案),缓存不存在设置的空标识过期时间需要单独控制,避免大量刷无效的数据导致内存使用率告警。
2、在新增更新数据的时候,要清理缓存,保证数据库和缓存一致,处理的方法是使用事务控制,在事务提交前删除redis缓存,如果是全量缓存,是在事务提交前设置缓存。
由于存在删除redis缓存之后,事务提交成功之前,有并发查询存在,导致加载脏数据,处理方案之一是新增缓存同步待处理表,在事务提交之前,插入一条缓存同步待处理数据,异步再清理一次缓存,保证缓存最终一致性。
其他处理方案:监听数据库binlog日志删除缓存;事务之外直接推送MQ监听删除;
设计Redis缓存结构,有一些注意项,要提前考虑好,避免后续业务量上升后再重构。常见的设计规范网上都有说明,不详细列出,下面只列出容易忽略的点。
缓存结构设计注意项:
缓存名称 |
缓存前缀 |
实例 |
主站地址缓存 |
ADR: |
ADR:001 |
X业态地址缓存 |
XADR: |
XADR:001 |
Y业态地址缓存 |
YADR: |
YADR:001 |
…..
Redis热点缓存key会有一个过期时间,到期后需要重新从数据库加载,但是由于业务版本迭代频繁,缓存也有可能出错,或者新增了业务,需要快速失效某个缓存key,在这种场景发生的时候,如何快速失效缓存,保证线上业务正常,我们引入了逻辑过期时间配置。
系统有张第三方绑定关系表,该表存放的是所有第三方绑定关系,例如微信、头条、百度等,由于业务升级,原表结构模型已经不适合新的业务拓展,需要迁移到新表中。由于迁移数据量大,还涉及到很多的接口功能改造联调等,定的方案是根据第三方类型逐渐迁移改造。
使用大数据技术按照第三方类型迁移数据到新表,新表对应的Redis缓存value是所有第三方信息列表,如果当前的查询列表缓存的接口已经对外提供(同一个缓存key),缓存尚未过期,缓存和数据库是不一致的,切换开关后,缓存中没有新迁移的第三方类型绑定关系数据,就会导致缓存信息缺失,数据不准确,由于切换前没有考虑redis缓存和数据库数据不一致的问题,遇到这种问题后就无法及时处理,这种场景引入了缓存逻辑过期时间的设计,可以快速保障线上业务不中断。
场景A:快速处理生产缓存数据异常问题
场景B:利用逻辑过期时间解决缓存雪崩问题
利用逻辑过期时间<缓存key过期时间,达到逻辑过期时间,当前请求正常返回,然后异步丢到线程池中,从db重新加载数据,线程池配置丢弃策略,控制db的压力,避免影响正常业务
Redis缓存大数据清理工具,主要是为了可以快速清理缓存,有些业务迭代可能需要清理缓存,提供大数据清理的工具,可以快速清理。
电商网站在大促活动节点,某些信息的调用量并发量大,需要提前把数据加载到缓存中,避免大促活动期间大量穿库导致db压力大而宕机,或者做某个抢购活动—例如抢茅台、抢华为手机,需要提前把活动资源加载到缓存中。
预热的方案:把要预热的数据推送到MQ,监听MQ预热缓存,如果非多活环境,也可以新增缓存预热待处理表,使用JOB处理。
业务系统一个事务提交同时操作Redis和数据库,如果在事务提交前有其他线程重新加载缓存,这种场景下会导致缓存和数据库数据不一致的问题。 解决一致性问题,就是要在事务提交成功后再删除一次缓存,下面提供了非多活系统和多活系统两种处理方案。
业务系统在事务提交前起异步线程延时删除Redis缓存,或在事务提交后再删除一次缓存。
//延迟删除缓存的时间配置(毫秒),从zookeeper获取
long delayMillis = 2000;
// 开始执行任务时间戳,计算单位:毫秒
long doTaskMillis = System.currentTimeMillis();
// 还需要延迟的时间 = zookeeper配置的延迟时间 - 任务从创建到执行的时间差(原因:该任务可能在队列中呆了一段时间, 这段时间也属于延迟时间的范围,需要减掉)
// createTaskMillis是任务创建时间
long delayMillis = delayMillis - (doTaskMillis - createTaskMillis);
try {
// 还需要延迟的时间 > 0
if (delayMillis > 0) {
// 睡眠还需要延迟这么长时间,(延迟的方法需要优化)
Thread.sleep(delayMillis);
}
// TODO 删除缓存操作
} catch (InterruptedException e) {
// TODO 输出日志,用于分析问题
// 中断该线程
Thread.currentThread().interrupt();
} catch (Exception e) {
// TODO 输出日志,用于分析问题
}
多机房DC#1、DC#N,数据库底层是binlog同步,同一个会员可能会同时到多个机房访问数据,多活Redis集群没有相互复制,所以缓存同步方案是缓存更新后,使用MQ的方式推送到各个机房,然后多活机房监听缓存变更的消息后处理缓存,保证数据库数据和redis的最终一致性。
作为一种非关系型数据库,redis也总是免不了有各种各样的问题,缓存穿透、缓存击穿和缓存雪崩是缓存典型的三个问题,下面结合业界常见思路和生产已在运行的设计,给出一些解决方案。
缓存穿透是指查询一个数据库一定不存在的数据。正常的使用缓存流程大致是,数据查询先进行缓存查询,如果key不存在或者key已经过期,再对数据库进行查询,并把查询到的对象,放进缓存。如果数据库查询对象为空,则不放进缓存。
这里需要注意缓存击穿的区别,缓存击穿,缓存击穿是指缓存中没有但数据库中有的数据,并且某一个key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个key在失效的瞬间(一般是缓存时间到期),持续的大并发就穿破缓存,直接请求数据库,就像在一个屏障上凿开了一个洞。
为了避免缓存穿透其实有很多种解决方案。下面介绍几种
1、缓存空对象
当存储层不命中后,即使返回的空对象也将其缓存起来,同时会设置一个过期时间,之后再访问这个数据将会从缓存中获取,保护了后端数据源。
//获取Redis返回result
String result = redisManager.get(key);
//缓存value是"NOTEXIST",认为是缓存空对象,避免频繁透库,防止缓存击穿。
if (StringUtils.isEmpty(result) || "NOTEXIST".equals(result)) {
return null;
}
return result;
缓存空对象的缓存过期时间建议单独控制,因为业务场景不同,有些业务根据key查询后,再来调用的几率很小,这种避免Redis内存资源浪费,可以独立设置"NOTEXIST" 过期时间。
2、布隆过滤器拦截
布隆过滤器拦截在访问缓存层和存储层之前,将存在的key用布隆过滤器提前保存起来,做第一层拦截,当收到一个对key请求时先用布隆过滤器验证是key否存在,如果存在在进入缓存层、存储层。可以使用bitmap做布隆过滤器。这种方法适用于数据命中不高、数据相对固定、实时性低的应用场景,代码维护较为复杂,但是缓存空间占用少。
布隆过滤器实际上是一个很长的二进制向量和一系列随机映射函数。布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率和删除困难。
布隆过滤器的使用场景
1.在爬虫系统中,我们需要对 URL 进行去重,已经爬过的网页就可以不用爬了。但是 URL 太多了,几千万几个亿,如果用一个集合装下这些URL 地址那是非常浪费空间的。这时候就可以考虑使用布隆过滤器。它可以大幅降低去重存储消耗,只不过也会使得爬虫系统错过少量的页面。
2.邮箱系统的垃圾邮件过滤功能也普遍用到了布隆过滤器,因为用了这个过滤器,所以平时也会遇到某些正常的邮件被放进了垃圾邮件目录中,这个就是误判所致,概率很低。
3.推荐系统,已经推荐过的不在进行推荐。
系统中没有使用,没有深入的研究,了解更多,请自行百度。
某一个热点key,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个key在失效的瞬间(一般是缓存时间到期),持续的大并发就可能击穿缓存,直接请求数据库,就像在一个屏障上凿开了一个洞。
缓存击穿发生的原因可能有以下两种:
1、分布式互斥锁
只允许一个线程重建缓存,其他线程等待重建缓存的线程执行完,重新从缓存获取数据即可。使用Redis的setnx命令。
这种方案思路比较简单,但是存在一定的隐患,如果在查询数据库+重建缓存(key失效后进行了大量的计算等)时间过长,也可能会存在死锁和线程池阻塞的风险,高并发情景下吞吐量会大大降低,但是这种方法能够较好地降低后端DB压力,使用setnx,也要考虑释放锁,某一个热点key高并发也会有Redis热点问题。
我们系统中付费活动(霸王餐、抢购专享券等),活动权益是有数量限制,正常是提前预热到缓存中,在活动火爆或缓存中资源用尽,需要透库查询的时候,这时使用setnx加锁,只允许一个进程查询数据库,避免全部透到数据库导致压力大或者直接宕机,影响其他正常业务。
2、永不过期—全量缓存
缓存key设置永久有效,“物理”不过期已经非热点缓存,所以不会出现热点key过期后产生的问题,全量缓存没有缓存击穿、缓存雪崩等问题。
选择使用全量缓存,要考虑应用的场景,全量缓存有以下几点问题:Redis内存资源占用大、缓存代码复杂度增大、多活缓存机房间同步问题、缓存全量加载、Redis宕机/主从切换解决方案、Redis扩缩容问题…
应用场景:调用量大、高并发、数据库抗不住…..具体使用要结合业务场景选择全量缓存。我们生产应用全量缓存场景有手机号定位会员id、Super会员身份等。
缓存由于某些原因不可用(宕机)或者大量缓存由于过期时间相同在同一时间失效,大量请求直接穿透到存储层,存储层压力过大甚至down机。和缓存击穿不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。
为了避免缓存雪崩也有很多种解决方案。下面介绍几种
1、高可用架构
可以把缓存层设计成高可用的,即使个别节点、个别机器、甚至是机房宕掉,依然可以提供服务。利用sentinel或cluster实现高可用架构。
如果出现Redis主从一组全宕机的情况,目前系统按照不可用设计,毕竟出现一组全宕机的概率很小,除非机房停电等整个不可用,这种情况结合多活架构,自动切换另外机房。
2、多级缓存
采用多级缓存,本地进程作为一级缓存,redis作为二级缓存,不同级别的缓存设置的超时时间不同,即使某级缓存过期了,也有其他级别缓存兜底
例如公共配置类数据,在接口服务中需要用到配置类数据,这些配置存放在公共库(单库),所有接口服务请求都需要查询对应配置走不同的业务分支流程。对于这种场景,我们使用的是:Ehcache+Redis二级缓存+分布式互斥锁+缓存空对象,这种思路可以解决缓存的三个热点问题。
3、设置不同的过期时间
缓存的过期时间用随机值,尽量让不同的key的过期时间不同(例如:定时任务预热缓存,设置的过期时间不相同)
4、缓存过期前重新加载
缓存过期前,提前重新加载,某些系统使用的后台定时任务,定时刷缓存,替换一些要过期的key,这种要结合大数据分析热点数据。
引入缓存逻辑过期时间配置,逻辑过期时间配置
例如一个网站会员数有可能达到几亿,但是活跃会员数远远达不到,只要解决这一部分活跃会员在缓存过期前延长过期时间,就可以达到减少数据库压力的效果。
//获取Redis返回result
String result = redisManager.get(key);
if (StringUtils.isEmpty(result)) {
return null;
}
// 获取缓存对象,RedisObjectWrap对象参数:缓存加载时间戳@JSONField(name = "t"),value值@JSONField(name = "v")
RedisObjectWrap wrap = JSON.parseObject(result, RedisObjectWrap.class);
//缓存value是"NOTEXIST",认为是缓存空对象,避免频繁透库,防止缓存击穿。
if ("NOTEXIST".equals(wrap.getValue())) {
return null;
} else {
//缓存值过期,直接返回空
if(当前时间戳-t>配置的逻辑过期时间){
// TODO:使用线程池异步加载缓存,线程池使用丢弃策略
}
return wrap.getValue();
}
热key问题是指:突然有几十万甚至更大的请求去访问redis上的某个特定key。这样会造成流量过于集中,达到Redis单实例瓶颈(一般是10W QPS级别),或者物理网卡上限,从而导致这台redis的服务器Hold不住,直到缓存服务器垮掉。
那我们怎么发现热key?怎么处理热key?
1、按业务场景,预估热点key
秒杀业务中, 秒杀的商品(抢茅台)、券(大额满减券-满300-199)、权益资源(霸王餐、腾讯视频VIP)都是热点key,这种需要对业务的预估和理解,缺点是预估往往有偏差,总会有想不到的地方成为热点,或者突发的状况。
2、客户端收集
这个方式就是在操作redis之前,加入一行代码进行数据统计。那么这个数据统计的方式有很多种,也可以是给外部的通讯系统发送一个通知信息。缺点就是对客户端代码造成入侵。
3、代理层收集
缓存层之前加一层proxy代理层(比如Twemproxy),代理层做缓存统一入口。优点:对代码无入侵;缺点:架构复杂,缓存架构需要代理层设计。
4、redis监控命令
redis本身提供了相应的监控命令
monitor命令:可以实时抓取出redis服务器接收到的命令,然后写代码统计出热key。缺点:该命令在高并发的条件下,不仅有内存暴增的隐患,还会降低redis的性能。
hotkeys命令:redis 4.0.3提供了redis-cli的热点key发现功能,执行redis-cli时加上–hotkeys选项即可,操作方便。缺点:该参数在执行的时候,如果key比较多,执行起来比较慢。
这种可以提供工具功能,分析热key后通知到业务系统。没有研究过,大家感兴趣可以分享下实现思路。这种工具功能一般由组件部门提供工具能力,例如引入Redis工具maven依赖,实现热点key的业务处理方法。
5、网络抓包分析
Redis客户端使用TCP协议与服务端进行交互,通信协议采用的是RESP。自己写程序监听端口,按照RESP协议规则解析数据,进行分析。缺点就是开发成本高,维护困难,有丢包可能性。
6、大数据流式计算技术实时统计
业务系统访问Redis打印日志,然后把日志采集到大数据平台,然后基于大数据领域的流式计算技术来进行实时数据访问次数的统计,比如storm、spark streaming或flink,这些技术都是可以的。例如1秒之内,某条数据突然访问次数超过了1000(可配置),就把这条数据判定为热点数据,通知到业务系统。
1、使用本地缓存(二级缓存)。
在你发现热key以后,把热key加载到系统的JVM中,针对这种热key请求,会直接从jvm中取,而不会走到redis层。
应用集群中单台机器配置可能是4C8G,留给本地缓存的空间是很少的,所以本地不适合存放大量数据,只存放热点缓存数据。
本地缓存需要设置过期时间,避免OOM,可以使用Ehcache缓存几分钟。JVM热点缓存内存占用或热点Key数量达到一定阈值后,需要考虑及时释放内存占用,避免热点缓存评估不合理导致大量数据存放到JVM,要预留内存存放最热点的数据。
使用本地缓存的方案,需要考虑数据库数据变化后,如何快速清理本地缓存。本地缓存加载和缓存清理,都可以结合Zookeeper去实现。
具体要结合系统业务特性,合理选择实现方案。
2、备份热key
网上提供的思路备份热点key到多组Redis节点,就是请求的时候缓存key加个随机数让其请求到不同的Redis节点,这种方案,可能要考虑以下几点问题:
1.Redis机器实例少,key+随机数的方式可能分散不均,路由到同一组Redis分片概率大,可以考虑指定Redis分片节点。
指定分片节点使用Redis分片的一致性hash算法实现,根据分片shardname请求到对应节点。
Redis分片的一致性hash算法是将每个分片根据hash规则计算hash值,均匀分布在hash环上,一个redis物理节点默认划分为160个虚拟节点(索引:0~159),每个节点的默认权重为1,redis一致性hash算法将对分片名称、权重和虚拟节点组合进行hash值计算,分片名称和权重之间以*号连接,构造hash环时将所有redis的分片节点进行hash值计算,并构造TreeMap有序存储分片的hash值和分片实例,在根据缓存key路由redis节点时,先计算缓存key的hash值,然后从redis分片的TreeMap中获取hash值大于等于缓存key的hash值的第一个节点作为该缓存key的路由节点。所以当缓存key计算出的hash值和redis节点计算出的hash值一致时,该缓存就必然路由至执行的redis节点。 例如使用key路由, key的生成规则=Key+*+节点权重+虚拟节点索引,即节点名称如果是shardName,权重是1,虚拟节点索引为0时(虚拟节点索引可以为0~159中的任意一个整数),该节点对应的缓存key就为shardName*10。 |
2.缓存一致性问题,如果存储层数据变化,如何清理存放在多组分片上的缓存,保证缓存和数据库的一致性。思路是确定热点key的时候,就明确下需要路由的Redis节点分片列表,如果存储层数据变化的时候,把热点key对应的所有节点都清理一遍。
3、熔断限流保护
熔断限流也是极端情况下需要考虑的事情。面对高并发可以加一个对热点数据访问的限流熔断保护措施,限定缓存集群每秒最多的请求次数。
案例:商品中心架构演进分享的时候提到了关于商品数据热点的问题,当主站有秒杀活动时,秒杀商品调用量会达到数十万次,由于key相同会落到统一组redis上,导致单组redis访问压力过大,影响整个集群。
商品中心的解决方案:
1.前端核心系统本地缓存N分钟,减少穿透到商品中心
2.采用jvm缓存+redis+hbase存储架构,减少穿透至redis。
商品中心的案例是按业务场景预估热点key,处理热点key使用二级缓存的方案。这种不能解决冷商品突然变成热商品导致的热key问题,他们最后提供的思路是让组件部门开发根据发现热key,通知业务系统,再由业务系统存放到本地缓存,思路都差不多,解决热key问题,还是要根据业务场景评估最合理的方案。
Redis由于业务量上升导致QPS瓶颈或内存使用率高,需要扩容Redis,有些场景可以只纵向扩容(一主多从、增加内存),下面主要介绍横向扩缩容,如何保证业务高并发调用下业务无感知、缓存数据库数据一致性、压力平稳的扩缩容。
1、夜间业务低谷,评估数据库缓存压力不大,可以直接扩缩shard分片,热点缓存数据可以从数据库重新加载,很多系统是按照这种简单方案进行扩缩容。
优点:简单
局限:需要评估扩缩容后,数据库能支撑扩容后瞬时透库的压力,以及评估短期没有促销活动,否则需要考虑预热等。
2、高并发场景(大促促销活动期间),要求Redis扩容时不能大量透库。
解决方案: 根据Redis key查询对应值,如果缓存查询为空,判断当前路由的分片是否是新扩的shard,如果是新扩容的,需要路由老shard分片再查询一次,查询到就复制到当前分片,减少透库。
缓存数据库一致性:扩容开关打开的时候,查询需要从老shard分片读取复制数据,更新场景需要多删除老shard分片的缓存数据,该扩容方案涉及到代码层改造,兼容新老shard缓存处理。待新扩容的shard加载部分数据或老shard分片的命令数下降后,可以关闭扩容开关。老shard分片不需要的缓存数据等自动过期。
3、有些系统redis有多个集群,可能会通过申请新集群,替换老集群/多集群的方式扩容,扩容方案和扩分片处理思路相同,多集群也可以考虑按照key切换,减少切换风险和数据库压力。
涉及全量缓存的扩缩容
全量缓存设计方案,支持把全量缓存切热点缓存,那么在全量缓存扩缩容的时候,提前把全量缓存切成热点缓存,扩缩容的方案就和热点缓存一致,扩容成功后,再切成全量缓存。
步骤: