当前位置: 首页 > 科技观察

巧妙设计多级缓存,减轻数据库负担

时间:2023-03-15 19:16:05 科技观察

作者介绍物流研发部架构师、GIS技术部负责人王子辰。2012年加入京东,有多年一线团队大促筹备经验,负责部分物流研发部门架构。专注于低延迟系统设计和海量数据处理。目前负责物流GIS部门,先后主持国标转京标、物流可视化等项目。自古兵家足智多谋,《谋攻篇》,“所以,一策是攻军,二是攻敌,二是攻军,三是攻城攻城之法,不得已。可见攻城的方法有很多种,而爬墙攻城是最不明智的做法。军队将被耗损,金钱和食物将丢失,人民将遭受损失。所以,我们有很多迂回的策略、谋略、外交、军事手段等等,每一种都比攻城更省钱、更轻巧,缓存设计也是如此。1、为什么要设计缓存?其实高并发的解决方案并不是互联网独有的。计算机的祖先早就为类似的场景做出了解决方案。比如《计算机组成原理》提到的CPU缓存的概念:它是一种高速缓存,容量比内存小,但速度要快得多。一百万倍的问题。传统的CPU直接通过fsb连接内存的方式,显然会因为等待内存访问导致CPU吞吐量下降,内存成为性能瓶颈。同时,由于访问内存时热点数据比较集中,因此需要在CPU和内存之间建立一层临时存储作为缓存。随着系统复杂度的增加,缓存与内存之间的速度进一步扩大。由于技术难度和成本,存在较大的二级和三级缓存。按照读取顺序,绝大多数请求先落在一级缓存上,再落在二级缓存上……因此,应用到SOA甚至微服务场景时,内存就相当于一个持久化数据库存储业务数据的,其吞吐量肯定远小于缓存,而对于java程序来说,本地的JVM缓存要好于集中式的Redis缓存。关系数据库易于操作,易于维护,访问数据灵活。但是,随着数据量的增加,检索和更新的效率会越来越低。因此,在高并发、低延迟要求的复杂场景下,需要减轻数据库的负担,减轻其压力。二、减轻数据库负担1、分布式缓存。在做多级缓存读请求时,写缓存,写缓存。写入缓存时,先写入本地缓存,再写入集中缓存。具体的缓存方式有很多,但需要注意几个原则:不要复制粘贴,避免重复代码;避免与业务耦合太紧,不利于后期维护;在开发初期,为了排查问题,往往会给Cache设置开关,但是太多的开关设置同时会增加系统的复杂度,需要结合统一的配置管理系统。比如京东物流就有一套叫UCC。综上所述,高耦合带来的痛苦,弥补起来代价很大,所以可以参考Spring缓存来实现,实现起来也比较简单。使用的时候,一个注解就可以搞定。写缓存失败怎么办?我应该先写入缓存还是数据库?既然是缓存设计,策略上肯定是保证最终一致性的,所以我们只需要使用异步消息来弥补即可。在大多数缓存应用场景中,读写比相差很大,读远大于写。这种场景只需要以数据库为中心,先写入数据库,再写入缓存即可。***补充一点,当数据库出现异常时,不要只捕获RuntimeException,而是抛出你关心的具体异常,然后进行针对性的异常处理。至于其他性能方面,缓存设计是尽量少占用。昂贵的内存资源和太大且难以维护驱使我们采用这种方式进行设计。因此,需要尽量减少缓存不必要的数据,有些同学为了图省事,将整个对象序列化存储起来。另外,序列化和反序列化也是很耗性能的。2.VS各种缓存同步方案缓存同步方案有很多,从一致性、数据库访问压力、实时性等方面考虑。一般来说,有以下几种方法:懒加载就是上一段说的,在阅读的时候顺便加载一下。为了更新缓存数据,需要使缓存过期。优点:简单明了。缺点:会造成缓存失效;这样,当用户并发很大时,缓存中没有数据,数据库就会承担瞬时流量过大的风险。懒加载太简单了,没有自动加载、异步刷新等机制,为了弥补它的不足,请参考下面两种方法:补充可以在缓存时将过期时间等信息写入异步队列,后台设置一个线程池定时扫描这个队列,在快要过期的时候主动重新加载缓存,让数据一直留在缓存中。如果缓存不存在,则无需查询数据库。常见的处理方式是使用binlog将消息处理成消息进行增量处理。优点:刷新缓存成为异步任务,由于任务队列的介入瞬间降低了数据库的压力,拉平了并发高峰。缺点:一旦消息积压,就会造成同步延迟,引入复杂度。定时加载需要一个异步线程池,周期性的把数据库数据刷到一个集中缓存中,比如Redis。优点:保证所有数据以最小的时间差同步到缓存中,延迟很低。缺点:如补充,需要任务调度框架,增加了复杂度,必须保证任务的顺序。如果想进一步加载到本地缓存中,则必须由本地应用自己启动线程抢占,该方案维护成本高。考虑使用mq或其他异步任务调度框架。ps:为防止队列过大导致调度问题,处理完的数据要尽快结转,监控数据积压情况和写入状态。3、防止缓存穿透缓存穿透就是查询key根本不存在,所以查询不到缓存,查询数据库。如果这样的key恰好有大量的并发请求,就会给数据库造成不必要的压力。如何解决?将所有存在的key存储在另一个存储的Set集合中,查询时先检查key是否存在;简单的更简单,给不可查询的key加上一个标识空值的Value,这样数据库就不会被查询了。比如场景是查询某省市某市街道对应的移动营业厅。如果某条街道没有移动营业厅,则key规则不变,可将该值设置为“0”等无意义字符。当然,这个方案必须保证缓存集群的高可用;这些key可能不会永远存在,所以需要根据业务场景设置过期时间。4.热点缓存和缓存淘汰策略有些场景只需要保留部分热点缓存,不需要缓存全量缓存,比如热门商品信息、热门商圈信息等购买的产品类型等。一般来说,缓存过期有3种策略:FIFO(FirstIn,FirstOut)意思是先进先出,淘汰最早进来的缓存数据,标准队列。以队列为基本数据结构,新数据从队首入队,队尾淘汰。LRU(LeastRecentlyUsed)最近最少使用,剔除最近未使用的缓存数据。如果数据最近被访问过,则不会被逐出。不同于FIFO,需要做一个链表的基本模型。读写的时间复杂度是O(1)。当有新数据写入头部时,链表已满,数据从尾部淘汰;最近访问的数据被移动到头部。第一部分,有很多实现算法,比如hashmap+双向链表等;问题是,如果最近偶尔频繁访问某些键,但不正常,数据就会被污染。LFU(LeastFrequentlyused)表示剔除最近最少使用的数据。注意与LRU的区别在于LRU的淘汰规则是基于访问时间的。LFU中的每个数据块都有一个引用计数,数据块根据引用计数进行排序。如果数据块碰巧有相同的引用计数,则按时间排序;因为新加入的数据访问计数为1,所以插在队尾;队列中的数据被新访问后,引用计数增加,队列重新排序;当需要淘汰数据时,删除排序表***中的数据块;有一个明显的问题就是如果在短时间内频繁访问多次,比如访问异常或者不受控制的循环,然后长时间不使用,数据会因为高频而被错误保留,并且会不被淘汰。尤其是新数据,由于它的初始计数为1,即使正常使用,也会因为不如旧数据而被淘汰。所以维基百科上说纯LFU算法不是经常单独使用而是和其他策略结合使用。五、关于缓存使用的一些常见问题Q1:那么你应该选择使用本地缓存(localcache)还是集中式缓存(Cachecluster)呢?A1:首先看数据量和更新缓存的开销。如果整体缓存数据量不是很大,而且变化不频繁,建议使用本地缓存。Q2:如何批量更新一批缓存数据?A2:从数据库中逐条读取,然后批量写入缓存,批量更新,设置版本过期key或者主动删除。Q3:不知道有哪些key,如何定时删除?A3:以Redis为例,key*太耗性能,不推荐。可以指定一个集合,把所有的key都存放在这个集合中,然后删除整个集合,这样就可以彻底清理干净了。Q4:一个key包含一个大set,Redis无法实现内存空间的统一分片?A4:1.可以简单的设置key过期时间,这样不保证缓存有效;2.设置keyVersion,比如两天后的当前时间,然后在读取缓存的时候根据时间判断是否需要重新加载缓存,作为版本过期的策略。