论缓存的具体实现以及现有开源方案

随着基于普通的SSM等类似项目体量逐渐变大时,项目的访问量急剧上升,此时对数据库的IO占用逐渐变大,同时数据库的庞大数据量检索速度和网络IO也是其中影响因素最大的一块

因此往往在这方面所采用的主要的手段有:

  • 分库分表
  • 读写分离
  • 索引优化
  • 缓存处理

其中前两个手段都是注重于去提升数据库的性能,但是分库分表设计以及索引的命中率排查往往都需要一定技术成本以及时间,且其中网络IO问题没有根本性的解决。

所以考虑到技术成本以及收益问题,往往都在一定的数据库优化基础上使用缓存进行额外的网络IO优化,同时运用二级缓存(进程缓存与网络缓存)可以达到更高的缓存命中率与相较数据库提升手段的CPU占用更低(详见下)的效果,同时转移了大部分的查询请求,使往往在同等计算资源的情况下,使用缓存会比不使用缓存能处理数倍的查询请求,并提高了数据库的可用性(防止大量请求使数据库瘫痪),并且实施起来有着很多样的缓存框架,非常适用于初创公司等技术沉淀较少或人力资源紧张的公司:

  • J2Cache、Guava等缓存容器框架
  • SpringCache、AutoLoadCache等缓存代理框架

其中分别解释为什么缓存能达到减少CPU计算成本与网络IO占用:

  • CPU计算成本

访问MySQL数据库时,受到可能存在的大量数据、数据库的索引命中情况和条件复杂度来说,容易导致处理速度差强人意而导致慢SQL的出现,且其中的检索原理基本都是检索设定好的索引与大量数据,所以会导致因数据数量变化而可能导致响应速度以某种程度降低(除非索引命中率非常高)。

而缓存的实现原理往往是通过相同的方法下,对比其中的参数,当缓存中不存在参数相同的值时,则访问数据库,反之获取获取缓存的值。简单的实现可以通过HashMap这样的KV数据结构实现简单的进程缓存,且网络缓存的实现也可以通过NoSQL数据库例如Redis、MongoDB这样现有的高性能中间件完成,而缓存的机制在面临大量的查询请求下则“越战越勇”,因为缓存上述机制自适应地提供缓存,而导致越密集的单一接口请求下,缓存的命中率越高,同时已存在的缓存并不会因其他缓存的增加而导致性能的下降或下降程度较低(因为KV结构),所以相较起来缓存能够提供的收益往往令人满意。

  • 网络IO

无论是使用MySQL数据库又或者是NoSQL数据库例如Redis,即使处理时间无限小,而网络的IO速度也往往瓶颈之一。

对比CPU与IO的性能影响比重,能减少网络IO的影响往往是高性能所不可避免的。于是二级缓存设计在网络缓存作为主缓存的基础下,会增加类似Guava Cache这样的进程缓存以优化网络IO带来的影响,实现节点中的“自给自足”。

缓存与业务耦合问题 若使用缓存容器框架作为业务层的处理的话,则需要业务代码耦合缓存容器框架进行对应的业务操作,例:

@Service
public class CacheServiceImpl implements CacheService {

    @Autowrite
    private Cache<Data> cache ;
    
    @Autowrite
    private Mapper<Data> mapper ;
    
    public Data findById(long id) {
        /*
        为了保证原子性,这样处理会更好,但为了可读性更强,所以写出下述线程不安全写法,实际生产环境请按照注释这里这块代码为准
        Data data = cache.computeIfAbsent(id,k -> {
            this.mapper.selectById(id) ;
        }) ;
        */
        Data data = cache.get(id) ;
        if(data == null) {
            data = this.mapper.selectById(id) ;
            cache.put(data) ;
        }
        return data ;
    }

}
复制代码

这样导致的问题首先导致了业务代码不够清晰,影响了可读性,若是缓存逻辑有进一步更新则会导致需要修改大量的代码。

此时可以通过AOP进行解耦,或者直接应用SpringCache和AutoLoadCache进行基于AOP层面实现的缓存框架,进一步降低了业务代码与缓存组件的耦合。例:引入SpringCache

@Service
public class CacheServiceImpl implements CacheService {

    @Autowrite
    private Mapper<Data> mapper ;
    
    @Override
    @Cacheable({"menu", "menuById"})
    public Data findById(long id) {
        return this.mapper.selectById(id) ; ;
    }

}
复制代码

但是这样的处理在解决了耦合代码的情况下仍然会面临着一些问题:

  • 缓存高并发场景问题

数据库中有五个条数据,除了主键id分别为1~5,而性别:男,年龄:20,职业:程序员等三个属性都一致的情况下:

此时条件搜索(年龄为20):结果返回这五条数据

随后条件搜索(性别为男):结果返回这五条数据

  • 空间利用率较低:往往缓存的数据结构中,缓存结构往往为KV,即参数为键值,值为该次方法返回的结果。这样的数据结构在面临不同参数下同样的数据也会被分为不同的缓存存储起来(类上述场景)

在大量缓存的情况下导致缓存的空间占用率和冗余的数据非常高。

此时id为1的数据性别修改为女

随后条件搜索(性别为男):结果返回这id:2~4的数据

  • 缓存维护成本:数据增删改后,旧缓存不再适用于这样的条件,所以需要将这条参数的缓存删除或者修改,但是若此时若存在大量的缓存,且缓存存储与Redis时往往都会进行一次序列化,使Redis中的数据解析并修改往往是一个很大成本的一个问题,所以通过key删除对应的缓存进行是大多数人的选择,其中SpingCache的@CacheEvict则是类似的处理。

但是这样的缓存处理方式会使在数据增删改的成本较高,同时万一含有某些复用率极高数据的缓存,可能会导致删除大量缓存。

针对上述两种问题,以上所述缓存代理框架貌似都没有一个较好的处理方式,此处笔者偶然想到一种较为可行的方式:

缓存散列化/索引化

分析实际场景中的持久类数据往往为PO的集合,为了使数据原子化

将PO集合拆分为各个PO独立存储在缓存之中,并以id为其key值,使id查询时可以通过id查询

并且针对于可能会返回的单条或多条的PO数据集的条件查询,可以将其参数和其中数据集的各个id替换原本的数据,将单条数据(元数据)拆分出来分别存储于Redis之中(可以通过Lua脚本进行Redis的批量操作IO优化)

缓存结构转变为参数-》id集-》多条元数据,使用Lua脚本进行Redis内实现通过解析对应参数Key获取的id集并收集其元数据组合数据集并返回,优化批量操作的IO成本

散列化后缓存结构image.png

此时针对于上述的问题:

空间利用率:散列化的元数据唯一且符合原子性,各个条件查询通过索引对id集采集对应元数据,避免了大量重复元数据的数据冗余。

缓存维护成本:条件查询使用索引进行对应数据的缓存建立(仅当该索引的id集无对应元数据的缓存时携带元数据进行缓存),减少了缓存重复序列化带来的性能损耗。同时缓存改变时仅删除索引,不影响元数据(若删除具体数据则删除索引和此条元数据)

而笔者也按此逻辑实践并开源了缓存代理框架Kache,解决了以上缓存代理框架所述的这两块痛点。

仓库链接:gitee.com/Kould/kache

有意见或者建议欢迎一起和平地讨论学习~~~

猜你喜欢

转载自juejin.im/post/7036272867230613535