前言
在传统单体行业时代,缓存的使用还没普及。当时互联网行业并不发达,很多传统行业公司的并发量不大,所以不使用缓存也一样能够把项目运行的很好。后面互联网快速兴起,项目的并发量越来越大,原有的技术已经远远不能满足我们当时的需要,而缓存就是应对高并发最有效的手段之一。
通常情况下,我们是如何使用缓存的呢?当系统接收到一个获取数据的请求时,系统会先从缓存中查找数据,而系统从缓存中查找所需要的数据时,分这么两种情况。如果缓存中有数据的话,就直接从缓存中读取数据,然后返回给请求方。如果缓存中没有,那就从数据库中读取数据,然后再更新到缓存中。这样下次再获取这条数据的时候,我们就可以直接从缓存中读取,而不用来读取数据库了。
这样可以在一定程度上减少了数据库的 IO ,不过这种方案在并发量不高的时候使用起来是没有问题的。但当并发较高的时候,我是不建议使用缓存过期这个策略的,我更希望缓存一直存在,通过后台系统来更新缓存系统中的数据,达到数据的一致性的目的。而且这种方案在并发很高的场景下还会存在漏洞,这个漏洞就是缓存穿透的情况。
缓存穿透
什么是缓存穿透?缓存穿透指的是查询一个一定不存在的数据。由于缓存不命中时需要从数据库中查询,而数据库也没有这套数据,这时候就无法更新缓存了。这将会导致这个不存在的数据每次请求时都要到数据库中去查询。如果有人利用这个漏洞进行恶意攻击的话,将会对数据库产生非常大的压力,严重的话会导致数据库宕机。
那么针对这种缓存穿透,我们有没有什么好的解决方案呢?其实有一个比较巧妙的做法,我们可以在这个不存在的 key 预先设定一个值,比如 K 为 test,值设定为空字符串 null,在返回这个空字符串 null 的值的时候,我们应用就可以认为这是一个不存在的 key,就可以决定是否需要继续等待,还是继续访问,或者干脆放弃掉这次操作。如果继续等待,访问过一个时间轮询点后,再次请求这个 key,如果取到的值不再是,那就可以认为这时候 K 是有值了。这就避免了穿透到数据库的情况出现,从而可以把大量的类似请求挡在了缓存之中,解决缓存穿透的问题。
还有一种办法就是使用布隆过滤器,实际上它是一个很长的二进制向量和一系列随机映射函数。布隆过滤器可以用于检索一个元素是否在一个集合中。优点是空间效率和查询时间都远远超过一般的算法,缺点就是有一定的误识别率,并且删除很困难。布隆过滤器的原理也是比较复杂的,代码层面不容易实现。不过 Google 的 Guava 工具为我们提供了现成的类库供我们使用。这样不但可以节约我们写布隆过滤器工具的成本,而且使用起来也非常的简单,在 java 中引入 Guava 的依赖即可。
那么既然使用 Guava 工具这么方便,我们该如何使用 Guava 来实现布隆过滤器呢?
arduino
private static int size = 5000;
private static BloomFilter < Integer > demoFilter = BloomFilter.create(Funnels.integerFunnel(), size);
public static void main(String[] args) {
for (int i = 0; i< size; i++) {
demoFilter.put(i);
}
//判断5000数字中是否包含200这个数
if (demoFilter.mightContain(200)) {
System.out.println("找到了");
}
}
这里我们使用官网 guava 类库 BloomFilter 类定义一个过滤器实例 demoFilter 在 main 方法中将 1 到 5000 中的所有数字都放到 demoFilter 实例中,然后通过 demoFilter 实例中的 mightContain 方法来判断传递过来的值是否在布隆过滤器中。如果不在,就表示这个数值可能缓存穿透来的,这样我们就可以直接返回,而不用穿透到数据库中查询了。
并发填充缓存
除了缓存穿透的场景,还有一个场景那就是并发填充缓存。假设这是有多个获取同一条数据的请求到来,从缓存中没有查询到数据,这时所有请求都会到数据库中去查询,然后所有请求再反复更新缓存数据,注意更新的是同一条数据,这样不仅会增加数据库的压力,还会因为反复更新问题占用 redis 的资源。
这个问题就叫做缓存并发填充问题。那我们该如何解决呢?这里我们给出一个解决方案。注意这个方案不包括缓存穿透的场景,我们只是假定能从数据库中查询到数据。
一个查询数据的请求。从客户端发起请求会先从缓存中读取数据,然后判断是否能从缓存中读取到。如果读取到了数据就直接返回给客户端,流程结束。
如果没有读取到,那么就在 redis 中使用 set NX 方法设置一个状态位,表示这是一种锁定状态。要是设置成功了,表示已经锁定成功。这时候请求从数据库中录取数据,然后更新缓存,最后再将数据返回给客户端。
要是设置没成功的话,表示这个状态位已经被其他请求锁定。这个请求等待一段时间会重新发起数据查询。这样就能保证在同一时间只能有一个请求来查询数据库更新进行缓存。其他请求只能等待重新发起查询,再次查询后发现缓存中已经有数据了,那么直接返回数据就可以。
缓存过期
第三种使用场景是缓存过期。如果使用不当,不仅会造成缓存穿透,而且还会造成缓存雪崩的效应出现。我们在请求的过程中会不断的往缓存中写数据,这样缓存中的数据就会越来越多,但真正被经常访问的数据可能只是其中的一小部分。所以在某些需求下,我们没有必要将全部数据都放到缓存中,于是就会设置缓存过期时间。这样一来不经常访问的数据就会自动过期,而不会占用缓存空间。
那么我们该如何设置缓存过期时间呢?一般情况下可能会将缓存的过期时间设置为固定的时间。比如一分钟、五分钟这些并发很高的时候,可能会出现某一个时间点同时设置了很多的缓存,并且过期时间都一样。当缓存到期时,缓存同时失效,请求全部转发到数据库,这是数据库的压力就会瞬间增大,就会造成缓存雪崩现象。
对于缓存雪崩的现象有两种解决方案,一个将缓存失效的时间分散开,比如我们可以在原有的失效时间基础上去增加一个随机值,比如 1 到 5 分钟的随机,这样每一个缓存的过期时间的重复率就会降低,也就很难引起集体失效的时间了。第二个解决方案就是缓存不过期,我们可以通过后台来更新缓存数据,来避免因为缓存失效造成缓存雪崩,也可以在一定程度上避免缓存失效带来的问题。
热点缓存
接下来我们再来聊聊缓存热点问题。我们以一个用户中心的案例入手,每个用户都会首先获取自己的用户信息,然后再进行其他相关的操作。有可能会有这样的场景,大量相同用户重复访问同一数据。
也就是说数据库存在的用户数据,可能某一些用户的请求量非常大。有些用户的请求量很小,因为用户的数据量非常大,受限于缓存空间的大小。然而又不能把全部用户数据都存放到缓存中,只能存放热点用户数据。那我们该如何来判断哪些用户数据是热点数据呢?我们可以以一个访问用户中心数据的案例作为切入点,来说明热点缓存的用法。
总体思路就是通过判断数据最新访问时间来做排名,过滤掉不常访问的数据,留下经常访问的数据。可以先通过缓存系统做一个排序队列,比如存放 1000 个用户,系统会根据用户的访问时间更新用户信息的时间,越是最近访问的用户排名越靠前。同时系统会定期过滤掉排名最后的 200 个用户,然后再从数据库中随机读取出 200 个用户加入到队列。这样请求每次到达的时候会先从队列中获取用户信息,如果命中就根据 userID,再从另一个缓存数据结构中读取用户信息返回。在 redis 中可以使用 zadd 的方法和 zrange 方法来完成队列排序和获取 200 个用户的操作。
订阅 Binlog 更新缓存
前面的内容中,我们都是将缓存操作与业务代码融合在一起来分享的。这样做虽然容易实现,但是未来缓存操作的复用性就变得很差。基本上每做一个新业务都要重新写一次相应的缓存操作,而且维护起来也不容易。我们想修改项目中某一块业务的缓存操作,就需要先通读一遍这块业务的代码,了解了业务逻辑后才能进行修改。
那如何来解决这个问题呢?有一种思路是将缓存操作与业务代码进行解耦。使用这个方案的前提是我们使用 mysql 数据库,同时也需要借助阿里的 canal 数据库同步组件。
我们举一个实际的场景,用户在应用后台添加配置数据,配置数据存储到了 mysql 数据库中。同时数据库更新了 binlog 日志数据。接着再通过使用 canal 组件来获取最新的 binlog 日志数据。然后解析日志数据,并通过业务定义好的新的数据格式,重新生成新的数据并发送到 MQ 中。
应用方就需要监听 MQ 了,接收到数据后判断这个数据的操作是添加、修改还是删除。然后根据不同的操作来插入、修改和删除 redis 缓存中相应的数据。
总结
现在让我们来回顾一下刚才讲的内容。首先是缓存穿透的两种解决方案,我推荐的是采用预设值方案。因为布隆过滤器本身存在误判的情况,实现起来也较复杂。接着讲到了缓存并发问题,我们可以利用 redis 的 set NX 方法来配合解决。之后讲到了遇到缓存过期的情况,我们该如何避免集体失效问题。这里我分享了两种解决方案,一种是热点缓存方案,另一种是利用 binlog 和 canal 实现缓存操作与业务分离的方案。
最后我要强调一下,缓存的使用给我们带来非常多的好处的同时,也要充分考虑缓存使用上面的一些潜在的坑。比如使用缓存的时候,一定要多考虑缓存和数据库数据一致的问题,还要考虑缓存容量限制以及每次存放到缓存的数据大小等。在实际工作中,我们经常会因为这些问题导致生产事故,造成比较严重的后果。