Mybatis之缓存详细解析

Mybatis之缓存详细解析

一、Cache 缓存

缓存的目的就是提升查询的效率和减少数据库的压力,在MyBatis中提供了一级缓存和二级缓存,并且预留了集成第三方缓存的接口。MyBatis中跟缓存相关的类都在cache包里面,其中有一个Cache接口,只有一个默认的实现类PerpetualCache,除此之外,还有很多的装饰器,通过这些装饰器可以额外实现很多的功能:回收策略,记录日志,定时刷新等
所有的缓存实现类总体上可分为三类:基本缓存、淘汰算法缓存、装饰器缓存

缓存实现类描述作用装饰条件
基本缓存缓存基本实现类默认是 PerpetualCache,也可以自定义比如 RedisCache、EhCache 等,具备基本功能的缓存类
LruCacheLRU 策略的缓存当缓存到达上限时候,删除最近最少使用的缓存 (Least Recently Use)eviction=“LRU”(默 认)
FifoCacheFIFO 策略的缓存当缓存到达上限时候,删除最先入队的缓存eviction=“FIFO”
SoftCache带清理策略的缓存通过 JVM 的软引用和弱引用来实现缓存,当 JVM 内存不足时,会自动清理掉这些缓存,基于 SoftReference 和 WeakReferenceeviction=“SOFT”
WeakCache带清理策略的缓存通过 JVM 的软引用和弱引用来实现缓存,当 JVM 内存不足时,会自动清理掉这些缓存,基于 SoftReference 和 WeakReferenceeviction=“WEAK”
LoggingCache带日志功能的缓存比如:输出缓存命中率基本
SynchronizedCache同步缓存基于 synchronized 关键字实现,解决并发问题基本
BlockingCache阻塞缓存通过在 get/put 方式中加锁,保证只有一个线程操 作缓存,基于 Java 重入锁实现blocking=true
SerializedCache支持序列化的缓存将对象序列化以后存到缓存中,取出时反序列化readOnly=false(默 认)
ScheduledCache定时调度的缓存在进行 get/put/remove/getSize 等操作前,判断 缓存时间是否超过了设置的最长缓存时间(默认是 一小时),如果是则清空缓存–即每隔一段时间清 空一次缓存flushInterval 不为 空
TransactionalCache事务缓存在二级缓存中使用,可一次存入多个缓存,移除多 个缓存在TransactionalCach eManager 中用 Map 维护对应关系

二、数据准备

示例数据参考这

三、一级缓存(本地缓存)

一级缓存也叫本地缓存,MyBatis 的一级缓存是在会话(SqlSession)层面进行缓存的。MyBatis 的一级缓存是默认开启的,不需要任何的配置。我们先思考一个问题,既然一级缓存是会话级别的,那么缓存对象应该放在那个对象里面进行维护呢?默认的DefaultSqlSession 里面只有两个属性Configuration和Executor ,Configuration 是全局的,所以缓存只能放在Executor里面去维护,在Executor的抽象实现BaseExecutor中持有PerpetualCache。在同一个会话里面,多次执行相同的 SQL 语句,会直接从内存取到缓存的结果,不 会再发送 SQL 到数据库。但是不同的会话里面,即使执行的 SQL 一模一样(通过一个 Mapper 的同一个方法的相同参数调用),也不能使用到一级缓存。
接下来写一个demo来验证一级缓存,首先二级缓存默认是开启的,我们要先关闭二级缓存,然后将localCacheScope设置为SESSION,也可以不设置,默认就是SESSION,缓存一个会话执行的所有查询

    <settings>
        <!-- 打印查询语句 -->
        <setting name="logImpl" value="STDOUT_LOGGING" />
        <!-- 控制全局缓存(二级缓存)-->
        <setting name="cacheEnabled" value="false"/>
        <!-- 一级缓存 SEEION 缓存一个会话的所有查询-->
        <setting name="localCacheScope" value="SESSION"/>
    </settings>

 
1. 验证相同会话和跨会话的查询
 

    /**
     * @Description: 测试一级缓存是否缓存的是一个会话的查询
     * @Author zdp
     * @Date 2022-01-05 14:09
     */
    @Test
    public void testFirstLevelCache() throws IOException {
        String resource = "mybatis-config.xml";
        InputStream inputStream = Resources.getResourceAsStream(resource);
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);

        SqlSession session1 = sqlSessionFactory.openSession();
        SqlSession session2 = sqlSessionFactory.openSession();
        try {
            BlogMapper mapper0 = session1.getMapper(BlogMapper.class);
            BlogMapper mapper1 = session1.getMapper(BlogMapper.class);
            Blog blog = mapper0.selectBlogById(1);
            System.out.println("第一次查询====================================");
            System.out.println(blog);

            System.out.println("第二次查询,相同会话===========================");
            System.out.println(mapper1.selectBlogById(1));

            System.out.println("第三次查询,不同会话===========================");
            BlogMapper mapper2 = session2.getMapper(BlogMapper.class);
            System.out.println(mapper2.selectBlogById(1));

        } finally {
            session1.close();
            session2.close();
        }
    }

效果展示
在这里插入图片描述
从控制台打印的信息可以看到,第一次查询打印了执行Sql,在相同的会话内第二次执行相同Sql确实是从缓存中获取的,第三次跨会话之后重新从数据库查询了,以上验证可以说明在MyBatis中将一级缓存设置为SEEION的时候,确实是缓存一个会话的所有查询
 
2. 验证相同会话内将查询的数据进行更新会怎样
 

/**
     * @Description: 相同会话内将查询的数据进行更新操作
     * @Author zdp
     * @Date 2022-01-05 14:22
     */
    @Test
    public void testCacheInvalid() throws IOException {
        String resource = "mybatis-config.xml";
        InputStream inputStream = Resources.getResourceAsStream(resource);
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);

        SqlSession session = sqlSessionFactory.openSession();
        try {
            BlogMapper mapper = session.getMapper(BlogMapper.class);
            System.out.println(mapper.selectBlogById(1));

            Blog blog = new Blog();
            blog.setBid(1);
            blog.setName("相同会话更新数据");
            mapper.updateByPrimaryKey(blog);
            session.commit();

            System.out.println("在执行更新操作之后,是否命中缓存?");
            System.out.println(mapper.selectBlogById(1));

        } finally {
            session.close();
        }
    }

效果展示
在这里插入图片描述
从控制台打印的信息可以看到,第一次查询后,执行更新操作,那么会导致一级缓存的失效,从而重新查询数据库
 
3. 验证跨会话内执行更新操作
 

/**
     * @Description: 跨会话更新数据
     * @Author zdp
     * @Date 2022-01-05 14:27
     */
    @Test
    public void testDirtyRead() throws IOException {
        String resource = "mybatis-config.xml";
        InputStream inputStream = Resources.getResourceAsStream(resource);
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);

        SqlSession session1 = sqlSessionFactory.openSession();
        SqlSession session2 = sqlSessionFactory.openSession();
        try {
            BlogMapper mapper1 = session1.getMapper(BlogMapper.class);
            System.out.println(mapper1.selectBlogById(1));

            // 会话2更新了数据,会话2的一级缓存更新
            Blog blog = new Blog();
            blog.setBid(1);
            blog.setName("after modified 112233445566");
            BlogMapper mapper2 = session2.getMapper(BlogMapper.class);
            mapper2.updateByPrimaryKey(blog);
            session2.commit();

            // 其他会话更新了数据,本会话的一级缓存还在么?
            System.out.println("会话1查到最新的数据了吗?");
            System.out.println(mapper1.selectBlogById(1));
        } finally {
            session1.close();
            session2.close();
        }
    }

效果展示
在这里插入图片描述
从控制台打印的信息可以看到,第一次查询后,在会话2执行更新操作,那么并不会导致一级缓存失效,导致查询出了错误的数据,在这里就看出了一级缓存的不足,在使用一级缓存的时候,因为缓存不能跨会话共享,不同的会话之间对于数据可能有不一样的缓存。在有多个会话或者分布式环境下,可能会存在脏数据的问题,接下来看看二级缓存是怎么解决这个问题的

四、二级缓存

        二级缓存是用来解决一级缓存不能跨会话共享的问题的,范围是 namespace 级别 的,可以被多个 SqlSession 共享(只要是同一个接口里面的相同方法,都可以共享), 生命周期和应用同步。
        到这里,停下来思考一下二级缓存是工作在一级缓存之前还是在一级缓存之后呢?二级缓存是在哪里维护的呢?
        在一级缓存的时候是在SqlSession中的BaseExecutor之中去维护的,要实现跨会话之间的缓存共享,显然这个BaseExecutor已经不能满足需求了,而且这个二级缓存的维护应该放到SqlSession之外,所以二级缓存应该是工作在一级缓存之前的。在MyBatis中它用了一个装饰器的类CachingExecutor来对其进行维护,如果启用了二级缓存,MyBatis在创建Exectuor对象的时候会对Executor进行装饰。CachingExecutor 对于查询请求,会判断二级缓存是否有缓存结果,如果有就直接返回,如果没有委派交给真正的查询器 Executor 实现类,比如 SimpleExecutor 来执行查询,再走到一级缓存的流程。最后会把结果缓存起来,并且返回给用户。
在这里插入图片描述
开启二级缓存,将cacheEnable设置为true,也可以不设置,MyBatis中默认就是true

	<setting name="cacheEnabled" value="true"/>

MyBatis中对cacheEnabled的解析

  private void settingsElement(Properties props) {
    //如果没有设置,那么默认为true....
    configuration.setCacheEnabled(booleanValueOf(props.getProperty("cacheEnabled"), true));
    //....
  }

cacheEnabled 决定了是否创建 CachingExecutor

  public Executor newExecutor(Transaction transaction, ExecutorType executorType) {
    executorType = executorType == null ? defaultExecutorType : executorType;
    //......
    if (cacheEnabled) {
      executor = new CachingExecutor(executor);
    }
    //......
  }

在Mapper.xml中配置 <cache/> 标签

    <cache type="org.apache.ibatis.cache.impl.PerpetualCache"
           size="1024"
           eviction="LRU"
           flushInterval="120000"
           readOnly="false"/>

配置<cache/>标签与否决定了ms中是否有缓存Cache对象

public class CachingExecutor implements Executor {
  @Override
  public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
      throws SQLException {
    Cache cache = ms.getCache();
    //如果配置了<cache/>标签,那么这里就不为null
    if (cache != null) {
      flushCacheIfRequired(ms);
      if (ms.isUseCache() && resultHandler == null) {
        ensureNoOutParams(ms, boundSql);
        @SuppressWarnings("unchecked")
        List<E> list = (List<E>) tcm.getObject(cache, key);
        if (list == null) {
          list = delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
          tcm.putObject(cache, key, list); // issue #578 and #116
        }
        return list;
      }
    }
    return delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
  }
}

cache属性解释:

属性含义取值
type缓存实现类需要实现 Cache 接口,默认是 PerpetualCache
size最多缓存对象个数默认 1024
eviction回收策略(缓存淘汰算法)LRU – 最近最少使用的:移除最长时间不被使用的对象(默认)FIFO – 先进先出:按对象进入缓存的顺序来移除它们。 SOFT – 软引用:移除基于垃圾回收器状态和软引用规则的对象。 WEAK – 弱引用:更积极地移除基于垃圾收集器状态和弱引用规则的对象
flushInterval定时自动清空缓存间隔自动刷新时间,单位 ms,未配置时只有调用时刷新
readOnly是否只读true:只读缓存;会给所有调用者返回缓存对象的相同实例。因此这些对象 不能被修改。这提供了很重要的性能优势。 false:读写缓存;会返回缓存对象的拷贝(通过序列化),不会共享。这 会慢一些,但是安全,因此默认是 false。 改为 false 可读写时,对象必须支持序列化
blocking是否使用可重入锁实现 缓存的并发控制true,会使用 BlockingCache 对 Cache 进行装饰 默认 false

在Mapper.xml 配置了之后,select()会被缓存。update()、delete()、insert() 会刷新缓存。只要 cacheEnabled=true 基本执行器就会被装饰。有没有配置,决定了在 启动的时候会不会创建这个 mapper 的 Cache 对象,最终会影响到 CachingExecutor query 方法里面的判断,如果某些查询方法对数据的实时性要求很高,不需要二级缓存,我们可以在单个 Statement ID 上显式关闭二级缓存(默认是 true):

<select id="selectBlog" resultMap="BaseResultMap" useCache="false">

1、二级缓存验证

基本了解二级缓存的使用后,接下来写一个二级缓存的测试demo:
(1). 验证跨会话查询是否缓存

/**
     * @Description: 测试二级缓存跨会话查询
     * @Author zdp
     * @Date 2022-01-06 10:44
     */
    @Test
    public void testSecondCache() throws IOException {
        String resource = "mybatis-config.xml";
        InputStream inputStream = Resources.getResourceAsStream(resource);
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);

        SqlSession session1 = sqlSessionFactory.openSession();
        SqlSession session2 = sqlSessionFactory.openSession();
        try {
            BlogMapper mapper1 = session1.getMapper(BlogMapper.class);
            System.err.println(mapper1.selectBlogById(1));
            //这里的事务提交很重要,如果不提交事务将导致二级缓存不生效
            session1.commit();

            System.err.println("跨会话第二次查询");
            BlogMapper mapper2 = session2.getMapper(BlogMapper.class);
            System.err.println(mapper2.selectBlogById(1));
        } finally {
            session1.close();
            session2.close();
        }
    }

效果展示
在这里插入图片描述
如果上面不提交事务,将无法使用二级缓存,这是因为二级缓存使用 TransactionalCacheManager(TCM)来管理,最后又调用了 TransactionalCache 的getObject()、putObject和 commit()方法。只有它的 commit()方法被调用的时候才会调用 flushPendingEntries()真正写入缓存。它就是在 DefaultSqlSession 调用 commit()的时候被调用的。

(2). 验证跨会话执行更新操作二级缓存是否失效

/**
     * @Description: 跨会话执行更新操作二级缓存是否失效
     * @Author zdp
     * @Date 2022-01-06 11:15
     */
    @Test
    public void testCacheInvalid() throws IOException {
        String resource = "mybatis-config.xml";
        InputStream inputStream = Resources.getResourceAsStream(resource);
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);

        SqlSession session1 = sqlSessionFactory.openSession();
        SqlSession session2 = sqlSessionFactory.openSession();
        SqlSession session3 = sqlSessionFactory.openSession();
        try {
            BlogMapper mapper1 = session1.getMapper(BlogMapper.class);
            BlogMapper mapper2 = session2.getMapper(BlogMapper.class);
            BlogMapper mapper3 = session3.getMapper(BlogMapper.class);
            System.out.println(mapper1.selectBlogById(1));
            session1.commit();

            // 是否命中二级缓存
            System.out.println("是否命中二级缓存?");
            System.out.println(mapper2.selectBlogById(1));

            Blog blog = new Blog();
            blog.setBid(1);
            blog.setName("二级缓存更新测试..............................");
            mapper3.updateByPrimaryKey(blog);
            session3.commit();

            System.out.println("更新后再次查询,是否命中二级缓存?");
            // 在其他会话中执行了更新操作,二级缓存是否被清空?
            System.out.println(mapper2.selectBlogById(1));

        } finally {
            session1.close();
            session2.close();
            session3.close();
        }
    }

效果展示
在这里插入图片描述
从上面可以看出,在session3执行更新操作之前,session2是从缓存里面取得数据,此时二级缓存是生效的,在执行了更新操作之后,session2再次查询,此时二级缓存已经失效,数据是从数据库里面查询出来的,这也验证了二级缓存解决了一级缓存的不足,解决了一级缓存中跨会话操作带来的脏数据问题

2、为什么增删改操作会导致二级缓存失效

开启了二级缓存之后,所有的增删改操作都会走到CachingExecutor中,这里我们直接从方法执行入口MapperProxy这个类的invoke方法看起,前面执行逻辑不清楚可以参考这

  @Override
  public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    try {
      if (Object.class.equals(method.getDeclaringClass())) {
        return method.invoke(this, args);
      } else {
        return cachedInvoker(method).invoke(proxy, method, args, sqlSession);
      }
    } catch (Throwable t) {
      throw ExceptionUtil.unwrapThrowable(t);
    }
  }

这里先从缓存methodCache<K,V>中去根据 method (method就是缓存中的Key) 获取,如果没有获取到MapperMethodInvoker,则执行 m -> {}这个自定义的实现,返回自定义的MapperMethodInvoker

  private MapperMethodInvoker cachedInvoker(Method method) throws Throwable {
    try {
      return MapUtil.computeIfAbsent(methodCache, method, m -> {
        //判断是否是默认方法
        if (m.isDefault()) {
          try {
            if (privateLookupInMethod == null) {
              return new DefaultMethodInvoker(getMethodHandleJava8(method));
            } else {
              return new DefaultMethodInvoker(getMethodHandleJava9(method));
            }
          } catch (IllegalAccessException | InstantiationException | InvocationTargetException
              | NoSuchMethodException e) {
            throw new RuntimeException(e);
          }
        } else {
          //普通方法
          return new PlainMethodInvoker(new MapperMethod(mapperInterface, method, sqlSession.getConfiguration()));
        }
      });
    } catch (RuntimeException re) {
      Throwable cause = re.getCause();
      throw cause == null ? re : cause;
    }
  }

这里我们会走到 PlainMethodInvoker调用其invoke方法

  private static class PlainMethodInvoker implements MapperMethodInvoker {
    private final MapperMethod mapperMethod;

    public PlainMethodInvoker(MapperMethod mapperMethod) {
      super();
      this.mapperMethod = mapperMethod;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args, SqlSession sqlSession) throws Throwable {
      return mapperMethod.execute(sqlSession, args);
    }
  }

这里的execute方法只看INSERT、UPDATE、DELETE

public class MapperMethod {
  public Object execute(SqlSession sqlSession, Object[] args) {
    //....
    switch (command.getType()) {
      case INSERT: {
        Object param = method.convertArgsToSqlCommandParam(args);
        result = rowCountResult(sqlSession.insert(command.getName(), param));
        break;
      }
      case UPDATE: {
        Object param = method.convertArgsToSqlCommandParam(args);
        result = rowCountResult(sqlSession.update(command.getName(), param));
        break;
      }
      case DELETE: {
        Object param = method.convertArgsToSqlCommandParam(args);
        result = rowCountResult(sqlSession.delete(command.getName(), param));
        break;
      }
       //....
    }
  }
}

我们接着看sqlSession.insert(command.getName(), param)sqlSession.update(command.getName(), param)sqlSession.delete(command.getName(), param)这三个方法,

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

到这里已经可以看出来,其实insert、delete、update都是调用的update(String statement, Object parameter)这个方法,executor.update这个方法里面最后会调用 flushCacheIfRequired(ms)这个方法

public class CachingExecutor implements Executor {
  //......
  @Override
  public int update(MappedStatement ms, Object parameterObject) throws SQLException {
    flushCacheIfRequired(ms);
    return delegate.update(ms, parameterObject);
  }
  //......
}
public class CachingExecutor implements Executor {

  private void flushCacheIfRequired(MappedStatement ms) {
    Cache cache = ms.getCache();
    //在这里获取了 isFlushCacheRequired() ,而这个方法就是获取 Mapper.xml映射文件中的 flushCache 属性的值,update、insert、delete的flushCache属性默认值就是true
    if (cache != null && ms.isFlushCacheRequired()) {
      tcm.clear(cache);
    }
  }
  }
  public boolean isFlushCacheRequired() {
    return flushCacheRequired;
  }

我们可以看下MappedStatement 这个ms对象在创建时候的flushCacheRequired 的值,

 public void parseStatementNode() {
    //......
    String nodeName = context.getNode().getNodeName();
    SqlCommandType sqlCommandType = SqlCommandType.valueOf(nodeName.toUpperCase(Locale.ENGLISH));
    boolean isSelect = sqlCommandType == SqlCommandType.SELECT;
    //非Select的操作都默认为true
    boolean flushCache = context.getBooleanAttribute("flushCache", !isSelect);
    //......
    builderAssistant.addMappedStatement(id, sqlSource, statementType, sqlCommandType,
        fetchSize, timeout, parameterMap, parameterTypeClass, resultMap, resultTypeClass,
        resultSetTypeEnum, flushCache, useCache, resultOrdered,
        keyGenerator, keyProperty, keyColumn, databaseId, langDriver, resultSets);
  }
  public MappedStatement addMappedStatement(/**...*/boolean flushCache/**...*/) {
    //.....
    boolean isSelect = sqlCommandType == SqlCommandType.SELECT;
    MappedStatement.Builder statementBuilder = new MappedStatement.Builder(configuration, id, sqlSource, sqlCommandType).resource(resource)
		//.....这里设置了flushCache的值,非Select 都是true
        .flushCacheRequired(valueOrDefault(flushCache, !isSelect))
		//.....
    MappedStatement statement = statementBuilder.build();
    configuration.addMappedStatement(statement);
    return statement;
  }

所以到这里我们可以看出在增删改操作会导致二级缓存失效是因为在Mapper.xml映射文件解析的时候,update、insert、delete元素中flushCache的属性默认值为true,在执行更新的时候会拿 flushCacheRequired的值(flushCache的值)来判断。为true就会去清空缓存

五、Redis做二级缓存

除了 MyBatis 自带的二级缓存之外,我们也可以通过实现 Cache 接口来自定义二级缓存,这里以Redis为例,将其作为二级缓存使用:

1. 引入mybatis-redis依赖

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

2. 在Mapper.xml映射文件中配置RedisCache

<!-- 使用Redis作为二级缓存 -->
<cache type="org.mybatis.caches.redis.RedisCache"
       eviction="FIFO" 
       flushInterval="60000" 
       size="512" 
       readOnly="true"/>

3. 配置redis.properties配置文件
这里如果不配置redis.properties的配置文件则使用默认的地址localhost和默认端口6372,如果配置了reids,则配置文件名称必须为redis.properties并且文件要放到resources目录下面
4. 编写测试类

    @Test
    public void testRedisCache() throws IOException {
        String resource = "mybatis-config.xml";
        InputStream inputStream = Resources.getResourceAsStream(resource);
        SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);

        SqlSession session1 = sqlSessionFactory.openSession();
        SqlSession session2 = sqlSessionFactory.openSession();
        try {
            BlogMapper mapper1 = session1.getMapper(BlogMapper.class);
            System.err.println(mapper1.selectBlogById(1));
            session1.commit();

            System.err.println("跨会话第二次查询");
            BlogMapper mapper2 = session2.getMapper(BlogMapper.class);
            System.err.println(mapper2.selectBlogById(1));
        } finally {
            session1.close();
            session2.close();
        }
    }

5. 效果展示
在这里插入图片描述
在这里插入图片描述
以上就是用Redis作为二级缓存的例子了,当然我们也可以实现MyBatis提供的Cache接口,自定义缓存实现。
以上就是MyBatis缓存的介绍了,如有错误希望你指正,希望能对你有点帮助!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值