Redis作为缓存应用场景分析

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

为什么使用缓存

Redis是一个内存型数据库也就是说所有的数据都会存在与内存中基于Redis的高性能特性我们将Redis用在缓存场景非常广泛。使用起来方便响应也是远超关系型数据库。

应用场景

Redis的应用场景非常广泛。虽然Redis是一个key-value的内存数据库但在实际场景中Redis经常被作为缓存来使用如面对数据高并发的读写、海量数据的读写等。
举个例子A网站首页一天有100万人访问其中有一个“积分商城”的板块要直接从数据库查询那么一天就要多消耗100万次数据库请求。如果将这些数据储存到Redis内存中要用的时候直接从内存调取不仅可以大大节省系统直接读取磁盘来获得数据的IO开销提高服务器的资源利用率还能极大地提升速度。
比如很多大型电商网站、视频网站和游戏应用等存在大规模数据访问对数据查询效率要求高。Redis服务可实现页面缓存、应用缓存、状态缓存、事件并行处理能够有效减少数据库磁盘IO提高数据查询效率减轻管理维护工作量降低数据库存储成本。对传统磁盘数据库是一个重要的补充成为了互联网应用尤其是支持高并发访问的互联网应用必不可少的基础服务之一。
在这里插入图片描述

具体而言分布式缓存Redis可用于以下场景

1、页面缓存

Redis可将Web页面的内容片段包括HTMLCSS和图片等静态数据缓存到Redis实例提高网站的访问性能。
比如在电商类应用中热销商品展示、秒杀推荐等数据面临高并发读的压力分布式缓存Redis的高并发及灵活扩展可轻松支持此类应用。

2、状态缓存

Redis可将Session会话状态及应用横向扩展时的状态数据等缓存到DCS实例实现状态数据共享。在应对游戏应用中爆发式增长的玩家数据存储和读写请求时使用分布式缓存Redis可通过将热点数据放入缓存加快用户端访问速度提升用户体验。

3、应用对象缓存

Redis可作为服务层的二级缓存对外提供服务减轻数据库的负载压力加速应用访问。

4、事件缓存

Redis可提供针对事件流的连续查询continuous query处理技术满足实时性需求。

使用缓存的收益和成本

如图左侧为客户端直接调用存储层的架构右侧为比较典型的缓存层+存储层架构下面分析一下缓存加入后带来的收益和成本。
在这里插入图片描述

收益
l 加速读写因为缓存通常都是全内存的而存储层通常读写性能不够强悍例如MySQL通过缓存的使用可以有效地加速读写优化用户体验。
l 降低后端负载帮助后端减少访问量和复杂计算例如很复杂的SQL语句在很大程度降低了后端的负载。
成本
l 数据不一致性缓存层和存储层的数据存在着一定时间窗口的不一致性时间窗口跟更新策略有关。
l 代码维护成本加入缓存后需要同时处理缓存层和存储层的逻辑增大了开发者维护代码的成本。
l 运维成本以Redis Cluster为例加入后无形中增加了运维成本。

缓存不一致

一致性

1、强一致性
如果你的项目对缓存的要求是强一致性的那么请不要使用缓存。这种一致性级别是最符合用户直觉的它要求系统写入什么读出来的也会是什么用户体验好但实现起来往往对系统的性能影响大。
2、弱一致性
这种一致性级别约束了系统在写入成功后不承诺立即可以读到写入的值也不承诺多久之后数据能够达到一致但会尽可能地保证到某个时间级别比如秒级别后数据能够达到一致状态
3**、最终一致性**
最终一致性是弱一致性的一个特例系统会保证在一定时间内能够达到一个数据一致的状态。这里之所以将最终一致性单独提出来是因为它是弱一致性中非常推崇的一种一致性模型也是业界在大型分布式系统的数据一致性上比较推崇的模型。一般情况下高可用只确保最终一致性不确保强一致性。
强一致性读请求和写请求会串行化串到一个内存队列里去这样会大大增加系统的处理效率吞吐量也会大大降低。

业务场景

在绝大多数的系统中数据库往往是用户并发访问最薄弱的地方并且在高并发下的读多写少的情况下我们往往会借助一些中间键来解决数据访问过大时造成的数据库宕机情况例如我们可以使用Redis来作为缓存让请求先访问到Redis而不是直接访问数据库。而在这种业务场景下可能会出现缓存和数据库数据不一致性的问题。

在这里插入图片描述

问题产生的原因

一般来说读取缓存步骤是不会有什么问题的但是一旦涉及到数据更新也就是数据库和缓存都操作就容易出现缓存(Redis)和数据库(MySQL)间的数据一致性问题。
在数据更新时我们需要做以下两步

  • 操作MySQL
  • 操作缓存

但是无论是先执行步骤1还是先执行步骤2都有可能出现数据不一致的情况主要是因为读写是并发的我们无法保证他们的先后顺序。
相关策略
先做一个说明从理论上来说给缓存设置过期时间是保证最终一致性的解决方案(如果要求强一致性的话我认为没有必要添加缓存了直接走数据库)。这种前提下我们可以对存入缓存的数据设置过期时间所有的写操作以数据库为准对缓存操作只是尽最大努力即可。也就是说如果数据库写成功缓存更新失败那么只要到达过期时间则后面的读请求自然会从数据库中读取新值然后回填缓存。因此接下来讨论的思路不依赖于给缓存设置过期时间这个方案。
给出了三种更新策略

  • 先更新数据库再更新缓存
  • 先删除缓存再更新数据库
  • 先更新数据库在删除缓存

先更新数据库值再更新缓存值

最不可能选择的策略原因是此种策略可能会在线程安全的角度和业务场景角度生成脏数据和性能问题。
原因一线程安全的角度
同时有请求A和请求B进行更新操作那么就会出现

  1. 请求A更新数据库
  2. 请求B更新数据库
  3. 请求B更新缓存
  4. 请求A更新缓存

这就出现请求A更新缓存应该比请求B更新缓存早才对但是因为网络等原因B比A更早更新了缓存。这就导致了脏数据因此不考虑。
在这里插入图片描述

业务场景角度
1如果是写数据库场景比较多而读数据场景比较少的业务需求那么采用这种方案就会导致数据压根还没读到缓存就被频繁的更新浪费性能缓存此类数据没有很大的意义。
2如果是写入数据库的值并不是直接写入缓存的而是要经过一系列复杂的计算再写入缓存。那么每次写入数据库后都再次计算写入缓存的值无疑是浪费性能的。显然删除缓存更为适合。

后面两种策略都是对缓存进行删除这里先做一个解释。
例子数据库在1小时内更新1000次那么缓存也更新1000次但是这个缓存可能在1小时内只被读了1次那么就没有必要更新1000次了。反过来如果是删除的话那么也只是做了1次删除操作当缓存真正被读取的时候才去更新。

删除缓存值再更新数据库值

  1. 请求A进行更新操作首先删除缓存
  2. 请求B查询发现缓存不存在
  3. 请求B去数据库查询得到旧值
  4. 请求B将旧值写入缓存
  5. 请求A将新值写入数据库

在这里插入图片描述

上述情况就会导致不一致的情形出现。而且如果不采用给缓存设置过期时间策略该数据永远都是脏数据。我们可以采用延迟双删策略来解决这个问题。
相对应的步骤

  1. 先淘汰缓存
  2. 再写数据库
  3. 休眠t秒再次淘汰缓存

这么做的目的就是确保读请求结束写请求可以删除读请求造成的缓存脏数据。

// 伪代码
public void updateDb(String key,Object data) {
    redis.delKey(key);
    db.updateData(data);    
    Thread.sleep(t);
    redis.delKey(key);
}

如果系统中MySQL使用了读写分离模式那么有可能会出现在主从同步没有完成时读请求就去读取数据了这时候就会读取到旧值这里我们可以延长睡眠时间让主从同步完成后在进行一次删除如果不考虑主从的情况下采用双删不用加延时时间也是可以保证一直性的。

先更新数据库值在删除缓存值

假设有两个请求请求A进行更新操作请求B进行查询操作。
那么会出现如下情形

  1. 请求A进行更新操作首先更新数据库
  2. 请求B进行查询操作击中缓存得到旧值
  3. 请求A进行删除缓存操作

在这里插入图片描述

在这种情况下如果其他线程并发读缓存的请求不多那么就不会有很多请求读取到旧值。而且请求 A 一般也会很快删除缓存值这样一来其他线程再次读取时就会发生缓存缺失进而从数据库中读取最新值。所以这种情况对业务的影响较小。
无论是策略2还是策略3都有可能会出现这种情况删除缓存失败这时我们可以采用重试机制来保证数据的一致性。

方案的详细设计

在相关策略的调用中虽然提出了一些简单解决方案但是没有考虑到列如 缓存删除失败数据库更新失败等情况因此需要增加重试策略但是还是可能会出现比较不一致的问题此处详细介绍几种方案。
在这里插入图片描述

流程如下

  1. 更新数据库数据;
  2. 缓存因为种种问题删除失败
  3. 将需要删除的key发送至消息队列
  4. 自己消费消息获得需要删除的key
  5. 继续重试删除操作直到成功

如果能够成功地删除或更新我们就要把这些值从消息队列中去除以免重复操作此时我们也可以保证数据库和缓存的数据一致了。否则的话我们还需要再次进行重试。如果重试超过的一定次数还是没有成功我们就需要向业务层发送报错信息了。

// 伪代码
public void updateDb(String key,Object data){
    db.updateData(data);
    if (!redis.delKey(key)) {
        mq.send(key);
        new Thread(() -> asyncDel()).start();
    }    
}
// 异步重试
private void asyncDel() {
    int count = 0;
    String key = mq.get();
    while(!redis.delKey(key)) {
        count++;
        if (count > 5) {
            throw new DelFailException();        
        }
    }
    mq.remove(key);
}

这种虽然可以解决但是会对业务代码造成侵入而且还需要去维护消息队列如果可以容忍的话我觉得是可选的方案之一。
注意 需要使用有序的消息队列保证消息的有序性。重试删除
在这里插入图片描述

订阅binlog

在这里插入图片描述

业务代码只会操作数据库不操作缓存。同时启动一个订阅binlog的程序去监听删除操作然后投递到消息队列中。再启动一个消费者根据消息去删除缓存。
canal是用来模拟MySQL slave来订阅MySQL master 的binlog。

异步重试

总结

对于读多写少的数据请使用缓存。
为了保持数据库和缓存的一致性会导致系统吞吐量的下降。
为了保持数据库和缓存的一致性会导致业务代码逻辑复杂。
缓存做不到绝对一致性但可以做到最终一致性。
对于需要保证缓存数据库数据一致的情况请尽量考虑对一致性到底有多高要求选定合适的方案避免过度设计。

缓存问题

缓存穿透

问题描述

缓存穿透是指查询一个根本不存在的数据缓存层和存储层都不会命中通常出于容错的考虑如果从存储层查不到数据则不写入缓存层如下图所示

在这里插入图片描述

整个过程分为如下3步

  1. 缓存层不命中。
  2. 存储层不命中不将空结果写回缓存。
  3. 返回空结果。

缓存穿透将导致不存在的数据每次请求都要到存储层去查询失去了缓存保护后端存储的意义。
缓存穿透问题可能会使后端存储负载加大由于很多后端存储不具备高并发性甚至可能造成后端存储宕掉。通常可以在程序中分别统计总调用数、缓存层命中数、存储层命中数如果发现大量存储层空命中可能就是出现了缓存穿透问题。

解决方案

造成缓存穿透的基本原因有两个。第一自身业务代码或者数据出现问题第二一些恶意攻击、爬虫等造成大量空命中。下面我们来看一下如何解决缓存穿透问题。

缓存空对象

如图所示当第2步存储层不命中后仍然将空对象保留到缓存层中之后再访问这个数据将会从缓存中获取这样就保护了后端数据源。
在这里插入图片描述

缓存空对象会有两个问题第一空值做了缓存意味着缓存层中存了更多的键需要更多的内存空间比较有效的方法是针对这类数据设置一个较短的过期时间让其自动剔除。第二缓存层和存储层的数据会有一段时间窗口的不一致可能会对业务有一定影响。例如过期时间设置为5分钟如果此时存储层添加了这个数据那此段时间就会出现缓存层和存储层数据的不一致此时可以利用消息系统或者其他方式清除掉缓存层中的空对象。

布隆过滤器拦截

布隆过滤器实际上是一个很长的二进制向量和一系列随机映射函数。布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都比一般的算法要好的多缺点是有一定的误识别率和删除困难。可以告诉你某样东西一定不存在或者可能存在。
在这里插入图片描述

如图所示在访问缓存层和存储层之前将存在的key用布隆过滤器提前保存起来做第一层拦截。例如一个推荐系统有4亿个用户id每个小时算法工程师会根据每个用户之前历史行为计算出推荐数据放到存储层中但是最新的用户由于没有历史行为就会发生缓存穿透的行为为此可以将所有推荐数据的用户做成布隆过滤器。如果布隆过滤器认为该用户id不存在那么就不会访问存储层在一定程度保护了存储层。

两种方案比对

在这里插入图片描述

缓存雪崩

如图描述了什么是缓存雪崩由于缓存层承载着大量请求有效地保护了存储层但是如果缓存层由于某些原因不能提供服务于是所有的请求都会达到存储层存储层的调用量会暴增造成存储层也会级联宕机的情况。
在这里插入图片描述

预防和解决缓存雪崩问题可以从以下三个方面进行着手。
(1) 保证缓存层服务高可用性。如果缓存层设计成高可用的即使个别节点、个别机器、甚至是机房宕掉依然可以提供服务例如前面介绍过的Redis Sentinel和Redis Cluster都实现了高可用。
(2) 依赖隔离组件为后端限流并降级。无论是缓存层还是存储层都会有出错的概率可以将它们视同为资源。作为并发量较大的系统假如有一个资源不可用可能会造成线程全部阻塞在这个资源上造成整个系统不可用。降级机制在高并发系统中是非常普遍的。实际项目中我们需要对重要的资源例如Redis、MySQL、HBase、外部接口都进行隔离让每种资源都单独运行在自己的线程池中即使个别资源出现了问题对其他服务没有影响。但是线程池如何管理比如如何关闭资源池、开启资源池、资源池阀值管理这些做起来还是相当复杂的。这里推荐使用Java依赖隔离工具Hystrix他是解决依赖隔离的利器。
(3) 提前演练。在项目上线前演练缓存层宕掉后应用以及后端的负载情况以及可能出现的问题在此基础上做一些预案设定。

缓存击穿热点数据集中失效

问题描述

当一个key是热点key,并发量很大而且重建缓存不能在短时间完成在缓存失效的一瞬间就会有大量的线程来重建缓存造成后端负载加大甚至让应用崩溃这就叫缓存击穿。如下图:在这里插入图片描述

解决方案

互斥锁

此方法只允许一个线程重建缓存其他线程等待重建缓存的线程执行完重新从缓存获取数据即可整个过程如图所示。
在这里插入图片描述

永远不过期

“永远不过期”包含两层意思
l 从缓存层面来看确实没有设置过期时间所以不会出现热点key过期后产生的问题也就是“物理”不过期。
l 从功能层面来看为每个value设置一个逻辑过期时间当发现超过逻辑过期时间后会使用单独的线程去构建缓存。
整个过程如图所示
在这里插入图片描述

此方法有效杜绝了热点key产生的问题但唯一不足的就是重构缓存期间会出现数据不一致的情况这取决于应用方是否容忍这种不一致。

两种方案对比

在这里插入图片描述

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6
标签: redis