概述

今天讲的这个话题,我相信是众多工程师和团队的痛。从我刚开始工作,那时候构建本地缓存,到后续memcache, Redis的出现,到现在各种分布式集群的缓存,例如redis Cluster等产品的出现,缓存越来越发达和复杂了,缓存对我们的系统也越发重要,现在很难相信一个后端服务里没有缓存的存在。在这篇文章里,我会和大家分享一下过去踩到的缓存踩坑故事,然后试图给出一些解决方案,大家可以一起讨论,最终拿出更好的方法。由于篇幅有限,所以这里的缓存讨论,只局限于后端服务的缓存,并且不涉及具体的框架。

从两个角度看

一、客户端:

html缓存:

手机页面通常在第一次加载后会进行缓存,然后每次刷新会使用缓存而不是去重新向服务器发送请求。 如果不希望使用缓存可以设置no-cache。

ajax缓存:

cache:

要求为Boolean类型的参数,默认为true(当dataType为script时,默认为false),设置为false将不会从浏览器缓存中加载请求信息

二、服务端

服务端肯定是有缓存的,涉及CDN,redis。

案例1,缓存和DB的同步更新不在同一个事务里并且没有重试补偿机制

为了减少系统间的依赖,不同系统的数据更新往往不放在同一个事务里,采用MQ来进行通信。大家可以看下图,后台系统CRM更新产品数据到DB,Product系统收到异步消息通知后,更新最新数据到缓存。这是一个最常见的缓存应用场景,我相信很多团队都是这样用的。在这个Case里容易出现的问题在于,如果批处理任务收到消息后服务crash掉了,缓存没有正常更新,就出现了与DB的数据不同步,前端系统一直不能读到最新数据,导致业务异常。

Java互联网架构-高并发缓存架构你那些年遇到的踩坑故事_第1张图片

解决方案:1. 失败消息一定要建立一定时间间隔的重试机制

2. 系统要有缓存更新的报警机制,方便更新失败或者重试超时后,可以人工介入进行补偿。

案例2, 同一数据被1个以上的服务执行写操作,其中一个服务的缓存数据没有版本控制

这也是两个不同服务更新数据过程中很常见的情况,见下图,CRM系统更新了某个用户的Profile, 保存更新数据库后,通过MQ通知用户系统更新缓存,由于是异步更新延迟,在缓存更新前,用户系统收到前端的指令,读取了当前缓存里的用户数据,做了修改,并更新到DB中。出现的结果就是数据库里的CRM的更新被错误覆盖。

Java互联网架构-高并发缓存架构你那些年遇到的踩坑故事_第2张图片

解决方案:缓存里的数据有一个标志位可以作为更新数据库数据的依据(Update_time or Version), 如果缓存里数据时间与数据库时间不能匹配,意味着另外一个服务更新了该数据,那么就先从DB里读取最新数据版本,然后在新版本上提交数据。

案例3, 并发查询缓存中同一数据,如果缓存没命中,导致DB瞬时被打爆

做促销活动的时候,存在大量用户的并发访问某一个特定商品,该商品数据缓存失效,或者做了数据更改,但是对应缓存还没有更新,那么所有这些访问将同时直接被作用到DB上。

Java互联网架构-高并发缓存架构你那些年遇到的踩坑故事_第3张图片

解决方案:做一个计数器或者锁(没有特别复杂逻辑的话,可以直接用HashMap),如果发现某个KEY缓存没有命中,那么在计数器+1, 然后访问数据库,拿到结果更新缓存,清理掉计数器中的key。 在这个过程中,如果有第二个线程或者更多的线程需要访问这个KEY时,发现计数器的值>1 或者被加锁, 那么wait, 直到计数器清理掉,当然,这个技术器阈值是可以在配置文件里配置的,不一定是1。

案例4, 缓存没有设置默认值,被***,缓存一直保持在被“穿透”状态

这个情况,和案例3比较类似,都是缓存无法命中,但不一样的地方在于,数据的KEY值是无法控制的,所以没法简单的用计数器和锁来处理, 比方,被人为***,制造的大量的无效userID访问。

解决方案:所有没有在缓存的KEY,全部分配一个默认VALUE “UNKOWN-KEY” ,具体是什么情况下,将默认值分配给没有命中的KEY, 这个可以根据自己的业务系统来定,比方说,可以根据特定的IP段,或者没有命中的总次数等,然后我们就可以决定是否继续访问DB还是直接返回默认值给前端,拒绝本次数据访问。这种做法的核心在于,每次数据访问,都会有缓存结果返回,根据系统的情况来决定是否要进一步访问DB。

总结,今天列举的这几个案例,归纳起来,可以总结为以下几点:

1. 保证缓存同步

2. 减少缓存并发

3. 杜绝缓存穿透

缓存与背后的DB是相互依存的关系,缓存系统的设计原则,就是将访问的异常处理或者压力尽可能的前置处理掉,将DB还原成它最初本来的存储功能。