8. mybatis缓存配置
1.一级缓存
springboot是在开启事务的情况下是开启一级缓存的,不开启事务的情况下不开启一级缓存。
一级缓存是指,在对查询的方法和结果存储到hashMap中,key值由方法名和参数列表决定的,value值是查询到的结果
如果再次进行相同的查询那么会取hashMap中的值而不是数据库中的值,这样做会残生一些问题。
测试
@Transactional
@Test
void test1Cache(){
SysUser sysUser = userMapper.selectByPrimaryKey(1L);
sysUser.setUserName("new name");
SysUser sysUser1 = userMapper.selectByPrimaryKey(1L);
Assert.assertEquals(sysUser,sysUser1);
log.info(sysUser1.getUserName());
}
这里的断言是通过的,也就是说sysUser和sysUser1指向的是相同的内存地址,要小心这种情况,可能你以为sysUser1是数据库中的值
关闭一级缓存可以通过添加select标签中的flushCache="true"
那么执行完查询后会清除当前的sqlSession所有的缓存,在进行增,删,改操作并成功提交的情况下会清空一级缓存。
2.二级缓存
mybatis开启二级缓存,先要全局性的打开缓存配置
<!--全局性地开启或关闭所有映射器配置文件中已配置的任何缓存。默认值为true-->
<setting name="cacheEnabled" value="true"/>
在mapper.xml文件中添加配置,也可以在mapper接口文件中添加
<!--开启二级缓存,
该文件下的select语句都会被缓存,
所有的delete update insert都会刷新缓存
缓存默认使用LRU算法收回~eviction
缓存不会按照时间顺序自动刷新~flushInterval 单位毫秒
缓存中会存储集合或者对象1024个引用~size
缓存会被视为read/ write(可读/可写)的,意味着对象检索不是共享的,而且可以安全地被调用者修改,而不干扰其他调用者或线程所做的潜在修改。
readOnly="true" 是说返回的对象是相同的实例,这提高了性能(但是只要修改了对象的属性,会导致数据库与缓存中的数据不一致会产生脏读),
默认为false,那么mybatis会通过序列化返回对象的拷贝,这就需要查询出来的对象实现可序列化接口
-->
<cache
eviction="FIFO"
flushInterval="60000"
size = "512"
readOnly="true"
/>
测试
@Test
void test1Cache(){
SysUser sysUser = userMapper.selectByPrimaryKey(1L);
sysUser.setUserName("new name");
SysUser sysUser1 = userMapper.selectByPrimaryKey(1L);
Assert.assertEquals(sysUser,sysUser1);
log.info(sysUser1.getUserName());
}
由于第一次select语句未执行所以命中率为0,这里是让 readOnly="true"所以修改对象以后,第二条读到的数据在缓存和数据库中的数据不一致是脏读
改为false并且令SysUser继承Serializable
接口可以解决这个问题
3.二级缓存使用的情况
- 已查询为主的表,该表很少进行增,删,改操作
- 绝大多数都是单表查询时,很少出现与其他的表相关联
- 如果要通过两张表查询数据,可能出现脏读的情况,可以使用参照缓存来解决。但是如果几十张表都已不同的关联关系存在时,显然参照缓存也不起作用了。当某几个表可以作为一个业务整体时,通常是让几个会关联的ER表同时使用同一个二级缓存,这样就能解决脏数据问题。
- 可以按照业务划分对表进行分组,如果关联的表比较少可以使用参照缓存
9. Mybatis插件开发
Mybatis允许在已映射语句执行过程中的某一点进行拦截调用。默认情况下, Mybatis允许使用插件来拦截的接口包括以下几个。
Executor
ParameterHandler
ResultSetHandler
StatementHandler
想要自定义插件就得自定义拦截器,通过继承interceptor
接口,在该类上配置拦截器注解
1. interceptor
public interface Interceptor {
Object intercept(Invocation invocation) throws Throwable;
default Object plugin(Object target) {
return Plugin.wrap(target, this);
}
default void setProperties(Properties properties) {
// NOP
}
}
setProperties设置插件的参数用来改变插件的行为,插件的参数在mybatis.config中配置的时候设置
<plugins>
<plugin interceptor="com.yogurt.plugin.MybatisInterceptor">
<property name="pro1" value="value1"/>
<property name="pro2" value="value2"/>
</plugin>
</plugins>
plugin(target:被拦截的对象)该方法会在创建被拦截接口对象时调用
Plugin.wrap(target, this)会自动判断拦截器的签名和被拦截对象的接口是否匹配,只有匹配的情况下才会动态代理拦截对象???
intercept:mybatis运行时要执行的拦截方法,通过invocation参数可以获取以下信息
@Override
public Object intercept(Invocation invocation) throws Throwable {
//获取当前被拦截的对象
Object target = invocation.getTarget();
//获取当前被拦截的方法
Method method = invocation.getMethod();
//获取当前被拦截的方法的参数
Object[] args = invocation.getArgs();
//真正的执行被拦截的方法就是 method.invoke(target,args);
Object proceed = invocation.proceed();
return null;
}
NOTE:
当配置多个拦截器时, Mybatis会遍历所有拦截器,按顺序执行拦截器的plugin方法,被拦截的对象就会被层层代理。在执行拦截对象的方法时,会一层层地调用拦截器,拦截器通过 Invocation.proceed()调用下一层的方法,直到真正的方法被执行。方法执行的结果会从最里面开始向外一层层返回,所以如果存在按顺序配置的A、B、C三个签名相同的拦截器,Mybaits会按照C>B>A> target.proceed()>A>B>C的顺序执行。如果A、B、C签名不同,就会按照 Mybatis 拦截对象的逻辑执行。
2. 拦截器签名
拦截器签名通过两个注解实现,@Intercepts和@Signature
@Intercepts注解中的属性是一个@Signature数组。用来拦截多个方法
type :需要拦截的类,从上面提到的四个中间选
method和args可以唯一定位到一个方法
@Intercepts(
@Signature(
type = ResultSetHandler.class,
method = "handleResultSets",
args = Statement.class
)
)
note: 可以被拦截的四个接口中的方法并不是都可以被拦截,详情请看《mybatis从入门到精通》
3.开发一个下划线键值转驼峰式插件
需求:我们在处理mybatis查询结果的时候为了方便扩展,有时候会使用Map作为返回值,那么返回回来的Map的key就是数据库中的列名,是下划线形式的,使用起来特别不方便,所以需要在结果返回回来之后进行处理,需要开发一个下划线键值转驼峰式插件,这个插件可以通过拦截ResultSetHandler中的 handleResultSets(Statement stmt) 方法来实现
ResultSetHandler中的
该方法是在存储过程返回值不为Cursor
/**
* 下滑线转驼峰插件
*/
@Intercepts(
@Signature(
type = ResultSetHandler.class,
method = "handleResultSets",
args = {Statement.class}
)
)
@SuppressWarnings({"unchecked", "rawTypes"})
public class UnderscoreToCamelCaseInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
//拿到该方法的返回值,这可不是乱强转的,是因为被拦截的方法返回值是List<E>才能这么转
List<Object> list = (List<Object>) invocation.proceed();
for (Object object : list) {
if (object instanceof Map) {
processMap((Map<String, Object>) object);
} else {
break;
}
}
return list;
}
/**
* 处理map类型
*/
private void processMap(Map<String, Object> map) {
Set<String> set = map.keySet();//set里面没有实现Iterator,所以转一下
HashSet<String> keySet = new HashSet<>(set);
for (String key : keySet) {
//将大写开头的字母转为小写,如果包含下划线则转换为驼峰
if (key.charAt(0) >= 'A' && key.charAt(0) <= 'Z' || key.indexOf("_") > 0) {
Object value = map.get(key);
map.remove(key);
map.put(underscoreToCamelCaseKey(key), value);
}
}
}
/**
* 将下划线分格转换成驼峰分格
*/
private String underscoreToCamelCaseKey(String key) {
StringBuilder sb = new StringBuilder();
//设置一个flag表示一个单词的结束,下一个单词的开始
boolean nextUpperCase = false;
//遍历key的每个字母
for (int i = 0; i < key.length(); i++) {
char c = key.charAt(i);
if (c == '_') {
if (sb.length() > 0) {
//如果这个当前字符是下划线,且不是sb的开头(有时候列名是_role_name的)那么下一个字母就是大写的
nextUpperCase = true;
}
} else {
if (nextUpperCase) {
sb.append(Character.toUpperCase(c));
nextUpperCase = false;//重置默认标记
} else {
sb.append(Character.toLowerCase(c));
}
}
}
return sb.toString();
}
}
最后在mybatis-config.xml文件中配置一下就能实现需求了。
<plugins>
<plugin interceptor="com.yogurt.plugin.UnderscoreToCamelCaseInterceptor"/>
</plugins>
4.实现分页插件
在实现分页查询的时候需要添加分页条件offset、limit,且由于每个数据库的分页实现都不一样,还要databaseId判断。如果要查询总数还要增加一个count并手动添加另外一条sql语句,所以显得sql十分臃肿,这时候可以通过拦截器来实现分页功能。
分页插件需要拦截的方法:
Executor 中的 query方法,这里注意是四个参数的,还有跟多参数的query方法由于Mybatis的内部实现无法被拦截
<E> List<E> query(MappedStatement ms//执行sql用的
, Object parameter//参数
, RowBounds rowBounds//包含了offset limit,后面还有PageRowBounds继承了RowBounds
, ResultHandler resultHandler//实现对结果的处理
) throws SQLException;
实现分页插件需要两个关键的类:PageInterceptor和Dialect
PageInterceptor负责实现分页和查询总数的逻辑,Dialect是一个接口,不同的数据库有不同的实现方式,主要是实现了查询总数的Sql,和分页sql的拼接等。
1. PageInterceptor
@Intercepts(
@Signature(
type = Executor.class,
method = "query",
args = {MappedStatement.class, Object.class,
RowBounds.class, ResultHandler.class}
)
)
public class PageInterceptor implements Interceptor {
private static final List<ResultMapping> EMPTY_RESULTMAPPING
= new ArrayList<ResultMapping>(0);
private Dialect dialect;
private Field additionalParametersField;
@Override
public Object intercept(Invocation invocation) throws Throwable {
//获取拦截方法的参数
Object[] args = invocation.getArgs();
MappedStatement ms = (MappedStatement) args[0];
Object parameterObject = args[1];
RowBounds rowBounds = (RowBounds) args[2];
//使用rowBounds参数判断是否需要进行分页,如果不需要,直接返回结果
if (!dialect.skip(ms.getId(), parameterObject, rowBounds)) {
ResultHandler resultHandler = (ResultHandler) args[3];
//当前的目标对象~被拦截的对象
Executor executor = (Executor) invocation.getTarget();
//获取执行的select ~ sql语句 例如 select * from xxx
BoundSql boundSql = ms.getBoundSql(parameterObject);
//反射获取动态参数
Map<String, Object> additionalParameters =
(Map<String, Object>) additionalParametersField.get(boundSql);
//根据当前rowBounds的类型是不是PageRowBounds来判断是否需要进行 count 查询
if (dialect.beforeCount(ms.getId(), parameterObject, rowBounds)){
//根据当前的 ms 创建一个返回值为 Long 类型的 ms
MappedStatement countMs = newMappedStatement(ms, Long.class);
//创建 count 查询的缓存 key
CacheKey countKey = executor.createCacheKey(
countMs,
parameterObject,
RowBounds.DEFAULT,
boundSql);
//调用方言获取 count sql
String countSql = dialect.getCountSql(
boundSql,
parameterObject,
rowBounds,
countKey);
//根据countSql获取到BoundSql对象
BoundSql countBoundSql = new BoundSql(
ms.getConfiguration(),
countSql,
boundSql.getParameterMappings(),
parameterObject);
//当使用动态 SQL 时,可能会产生临时的参数,这些参数需要手动设置到新的 BoundSql 中
for (String key : additionalParameters.keySet()) {
countBoundSql.setAdditionalParameter(
key, additionalParameters.get(key));
}
//执行 count 查询
Object countResultList = executor.query(
countMs,
parameterObject,
RowBounds.DEFAULT,
resultHandler,
countKey,
countBoundSql);
Long count = (Long) ((List) countResultList).get(0);
//处理查询总数,把数据库中的总条数放进PageRowBounds的total中
dialect.afterCount(count, parameterObject, rowBounds);
//没有数据不执行之后的流程
if(count == 0L){
//当查询总数为 0 时,直接返回空的结果
return dialect.afterPage(
new ArrayList(),
parameterObject,
rowBounds);
}
}
//根据有没有rowBounds参数来判断是否需要进行分页查询
if (dialect.beforePage(ms.getId(), parameterObject, rowBounds)){
//生成分页的缓存 key
CacheKey pageKey = executor.createCacheKey(
ms,
parameterObject,
rowBounds,
boundSql);
//调用方言获取分页 sql,每个数据库的分页查询都不一样这里获取的是Mysql的分页查询
String pageSql = dialect.getPageSql(
boundSql,
parameterObject,
rowBounds,
pageKey);
BoundSql pageBoundSql = new BoundSql(
ms.getConfiguration(),
pageSql,
boundSql.getParameterMappings(),
parameterObject);
//设置动态参数
for (String key : additionalParameters.keySet()) {
pageBoundSql.setAdditionalParameter(
key, additionalParameters.get(key));
}
//执行分页查询
List resultList = executor.query(
ms,
parameterObject,
RowBounds.DEFAULT,
resultHandler,
pageKey,
pageBoundSql);
//返回resultList
return dialect.afterPage(resultList, parameterObject, rowBounds);
}
}
//返回默认查询
return invocation.proceed();
}
/**
* 根据现有的 ms 创建一个新的,使用新的返回值类型
*
* @param ms
* @param resultType
* @return
*/
public MappedStatement newMappedStatement(
MappedStatement ms, Class<?> resultType) {
MappedStatement.Builder builder = new MappedStatement.Builder(
ms.getConfiguration(),
ms.getId() + "_Count",
ms.getSqlSource(),
ms.getSqlCommandType()
);
builder.resource(ms.getResource());
builder.fetchSize(ms.getFetchSize());
builder.statementType(ms.getStatementType());
builder.keyGenerator(ms.getKeyGenerator());
if (ms.getKeyProperties() != null
&& ms.getKeyProperties().length != 0) {
StringBuilder keyProperties = new StringBuilder();
for (String keyProperty : ms.getKeyProperties()) {
keyProperties.append(keyProperty).append(",");
}
keyProperties.delete(
keyProperties.length() - 1, keyProperties.length());
builder.keyProperty(keyProperties.toString());
}
builder.timeout(ms.getTimeout());
builder.parameterMap(ms.getParameterMap());
//count查询返回值int
List<ResultMap> resultMaps = new ArrayList<ResultMap>();
ResultMap resultMap = new ResultMap.Builder(
ms.getConfiguration(),
ms.getId(),
resultType,
EMPTY_RESULTMAPPING).build();
resultMaps.add(resultMap);
builder.resultMaps(resultMaps);
builder.resultSetType(ms.getResultSetType());
builder.cache(ms.getCache());
builder.flushCacheRequired(ms.isFlushCacheRequired());
builder.useCache(ms.isUseCache());
return builder.build();
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
String dialectClass = properties.getProperty("dialect");
try {
//这里是初始化了MySqlDialect它实现了Dialect接口
dialect = (Dialect) Class.forName(dialectClass).newInstance();
} catch (Exception e) {
throw new RuntimeException(
"使用 PageInterceptor 分页插件时,必须设置 dialect 属性");
}
//这里时也没干
dialect.setProperties(properties);
try {
//反射获取 BoundSql 中的 additionalParameters 属性
//additionalParameters是一个map对象。
additionalParametersField = BoundSql.class.getDeclaredField(
"additionalParameters");
//使得private也能用,可以使用该对象获取键和值
additionalParametersField.setAccessible(true);
} catch (NoSuchFieldException e) {
throw new RuntimeException(e);
}
}
}
该类的逻辑是:拿到dialect的实例,这个实例是配置插件的时候就通过property设置好的。先判断是否进行分页查询,再判断是否要查询数据库的总条数,如果进行总条数查询则查询到数据库的总条数,如果数据库的总条数是0就直接返回一个空的ArrayList。再判断是否执行分页查询(这一层其实我觉得多余了)。。
在controller层传过来的所有的参数除了rowBounds都会保存到query的parameterObject中,如果你还是用了动态sql(if where..)那么在动态sql中使用的参数都会保存到additionalParameters中,需要让BoundSql添加这些动态sql需要的参数
2. dialect
public interface Dialect {
/**
* 跳过 count 和 分页查询
*
* @param msId 执行的 MyBatis 方法全名
* @param parameterObject 方法参数
* @param rowBounds 分页参数
* @return true 跳过,返回默认查询结果,false 执行分页查询
*/
boolean skip(String msId, Object parameterObject, RowBounds rowBounds);
/**
* 执行分页前,返回 true 会进行 count 查询,false 会继续下面的 beforePage 判断
*
* @param msId 执行的 MyBatis 方法全名
* @param parameterObject 方法参数
* @param rowBounds 分页参数
* @return
*/
boolean beforeCount(String msId, Object parameterObject, RowBounds rowBounds);
/**
* 生成 count 查询 sql
*
* @param boundSql 绑定 SQL 对象
* @param parameterObject 方法参数
* @param rowBounds 分页参数
* @param countKey count 缓存 key
* @return
*/
String getCountSql(BoundSql boundSql, Object parameterObject, RowBounds rowBounds, CacheKey countKey);
/**
* 执行完 count 查询后
*
* @param count 查询结果总数
* @param parameterObject 接口参数
* @param rowBounds 分页参数
*/
void afterCount(long count, Object parameterObject, RowBounds rowBounds);
/**
* 执行分页前,返回 true 会进行分页查询,false 会返回默认查询结果
*
* @param msId 执行的 MyBatis 方法全名
* @param parameterObject 方法参数
* @param rowBounds 分页参数
* @return
*/
boolean beforePage(String msId, Object parameterObject, RowBounds rowBounds);
/**
* 生成分页查询 sql
*
* @param boundSql 绑定 SQL 对象
* @param parameterObject 方法参数
* @param rowBounds 分页参数
* @param pageKey 分页缓存 key
* @return
*/
String getPageSql(BoundSql boundSql, Object parameterObject, RowBounds rowBounds, CacheKey pageKey);
/**
* 分页查询后,处理分页结果,拦截器中直接 return 该方法的返回值
*
* @param pageList 分页查询结果
* @param parameterObject 方法参数
* @param rowBounds 分页参数
* @return
*/
Object afterPage(List pageList, Object parameterObject, RowBounds rowBounds);
/**
* 设置参数
*
* @param properties 插件属性
*/
void setProperties(Properties properties);
}
3.MysqlDialect和PageRowBounds
MysqlDialect实现了在mysql数据库中的总条数查询sql语句和分页语句
PageRowBounds继承了rowBounds除了offset、limit还添加了一个参数total,如果用户设置了PageRowBounds那么会多进行一次sql查询,查询到的总条数保存到total变量中
public class MySqlDialect implements Dialect {
@Override
public boolean skip(String msId, Object parameterObject, RowBounds rowBounds) {
//这里使用 RowBounds 分页,默认没有 RowBounds 参数时,会使用 RowBounds.DEFAULT 作为默认值
return rowBounds == RowBounds.DEFAULT;
}
//返回false执行 beforePage 返回true执行 countSql
@Override
public boolean beforeCount(String msId, Object parameterObject, RowBounds rowBounds) {
//只有使用 PageRowBounds 才能记录总数,否则查询了总数也没用
return rowBounds instanceof PageRowBounds;
}
@Override
public String getCountSql(BoundSql boundSql, Object parameterObject, RowBounds rowBounds, CacheKey countKey) {
//简单嵌套实现 MySql count 查询,count(*)是 boundSql.getSql() 返回的条数
log.info(boundSql.getSql());
//这里是使用派生表查询到数据库的总条数,执行了两次sql查询猜得到count(*),实际上可以优化
return "select count(*) from (" + boundSql.getSql() + ") temp";
}
@Override
public void afterCount(long count, Object parameterObject, RowBounds rowBounds) {
//记录总数,按照 beforeCount 逻辑,只有 PageRowBounds 时才会查询 count,所以这里直接强制转换
((PageRowBounds)rowBounds).setTotal(count);
}
@Override
public boolean beforePage(String msId, Object parameterObject, RowBounds rowBounds) {
//执行分页前,返回 true 会进行分页查询,false 会返回默认查询结果
return rowBounds != RowBounds.DEFAULT;
}
@Override
public String getPageSql(BoundSql boundSql, Object parameterObject, RowBounds rowBounds, CacheKey pageKey) {
//pageKey 会影响缓存,通过固定的 RowBounds 可以保证二级缓存有效
pageKey.update("RowBounds");
return boundSql.getSql() + " limit " + rowBounds.getOffset() + "," + rowBounds.getLimit();
}
@Override
public Object afterPage(List pageList, Object parameterObject, RowBounds rowBounds) {
return pageList;
}
@Override
public void setProperties(Properties properties) {
}
}
public class PageRowBounds extends RowBounds{
private long total;
public PageRowBounds() {
super();
}
public PageRowBounds(int offset, int limit) {
super(offset, limit);
}
public long getTotal() {
return total;
}
public void setTotal(long total) {
this.total = total;
}
}
5.总结
如果要根据自己的需求开发插件,必须要学好Mybatis中的源码,任重而道远!!!