Redis教程

Redis 实现的数据查询缓存通用模型(1)

本文主要是介绍Redis 实现的数据查询缓存通用模型(1),对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

收录于墨的2020~2021开发经验总结

1、缓存的意义

在经济学中,有一个定律叫做二八定律,含义是社会上百分之20的人却占了百分之80的财富。这一定律同样在计算机学科中适用,少部分的资源在计算中会被频繁使用,因此计算机的存储设计中,从低到高,从快到慢,设计了多级缓存。
在这里插入图片描述
从CPU的一级、二级、三级缓存,到内存,到磁盘,到远程的分布式文件系统。它们的容量从小到大,速度从快到慢。常用的数据会被优先存放在高速的缓存上。
在网站的架构设计中,也要考虑到缓存的使用,因为大部分网站的实际运行中,对数据的访问也是呈现出二八定律,百分之八十的业务会集中到百分之二十的数据中。例如像微博这样的应用,某个大瓜爆出来,不久之后就会看到微博因此奔溃的新闻(我不吃瓜,但是刷技术文章经常看到),其中原因也是瞬间的大量访问集中到了某个热点中。平常我们开发的一些增删改查的应用,如果有做分页查询,那么当用户进入页面时,自动就会请求第一页的页面,对于其他页面而言,第一页的访问频率最高,如果能优化第一页的查询效率,那么用户体验将会因此变好。在我们的开发中,同样也呈现着二八原则,百分之二十的表可能会出现在百分之八十的查询中,这些表可能是用户表,组织表,字典表,型号表等,对于这些表,如果能针对性的做查询缓存,而不是每次都去查询数据库,会降低很多数据库的压力,并且使程序的运行速度加快。
我们可以使用很多方式来缓存数据。在Java应用中,组件丰富,我们可以用ehcache、memcache、redis这一些开源的组件。

本篇通过实现一个通用的缓存模型来简化对缓存的处理,使其使用的成本更低,维护更容易。

2、从使用角度分析实现角度(需求到设计)

2.1 注解设计

对于一个好的缓存模型,首先要满足的是能支撑常见的业务场景,其次是使用简单,然后是快速可靠。

Java自从有了注解之后,框架的世界就变的更加丰富多彩了,注解方式编码相比过程型编码和配置型编码更加清晰易懂,修改简单,易于维护。这是因为注解究其本源是属于声明式的语言,包含的逻辑简单,不存在复杂的过程语句和逻辑判断,故而一目了然。

因此缓存模型通过注解来实现是再合适不过了。想像有这样一个注解:

    @DawnCacheable(cacheType = DawnCacheType.REDIS, expire = 1, cacheTimeUnit = TimeUnit.MINUTES)
    public PageInfo<Product> searchProduct(ProductSearchDto productSearchDto, Pageable pageable) {
    	// 查询过程 省略
    	return pageInfo;
   	}

其含义是缓存产品分页查询的查询结果,缓存的类型是Redis,过期时间是1(单位:分钟)。

当redis没有这次查询的缓存时,将会执行方法内的查询代码,然后将查询结果写入缓存。

下次查询时,如果从缓存中取到了信息,则跳过方法内的查询代码,直接从缓存中读取并返回给前端。

只需这样一个注解,无需配置多余的代码,即可实现缓存。

但是对于缓存而言,其实是一种 key - value 的内存存储,我们需要一个key取写缓存,之后再用这个key到缓存来读数据。

我们还需要一个key的生成规则。这个规则需要支持定制。所以再加入一个配置参数cacheKeyCreator

    @DawnCacheable(cacheKeyCreator = ProductIdCacheKeyCreator.class, cacheType = DawnCacheType.REDIS, expire = 0, cacheTimeUnit = TimeUnit.SECONDS)
    public Product getProductById(Long id) {
    	// 查询过程 省略
    	return product;
	}

2.2 cacheKeyCreator 的设计

cacheKeyCreator其设计的思路来自于mybatis的SqlProvider,这种通过实现一个class的方式来完成注解的某一块功能,具有灵活和易于阅读的优点。对于ProductIdCacheKeyCreator.class,这个类的功能就是用来生成针对产品ID的缓存,它生成的Key可能类似于getProductById.234,其中234是ID,它应该是不重复的主键。

我们还需要这个cacheKeyCreator的功能足够强大,它应该尽可能的读取到这个方法的内容、这次请求的信息,从而可以随心所欲的从所需的重要信息里生成这次缓存的Key。

实现一个接口,它定义了cacheKeyCreator 可以传入的参数:

public interface DawnCacheKeyCreator {

    /**
     * 生成KEY
     * 
     * @param request 请求
     * @param target  注解的方法的所在对象
     * @param method  注解的方法
     * @param params  方法携带的参数Map<String,Object>
     * @return Key 字符串
     */
    String createCacheKey(HttpServletRequest request, Object target, Method method, Map<String, Object> params);
}

在注解中,我们要求cacheKeyCreator 需要实现该接口:

    /**
     * 缓存的key生成类
     *
     * 默认是{@link DawnDefaultCacheKeyCreator}
     * @return DawnCacheKeyCreator
     */
    Class<? extends DawnCacheKeyCreator> cacheKeyCreator() default DawnDefaultCacheKeyCreator.class;

所以我们自定义的ProductIdCacheKeyCreator便实现了该接口,具有了该接口的行为:

public class ProductIdCacheKeyCreator implements DawnCacheKeyCreator {

    public final static String CACHE_PER = "DAWN-CACHE-PRODUCT-ID";

    @Override
    public String createCacheKey(HttpServletRequest request, Object target, Method method, Map<String, Object> params) {
        Object id = params.get("id");
        if (id == null) {
            Object product = params.get("product");
            if (product instanceof Product p) {
                id = p.getId();
            }
        }
        if ((!(id instanceof Long))) {
            return null;
        }
        return String.format("%s.%d", CACHE_PER, id);
    }
}

它的规则是:如果从参数中直接取到了id,那么直接使用该ID;如果参数中不包含ID,而是包含product,那么取product的id;之后使用前缀DAWN-CACHE-PRODUCT-ID加上.加上ID生成了KEY。

我们还可以使用请求携带的信息生成ID,这也是DawnDefaultCacheKeyCreator.class这一默认实现做到的:

public class DawnDefaultCacheKeyCreator implements DawnCacheKeyCreator {

    public final static String CACHE_PER = "DAWN-CACHE";
    
    @Override
    public String createCacheKey(HttpServletRequest request, Object target, Method method, Map<String, Object> params) {
        String queryString = request.getQueryString();
        String targetClassName = target.getClass().getName();
        String methodName = method.getName();
        if (StringUtil.isEmpty(queryString) && params != null && params.size() > 0) {
            queryString = JSON.toJSONString(params);
        }
        return String.format("%s.%s.%s.%s", CACHE_PER, targetClassName, methodName , queryString);
    }
}

它的key生成规则是,取 缓存前缀 DAWN-CACHE加上 目标对象的类名加上 注解设置的方法名加上url后边的请求参数或方法参数来生成的。

这样的规则将可以适配大部分传参方式单一的get请求、post请求、delete请求。因此将之设置为默认的key生成方式。

我们还可以做一种骚操作,对于某一类的请求我们生成Key,而其他的请求我们直接生成null空作为返回,对于空的key我们是不执行缓存的。

比如我临时想到的这样一种情况:针对某个表格或者报表,我们优先缓存它不带任何查询条件的第一页,那么直接判断第一页查询时的查询条件和当前查询条件比较,如果一致才进行缓存,生成Key返回。否则则返回默认的null,不缓存。这样我们就可以确保缓存资源被用到了刀尖上。

String key = null;
if ("size=10&page=0".equals(queryString)) {
	key ="page0";
}
return key;

2.3 并发请求下同步

想象有这样一个同学Z,老师交代他给班里的同学们收集答疑的问题,然后汇总这些问题给老师,然后老师在下一节课针对性的讲解。

A同学、B同学、C同学 同时向Z同学问了同一个问题。那么,如果三个同学一起描述问题,对于Z同学来说,他需要花费三份的精力去倾听和记录。如果A同学、B同学、C同学顺序问问题,那么Z同学听完了A同学的问题并记录之后,对于B同学、C同学,他只要花很少的时间知道了他们实际上是和A同学问的是一个问题,那就可以跳过他们了。

这个例子想表达的是并发下面对的缓存初次写入的问题。也有人叫它缓存击穿。并不一定是初次写入,也有可能是缓存到了过期时间,失效了。

假设这个请求是耗时计算操作,需要查询多张表并计算然后呈现数据。如果它需要耗时1S:

请求01 -》没有缓存 -》 执行查询# -》》》》— 1S —》》》》-》写入缓存# -》返回结果

在举例的两个#直接的那一段,我愿称之为缓存真空期。那么,如果在这1S左右的时间里如果突然涌入了上千个请求,上千个请求在这段时间里都是取不到缓存的,因此都需要走这一段耗时查询。数据库瞬间面临上千句复杂SQL,瞬间被击穿宕机。此外作为应用的后端也需要执行上千次重复的计算,占用了大量的CPU资源和内存资源。

所以解决缓存穿透的方式很简单,1、在程序启动时写入永久缓存,后续所有的请求都直接查询缓存,那更新怎么办呢?更新可以直接通过键值覆盖从而保证缓存的有效性,删除的话则设置空值,这样永远保证该查询只能走缓存。
2、也是第二种方式,这种方式更为优雅。那就是让A、B、C同学顺序过来。当然我们已经未卜先知,A、B、C同学问的是一个问题。在程序中我们加上锁:

如果注解设置 sync = true那么我们对该方法的执行加锁,保证同时只有一个请求可以执行真查询,而其他同类请求都需要等该请求执行完然后去读缓存。

请参看:

		Object result;
        if (StringUtil.isNotEmpty(key)) {
            if (dawnCacheable.sync()) {
                synchronized (key.intern()) {
                    result = getFromCacheOrSearch(dawnCacheable, key, proceedingJoinPoint);
                }
            } else {
                result = getFromCacheOrSearch(dawnCacheable, key, proceedingJoinPoint);
            }
            return result;
        }
        return proceedingJoinPoint.proceed();

key.intern返回的是key这个字符串在常量池里的对应的常量,如果字符串不在常量池,则会先放入常量池。对于同样的字符串,intern()取回的对象是一致的。所以我们可以用它来给同样的key加锁,保证同样的key只有一个请求可以进入查询方法。

为什么说这种设计是优雅的。很大一部分原因在于其锁的粒度。通过key.intern我们把锁的粒度控制的很小。假设有100个不同的key,那么他们同样可以并发执行。我们只针对那些“重复性的计算”,对于并发请求不同数据的渴望我们一样包容。

getFromCacheOrSearch的实现也很简单:
1、尝试取缓存,2、如果取不到缓存则执行查询,然后写入缓存,3、返回结果(我不关心是从缓存取的还是实际查询的,它都是object)

	private Object getFromCacheOrSearch(DawnCacheable dawnCacheable, String key,
                                       ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
       Object result = getFromCache(key, dawnCacheable.cacheType());
       if (result == null) {
           result = proceedingJoinPoint.proceed();
           long timeToLiveSeconds = dawnCacheable.cacheTimeUnit().toSeconds(dawnCacheable.expire());
           setCache(key, result, timeToLiveSeconds, dawnCacheable.cacheType());
       } else {
           log.info("缓存命中:" + key);
       }
       return result;
   }

通过这种同步设计,可以进一步的增加并发量,减少应用所消耗的资源,无论是IO资源还是CPU资源、内存资源。

然而这么实用的操作,通过注解实现之后,我们只需要简单的配置:

    @DawnCacheable(sync=true, cacheType = DawnCacheType.SIMPLE, expire = 30)
    public BigDecimal getProductTopPrice() {
		// 这个也许是个复杂的计算
		// ...
		return topPrice;
	}

这便是注解的优雅之处,架构的魅力所在。

2.4 默认值设计

对于易于上手的架构设计来说其根源就是尽可能保持简洁,我们需要隐藏重复的细节,这样可以降低理解成本,减少冗余代码。

默认值的设计是很关键的一环,它应该尽可能的优中取优,以保证在偷懒少写代码的前提下还能够获得较好的优化。

设计默认值时可以大胆的替用户做一些优化,如果超过半数的用户都会需要的话就可以这么去做了,少数服从多数不是吗。毕竟少数还可以自己配置,也并不是不能修改。

所以我得到了我大胆的默认值设计:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DawnCacheable {

    /**
     * 缓存的key生成类
     *
     * 默认是{@link DawnDefaultCacheKeyCreator}
     * @return DawnCacheKeyCreator
     */
    Class<? extends DawnCacheKeyCreator> cacheKeyCreator() default DawnDefaultCacheKeyCreator.class;

    /**
     * 缓存过期时间的时间单位
     *
     * @return TimeUnit
     */
    TimeUnit cacheTimeUnit() default TimeUnit.SECONDS;

    /**
     * 缓存过期时间
     *
     * 默认是0,永不过期
     * @return long
     */
    long expire() default 0;

    /**
     * 当缓存中无该数据时,通过加锁使得该方法某一刻只能执行一次
     *
     * 默认是true
     * @return boolean
     */
    boolean sync() default true;

    /**
     * 使用的缓存方式
     *
     * @return 默认是 DawnCacheType.DEFAULT
     */
    DawnCacheType cacheType() default DawnCacheType.DEFAULT;
}

1、缓存的key默认生成,使用了之前描述的根据请求信息生成的key的DawnDefaultCacheKeyCreator类。
2、缓存的默认过期时间单位,使用了秒,这一粒度合适的时间单位。
3、缓存的过期时间设置为0,永不过期,这是考虑到大部分小规模应用会考虑到使用缓存的地方并不多,永久缓存的成本也并不高。
4、缓存默认加锁同步,如果这个需要设置成fase也是比较奇怪了。可以先设置成false,然后后边改回true作为优化,然后就可以加钱啦!
5、缓存方式默认为DawnCacheType.DEFAULT,这个是预留的配置项目,通过在yml配置该默认缓存指向哪种缓存,可以同时设置应用内所有的默认缓存使用何种。这样会比较灵活,易于迁移和扩展。假设刚开始比较穷,机器内存小只有两个G(我是在说我自己),那就直接用Map<String,Object>这种实现的简单缓存,后边应用使用的人多了,机器顶不住了,换一台大内存的8个G,那奢侈一把,弄个Ehcache,可以使用堆外内存。后边用户更多了,那么搞分布式缓存,memcache、redis都可以。像这样的硬件升级,我们只需要改配置里default的缓存类型是哪个就行了。我愿称这种架构思路为,集中配置之一处播种遍地开花之翻手之间变动星河之只改一个地方就很香。

END(1)

如果你已经读到了这里。那么,谢谢你,陌生人~


愿你前程似锦,所过之处,风景美好,百花盛开。

——墨 20210610

这篇关于Redis 实现的数据查询缓存通用模型(1)的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!