Rubin's Blog

  • 首页
  • 关于作者
  • 隐私政策
享受恬静与美好~~~
分享生活的点点滴滴~~~
  1. 首页
  2. MyBatis
  3. 正文

MyBatis之缓存

2021年 5月 12日 811点热度 0人点赞 0条评论

前言

我们在开发中经常会使用缓存来减轻生产数据库的压力,提升网站的QPS。MyBatis同样提供了两种缓存机制,虽然我们在开发中很少用到,但是作为面试中的常问知识点,我们还是要有一定的了解。

本博文详细阐述了MyBatis缓存的原理以及使用方式,详情看正文。

MyBatis缓存详解

总览

MyBatis的缓存分为一级缓存和二级缓存。其结构图如下:

由上图可知:

  • 一级缓存是SqlSession级别的缓存。在操作数据库时需要构造sqlSession对象,在对象中有一个数据结构(HashMap)用于存储缓存数据。不同的sqlSession之间的缓存数据区域(HashMap)是互相不影响的。
  • 二级缓存是Mapper级别的缓存,多个SqlSession去操作同一个Mapper的sql语句,多个SqlSession可以共用二级缓存,二级缓存是跨SqlSession的。

一级缓存

Session是什么

我们知道,一级缓存是SqlSession级别的。在这里我们首先来解释一下Session是什么。

我们在各大框架的学习过程中(无论是Spring还是MyBatis等等),都会有Session的概念。Session意义上来说就是“会话”。这个怎么理解呢?其实也就是我们客户端和服务端的一次交互,交互的内容和双方身份以及交互中产生的数据结果都可以存储到会话中,并且会话不会永久储存,都会有过期时间。

对于SpringMVC来说,Session使用Cookie技术来实现。我们的MyBatis使用工厂来生产会话对象。

为什么MyBatis集成Spring之后一级缓存失效

我们听说过MyBatis有一级缓存,但是在我们的日常开发中(通常使用SSM架构)的时候,根本就没有用过MyBatis的一级缓存。查看过源码的同学都知道,MyBatis的一级缓存默认是开启的(是一个Map结构),为什么在集成Spring之后就失效了呢?

其实,一级缓存并没有失效,而是我们每次执行查询请求,Spring都会新开启一个SqlSession来进行数据库交互,交互完就销毁掉了。所以我们的一级缓存是不生效的。在Spring中,MyBatis的执行流程如下:

  1. 接受到请求,Spring申请一个SqlSession(将其视为资源),并绑定当前线程的上下文中(ThreadLocal)
  2. Mapper代理对象内部的template属性获取SqlSession去执行操作
  3. 查询结束,释放资源
  4. 返回响应结果

具体Spring怎么将Mapper对象生成代理而放入容器中我们不细讲,我们从Mapper的代理过程讲起,来更具体的阐述一下原因。

我们都知道,Mapper的代理接口是由SqlSession产生的,而SqlSession是由SqlSessionFactory产生的。我们查看一个SSM环境下的SqlSession接口的实现如下:

可以看到,有三个实现类,其中我们需要关注的是SqlSessionTemplate这个实现类。如下图:

我们可以看到,本类就是一个SqlSeesion实现类,它的getMapper方法的实现和默认的一样,都是一层一层的调用,最终将自己封装进动态代理类里面。有兴趣的可以自己翻阅源码看一下,这里就不再赘述。我们在调用代理Mapper的方法的时候,都会调用到本类的增删改查方法,而本类的增删改查方法的实现如下图所示:

由上图可以看出,所有的方法都是调用内部属性sqlSessionProxy的方法。那么,这个sqlSessionProxy是怎么产生的呢?

由构造器我们可以看出,他是一个接口代理类,实现逻辑是通过一个叫SqlSessionInterceptor的内部类实现的,我们看一下这个内部类:

由内部类逻辑我们可以看出来,我们的每次数据库操作真正执行的SqlSession使用过一个SqlSessionUtils来生产的,我们点进去看一下:

看到这里,逻辑就很清晰了。获取SqlSession的过程就是先去资源池(Spring会把SqlSession当作资源,会在请求开始的时候放到线程上下文,也就是ThreadLocal里面)中获取,获取不到就新创建一个。也就是说,我们每次请求的执行都是新的SqlSession来执行的,所以一级缓存会失效。

一级缓存的简单使用

在一个sqlSession中,对User表根据id进行两次查询,查看他们发出SQL语句的情况:

@Test
public void test1(){
    //根据 sqlSessionFactory 产生 session
    SqlSession sqlSession = sessionFactory.openSession();
    UserMapper userMapper = sqlSession.getMapper(UserMapper.class);
    //第一次查询,发出sql语句,并将查询出来的结果放进缓存中
    User u1 = userMapper.selectUserByUserId(1);
    System.out.println(u1);
    //第二次查询,由于是同一个sqlSession,会在缓存中查询结果
    //如果有,则直接从缓存中取出来,不和数据库进行交互
    User u2 = userMapper.selectUserByUserId(1);
    System.out.println(u2);
    sqlSession.close();
}

查看控制台打印情况:

我们发现,第二次查询是走了缓存的。那么缓存什么时候会失效呢?带着这个疑问我们进行下面的验证:

@Test
public void test2(){
    //根据 sqlSessionFactory 产生 session
    SqlSession sqlSession = sessionFactory.openSession();
    UserMapper userMapper = sqlSession.getMapper(UserMapper.class);
    //第一次查询,发出sql语句,并将查询的结果放入缓存中
    User u1 = userMapper.selectUserByUserId( 1 );
    System.out.println(u1);
    //第二步进行了一次更新操作,sqlSession.commit()
    u1.setSex("女");
    userMapper.updateUserByUserId(u1);
    sqlSession.commit();
    //第二次查询,由于是同一个sqlSession.commit(),会清空缓存信息
    //则此次查询也会发出sql语句
    User u2 = userMapper.selectUserByUserId(1);
    System.out.println(u2);
    sqlSession.close();
}

查看控制台打印情况:

我们看到,更新操作会清掉一级缓存。

由此,我们总结如下:

  1. 第一次发起查询用户id为1的用户信息,先去找缓存中是否有id为1的用户信息,如果没有,从数据库查询用户信息。得到用户信息,将用户信息存储到一级缓存中。
  2. 如果中间sqlSession去执行commit操作(执行插入、更新、删除),则会清空SqlSession中的 一级缓存,这样做的目的为了让缓存中存储的是最新的信息,避免脏读。
  3. 第二次发起查询用户id为1的用户信息,先去找缓存中是否有id为1的用户信息,缓存中有,直接从缓存中获取用户信息。

一级缓存的实现原理

一级缓存到底是什么?一级缓存什么时候被创建、一级缓存的工作流程是怎样的?我们可能会有这样的疑问,下面我们就从源码中看一下一级缓存的实现原理。
大家可以这样想,上面我们一直提到一级缓存,那么提到一级缓存就绕不开SqlSession,所以索性我们就直接从SqlSession入手,看看有没有创建缓存或者与缓存有关的属性或者方法。

调研了一圈,发现上述所有方法中,好像只有clearCache()和缓存沾点关系,那么就直接从这个方法入手,分析源码时,我们要看它(此类)是谁,它的父类和子类分别又是谁,对如上关系了解了,我们才会对这个类有更深的认识。

再深入分析,流程走到Perpetualcache中的clear()方法之后,会调用其cache.clear()方法,那么这个cache是什么东西呢?

点进去发现,cache其实就是private Map cache = new HashMap();也就是一个Map,所以说cache.clear()其实就是map.clear(),也就是说,缓存其实就是本地存放的一个map对象,每一个SqISession都会存放一个map对象的引用,那么这个cache是何时创建的呢?

你觉得最有可能创建缓存的地方是哪里呢?我觉得是Executor,为什么这么认为?因为Executor是执行器,用来执行SQL请求,而且清除缓存的方法也在Executor中执行,所以很可能缓存的创建也很有可能在Executor中,看了一圈发现Executor中有一个createCacheKey方法,这个方法很像是创建缓存的方法啊,跟进去看看,你发现createCacheKey方法是由BaseExecutor执行的,代码如下:

CacheKey cacheKey = new CacheKey();
//MappedStatement 的 id
// id就是Sql语句的所在位置包名+类名+ SQL名称
cacheKey.update(ms.getId());
// offset 就是 0
cacheKey.update(rowBounds.getOffset());
// limit 就是 Integer.MAXVALUE
cacheKey.update(rowBounds.getLimit());
//具体的SQL语句
cacheKey.update(boundSql.getSql());
//后面是update 了 sql中带的参数
cacheKey.update(value);
...
if (configuration.getEnvironment() != null) {
    // issue #176
    cacheKey.update(configuration.getEnvironment().getId());
}

创建缓存key会经过一系列的update方法,update方法由一个CacheKey这个对象来执行的,这个update方法最终由updateList的list来把五个值存进去。如下图所示:

那么我们回归正题,那么创建完缓存之后该用在何处呢?总不会凭空创建一个缓存不使用吧?绝对不会的,经过我们对一级缓存的探究之后,我们发现一级缓存更多是用于查询操作,毕竟一级缓存也叫做查询缓存。我们先来看一下这个缓存到底用在哪了,我们跟踪到query方法如下:

Override
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
    BoundSql boundSql = ms.getBoundSql(parameter);
    //创建缓存
    CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql);
    return query(ms, parameter, rowBounds, resultHandler, key, boundSql);
}
@SuppressWarnings("unchecked")
Override
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
    ...
    list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
    if (list != null) {
        //这个主要是处理存储过程用的。
        handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
    } else {
        list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
    }
    ...
}
// queryFromDatabase 方法
private <E> List<E> queryFromDatabase(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
    List<E> list;
    localCache.putObject(key, EXECUTION_PLACEHOLDER);
    try {
        list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);
    } finally {
        localCache.removeObject(key);
    }
    localCache.putObject(key, list);
    if (ms.getStatementType() == StatementType.CALLABLE) {
        localOutputParameterCache.putObject(key, parameter);
    }
    return list;
}

如果查不到的话,就从数据库查,在queryFromDatabase中,会对localcache进行写入。 localcache对象的put方法最终交给Map进行存放。

private Map<Object, Object> cache = new HashMap<Object, Object>();

@Override
public void putObject(Object key, Object value) { 
    cache.put(key, value);
}

二级缓存

原理

二级缓存的原理和一级缓存原理一样,第一次查询,会将数据放入缓存中,然后第二次查询则会直接去缓存中取。但是一级缓存是基于sqlSession的,而二级缓存是基于mapper文件的namespace的,也就是说多个sqlSession可以共享一个mapper中的二级缓存区域,并且如果两个mapper的namespace相同,即使是两个mapper,那么这两个mapper中执行SQL查询到的数据也将存在相同的二级缓存区域中。

二级缓存的使用

二级缓存默认是关闭的,需要手动开启。开启方法如下:

首先在全局配置文件sqlMapConfig.xml文件中加入如下代码:

<!--开启二级缓存-->
<settings>
    <setting name="cacheEnabled" value="true"/>
</settings>

其次在UserMapper.xml文件中开启缓存:

<!--开启二级缓存-->
<cache></cache>

我们可以看到mapper.xml文件中就这么一个空标签,其实这里可以配置。PerpetualCache这个类是mybatis默认实现缓存功能的类。我们不写type就使用mybatis默认的缓存,也可以去实现Cache接口来自定义缓存,我们在此不在赘述,有兴趣的小伙伴可以试一下,但是要注意返回对象实现序列化接口,否则缓存会有问题。

二级缓存的使用和一级缓存大体类似,开启了之后,查询操作会在命名空间内共享查询结果。有事务提交操作会清空缓存。

useCache和flushCache

MyBatis中还可以配置useCache和flushCache等配置项。细节如下:

  • useCache是用来设置是否禁用二级缓存的,在statement中设置useCache=false可以禁用当前select语句的二级缓存,即每次查询都会发出 SQL去查询,默认情况是true,即该SQL使用二级缓存。
  • flushCache是用来设置是不是要刷新缓存的,默认是true,即默认是刷新缓存的。如果update、insert或者delete操作设置成了false,则不会刷新缓存,有可能造成数据脏读。

二级缓存整合Redis

上面我们介绍了MyBatis自带的二级缓存,但是这个缓存是单服务器工作,无法实现分布式缓存。 那么什么是分布式缓存呢?假设现在有两个服务器1和2,用户访问的时候访问了 1服务器,查询后的缓存就会放在1服务器上,假设现在有个用户访问的是2服务器,那么他在2服务器上就无法获取刚刚那个缓存,如下图所示:

为了解决这个问题,就得找一个分布式的缓存,专门用来存储缓存数据的,这样不同的服务器要缓存数据都往它那里存,取缓存数据也从它那里取,如下图所示:

如上图所示,在几个不同的服务器之间,我们使用第三方缓存框架,将缓存都放在这个第三方框架中, 然后无论有多少台服务器,我们都能从缓存中获取数据。
这里我们介绍MyBatis与Redis的整合。
刚刚提到过,MyBatis提供了一个Cache接口,如果要实现自己的缓存逻辑,实现Cache接口开发即可。MyBatis本身默认实现了一个,但是这个缓存的实现无法实现分布式缓存,所以我们要自己来实现。
Redis分布式缓存就可以,MyBatis提供了一个针对Cache接口的Redis实现类,该类存在mybatis-redis包中。所以我们要使用Redis来实现二级缓存,就要加入如下依赖:

<dependency>
    <groupId>org.mybatis.caches</groupId>
    <artifactId>mybatis-redis</artifactId>
    <version>1.0.0-beta2</version>
</dependency>

修改我们的Mapper文件:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.rubin.mapper.IUserMapper">
    <cache type="org.mybatis.caches.redis.RedisCache" />
    <select id="findAll" resultType="com.rubin.pojo.User" useCache="true">
        select * from user
    </select>
</mapper>

在我们的classpath下创建redis.properties配置文件:

redis.host=localhost
redis.port=6379
redis.connectionTimeout=5000
redis.password=
redis.database=0

编写测试代码:

@Test
public void SecondLevelCache(){
    SqlSession sqlSession1 = sqlSessionFactory.openSession();
    SqlSession sqlSession2 = sqlSessionFactory.openSession();
    SqlSession sqlSession3 = sqlSessionFactory.openSession();
    IUserMapper mapper1 = sqlSession1.getMapper(IUserMapper.class);
    lUserMapper mapper2 = sqlSession2.getMapper(lUserMapper.class);
    lUserMapper mapper3 = sqlSession3.getMapper(IUserMapper.class);
    User user1 = mapper1.findUserById(1);
    //清空一级缓存
    sqlSession1.close(); 
    User user = new User();
    user.setId(1);
    user.setUsername("lisi");
    mapper3.updateUser(user);
    sqlSession3.commit();
    User user2 = mapper2.findUserById(1);
    System.out.println(user1==user2);
}

二级缓存的问题

二级缓存由于是namespace级别的缓存,就是说,在事务间是共享的。那么在高并发的场景下,就可能会引发线程安全问题,也就是脏读问题。我们的MyBatis已经很好的解决了这个问题,解决的方式就是利用装饰器模式装饰了一个事务级别的Cache。详情如下:

通过TransactionalCacheManager来管理事务之间的二级缓存:

/** 事务缓存管理器 */
public class TransactionalCacheManager {

    // Cache 与 TransactionalCache 的映射关系表
    private final Map<Cache, TransactionalCache> transactionalCaches = new HashMap<Cache, TransactionalCache>();

    public void clear(Cache cache) {
      // 获取 TransactionalCache 对象,并调用该对象的 clear 方法,下同
      getTransactionalCache(cache).clear();
    }
    public Object getObject(Cache cache, CacheKey key) {
        // 直接从TransactionalCache中获取缓存
        return getTransactionalCache(cache).getObject(key);
    }
    public void putObject(Cache cache, CacheKey key, Object value) {
        // 直接存入TransactionalCache的缓存中
        getTransactionalCache(cache).putObject(key, value);
    }
    public void commit() {
        for (TransactionalCache txCache : transactionalCaches.values()) {
            txCache.commit();
        }
    }
    public void rollback() {
        for (TransactionalCache txCache : transactionalCaches.values()) {
            txCache.rollback();
        }
    }
    private TransactionalCache getTransactionalCache(Cache cache) {
        // 从映射表中获取 TransactionalCache
        TransactionalCache txCache = transactionalCaches.get(cache);
        if (txCache == null) {
            // TransactionalCache 也是一种装饰类,为 Cache 增加事务功能
            // 创建一个新的TransactionalCache,并将真正的Cache对象存进去
            txCache = new TransactionalCache(cache);
            transactionalCaches.put(cache, txCache);
        }
        return txCache;
    }
}

TransactionalCacheManager 内部维护了 Cache 实例与 TransactionalCache 实例间的映射关系,该类也仅负责维护两者的映射关系,真正做事的还是 TransactionalCache。TransactionalCache 是一种缓存装饰器,可以为 Cache 实例增加事务功能。我在之前提到的脏读问题正是由该类进行处理的。下面分析一下该类的逻辑。

public class TransactionalCache implements Cache {
    //真正的缓存对象,和上面的Map<Cache, TransactionalCache>中的Cache是同一个
    private final Cache delegate;
    private boolean clearOnCommit;
    // 在事务被提交前,所有从数据库中查询的结果将缓存在此集合中
    private final Map<Object, Object> entriesToAddOnCommit;
    // 在事务被提交前,当缓存未命中时,CacheKey 将会被存储在此集合中
    private final Set<Object> entriesMissedInCache;
    @Override
    public Object getObject(Object key) {
        // 查询的时候是直接从delegate中去查询的,也就是从真正的缓存对象中查询
        Object object = delegate.getObject(key);
        if (object == null) {
            // 缓存未命中,则将 key 存入到 entriesMissedInCache 中
            entriesMissedInCache.add(key);
        }
        if (clearOnCommit) {
            return null;
        } else {
            return object;
        }
     }
        @Override
        public void putObject(Object key, Object object) {
        // 将键值对存入到 entriesToAddOnCommit 这个Map中中,而非真实的缓存对象 delegate中
        entriesToAddOnCommit.put(key, object);
    }
    @Override
    public Object removeObject(Object key) {
        return null;
    }
    @Override
    public void clear() {
        clearOnCommit = true;
        // 清空 entriesToAddOnCommit,但不清空 delegate 缓存
        entriesToAddOnCommit.clear();
    }
    public void commit() {
        // 根据 clearOnCommit 的值决定是否清空 delegate
        if (clearOnCommit) {
            delegate.clear();
        }
        // 刷新未缓存的结果到 delegate 缓存中
        flushPendingEntries();
        // 重置 entriesToAddOnCommit 和 entriesMissedInCache
        reset();
    }
    public void rollback() {
        unlockMissedEntries();
        reset();
    }
    private void reset() {
        clearOnCommit = false;
        // 清空集合
        entriesToAddOnCommit.clear();
        entriesMissedInCache.clear();
    }
    private void flushPendingEntries() {
        for (Map.Entry<Object, Object> entry : entriesToAddOnCommit.entrySet()) {
            // 将 entriesToAddOnCommit 中的内容转存到 delegate 中
            delegate.putObject(entry.getKey(), entry.getValue());
        }
        for (Object entry : entriesMissedInCache) {
            if (!entriesToAddOnCommit.containsKey(entry)) {
                // 存入空值
                delegate.putObject(entry, null);
            }
        }
    }
    private void unlockMissedEntries() {
        for (Object entry : entriesMissedInCache) {
            try {
                // 调用 removeObject 进行解锁
                delegate.removeObject(entry);
            } catch (Exception e) {
                log.warn("...");
            }
        }
    }
}

存储二级缓存对象的时候是放到了TransactionalCache.entriesToAddOnCommit这个map中,但是每次查询的时候是直接从TransactionalCache.delegate中去查询的,所以这个二级缓存查询数据库后,设置缓存值是没有立刻生效的,主要是因为直接存到 delegate 会导致脏数据问题。

那我们来看下SqlSession.commit()方法做了什么。

// SqlSession.commit()
@Override
public void commit(boolean force) {
    try {
        // 主要是这句
        executor.commit(isCommitOrRollbackRequired(force));
        dirty = false;
    } catch (Exception e) {
        throw ExceptionFactory.wrapException("Error committing transaction.Cause: " + e, e);
    } finally {
        ErrorContext.instance().reset();
    }
}

// CachingExecutor.commit()
@Override
public void commit(boolean required) throws SQLException {
    delegate.commit(required);
    // 在这里
    tcm.commit();
}

// TransactionalCacheManager.commit()
public void commit() {
    for (TransactionalCache txCache : transactionalCaches.values()) {
        txCache.commit();// 在这里
    }
}

// TransactionalCache.commit()
public void commit() {
    if (clearOnCommit) {
        delegate.clear();
    }
    //这一句
    flushPendingEntries();
    reset();
}

// TransactionalCache.flushPendingEntries()
private void flushPendingEntries() {
    for (Map.Entry<Object, Object> entry : entriesToAddOnCommit.entrySet()) {
        // 在这里真正的将entriesToAddOnCommit的对象逐个添加到delegate中,只有这时,二级缓存才真正的生效
        delegate.putObject(entry.getKey(), entry.getValue());
    }
    for (Object entry : entriesMissedInCache) {
        if (!entriesToAddOnCommit.containsKey(entry)) {
            delegate.putObject(entry, null);
        }
    }
}

我们来看看SqlSession的更新操作:

public int update(String statement, Object parameter) {
    int var4;
    try {
        this.dirty = true;
        MappedStatement ms = this.configuration.getMappedStatement(statement);
        var4 = this.executor.update(ms, this.wrapCollection(parameter));
    } catch (Exception var8) {
        throw ExceptionFactory.wrapException("Error updating database. Cause: " + var8, var8);
    } finally {
        ErrorContext.instance().reset();
    }
    return var4;
}

public int update(MappedStatement ms, Object parameterObject) throws SQLException {
    this.flushCacheIfRequired(ms);
    return this.delegate.update(ms, parameterObject);
}

private void flushCacheIfRequired(MappedStatement ms) {
    //获取MappedStatement对应的Cache,进行清空
    Cache cache = ms.getCache();
    //SQL需设置flushCache="true" 才会执行清空
    if (cache != null && ms.isFlushCacheRequired()) {
        this.tcm.clear(cache);
    }
}

MyBatis二级缓存只适用于不常进行增、删、改的数据,比如国家行政区省市区街道数据。一但数据变更,MyBatis会清空缓存。因此二级缓存不适用于经常进行更新的数据。

二级缓存的总结

在二级缓存的设计上,MyBatis大量地运用了装饰者模式,如CachingExecutor, 以及各种Cache接口的装饰器。

  • 二级缓存实现了Sqlsession之间的缓存数据共享,属于namespace级别。
  • 二级缓存具有丰富的缓存策略。
  • 二级缓存可由多个装饰器,与基础缓存组合而成。
  • 二级缓存工作由 一个缓存装饰执行器CachingExecutor和 一个事务型预缓存TransactionalCache完成。
本作品采用 知识共享署名 4.0 国际许可协议 进行许可
标签: MyBatis
最后更新:2022年 6月 9日

RubinChu

一个快乐的小逗比~~~

打赏 点赞
< 上一篇
下一篇 >

文章评论

razz evil exclaim smile redface biggrin eek confused idea lol mad twisted rolleyes wink cool arrow neutral cry mrgreen drooling persevering
取消回复
文章目录
  • 前言
  • MyBatis缓存详解
    • 总览
    • 一级缓存
      • Session是什么
      • 为什么MyBatis集成Spring之后一级缓存失效
      • 一级缓存的简单使用
      • 一级缓存的实现原理
    • 二级缓存
      • 原理
      • 二级缓存的使用
      • 二级缓存的问题
      • 二级缓存的总结
最新 热点 随机
最新 热点 随机
问题记录之Chrome设置屏蔽Https禁止调用Http行为 问题记录之Mac设置软链接 问题记录之JDK8连接MySQL数据库失败 面试系列之自我介绍 面试总结 算法思维
RabbitMQ之集群与运维 Kafka高级特性之重试队列 MyBatis之基本应用 MySQL之Sharding-JDBC数据分片 Neo4j之基础介绍 Redis之扩展功能

COPYRIGHT © 2021 rubinchu.com. ALL RIGHTS RESERVED.

Theme Kratos Made By Seaton Jiang

京ICP备19039146号-1