缓存设计的意义
当我们在设计系统时,项目的初期一般不会考虑缓存的设计,理由大致是一开始业务增长缓慢,不会有太多的请求量,系统的负载问题没有那么突出。当业务不断增长,服务端请求量激增导致系统对底层存储(SQL or NoSQL等)读写数据压力增加,这时我们缓存的接入就十分必要。一方面缓存可以是in memory的一块存储空间,在单点的服务上缓存获取到请求及对应的响应(当然一套分布式的同步机制也是可以的,这里略过);另外缓存可以是第三方的内存存储,如Redis,通过redis提供的各类数据string、set、hashmap、zset等来缓存需要的数据。
缓存的接入可以有效的保护请求穿透,减轻对底层DB的压力(这里分库分表不合理或者业务存储不分离,很可能是灾难式的全站崩溃);同时对于用户的体验可以得到有效的保障,一个页面每加载多1s,用户的流失就多了x%,超过3s的话基本就只有一半的留存了。
但是缓存的设计与具体的业务息息相关,盲目使用不贴合实际场景的缓存总归不是最理想(比如缓存的数据结构不是最终数据,服务端需要做中间处理,同样会给系统带来不小的计算开销),因此对自身业务的理解对于缓存设计也是至关重要。这里可以看一篇美团团队在DSP系统中的缓存设计。
实际场景
这里介绍在实际工作中遇到的一个具体的缓存设计场景,最开始的目标是使得系统更加稳定,不需要因为请求压力天天收各种告警导致神经衰弱,到现在也希望这套系统未来是可适配可插拔的一套业务缓存系统。
先来描述下业务场景,我们需要展示的数据在一段时间内是不断变化的,有时候数据的变化在10s内;当超过一段时间后(一般是1-2天),数据访问的热度会下降,在相当长的一段时间里偶尔会有一些“长尾”的访问(这里指用户会不定时重新访问数据并有可能是一波突增的流量)。同时访问量在热点时期预计是在百万的并发。
方案1 hit + cache
项目开始阶段,我们的设计是直接使用共用的一套小型的redis集群,对于业务请求有一定的预估,架构图如下:
当用户请求进来后,我们从redis中读取,当读取不到时再从DB中读取返回给用户,并将数据做缓存。
优点:开发相对简单,都是CRUD的操作,不需要过度设计
缺点:数据实时性不太好,数据的TTL需要设置比较短,来保障数据过期可以重新获取最新的数据;同时如果是并发访问的场景,有可能在一开始会有大量请求击穿我们的服务到达DB
方案2 API与缓存层分离
基于方案1中数据实时性和击穿的问题,第二版的缓存我们将API层与缓存层分离开,架构如下:
API层负责接收请求,读取redis,判断数据是否存在,当数据不存在时,会在本地做随机等待(最多等待300ms)同时告知缓存层需要开启的缓存任务,等待缓存任务完成返回数据并回包,之后的请求再从redis直接取数据回包(这里的完成通知是用golang的sync.Cond来实现的)。这里所有访问相同数据的请求都会进入等待状态,等缓存完成统一通知回包。这样可以防止方案1里的穿透的问题,同时缓存任务来保障数据第一时间更新。
缓存层接收API(这里是抽象设计)传递的缓存请求,针对数据开启缓存任务,这里我们认为有用户访问就是热点数据,会定时刷新数据进入redis。
优点:API层内聚性比之前更好一些,不需要关心缓存实时性;缓存层来管理所有的缓存任务,可以对过时的数据做淘汰(这里我们根据数据的热度判断,一般数据在1-2天后会进入所谓的“长尾”周期)
缺点:缓存层同时启动所有的缓存任务执行,其实某种意义上也是对DB产生大量的读请求;同时缓存层的db连接池内临时连接数增多,内存开销增大。
方案3 缓存层缓存策略优化
先上架构图:
缓存层对不同类型的数据,会启动不同的随机定时器保证优先级高(更新频繁)的数据能优先执行,不活跃的数据我们降低权重做更新,并且淘汰(停止缓存任务)的权重也更高。任务入口从之前的rpc调用改为从消息队列消费任务,和之前设计成通用可插拔的缓存系统有关系,不同的业务层可以在缓存层配置自己的任务,对应的原始数据DB,如果存储缓存结构,优先级是如何?
cache对应相同类型的数据缓存任务,后续将配合访问频次采用LRU(Least Recently Used)的算法来做淘汰,进而优化配置的(相对固定)缓存任务过期时间。
结束语
缓存的方案多种多样,google一下有很多的架构设计与方案解释。适用于业务的才是最好的,了解缓存在系统演化过程中的角色和各个业务阶段中缓存的设计,对于真实的应用场景个人觉得不需要过度设计,虽说顶层设计看对系统未来的规划能力,但不是一步到位,这样往往会事倍功半。
参考内容:
https://tech.meituan.com/cache_about.html
https://coolshell.cn/articles/17416.html
有疑问加站长微信联系(非本文作者)