缓存

116 阅读18分钟

缓存-讲解大纲

1 缓存概述

1.1 缓存能解决什么问题?

  1. 性能优化 (减少数据库or磁盘负担、降低传输频率)

  2. 提高可用性(服务即使短暂停止,仍可从缓存中拿数据)

    1.2 缓存存储的位置

    内存(需频繁获取的数据)

    磁盘(需大量缓存的数据)

    1.3 缓存带来的新问题

  3. 缓存一致性问题:

描述:源数据频繁更新,导致缓存数据不一致

解决方案:依赖于缓存过期策略与淘汰策略

  1. 缓存并发:

描述:缓存在更新时,多个用户在访问缓存,导致用户获取的数据不一致

解决方案:更新时加锁

  1. 缓存击穿:

描述:高频率查询不存在的值,从而越过缓存查询数据库,造成数据库查询负担

解决方案:通过Map过滤

  1. 缓存雪崩:

描述:缓存如果同时过期,导致大量查询又从数据库中获取

解决方案:缓存过期的时间均匀分散

1.4 缓存适用与不适用的场景

  • 适用:
  • 写少读多
  • 一致性要求不严格
  • 大数据量
  • 不适用:
  • 以上反之

2 Hibernate的缓存机制

2.1 N+1问题

先通过一个例子来看一下,什么是N+1问题?

list()获得对象:

Java

List ls = (List)session.createQuery("from Student")
.setFirstResult(0).setMaxResults(30).list();
Iterator stus = ls.iterator();
for(;stus.hasNext();)
{
Student stu = (Student)stus.next();
System.out.println(stu.getName());
}

如果通过list()方法来获得对象,毫无疑问,hibernate会发出一条sql语句,将所有的对象查询出来。

SQL
Hibernate: select student0_.id as id2_, student0_.name as name2_, student0_.rid as rid2_, student0_.sex as sex2_ from t_student student0_ limit ?

那么,再来看看iterator()这种情况

iterator()获得对象

Java
/**
* 如果使用iterator方法返回列表,对于hibernate而言,它仅仅只是发出取id列表的sql
* 在查询相应的具体的某个学生信息时,会发出相应的SQL去取学生信息
* 这就是典型的N+1问题
* 存在iterator的原因是,有可能会在一个session中查询两次数据,如果使用list每一次都会把所有的对象查询上来
* 而是要iterator仅仅只会查询id,此时所有的对象已经存储在一级缓存(session的缓存)中,可以直接获取
*/
Iterator stus = (Iterator)session.createQuery("from Student")
.setFirstResult(0).setMaxResults(30).iterate();
for(;stus.hasNext();)
{
Student stu = (Student)stus.next();
System.out.println(stu.getName());
}

在执行完上述的测试用例后,来看看控制台的输出,看会发出多少条 sql 语句:

SQL
Hibernate: select student0_.id as col_0_0_ from t_student student0_ limit ?
Hibernate: select student0_.id as id2_0_, student0_.name as name2_0_, student0_.rid as rid2_0_, student0_.sex as sex2_0_ from t_student student0_ where student0_.id=?
张三
Hibernate: select student0_.id as id2_0_, student0_.name as name2_0_, student0_.rid as rid2_0_, student0_.sex as sex2_0_ from t_student student0_ where student0_.id=?
李四
Hibernate: select student0_.id as id2_0_, student0_.name as name2_0_, student0_.rid as rid2_0_, student0_.sex as sex2_0_ from t_student student0_ where student0_.id=?
王五.........

可以看到,当如果通过iterator()方法来获得对象的时候,hibernate首先会发出1条sql去查询出所有对象的 id 值,如果需要查询到某个对象的具体信息的时候,hibernate此时会根据查询出来的 id 值再发sql语句去从数据库中查询对象的信息,这就是典型的 N+1 的问题。

那么这种 N+1 问题如何解决呢,其实只需要使用 list() 方法来获得对象即可。但是既然可以通过 list() 就不会出现 N+1的问题,那么为什么还要保留 iterator()这种形式呢?考虑这样一种情况,如果需要在一个session当中要两次查询出很多对象,此时如果写两条 list()时,hibernate此时会发出两条 sql 语句,而且这两条语句是一样的,但是如果第一条语句使用 list(),而第二条语句使用 iterator()的话,此时也会发两条sql语句,但是第二条语句只会将查询出对象的id,所以相对应取出所有的对象而已,显然这样可以节省内存,而如果再要获取对象的时候,因为第一条语句已经将对象都查询出来了,此时会将对象保存到session的一级缓存中去,所以再次查询时,就会首先去缓存中查找,如果找到,则不发sql语句了。这里就牵涉到了接下来这个概念:hibernate的一级缓存。

2.2 一级缓存

先看看一级缓存

Java
/**
* 此时会发出一条sql,将所有学生全部查询出来,并放到session的一级缓存当中
* 当再次查询学生信息时,会首先去缓存中看是否存在,如果不存在,再去数据库中查询
* 这就是hibernate的一级缓存(session缓存)
*/
List stus = (List)session.createQuery("from Student")
.setFirstResult(0).setMaxResults(30).list();
Student stu = (Student)session.load(Student.class, 1);
Apache
Hibernate: select student0_.id as id2_, student0_.name as name2_, student0_.rid as rid2_, student0_.sex as sex2_ from t_student student0_ limit ?

此时hibernate仅仅只会发出一条 sql 语句,因为第一行代码就会将整个的对象查询出来,放到session的一级缓存中去,当我如果需要再次查询学生对象时,此时首先会去缓存中看是否存在该对象,如果存在,则直接从缓存中取出,就不会再发sql了,但是要注意一点:

hibernate的一级缓存是session级别的,所以如果session关闭后,缓存就没了,此时就会再次发sql去查数据库

session关闭以后,一级缓存就不存在了,所以如果再查询的时候,就会再发sql。要解决这种问题,应该怎么做呢?这就要来配置hibernate的二级缓存了,也就是sessionFactory级别的缓存。

2.3 二级缓存

关于二级缓存的配置可以参考博客:www.cnblogs.com/tianyuchen/…

因为二级缓存是sessionFactory级别的缓存,当session关闭以后,再去查询对象的时候,此时hibernate首先会去二级缓存中查询是否有该对象,有就不会再发sql了。

D
public class TestSecondCache
{
@Test
public void testCache1()
{
Session session = null;
try
{
session = HibernateUtil.openSession();

Student stu = (Student) session.load(Student.class, 1);
System.out.println(stu.getName() + "-----------");
}
catch (Exception e)
{
e.printStackTrace();
}
finally
{
HibernateUtil.close(session);
}
try
{
/**
* 即使当session关闭以后,因为配置了二级缓存,而二级缓存是sessionFactory级别的,所以会从缓存中取出该数据
* 只会发出一条sql语句
*/
session = HibernateUtil.openSession();
Student stu = (Student) session.load(Student.class, 1);
System.out.println(stu.getName() + "-----------");
/**
* 因为设置了二级缓存为read-only,所以不能对其进行修改
*/
session.beginTransaction();
stu.setName("aaa");
session.getTransaction().commit();
}
catch (Exception e)
{
e.printStackTrace();
session.getTransaction().rollback();
}
finally
{
HibernateUtil.close(session);
}
}
SQL
Hibernate: select student0_.id as id2_2_, student0_.name as name2_2_, student0_.sex as sex2_2_, student0_.rid as rid2_2_, classroom1_.id as id1_0_, classroom1_.name as name1_0_, classroom1_.sid as sid1_0_, special2_.id as id0_1_, special2_.name as name0_1_, special2_.type as type0_1_ from t_student student0_ left outer join t_classroom classroom1_ on student0_.rid=classroom1_.id left outer join t_special special2_ on classroom1_.sid=special2_.id where student0_.id=?
aaa-----------
aaa-----------

需要注意的是,二级缓存缓存的仅仅是对象,如果查询出来的是对象的一些属性,则不会被加到缓存中去。

2.4 如何查看Smartbi中的二级缓存

  • 哪些对象会被存放到缓存中?

配置了@Cache(usage = CacheConcurrencyStrategy.READ_WRITE, region = "POJO")

  • 如何查看?

  • 如何操控二级缓存?

通过smartbix.cache.CacheManager对象

  • 如何清除缓存?
  • 前端:远程调用util.remoteInvokeEx("ConfigClientService", "clearCache", []);
  • 后端:smartbix.smartbi.util.clean.SmartbixEhCacheCleaner.doClean()

3 Smartbi中用到的缓存技术——基础

3.1 ehcache

上面提到的二级缓存其实就是基于ehcache使用的,若没有学过则可以参考视频:java专题之缓存模块(ehcache/guava cache/自定义spring的CacheManager/自定义缓存)_哔哩哔哩_bilibili

3.2 对象池

我们产品中的对象池对象是**smartbi.pool.BaseSmartbiObjectPool,**其具体实现是如下图:

它是改写了commons-pool的org.apache.commons.pool.BaseKeyedObjectPool。

关于怎么理解对象池?类似于我们经常遇到线程池、连接池,它是将一系列的资源事先准备好放在一个地方,等需要的时候直接拿过去用。而用完之后再放回来。和我们平常的需要使用资源再创建的方式相比,这种池的方式节省了创建和销毁资源的这么一个过程。所以说,对于一些比较比较稀缺的资源或者创建和销毁影响系统性能的资源,采用池的方式可以有效的提高整体性能。

整个对象池的关键部分就三个:**工厂、对象、池,**之间的关系可以用下图来表示

关于工厂的核心方法包括:

Java
//新增对象
Object makeObject();
//在他从池中借出的之前检测对象是否可用
boolean validateObject(PooledObject obj);
//销毁对象
void destroyObject(PooledObject obj);

关于的核心方法包括:

Java
//从池中借走到一个对象。借走不等于删除。
Object borrowObject();
//把对象归还给对象池。归还不等于添加。
void returnObject(Object key, Object obj);
//销毁一个对象。这个方法才会将对象从池子中删除,当然这其中最重要的就是释放对象本身持有的各种资源。
void invalidateObject(T obj) throws Exception;
//往池中添加一个对象。池子里的所有对象都是通过这个方法进来的
//清空池
void clear() throws Exception, UnsupportedOperationException;

3.3 Redis

关于redis的使用,若没有学过可以参考视频:Redis入门记(完结),Redis6零基础快速入门教程2022版_哔哩哔哩_bilibili

补充

3.4 HSQL教程

HSQLDB教程

3.5 Request缓存

Request的attribute也可以充当缓存的作用,例如在产品中的PageRefreshRequestCache类中,存放的各个Request的键。

PHP
public class PageRefreshRequestCache {
/**
* BUSINESS_TABLE_FIELD_KEY
*/
public static final String BUSINESS_TABLE_FIELD_KEY = "BUSINESS_TABLE_FIELD.KEY";
/**
* FIELD_NODES_KEY
*/
public static final String FIELD_NODES_KEY = "BASE_DATASET_FIELD_NODES.KEY";
/**
* TABLE_SHORT_NAME_KEY
*/
public static final String TABLE_SHORT_NAME_KEY = "TABLE_SHORT_NAME.KEY";
/**
* GET_DATASOURCE_BY_ID_KEY
*/
public static final String GET_DATASOURCE_BY_ID_KEY = "GET_DATASOURCE_BY_ID"
...
}

同理,session的attribute也可以充当缓存对象。但需要注意的是request的缓存仅仅只是在当前线程起作用,如果遇到多线程的读写时,则可能会出现问题。而session是全局的,无需考虑这一点。

可参考EPPR-52129

4 Smartbi中用到的缓存技术——应用

着重讲解上述聊到的基础技术在smartbi中缓存的运用:

4.1数据集定义缓存

Smartbi在构建数据集定义对象时,需要进行一系列的加载、解析过程,会较为耗时,因此所有的数据集定义对象生成后会缓存起来,下次使用时直接从缓存中获取。数据集定义对象缓存池的大小可以在:系统选项->缓存设置->数据集定义对象池设置。

大致流程可如下:

数据集定义对象(BusinessViewBO)获取的主要流程为:

(1) 尝试从session中取出BusinessViewBO,如存在,则存入request中。

(2) 尝试从request中取出BusinessViewBO,如存在则返回BusinessViewBO,进入第(5)步;如 不存在继续第(3)步。

(3) 尝试从缓冲池中取出BusinessViewBO,如存在,则从缓冲池中取出,存入request中,并返回BusinessViewBO,进入第(5)步;如不存在继续第(4)步。

(4) 创建BusinessViewBO对象,然后存入request,并返回BusinessViewBO,进入第(5)步。

(5) BusinessViewBO对象使用完毕后,返还到缓冲池中,并清除request缓存的BusinessViewBO,结束。

下面介绍数据集定义缓存的实现逻辑

1、打开报表、刷新数据等操作均需要获取到数据集对象。获取数据集时,首先会进入MetaDataRuntimeContext类的searchBusinessView方法,根据当前的数据集ID查找。

2、根据数据集ID,先从session中查找数据集定义对象。

3、如果在session中找到数据集定义的缓存对象,则缓存至当前request(请求)的缓存对象中,后面再通过request的缓存获取。(当前request的缓存对象是会随着请求结束而销毁的,之所以缓存到当前请求中,是因为在一个请求内,可能会存在多个地方需要获取数据集定义的地方,如加载一个灵活分析,构建报表对象时,需要数据集定义对象,构建参数对象时,也需要数据集定义对象,而构建报表对象和参数对象是在一个请求中完成的)

4 如果在request缓存中没有找到对应的BusinessViewBO(数据集定义对象),则需要到BusinessViewBOPool(数据集定义对象缓存池)中获取(借用,后面使用完需要还的)。

5、如果对应的BusinessViewBO对象还未被加载过,那么肯定无法通过BusinessViewBOPool缓存池中直接获取。此时会调用BaseSmartbiKeyedPoolableObjectFactory的makeObject方法创建BusinessViewBO对象。

6、当获取到的BusinessViewBO使用完毕后(一般是在请求结束时),会将BusinessViewBO对象返还到缓存池中。具体逻辑为:请求结束时,会调用StateModule的doEndRequest方法,其中会调用IRequestListner的doEndRequest方法。

7、最后会进入BaseSmartbiObjectPool的onEndRequest方法,并调用returnObject返还BusinessViewBO对象到缓存池中,这样即可在下一次使用时,直接从缓存池中获取。

8 通过以上的缓存逻辑,我们可以了解到,目前没有任何机制可以禁用数据集定义的缓存,如果需要清除缓存,只能通过外部调用清除服务器缓存的方法清除。或者更新数据集定义时,会触发清除数据集定义缓存。当更新数据集定义时,会进入DAOModule类的update方法,调用对象池变更监听器接口IObjectPoolChangeListener的update方法。

而Smartbi对象缓存池实现类BaseSmartbiObjectPool,已经通过内部类实现了IObjectPoolChangeListener接口,并实现update方法,根据数据集ID清除指定数据集定义缓存。

4.2 数据集数据缓存

数据集从数据库获取到数据的过程受多种因素影响,经常会出现慢的问题。如:数据量过大、数据库服务器并发访问过大、SQL过于复杂等。为解决慢的问题,会通过缓存策略,以便尽量减少重复获取数据的次数,从而减轻服务器压力。

Smartbi的数据集数据主要使用HSQL(内存数据库)缓存,支持用户设置,可以在:系统选项》缓存》业务数据缓冲池 中设置。

数据集获取数据的大致流程为:

(1) 判断数据是否已经存在HSQL内存数据库中,如果不存在进入第(2)步;如果存在进入第(4)步。

(2) 判断数据是否已经存在HSQL文件中,如果存在并且数据集启用了缓存进入第(4)步;否则进入第(3)步。

(3) 从原始数据库获取数据,然后判断是否启用HSQL文件缓存,启用了则将数据插入HSQL文件中,如果没有启用则将数据插入HSQL内存数据库中,进入第(4)步。

(4) 判断是否启用HSQL文件缓存,如果启用了进入第(5)步;如果没有启用进入第(6)步。

(5) 从HSQL文件中读取数据,结束。

(6) 从HSQL内存数据库中读取数据,如果数据集没有启用缓存,则清除对应HSQL内存数据库的数据,结束。

下面介绍数据集数据缓存的具体实现逻辑。

1、数据集刷新数据时,首先会进入SQLResultStore的getGridData方法。

2、接着进入DBSQLResultStore的getGridDataInternal方法。首先判断获取的页数是否在内存数据库(HSQL)中。如果数据已经缓存在HSQL中,则直接返回,然后继续通过当前数据集的输出字段、告警信息等拼接HSQL执行的SQL语句,最后执行拼接的SQL,从HSQL中获取缓存的数据。

3、如果数据没有缓存在HSQL中,则继续判断是否缓存在HSQL的文件中(同时需要判断数据集是否启用缓存),如果存在,则同样返回,然后继续通过当前数据集的输出字段、告警信息等拼接HSQL执行的SQL语句,最后执行拼接的SQL,从HSQL文件中获取缓存的数据。(是否使用HSQL文件缓存数据,需要在:系统选项》数据集》文件缓存设置中设置)

4、如果数据没有缓存在HSQL文件中,则直接到原始数据库中获取,最终即进入DBSQLResultStore的executeInDatabaseInner方法中,根据当前数据集的输出字段、参数等信息拼接SQL,最后执行SQL,从原始数据库中获取数据。

5、从原始数据库获取到数据后,立即将数据插入到HSQL数据库或HSQL文件中,然后返回总行数,最终依然按照上面的步骤从HSQL数据库或HSQL文件中读取数据。

6、当没有启用数据集缓存时,程序会调用DBSQLResultStore的close方法,如果没有启用HSQL文件缓存,则会清除HSQL中缓存的数据。(注意,当启用HSQL文件缓存时,不会另外执行清除HSQL文件的操作,因为在判断数据是否存在HSQL文件中时,同时判断了数据集是否启用缓存)

从上面的实现逻辑来看,Smartbi数据集的数据是缓存在HSQL或HSQL文件中的,只要数据集不启用缓存,每次数据获取完成后都会清除HSQL中对应的缓存数据。而只要启用了缓存,数据就一定会缓存在HSQL或HSQL文件中。

4.3 数据存储对象缓存

这里先提出一个问题,数据集的缓存是否是所有用户共用的?答案是肯定的,但是要说明这个问题,我们需要先了解清楚上面进行数据集数据缓存所使用的DBSQLResultStore对象是如何生成的,因为数据集是否启用缓存,与DBSQLResultStore的生成方式有着紧密联系。

1、打开报表时,会先获取到对应的BusinessViewBO(数据集定义对象,前面已说明获取逻辑),然后根据当前报表对象(不同的报表,会使用不用的对象,如:灵活分析-SimpleReportBO,仪表分析-DashboardBO)和BusinessViewBO对象构建BusinessViewState(数据集状态对象)。

2、获取到BusinessViewState对象后,再根据数据集ID、BusinessViewState等构建DBSQLResultStoreKey对象。然后判断数据集是否启用缓存:如果没有启用,则直接新建一个DBSQLResultStore对象;如果启用了缓存,则使用DBSQLResultStoreKey到DBSQLResultStore对象池(DBSQLResultStorePool)中获取DBSQLResultStore对象。

3、启用缓存时,最后会进入BaseSmartbiObjectPool中的borrowRequestObject方法,其中会将借用到的DBSQLResultStore对象缓存到对象中。

4、当DBSQLResultStore使用完毕后,(一般是在请求结束时),会将DBSQLResultStore对象返还到缓存池中。具体逻辑为:请求结束时,会调用StateModule的doEndRequest方法,其中会调用IRequestListner的doEndRequest方法。

5、最后会进入BaseSmartbiObjectPool的onEndRequest方法,然后调用returnObject返还DBSQLResultStore对象到缓存池中,这样即可在下一次使用时,直接从缓存池中获取。

6、如果报表关闭时,会尝试获取到DBSQLResultStore对象,执行其close方法,以清除HSQL的缓存。而如果启用了缓存,DBSQLResultStore对象会在每次刷新完数据后返还给对象池,此时将无法获取到DBSQLResultStore对象,则不执行清除HSQL缓存的操作。

从整个DBSQLResultStore对象的获取逻辑中,我们可以了解到,数据集数据缓存与数据集和报表是绑定在一起的(即缓存的KEY值是由数据集和报表共同产生的),而与用户等其他因素是无关的,因此可以支持所有用户在打开同一张报表时,共用缓存。

5 补充

5.1 按次抽取

按次抽取是结合用户的登录生成的Session及查询的参数组合进行抽取。

使用场景:

  • 有些客户希望实时数据,但又无法使用直连模式(如数据模型中含有存储过程模型表)。

  • 数据模型中一部分数据和用户信息密切相关(如权限等),但又不能使用直连模式。

    可以参考SMS-37158

    6 经典缓存问题

    导致缓存失效的原因有哪些?

    可以参考

    EPPR-49464EPPR-49866

    思路:

    如何解决?

    从上述可以看出,以上都是前面提到的【缓存并发】而导致的,解决方案就是加锁控制。

    考核

    1 请追踪产品中的【清空缓存】,清空了哪些方面的缓存?