前言对于从事后端开发的同学来说,缓存已经成为项目中不可或缺的技术之一。没错,缓存可以显着提高我们系统的性能。但是如果使用不好或者缺乏相关经验,也会带来很多意想不到的问题。今天就来聊聊项目中引入缓存可能给我们带来的以下三大问题。看看你是不是被骗了?1、缓存穿透问题大多数情况下,添加缓存的目的是为了减轻数据库的压力,提高系统性能。1.1我们如何使用缓存?一般如果有用户请求,先检查缓存,如果缓存中有数据就直接返回。如果缓存中不存在,则再次检查数据库。如果存在于数据库中,则将数据放入缓存中并返回。如果数据库中不存在,则直接返回失败。流程图如下:上图小伙伴们一定不陌生,因为大部分的缓存都是这样使用的。1.2什么是缓存穿透?但是如果出现以下两种特殊情况,比如:用户请求的id在缓存中不存在。恶意用户伪造一个不存在的id发起请求。这样的用户请求的结果是每次都无法从缓存中找到数据,需要查询数据库。同时在数据库中找不到数据,无法放入缓存。也就是说,用户每次请求,都要查询一次数据库。图中红色箭头表示每次所走的路线。很明显,缓存根本不起作用,就像被穿透了一样,每次都会去数据库。这就是我们所说的:缓存穿透问题。如果此时缓存被穿透,直接请求数据库的次数非常多,数据库可能会因为承受不住压力而挂掉。呜呜呜。那么问题来了,如何解决这个问题呢?1.3检查参数我们可以检查用户id。比如你的合法id是15xxxxxx,15开头,如果用户传入16开头的id,比如:16232323,参数校验不通过,直接拦截相关请求。这样可以过滤掉一些恶意伪造的用户id。1.4Bloomfilter如果数据比较小,我们可以把数据库中的数据全部放到内存中的一个map中。这样可以非常快速地识别数据是否存在于缓存中。如果存在,就让它访问缓存。如果不存在,则直接拒绝请求。但是如果数据量太大,几千万或者上亿的数据,全部存储在内存中,显然会占用过多的内存空间。那么,有什么办法可以减少内存空间呢?答:这需要使用布隆过滤器。布隆过滤器底层使用位数组存储数据,数组中元素的默认值为0。布隆过滤器在第一次初始化时,会通过一系列的哈希算法(例如:三种哈希算法)计算出数据库中所有存在的key,每个key会计算出多个位置,然后这些位置上的元素值设置为1。之后,当一个用户密钥请求过来时,使用相同的哈希算法来计算位置。如果多个位置的元素值都是1,说明该key已经存在于数据库中。此时允许继续向后运行。如果多个位置的元素值为0,则表示该key在数据库中不存在。这时可以拒绝请求,直接返回。使用布隆过滤器确实可以解决缓存穿透的问题,但是也带来了两个问题:存在误判的情况。存在数据更新问题。首先我们看看为什么会出现误判?上面说了,在初始化数据的时候,会针对每个key使用多个hash算法计算出一些位置,然后将这些位置的元素值设置为1。但是我们都知道hash算法会存在hash冲突,即也就是说,不同的key可能计算出相同的位置。上图中下标2的位置存在hash冲突,key1和key2计算的位置相同。如果有几千万或者几亿的数据,Bloomfilter中的hash冲突会非常明显。如果是某个用户key,在多次hash计算出的位置,其元素值恰好被其他key初始化为1。这时候就会出现误判。本来key在数据库中是不存在的,但是Bloomfilter确认存在。如果布隆过滤器判断某个key存在,可能会出现误判。如果判断一个key不存在,那么这个key一定不存在于数据库中。一般情况下,布隆过滤器的误报率还是比较小的。即使有少量误判请求直接访问数据库,如果访问量不大,对数据库的影响也不会很大。另外,如果想降低误判率,可以适当增加哈希函数。图中使用的3次hash可以增加到5次。其实布隆过滤器最致命的问题是,如果数据库中的数据有更新,布隆过滤器需要同步更新。但是它和数据库是两个数据源,所以可能会出现数据不一致的情况。例如:数据库中新增用户,需要将用户数据实时同步到布隆过滤器。但是由于网络异常导致同步失败。这时,用户刚好请求了。由于布隆过滤器没有key的数据,直接拒绝请求。但这是一个普通用户,也被屏蔽了。显然,如果这样的正常用户被拦截,一些商家是无法容忍的。所以布隆过滤器要根据实际的业务场景来决定是否使用。它帮助我们解决了缓存穿透的问题,但同时也带来了新的问题。1.5缓存空值上面使用了布隆过滤器,虽然可以过滤掉很多不存在的用户id请求。但是除了增加系统的复杂度之外,还会带来两个问题:Bloomfilter可能会造成误杀,它可能会过滤掉一小部分正常用户的请求。如果用户信息发生变化,需要实时同步到布隆过滤器,否则会出现问题。因此,一般情况下,我们很少使用布隆过滤器来解决缓存穿透的问题。其实还有一个更简单的解决方案,即:缓存空值。当在缓存或数据库中找不到用户id时,也需要缓存用户id,但值为空。这样,当后续使用相同的用户id进行请求时,可以从缓存中获取空数据直接返回,而无需再次查数据库。优化后的流程图如下:重点是无论是否从数据库中查到数据,都将结果放入缓存,如果查不到数据,则缓存中的值为空。2.缓存击穿问题2.1什么是缓存击穿?有时,当我们访问热点数据时。例如:我们在某商城购买了一款热门商品。为了保证访问速度,正常情况下,商城系统会将商品信息存储在缓存中。但是如果在某个时刻,产品过期了就过期了。这时候,如果大量用户请求同一个商品,但是该商品在缓存中是无效的,那么这些用户的请求会一下子全部直接发送到数据库,可能会造成数据库瞬间压力过大,并直接挂断。流程图如下:那么,如何解决这个问题呢?2.2被锁数据库压力过大的根本原因是同时访问数据库的请求过多。如果能限制一次只能有一个请求访问某个productId的数据库产品信息,不就解决问题了吗?答:是的,我们可以通过加锁来实现以上功能。伪代码如下:unlock(productId,requestId);}returnnull;访问数据库时加锁,防止同一个productId的多个请求同时访问数据库。然后,需要一段代码把从数据库中查询出来的结果放回缓存中。方法有很多,这里就不展开了。2.3自动续订的缓存崩溃问题是由于key过期导致的。那么,我们换个思路,在密钥快过期之前自动续订,不就可以了吗?答:是的,我们可以使用job来自动更新指定的key。比如我们有一个分类功能,设置的缓存过期时间是30分钟。但是有一个job每20分钟执行一次,自动更新缓存,重置过期时间为30分钟。这样可以保证分类缓存不会失效。另外,在请求很多第三方平台接口的时候,我们往往需要先调用一个接口获取一个token,然后再以这个token为参数去请求真正的业务接口。一般来说,获取到的token是有有效期的,比如24小时后就会过期。如果我们每次请求对方的业务接口,都要先调用token获取接口,显然很麻烦,性能也不是很好。这时候我们可以把第一次获取到的token缓存起来,在请求对方业务接口的时候从缓存中获取token。同时有一个job每隔一段时间请求token接口,比如每隔12小时,不断的刷新token,重新设置token的过期时间。2.4缓存不过期另外,对于很多流行的key,其实是可以不设置过期时间,让它们永久有效的。例如,参加闪购活动的热门商品并不多,由于此类商品的ID不多,我们不需要在缓存中设置过期时间。在秒杀活动开始之前,我们先用一个程序提前从数据库中查询商品数据,然后同步到缓存中进行提前预热。秒杀活动结束一段时间后,我们可以手动删除这些无用的缓存。3.缓存雪崩问题3.1什么是缓存雪崩?我们已经讲过缓存击穿的问题。缓存雪崩是缓存击穿的升级版。缓存击穿是指某个流行键失效,而缓存雪崩是指多个流行键同时失效。如果存在缓存雪崩,问题似乎会更糟。目前有两种缓存雪崩:大量流行的缓存同时失效。会造成大量访问数据库的请求。而数据库很有可能因为承受不住压力而直接挂掉。缓存服务器宕机,可能是硬件问题,也可能是机房网络问题。简而言之,整个缓存不可用。说到底就是大量的请求,通过缓存,直接访问数据库。那么,如何解决这个问题呢?3.2过期时间加随机数要解决缓存雪崩问题,首先要尽量避免缓存同时失效。这就要求我们不要设置相同的过期时间。您可以根据设置的到期时间添加一个1到60秒的随机数。实际过期时间=过期时间+1到60秒的随机数。这样即使在高并发的情况下,多个请求同时设置过期时间。由于随机数的存在,相同的过期密钥不会太多。3.3高可用在缓存服务器宕机的情况下,可以在系统设计初期设计一些高可用的架构。例如:如果使用redis,可以使用哨兵模式或者集群模式,避免出现单个节点故障导致整个redis服务不可用的情况。使用sentinel模式后,当一个master服务下线时,master下的一个slave服务会自动升级为master服务,代替下线的master服务继续处理请求。3.4服务降级如果实现了高可用架构,redis服务还是宕机了,怎么办?这时候就需要服务降级。我们需要配置一些默认的口袋数据。程序中有一个全局开关。比如最近一分钟有10个请求,从redis获取数据失败,则打开全局开关。后续新的请求会直接从配置中心获取默认数据。当然还需要一个job,定时从redis中获取数据。如果最后一分钟能两次获取到数据(这个参数可以自己设置),则关闭全局开关。后面的请求又可以正常从redis中获取数据了。特别说明,该方案并不适用于所有场景,需要根据实际业务场景确定。
