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映射配置文件中,需要添加标签<cache></cache>,这样就可以开启二级缓存功能。

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).
 *
 * <p>
 * <b>How to use:</b>
 *
 * <pre>
 * &#064;CacheNamespace(implementation = CustomCache.class, properties = {
 *   &#064;Property(name = "host", value = "${mybatis.cache.host}"),
 *   &#064;Property(name = "port", value = "${mybatis.cache.port}"),
 *   &#064;Property(name = "name", value = "usersCache")
 * })
 * public interface UserMapper {
 *   // ...
 * }
 * </pre>
 *
 * @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<? extends Cache> implementation() default PerpetualCache.class;

  /**
   * Returns the cache evicting implementation type to use.
   *
   * @return the cache evicting implementation type
   */
  Class<? extends Cache> 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.
 * <p>
 * One instance of cache will be created for each namespace.
 * <p>
 * The cache implementation must have a constructor that receives the cache id as an String parameter.
 * <p>
 * MyBatis will pass the namespace as id to the constructor.
 *
 * <pre>
 * public MyCache(final String id) {
 *   if (id == null) {
 *     throw new IllegalArgumentException("Cache instances require an ID");
 *   }
 *   this.id = id;
 *   initialize();
 * }
 * </pre>
 *
 * @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.
   * <p>
   * 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<Object> {

    @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<String,Object> redisTemplate(RedisConnectionFactory redisConnectionFactory){
        RedisTemplate<String,Object> 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<Object, Object> cache = new HashMap<>();

    private RedisTemplate<String,Object> 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);

}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,287评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,346评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,277评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,132评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,147评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,106评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,019评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,862评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,301评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,521评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,682评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,405评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,996评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,651评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,803评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,674评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,563评论 2 352

推荐阅读更多精彩内容