缓存穿透、缓存并发、热点缓存的最佳实践缓存文件

发布时间:2024-07-11浏览:4

一、引言

我们在使用缓存的时候,无论是Redis还是Memcached,基本都会遇到以下三个问题:

缓存穿透

笔记:

上面三张图有什么问题?

我们在项目中使用缓存的时候,一般会先检查缓存是否存在,如果存在就直接返回缓存内容,如果不存在就直接查询数据库然后把查询结果缓存起来返回。这时候如果我们查询的某条数据在缓存中不存在,就会导致每次请求都要去查询DB,这样缓存就失去了意义,当流量大的时候,DB可能会crash。那么有什么好的办法可以解决这个问题呢?

如果有人频繁使用不存在的密钥攻击我们的应用程序,这就是一个漏洞。

一个聪明的方法是给这个不存在的键预设一个值。

例如“key”、“&&”。

当返回&&值的时候,我们的应用就可以认为这是一个不存在的key,然后我们的应用就可以决定是继续等待访问还是放弃本次操作。如果继续等待访问,经过一个时间轮询点之后,我们再次请求这个key,如果获取到的值不再是&&,就可以认为此时这个key是有值的,这样就避免了透传到数据库,从而阻塞在缓存中大量的类似请求。

缓存并发

有时候网站并发量很大,某个缓存失效了,可能会有多个进程同时去查询DB,同时去设置缓存,如果并发量特别大的话,也有可能造成DB压力过大,出现缓存频繁更新的问题。

我现在的想法是,对缓存查询进行加锁,如果KEY不存在,就加锁,然后把DB查进缓存中,再解锁,其他进程如果发现有锁就等待,解锁后再返回数据或者进入DB查询。

这种情况和前面提到的预设值问题类似,只不过使用锁会导致部分请求等待。

缓存失效

导致这个问题的主要原因是并发量高,通常我们在设置缓存的过期时间的时候,可能会设置为1分钟,5分钟等,当并发量很高的时候,可能会同时生成很多缓存,并且过期时间都一样,这时候过期时间到了,这些缓存可能同时失效,所有的请求都会被转发到DB,可能会造成DB的超负荷。

那么我们该如何解决这些问题呢?

一个简单的解决方案就是将缓存过期时间分散开来,比如在原有的过期时间基础上增加一个随机值,比如1-5分钟,这样各个缓存过期时间的重复率就会降低,不容易引发集体失效事件。

我们讨论的第二个问题是针对同一个缓存的,第三个问题是针对多个缓存的。

总之:

缓存穿透:查询一个肯定不存在的数据。比如文章表,查询一个不存在的id,每次都会访问DB,如果有人恶意破坏,很有可能直接影响DB。

缓存失效:如果缓存在一定时间内失效,对DB的压力就会比较突出。目前没有完美的解决方案,但可以分析用户行为,尽量均匀分布失效时间点。

当出现大量缓存穿透的情况时,就会发生缓存雪崩,比如大量并发访问无效缓存。

问题总结问题1:

如何解决DB和缓存一致性问题?

A:修改数据库之后,有没有及时修改缓存?这个问题之前也练习过,数据库修改成功,但是修改缓存失败的情况,主要是因为缓存服务器宕机了。网络问题导致的没能及时更新,可以通过重试机制来解决。如果缓存服务器宕机了,请求自然就达不到了,直接访问数据库。那么修改数据库之后,我们就没法修改缓存了。这时候,我们可以把这个数据放到数据库中,并且启动一个异步任务,去检测缓存服务器是否连接成功。一旦连接成功,就按顺序从数据库中取出修改后的数据,依次修改最新的缓存值。

缓存英文_缓存文件_缓存

问题2:

我想问一下缓存穿透的问题!比如用户通过ID搜索一篇文章,按照我之前说的,缓存的KEY是预先设置好的值,如果插入ID后,是预先设置好的值,比如“&&”,那继续等待访问是什么意思呢?什么时候ID才会真正附上用户需要的值呢?

A:刚才说的主要是后端配置、前端获取的场景,前端如果获取不到对应的key,就会等待或者放弃。在后端配置界面配置好相关的key和value之后,自然就会把之前的key&&替换掉。你提到的情况,自然应该有一个过程,会在某一时刻把这个ID设置到缓存中,当有新的请求到来的时候,再去获取最新的ID和value。

问题 3:

其实如果用redis的话,前几天看到一个很好的例子,双key,有一个当时生成的辅key用来标记数据修改的过期时间,然后在快要过期的时候重新加载数据。如果觉得key太多的话,可以把结束时间放在主key里,辅key起到锁的作用。

A:我们之前尝试过这种方案,这种方案会产生重复数据,并且需要同时控制附属键和键的对应关系,操作上有些复杂。

问题 4:

多级缓存是什么概念?

A:多级缓存就像我今天发的这篇文章里提到的一样,使用ehcache、redis作为二级缓存,就像我之前写的文章里提到的一样。但是也会有一致性的问题,如果我们需要强一致性,那么缓存和数据库同步就会有一个时间差。所以在具体开发过程中,一定要根据场景来分析。二级缓存解决的问题比较多,比如缓存穿透、程序健壮性等,当集中式缓存出现问题的时候,我们的应用还能继续运行。

注:本文提到的缓存可以理解为Redis。

2. 缓存穿透及并发解决方案

上面的文章介绍了一些关于缓存穿透和并发的常见思想,但是并没有明确一些思想的应用场景,下面我们继续深入探讨一下。相信很多朋友之前也看过很多类似的文章,但是归根结底还是有两个问题:

在并发量较高的时候,我其实不建议使用缓存过期策略,我更希望缓存一直存在,通过后台系统更新缓存系统中的数据,达到数据的一致性。有朋友可能会问,如果缓存系统崩溃了怎么办,这样数据库更新了,缓存没更新,达不到一致的状态。

解决问题的思路是:如果因为网络问题导致缓存无法更新数据,建议重试多次,如果还是更新不成功,则认为缓存系统不可用。此时客户端会把数据的KEY插入到消息系统中,消息系统可以过滤相同的KEY,只要保证消息系统中不存在相同的KEY即可。当缓存系统恢复时,再依次从mq中取出KEY值并从数据库读取最新的数据更新缓存。注意:在更新缓存前,缓存中还是有旧数据,所以不会发生缓存穿透。

下图展示了整个思考过程:

看完上面的解决方法很多朋友都会有疑问,第一次使用缓存或者缓存中没有我需要的数据怎么办?

解决问题的思路:此场景下,客户端根据KEY从缓存中读取数据,如果读到了数据,则结束流程。如果没有读到数据(可能有多个并发的请求都没有读到数据),那么就使用缓存系统中的setNX方法设置一个值(这个方法类似于加锁)。没有设置成功的请求会休眠一段时间,设置成功的请求则会读取数据库获取值。如果获取到了值,则更新缓存,结束流程。之前休眠的请求此时被唤醒,直接从缓存中读取数据,结束流程。

看完这个流程我觉得这里会有一个漏洞,如果数据库没有我们需要的数据怎么办?如果不处理的话,请求会造成死循环,不断查询缓存和数据库。这时候我们就会按照我上一篇文章中如果读不到数据就往缓存中插入一个NULL字符串的思路,这样其他请求就直接按照“NULL”来处理,直到后台系统成功将数据插入数据库并同步更新清理NULL数据并更新缓存。

流程图如下:

总结:实际工作中我们经常会将以上两种方案结合起来使用,以达到最佳效果。第二种方案虽然也会导致请求阻塞,但只在第一次使用或者缓存中暂时没有数据时才会出现。经生产测试,TPS 低于几万时不会出现问题。

三、热点缓存解决方案1、缓存使用背景:

我们以用户中心为例来说明一下:每个用户都会先获取自己的用户信息,然后再进行其他相关操作。可能会有以下几种场景:

2.思维分析

请看下图:

我们会利用缓存系统做一个排序好的队列,比如有1000个用户,系统会根据用户的访问时间更新用户信息,越近访问的用户排名越靠前。系统会定期筛选出最近的200个用户,然后从数据库中随机取出200个用户加入到队列中。这样每次有请求到来时,都会先从队列中获取用户信息,如果命中,再根据userId从另一个缓存数据结构中读取用户信息,如果没有命中,说明该用户的请求频率不高。

JAVA伪代码如下:

  1. for (int i = 0; i < times; i++) {

  2.     user = new ExternalUser();

  3.     user.setId(i+"");

  4.     user.setUpdateTime(new Date(System.currentTimeMillis()));

  5.     CacheUtil.zadd(sortKey, user.getUpdateTime().getTime(), user.getId());

  6.     CacheUtil.putAndThrowError(userKey+user.getId(), JSON.toJSONString(user));

  7. }

  8. Set<String> userSet = CacheUtil.zrange(sortKey, 0, -1);

  9. System.out.println("[sortedSet] - " + JSON.toJSONString(userSet) );

  10. if(userSet == null || userSet.size() == 0)

  11.     return;

  12. Set<Tuple> userSetS = CacheUtil.zrangeWithScores(sortKey, 0, -1);

  13. StringBuffer sb = new StringBuffer();

  14. for(Tuple t:userSetS){

  15.     sb.append("{member: ").append(t.getElement()).append(", score: ").append(t.getScore()).append("}, ");

  16. }

  17. System.out.println("[sortedcollect] - " + sb.toString().substring(0, sb.length() - 2));

  18. Set<String> members = new HashSet<String>();

  19. for(String uid:userSet){

  20.     String key = userKey + uid;

  21.     members.add(uid);

  22.     ExternalUser user2 = CacheUtil.getObject(key, ExternalUser.class);

  23.     System.out.println("[user] - " + JSON.toJSONString(user2) );

  24. }

  25. System.out.println("[user] - "  + System.currentTimeMillis());

  26. String[] keys = new String[members.size()];

  27. members.toArray(keys);

  28. Long rem = CacheUtil.zrem(sortKey, keys);

  29. System.out.println("[rem] - " + rem);

  30. userSet = CacheUtil.zrange(sortKey, 0, -1);

  31. System.out.println("[remove - sortedSet] - " + JSON.toJSONString(userSet));

热点资讯