JAVA工程师常见面试题(三):mybatis缓存+手写redis二级缓存

缓存是一般的ORM 框架都会提供的功能,目的就是提升查询的效率和减少数据库的压力。跟Hibernate 一样,MyBatis 也有一级缓存二级缓存,并且预留了集成第三方缓存的接口。

整体架构

image.png

一级缓存

image.png

一级缓存是在SqlSession 层面进行缓存的。即,同一个SqlSession ,多次调用同一个Mapper和同一个方法的同一个参数,只会进行一次数据库查询,然后把数据缓存到缓冲中,以后直接先从缓存中取出数据,不会直接去查数据库。
我们来看一级缓存的触发条件:

  • 必须是相同的SQL和参数
  • 必须是相同的会话(SqlSession)
  • 必须是相同的namespace 即同一个mapper
  • 必须是相同的statement 即同一个mapper 接口中的同一个方法
  • 查询语句中间没有执行session.clearCache() 方法
  • 查询语句中间没有执行 insert update delete 方法(无论变动记录是否与 缓存数据有无关系)

必须同时满足上述所有条件,一级缓存才会触发。

源码案例

我们使用最新版本的spring boot构建项目。

1.搭建spring boot+mybatis测试项目

spring官方网站速度太慢,切换到阿里云。


image.png

选择如下的依赖:


image.png

2.连接数据库

使用IDEA自带的DataBase工具连接。


image.png

选择Mysql作为数据源。


image.png

填入数据库地址及用户名、密码,如果提示需要下载驱动,点击下载即可,默认选择的mysql驱动版本为8.0+。
image.png

设置一下时区,否则8.0的驱动是无法访问数据库的。


image.png

这样在右边就可以看到我们的数据库表了,右键自动生成实体类。
image.png

按照需要在这里勾选对应的功能,然后点击生成即可。
image.png

这样pojo、dao、mapper映射文件就自动生成了。
image.png

3.配置mybatis

复制下面的配置模板到项目中,修改对应的内容以匹配数据库。

# 应用名称
spring.application.name=mybatis-test
# 应用服务 WEB 访问端口
server.port=8080
# 数据库驱动:
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
# 数据源名称
spring.datasource.name=defaultDataSource
# 数据库连接地址
spring.datasource.url=jdbc:mysql://localhost:3306/mp?serverTimezone=UTC
# 数据库用户名&密码:
spring.datasource.username=root
spring.datasource.password=123456
#扫描映射文件路径
mybatis.mapper-locations=classpath:/mapper/**.xml
#日志输出
mybatis.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl

引导类上添加注解@MapperScan("com.brianxia.demo")

package com.brianxia.demo;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@MapperScan("com.brianxia.demo")
public class MybatisCacheTestApplication {

    public static void main(String[] args) {
        SpringApplication.run(MybatisCacheTestApplication.class, args);
    }

}

4.测试一级缓存

package com.brianxia.demo;

import com.brianxia.demo.dao.TbUserDao;
import com.brianxia.demo.pojo.TbUser;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
class MybatisCacheTestApplicationTests {

    @Autowired
    private TbUserDao tbUserDao;

    //一级缓存不生效,每次查询都会生成新的sqlsession并执行提交,不在同一个sqlsession中
    @Test
    void test1() {
        TbUser tbUser1 = tbUserDao.selectByPrimaryKey(1L);
        TbUser tbUser2 = tbUserDao.selectByPrimaryKey(1L);
        System.out.println(tbUser1 == tbUser2);
    }

}

这个时候会发现一级缓存并没有生效,每次查询都会创建一个新的SqlSession并发送Sql语句到mysql中。


image.png

可以通过增加事务注解避免重复创建SqlSession会话,再次进行测试:

package com.brianxia.demo;

import com.brianxia.demo.dao.TbUserDao;
import com.brianxia.demo.pojo.TbUser;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;

@SpringBootTest
class MybatisCacheTestApplicationTests {

    @Autowired
    private TbUserDao tbUserDao;

    //一级缓存不生效,每次查询都会生成新的sqlsession并执行提交,不在同一个sqlsession中
    @Test
    @Transactional
    void test1() {
        TbUser tbUser1 = tbUserDao.selectByPrimaryKey(1L);
        TbUser tbUser2 = tbUserDao.selectByPrimaryKey(1L);
        System.out.println(tbUser1 == tbUser2);
    }

}

这次只创建了一个SqlSession,并且可以看到两个对象指向同一块堆内存区域,所以一级缓存已经生效。


image.png

5.测试必要条件

  • 必须是相同的SQL和参数
 //一级缓存不生效,必须是相同的SQL和参数
    @Test
    @Transactional
    void test1() {
        TbUser tbUser1 = tbUserDao.selectByPrimaryKey(1L);
        TbUser tbUser2 = tbUserDao.selectByPrimaryKey(2L);
        System.out.println(tbUser1 == tbUser2);
    }
  • 必须是相同的statement 即同一个mapper 接口中的同一个方法
    @Select("select * from tb_user where id = #{id}")
    TbUser selectByPrimaryKey2(Long id);

    //一级缓存不生效,必须是相同的statement 即同一个mapper 接口中的同一个方法
    @Test
    @Transactional
    void test2() {
        TbUser tbUser1 = tbUserDao.selectByPrimaryKey(1L);
        TbUser tbUser2 = tbUserDao.selectByPrimaryKey2(1L);
        System.out.println(tbUser1 == tbUser2);
    }
  • 查询语句中间没有执行 insert update delete 方法(无论变动记录是否与 缓存数据有无关系)
 //一级缓存不生效,查询语句中间没有执行 insert update delete 方法
    @Test
    @Transactional
    void test2() {
        TbUser tbUser1 = tbUserDao.selectByPrimaryKey(1L);
        TbUser tbUser = tbUserDao.selectByPrimaryKey(2L);
        tbUserDao.updateByPrimaryKey(tbUser);
        TbUser tbUser2 = tbUserDao.selectByPrimaryKey(1L);
        System.out.println(tbUser1 == tbUser2);
    }

二级缓存

一级缓存无法实现在多个SqlSession中共享数据,所以mybatis提供了二级缓存,在SqlSessionFactory层面给各个SqlSession 对象共享。默认二级缓存是不开启的,需要手动进行配置。

1.注解方式开启

如果使用纯注解的方式,首先需要在mapper接口上添加注解@CacheNamespace,这样才能开启二级缓存功能。

package com.brianxia.demo.dao;

import com.brianxia.demo.pojo.TbUser;
import org.apache.ibatis.annotations.CacheNamespace;
import org.apache.ibatis.annotations.CacheNamespaceRef;
import org.apache.ibatis.annotations.Select;

@CacheNamespace
public interface TbUserDao2 {

    @Select("select * from tb_user where id = #{id}")
    TbUser selectByPrimaryKey(Long id);

}

然后编写查询方法:

  //二级缓存
    @Test
    void test3() {
        TbUser tbUser1 = tbUserDao2.selectByPrimaryKey(1L);
        TbUser tbUser2 = tbUserDao2.selectByPrimaryKey(1L);
    }

可以观察一下日志,应该只有一次SQL查询,第二次SqlSession创建之后,会通过二级缓存查询出数据返回。

默认的二级缓存会有如下效果

  • 映射语句文件中的所有 SELECT 语句将会被缓存。
  • 映射语句文件中的所有INSERT、UPDATE、DELETE 语句会刷新缓存。
  • 缓存会使用 Least Recently Used ( LRU,最近最少使用的)算法来收回。
  • 根据时间表刷新缓存(如 no Flush Interval ,没有刷新间隔,缓存不会以任何时间顺序来刷新)。
  • 缓存会存储集合或对象(无论查询方法返回什么类型的值)的 1024 个引用。
  • 缓存会被视为 read/write (可读/可写)的,意味着对象检索不是共享的,而且可以安全地被调用者修改,而不干扰其他调用者或线程所做的潜在修改。

接口关闭缓存

如果对于某些接口需要关闭缓存,可以在接口上通过@Options注解添加具体的关闭缓存配置项,如下:

    @Options(useCache = false)
    @Select("select * from tb_user where id = #{id}")
    TbUser selectByPrimaryKey(Long id);

这样缓存就不会生效了。

2.配置文件实现方式

在mapper.xml映射配置文件中,需要添加标签,这样就可以开启二级缓存功能。

image.png

其余功能与注解方式相同。

3.配置项详解

以注解的使用方式为例,源码如下:

/**
 *    Copyright 2009-2020 the original author or authors.
 *
 *    Licensed under the Apache License, Version 2.0 (the "License");
 *    you may not use this file except in compliance with the License.
 *    You may obtain a copy of the License at
 *
 *       http://www.apache.org/licenses/LICENSE-2.0
 *
 *    Unless required by applicable law or agreed to in writing, software
 *    distributed under the License is distributed on an "AS IS" BASIS,
 *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *    See the License for the specific language governing permissions and
 *    limitations under the License.
 */
package org.apache.ibatis.annotations;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import org.apache.ibatis.cache.Cache;
import org.apache.ibatis.cache.decorators.LruCache;
import org.apache.ibatis.cache.impl.PerpetualCache;

/**
 * The annotation that specify to use cache on namespace(e.g. mapper interface).
 *
 * 

* How to use: * *

 * @CacheNamespace(implementation = CustomCache.class, properties = {
 *   @Property(name = "host", value = "${mybatis.cache.host}"),
 *   @Property(name = "port", value = "${mybatis.cache.port}"),
 *   @Property(name = "name", value = "usersCache")
 * })
 * public interface UserMapper {
 *   // ...
 * }
 * 
* * @author Clinton Begin * @author Kazuki Shimizu */ @Documented @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE) public @interface CacheNamespace { /** * Returns the cache implementation type to use. * * @return the cache implementation type */ Class implementation() default PerpetualCache.class; /** * Returns the cache evicting implementation type to use. * * @return the cache evicting implementation type */ Class eviction() default LruCache.class; /** * Returns the flush interval. * * @return the flush interval */ long flushInterval() default 0; /** * Return the cache size. * * @return the cache size */ int size() default 1024; /** * Returns whether use read/write cache. * * @return {@code true} if use read/write cache; {@code false} if otherwise */ boolean readWrite() default true; /** * Returns whether block the cache at request time or not. * * @return {@code true} if block the cache; {@code false} if otherwise */ boolean blocking() default false; /** * Returns property values for a implementation object. * * @return property values * @since 3.4.2 */ Property[] properties() default {}; }

配置项

eviction(收回策略)

  • LRU(最近最少使用的):移除最长时间不被使用的对象,这是默认值。

  • FIFO(先进先出):按对象进入缓存的顺序来移除它们。

  • SOFT(软引用):移除基于垃圾回收器状态和软引用规则的对象。

  • WEAK(弱引用):更积极地移除基于垃圾收集器状态和弱引用规则的对象。

flushinterval(刷新间隔)

可以被设置为任意的正整数,而且它们代表一个合理的毫秒形式的时间段。默认情况不设置,即没有刷新间隔,缓存仅仅在调用语句时刷新。

size(引用数目)

可以被设置为任意正整数,要记住缓存的对象数目和运行环境的可用内存资源数目。默认值是1024 。

readOnly(只读)

属性可以被设置为 true 或 false。只读的缓存会给所有调用者返回缓存对象的相同实例,因此这些对象不能被修改,这提供了很重要的性能优势。可读写的缓存会通过序列化返回缓存对象的拷贝,这种方式会慢一些,但是安全,因此默认是 false。

配置方式

@CacheNamespace(

eviction = FifoCache.class,
flushinterval = 60000,
size = 512,
readWrite = true

)

加餐1:使用redis作为二级缓存

自定义二级缓存只需要实现Cache接口,源码如下:

/**
 *    Copyright 2009-2020 the original author or authors.
 *
 *    Licensed under the Apache License, Version 2.0 (the "License");
 *    you may not use this file except in compliance with the License.
 *    You may obtain a copy of the License at
 *
 *       http://www.apache.org/licenses/LICENSE-2.0
 *
 *    Unless required by applicable law or agreed to in writing, software
 *    distributed under the License is distributed on an "AS IS" BASIS,
 *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *    See the License for the specific language governing permissions and
 *    limitations under the License.
 */
package org.apache.ibatis.cache;

import java.util.concurrent.locks.ReadWriteLock;

/**
 * SPI for cache providers.
 * 

* One instance of cache will be created for each namespace. *

* The cache implementation must have a constructor that receives the cache id as an String parameter. *

* MyBatis will pass the namespace as id to the constructor. * *

 * public MyCache(final String id) {
 *   if (id == null) {
 *     throw new IllegalArgumentException("Cache instances require an ID");
 *   }
 *   this.id = id;
 *   initialize();
 * }
 * 
* * @author Clinton Begin */ public interface Cache { /** * @return The identifier of this cache */ String getId(); /** * @param key * Can be any object but usually it is a {@link CacheKey} * @param value * The result of a select. */ void putObject(Object key, Object value); /** * @param key * The key * @return The object stored in the cache. */ Object getObject(Object key); /** * As of 3.3.0 this method is only called during a rollback * for any previous value that was missing in the cache. * This lets any blocking cache to release the lock that * may have previously put on the key. * A blocking cache puts a lock when a value is null * and releases it when the value is back again. * This way other threads will wait for the value to be * available instead of hitting the database. * * * @param key * The key * @return Not used */ Object removeObject(Object key); /** * Clears this cache instance. */ void clear(); /** * Optional. This method is not called by the core. * * @return The number of elements stored in the cache (not its capacity). */ int getSize(); /** * Optional. As of 3.2.6 this method is no longer called by the core. *

* Any locking needed by the cache must be provided internally by the cache provider. * * @return A ReadWriteLock */ default ReadWriteLock getReadWriteLock() { return null; } }

从上面源码可以分析出来,存放和获取的对象类型都统一为Object类型,所以如果要将Object类型转换为json存放到redis中会遇到反序列化类型无法获取的问题,所以需要自定义序列化器,而不能用Json作为序列化方式。
代码如下:

package com.brianxia.demo.utils;

import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.SerializationException;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;

public class ObjectSerializer implements RedisSerializer {

    @Override
    public byte[] serialize(Object o) throws SerializationException {
        ObjectOutputStream oos = null;
        ByteArrayOutputStream baos = null;
        try {
            baos = new ByteArrayOutputStream();
            oos = new ObjectOutputStream(baos);
            oos.writeObject(o);
            byte[] bytes = baos.toByteArray();
            return bytes;
        } catch (Exception e) {
            e.printStackTrace();
        }finally {
            try {
                if(baos != null){
                    baos.close();
                }
                if (oos != null) {
                    oos.close();
                }
            } catch (Exception e2) {
                e2.printStackTrace();
            }
        }
        return null;
    }

    /*
     * 反序列化
     * */
    public Object deserialize(byte[] bytes){
        ByteArrayInputStream bais = null;
        ObjectInputStream ois = null;
        try{
            bais = new ByteArrayInputStream(bytes);
            ois = new ObjectInputStream(bais);
            return ois.readObject();
        }catch(Exception e){
            e.printStackTrace();
        }finally {
            try {

            } catch (Exception e2) {
                e2.printStackTrace();
            }
        }
        return null;
    }

}

使用JDK的byte序列化器来进行序列化,转换成byte[]存放到redis中。
创建RedisTemplate:

 @Bean
    public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory){
        RedisTemplate template = new RedisTemplate<>();
        template.setConnectionFactory(redisConnectionFactory);
        template.setValueSerializer(new ObjectSerializer());
        template.setKeySerializer(new StringRedisSerializer());
        template.afterPropertiesSet();
        return template;
    }

其中key的序列化器可以使用String,因为对key没有get还原成原始对象的操作,只是作为寻址参数。value的序列化器必须使用ObjectSerializer,否则无法还原出原本的类型。
编写Cache接口实现类:

package com.brianxia.demo.cache;

import com.alibaba.fastjson.JSON;
import com.brianxia.demo.utils.RedisTemplateUtil;
import org.apache.ibatis.cache.Cache;
import org.apache.ibatis.cache.CacheException;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;

import java.util.HashMap;
import java.util.Map;

/**
 * @author brianxia
 * @version 1.0
 * @date 2020/12/19 9:38
 */

public class MyCache implements Cache {

    private final String id;

    private final Map cache = new HashMap<>();

    private RedisTemplate stringRedisTemplate;

    private String cacheKey2String(Object key){
        return JSON.toJSONString(key);
    }
    public MyCache(String id) {
        synchronized (this){
            if(stringRedisTemplate == null){
                stringRedisTemplate =  RedisTemplateUtil.redisTemplate();
            }
        }

        this.id = id;
    }

    @Override
    public String getId() {
        return id;
    }

    @Override
    public int getSize() {
        return stringRedisTemplate.opsForHash().size("testCache").intValue();
    }

    @Override
    public void putObject(Object key, Object value) {
        System.out.println("用了我自己的cache");
        stringRedisTemplate.opsForHash().put("testCache",cacheKey2String(key), value);
    }

    @Override
    public Object getObject(Object key) {
        return stringRedisTemplate.opsForHash().get("testCache",cacheKey2String(key));
    }

    @Override
    public Object removeObject(Object key) {
        return stringRedisTemplate.opsForHash().delete("testCache",cacheKey2String(key));
    }

    @Override
    public void clear() {
        stringRedisTemplate.delete("testCache");
    }

    @Override
    public boolean equals(Object o) {
        if (getId() == null) {
            throw new CacheException("Cache instances require an ID.");
        }
        if (this == o) {
            return true;
        }
        if (!(o instanceof Cache)) {
            return false;
        }

        Cache otherCache = (Cache) o;
        return getId().equals(otherCache.getId());
    }

    @Override
    public int hashCode() {
        if (getId() == null) {
            throw new CacheException("Cache instances require an ID.");
        }
        return getId().hashCode();
    }

}

  • putObject存放缓存
  • getObject获取缓存
  • removeObject 移除缓存
  • clear清理所有缓存

这里将所有的cache存放在hash中,方便进行统一的管理,否则clear方法清理大量key非常损耗性能。

最后进行测试:

package com.brianxia.demo.dao;

import com.brianxia.demo.cache.MyCache;
import com.brianxia.demo.pojo.TbUser;
import org.apache.ibatis.annotations.CacheNamespace;
import org.apache.ibatis.annotations.CacheNamespaceRef;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Select;

@CacheNamespace(implementation = MyCache.class)
public interface TbUserDao2 {

    //@Options(useCache = false)
    @Select("select * from tb_user where id = #{id}")
    TbUser selectByPrimaryKey(Long id);

}

你可能感兴趣的:(JAVA工程师常见面试题(三):mybatis缓存+手写redis二级缓存)