mybatis缓存机制详解与实践

建议戳源地址观看

文档源地址:
文档:mybatis的缓存机制.note
链接:http://note.youdao.com/noteshare?id=61c261902c55f42dcc622b75ffeaf335&sub=BFF9B0D8A94C40BB9386F009C49397D0

mybatis的缓存机制
1、一级缓存(本地缓存):
1、1 原理
1、2一级缓存的组织(即SqlSession中的缓存的组织)
1、3 PerpetualCache实现原理
1、4 一级缓存的生命周期
1、5 SqlSession 一级缓存的工作流程:
1、6 Cache接口的设计以及CacheKey的定义
1.7 实践验证
2、二级缓存:
2、1 原理
2.2 注意点
2.3 使用方式
2.4 测试方法
单独使用@options开启缓存
2.5 额外声明:
二、总结
1、关于使用上,
2、实际场景中应用作用更大的应该是二级缓存,因为他的跨session性
3、关于生命周期的说明:
3.1 一级缓存没有过期时间,只有生命周期
3.2 二级缓存有过期时间,但是没有后台线程检测
4、关于@CacheNamespace的说明:
mybatis的缓存机制
1、一级缓存(本地缓存):

1、1 原理
与数据库同一次会话期间查询到的数据会放入的本地缓存当中。
如果以后需要获取相同的数据直接去缓存当中拿,没必要再去查询数据库
MyBatis会在表示会话的SqlSession对象中建立一个简单的缓存,将每次查询到的结果结果缓存起来,当下次查询的时候,如果判断先前有个完全一样的查询,会直接从缓存中直接将结果取出,返回给用户,不需要再进行一次数据库查询了。

1、2一级缓存的组织(即SqlSession中的缓存的组织)
由于MyBatis使用SqlSession对象表示一次数据库的会话,那么,对于会话级别的一级缓存也应该是在SqlSession中控制的。
实际上, MyBatis只是一个MyBatis对外的接口,SqlSession将它的工作交给了Executor执行器这个角色来完成,负责完成对数据库的各种操作。当创建了一个SqlSession对象时,MyBatis会为这个SqlSession对象创建一个新的Executor执行器,而缓存信息就被维护在这个Executor执行器中,MyBatis将缓存和对缓存相关的操作封装成了Cache接口中。SqlSession、Executor、Cache之间的关系如下列类图所示:

如上述的类图所示,Executor接口的实现类BaseExecutor中拥有一个Cache接口的实现类PerpetualCache,则对于BaseExecutor对象而言,它将使用PerpetualCache对象维护缓存

综上,SqlSession对象、Executor对象、Cache对象之间的关系如下图所示:

1、3 PerpetualCache实现原理
其内部就是通过一个简单的HashMap<k,v> 来实现的,没有其他的任何限制。如下是PerpetualCache的实现代码:
package org.apache.ibatis.cache.impl;

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReadWriteLock;

import org.apache.ibatis.cache.Cache;
import org.apache.ibatis.cache.CacheException;

/**

  • 使用简单的HashMap来维护缓存
  • @author Clinton Begin
    */
    public class PerpetualCache implements Cache {

private String id;

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

public PerpetualCache(String id) {
this.id = id;
}

public String getId() {
return id;
}

public int getSize() {
return cache.size();
}

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

public Object getObject(Object key) {
return cache.get(key);
}

public Object removeObject(Object key) {
return cache.remove(key);
}

public void clear() {
cache.clear();
}

public ReadWriteLock getReadWriteLock() {
return null;
}

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());

}

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

}

1、4 一级缓存的生命周期
a. MyBatis在开启一个数据库会话时,会 创建一个新的SqlSession对象,SqlSession对象中会有一个新的Executor对象,Executor对象中持有一个新的PerpetualCache对象;当会话结束时,SqlSession对象及其内部的Executor对象还有PerpetualCache对象也一并释放掉。

b. 如果SqlSession调用了close()方法,会释放掉一级缓存PerpetualCache对象,一级缓存将不可用;

c. 如果SqlSession调用了clearCache(),会清空PerpetualCache对象中的数据,但是该对象仍可使用;

d.SqlSession中执行了任何一个update操作(update()、delete()、insert()) ,都会清空PerpetualCache对象的数据,刷新缓存,但是该对象可以继续使用;

1、5 SqlSession 一级缓存的工作流程:
1.对于某个查询,根据statementId,params,rowBounds来构建一个key值,根据这个key值去缓存Cache中取出对应的key值存储的缓存结果;
2. 判断从Cache中根据特定的key值取的数据数据是否为空,即是否命中;
3. 如果命中,则直接将缓存结果返回;
4. 如果没命中:
4.1 去数据库中查询数据,得到查询结果;

    4.2  将key和查询到的结果分别作为key,value对存储到Cache中;

    4.3. 将查询结果返回;
  1. 结束。

1、6 Cache接口的设计以及CacheKey的定义
如下图所示,MyBatis定义了一个org.apache.ibatis.cache.Cache接口作为其Cache提供者的SPI(Service Provider Interface) ,所有的MyBatis内部的Cache缓存,都应该实现这一接口。MyBatis定义了一个PerpetualCache实现类实现了Cache接口,实际上,在SqlSession对象里的Executor 对象内维护的Cache类型实例对象,就是PerpetualCache子类创建的。

(MyBatis内部还有很多Cache接口的实现,一级缓存只会涉及到这一个PerpetualCache子类,Cache的其他实现将会放到二级缓存中介绍)。

我们知道,Cache最核心的实现其实就是一个Map,将本次查询使用的特征值作为key,将查询结果作为value存储到Map中。

现在最核心的问题出现了:怎样来确定一次查询的特征值?

换句话说就是:怎样判断某两次查询是完全相同的查询?

也可以这样说:如何确定Cache中的key值?

MyBatis认为,对于两次查询,如果以下条件都完全一样,那么就认为它们是完全相同的两次查询:

  1. 传入的 statementId

  2. 查询时要求的结果集中的结果范围 (结果的范围通过rowBounds.offset和rowBounds.limit表示);

  3. 这次查询所产生的最终要传递给JDBC java.sql.Preparedstatement的Sql语句字符串(boundSql.getSql() )

  4. 传递给java.sql.Statement要设置的参数值
    1.7 实践验证

springboot中开启缓存
mybatis:
configuration:
#开启一级缓存,默认是开启的
cache-enabled: true/false
#调整缓存级别
local-cache-scope: session(一次会话)/statment(一个sql语句执行)

@PostMapping(“/testSql”)
public void testCache(){
testService.testGetId();
//测试下mybatis的缓存
testService.testGetId();
testService.getById(1);
}

执行结果,缓存失效:

原因:
结论:Spring将MyBatis的DefaultSqlSession类替换成了SqlSessionTemplate

MyBatis的一级缓存是基于SqlSession来实现的,对应MyBatis中sqlSession接口的默实认现类是DefaultSqlSession,如果执行的SQL相同时,并且使用的是同一个SqlSession对象,那么就会触发对应的缓存机制。

但是在Spring整合MyBatis后,Spring使用MyBatis不再是直接调用MyBatis中的信息,而是通过调用调用mybatis-spring.jar中的类,继而达到间接调用MyBatis的效果。但在mybatis-spring.jar中,引入了一个SqlSessionTemplate类,它和Spring的事务管理器共同配合,创建对应的SqlSession连接。

即在没有添加@Transactional注解的情况下,每调用一次查询SQL,就会通过SqlSessionTemplate去创建sqlSession,即相当于新创建一次连接,故而每次查询在调试结果看来就是一级缓存失效

核心就是注册的方法,我测试的场景是没有加@Transactional注解的时候,此处判断为false就不会再向缓存中添加数据。当然如果判断成功就是会调用TransactionSynchronizationManager.registerSynchronization(new SqlSessionSynchronization(holder, sessionFactory))方法,将该sqlSession对象添加到对应的缓存中,数量+1,即最终注册到synchronizations对象的缓存中。缓存池使用的是一个ThreadLocal(用于处理多个线程中数据的隔离问题,内部维护一个ThreadLocalMap)来存储
synchronizations = new NamedThreadLocal<>(“Transaction synchronizations”);
总结
如果我们没有添加@Transactional注解,Spring认为我的每一次查询都都是相互独立的,便开启了三次不同的事务也即是创建了三个不同的sqlSession对象。即无法使用到MyBatis的一级缓存。
如果我们添加了@Transactional注解,Spring在执行了第一次查询后,会将当前线程的事务情况存储到synchronizations 的集合中,当第二次再执行查询的时候,能够在缓存中直接获取到当前的事务情况(包含sqlSession对象),即不会再去调用openSession方法,继而创建一个新的sqlSession对象,而是使用缓存中的sqlSession对象。这就保证了在添加@Transactional注解的情况下,能够走MyBatis的一级缓存
开启事务后
@Transactional
@PostMapping(“/testSqlTwo”)
public void testCacheTwo(){
testService.testGetId();
testService.testGetId();
testService.testGetId();
}

访问结果:
当前的数据库标签A
2022-09-02 16:55:12:776|http-nio-28090-exec-1|DEBUG|c.s.m.p.i.s.m.R.getRuleName|> Preparing: select rule_name from rule where brm_rule_code = ?
2022-09-02 16:55:12:956|http-nio-28090-exec-1|DEBUG|c.s.m.p.i.s.m.R.getRuleName|
> Parameters: S00001(String)
2022-09-02 16:55:13:108|http-nio-28090-exec-1|DEBUG|c.s.m.p.i.s.m.R.getRuleName|<== Total: 0
当前的数据库标签A
当前的数据库标签A

当语句执行完毕后,事务结束,所以就会导致sqlsession关闭

2、二级缓存:
2、1 原理

二级缓存也叫全局缓存,一级缓存作用域太低了,所有诞生了二级缓存
基于namespace级别的缓存,一个名称空间,对应一个二级缓存;
工作机制
一个会话查询一条数据,这个数据就会被放在当前会话的一级缓存中;
如果当前会话关闭了,这个会话对应的一级缓存就没了,但是我们想要的是,会话关闭了,一级缓存中的数据被保存到二级缓存中;
新的会话被查询信息,就可以从二级缓存中获取内容;
不同的mapper查出的数据会放在自己对应的缓存(map)中;
mapper级别的缓存,同一个namespace公用这一个缓存,所以对SqlSession是共享的。
二级缓存需要我们手动开启。(全局级别) 二级缓存是mapper级别的缓存,多个sqlSession去操作同一个Mapper的sql语句,多个sqlSession可以共用二级缓存,二级缓存是跨sqlSession的。
2.2 注意点
1、二级缓存有一个很大的坑,缓存是针对命名空间而言的,也就是说如果你创建了两个Mapper对象,那么两个Mapper之间的缓存是不共用的。并且mybatis的缓存只适用单机环境,如果需要在分布式系统中实现缓存,只能借助第三方的缓存。
2、缓存的配置和缓存实例会被绑定到 SQL 映射文件的命名空间中。 因此,同一命名空间中的所有语句和缓存将通过命名空间绑定在一起。 每条语句可以自定义与缓存交互的方式,或将它们完全排除于缓存之外,这可以通过在每条语句上使用两个简单属性来达成。 默认情况下,语句会这样来配置:
<select … flushCache=“false” useCache=“true”/>
<insert … flushCache=“true”/>
<update … flushCache=“true”/>
<delete … flushCache=“true”/>
鉴于这是默认行为,显然你永远不应该以这样的方式显式配置一条语句。但如果你想改变默认的行为,只需要设置 flushCache 和 useCache 属性。比如,某些情况下你可能希望特定 select 语句的结果排除于缓存之外,或希望一条 select 语句清空缓存。类似地,你可能希望某些 update 语句执行时不要刷新缓存。
2.3 使用方式
2.3.1 注解方式 在xml配置文件中开启二级缓存只需要在你的 SQL 映射文件中添加一行:

使用自定义缓存
除了上述自定义缓存的方式,你也可以通过实现你自己的缓存,或为其他第三方缓存方案创建适配器,来完全覆盖缓存行为。

这个示例展示了如何使用一个自定义的缓存实现。type 属性指定的类必须实现 org.apache.ibatis.cache.Cache 接口,且提供一个接受 String 参数作为 id 的构造器。 这个接口是 MyBatis 框架中许多复杂的接口之一,但是行为却非常简单。
public interface Cache {
String getId();
int getSize();
void putObject(Object key, Object value);
Object getObject(Object key);
boolean hasKey(Object key);
Object removeObject(Object key);
void clear();
}

为了对你的缓存进行配置,只需要简单地在你的缓存实现中添加公有的 JavaBean 属性,然后通过 cache 元素传递属性值
例如,下面的例子将在你的缓存实现上调用一个名为 setCacheFile(String file) 的方法:


你可以使用所有简单类型作为 JavaBean 属性的类型,MyBatis 会进行转换。 你也可以使用占位符(如 ${cache.file}),以便替换成在配置文件属性中定义的值。
从版本 3.4.2 开始,MyBatis 已经支持在所有属性设置完毕之后,调用一个初始化方法。 如果想要使用这个特性,请在你的自定义缓存类里实现 org.apache.ibatis.builder.InitializingObject 接口。
public interface InitializingObject {
void initialize() throws Exception;
}

2.3.2 注解方式:
@Repository
@Mapper
@CacheNamespace
public interface RuleMapping{

我们平常使用默认方式的的时候可以直接使用,如果使用自定义的方式,则需要指明实现类,如 implementation = MybatisCache.class

在xml配置文件声明使用二级缓存

<cache eviction=“FIFO” #收回策略
flushInterval=“6000” #刷新间隔,缓存多长时间清空一次,默认不清空,设置一个毫秒值
size=“512” #引用数目 缓存多少元素
readOnly=“true”/> #只读

参数的具体细节
eviction(收回策略)
LRU(最近最少使用的):移除最长时间不被使用的对象,这是默认值。
FIFO(先进先出):按对象进入缓存的顺序来移除它们。
SOFT(软引用):移除基于垃圾回收器状态和软引用规则的对象。
WEAK(弱引用):更积极地移除基于垃圾收集器状态和弱引用规则的对象。

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

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

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

2.3.3 @Options方式:

useCache = true 表示将缓存本次结果
flushCache = Options.FlushCachePolicy.FALSE 表示查询时不刷新缓存
timeout = 10000 表示查询结果缓存的时间
@Options(useGeneratedKeys = true,useCache = true,flushCache = Options.FlushCachePolicy.FALSE ,timeout = 10000)

useGeneratedKeys = true 可以设置自增的主键值,这个在做缓存的时候可以不使用。
2.4 测试方法
2.4.1 xml配置文件测试
@Test
public void findById() throws IOException {
// 1.加载SqlMapConfig配置文件
InputStream resourceAsStream = Resources.getResourceAsStream(“SqlMapConfig.xml”);
//2.创建sqlSessionFactory工厂
SqlSessionFactory factory = new SqlSessionFactoryBuilder().build(resourceAsStream);
//3.sqlSessionFactory创建sqlSession
SqlSession sqlSession = factory.openSession();
SqlSession sqlSession2 = factory.openSession();

//4.通过Session创建UserDao接口代理对象
UserDao mapper = sqlSession.getMapper(UserDao.class);
UserDao mapper2 = sqlSession2.getMapper(UserDao.class);

User user1 = mapper.findById(1);
System.out.println(user1.toString());
// 将其一级缓存的数据放进二级缓存中,并清空一级缓存
sqlSession.commit();

System.out.println("-----------------");
User user2 = mapper2.findById(1);
System.out.println(user2.toString());
System.out.println(user1 == user2);
// 将其一级缓存的数据放进二级缓存中,并清空一级缓存
sqlSession2.commit();



//7.关闭资源
sqlSession.close();
resourceAsStream.close();

}

注意点:
打印发现2个对象的地址值不一样,但是确实只发送了一次SQL语句的查询,二级缓存中存储的是数据,不是对象。
二级缓存的生效必须要调用session.commit()或者session.close()方法才能生效。
因为我们的数据默认存储在一级缓存中,只有将当前会话关闭数据才会写入二级缓存中。
2.4.1 注解方式进行测试
@Repository
@Mapper
@CacheNamespace
public interface RuleMapping{
@Select(“select rule_name from rule where brm_rule_code = #{code}”)
String getRuleName(@Param(“code”) String code);
}

测试方法
public void testGetId(){
SessionContext.setDbLabel(“A”);
String dbLabel = SessionContext.getDbLabel();
System.out.println(“当前的数据库标签”+dbLabel);
ruleMapping.getRuleName(“S00001”);
}

@PostMapping(“/testSql”)
public void testCache(){
testService.testGetId();
//测试下mybatis的缓存
testService.testGetId();
// testService.getById(1);
}

@Transactional
@PostMapping(“/testSqlTwo”)
public void testCacheTwo(){
testService.testGetId();
testService.testGetId();
testService.testGetId();
}

调用第一个测试·方法

调用第二个测试方法

需要额外注意的,
mybatis生成的代码中,如果是接口类,但是最后实现交给了xml文件,那么单独的依靠@Cache注解实现是不可以的,需要依靠xml中的方式来使用二级缓存

需要注意的是当二级缓存没有设置成只读性时,需要在实体类上实现反序列化接口,解释如下
MyBatis使用SerializedCache序列化缓存来实现可读写缓存类,并通过序列化和反序列化来保证通过缓存获取数据时,得到的是一个新的实例。如果配置为只读缓存,MyBatis就会使用Map来存储缓存值,这种情况下,从缓存中获取的对象就是同一个实例。

MyBatis中配置缓存时,缓存元素有个readOnly属性,readOnly属性可以被设置为 true 或 false。只读缓存将对所有调用者返回同一个实例,因为对象没有进行序列化,所以速度最快。可写的缓存将通过序列化来返回一个缓存对象的拷贝。因为对象进行了序列化,会比较慢,但是得到的都是新的对象,线程安全。默认值是 false。即Mybatis的二级缓存默认是可写的,可写缓存会使用序列化。
序列化缓存

  • 先将对象序列化成2进制,再缓存,好处是将对象压缩了,省内存
  • 坏处是速度慢了(因为对象需要进行序列化)

总结:Mybatis通过序列化得到对象的新实例,保证多线程安全(因为是从缓存中取数据,速度还是比从数据库获取要快)。具体说就是对象序列化后存储到缓存中,从缓存中取数据时是通过反序列化得到新的实例。

事实证明:缓存生效
用@option注解,使单独的方法不使用缓存:
@Repository
@Mapper
@CacheNamespace
public interface RuleMapping{
@Select(“select rule_name from rule where brm_rule_code = #{code}”)
@Options(useCache = false,flushCache = Options.FlushCachePolicy.FALSE ,timeout = 10000)
String getRuleName(@Param(“code”) String code);
}

调用后的结果:

单独使用@options开启缓存

@Repository
@Mapper
// @CacheNamespace
public interface RuleMapping{
@Select(“select rule_name from rule where brm_rule_code = #{code}”)
@Options(useCache = true,flushCache = Options.FlushCachePolicy.FALSE ,timeout = 10000)
String getRuleName(@Param(“code”) String code);
}

测试结果

结论@options操作的应该是一级缓存
注意实际的sql在xml中的情况

2.5 额外声明:
cache-ref
回想一下上一节的内容,对某一命名空间的语句,只会使用该命名空间的缓存进行缓存或刷新。 但你可能会想要在多个命名空间中共享相同的缓存配置和实例。要实现这种需求,你可以使用 cache-ref 元素来引用另一个缓存。

对应的注解
@CacheNamespaceRef(name = )

二、总结
1、关于使用上,
1.1 不开启事物的情况下相当于关闭了一级缓存,但是,当开启事务后,sqlssion与事务会有一对一的绑定关系,如果涉及到了切换多个库的操作,那么可能会出现切换数据源的问题。
1.2
2、实际场景中应用作用更大的应该是二级缓存,因为他的跨session性
3、关于生命周期的说明:
3.1 一级缓存没有过期时间,只有生命周期
(1).Mybatis在开启一个数据库会话时,会创建一个新的SqlSession对象。SqlSession对象的缓存是Mybatis的一级缓存,在操作数据库时需要创建SqlSession对象,在对象中有hashMap用于保存缓存数据(对象的id作为key,而对象作为 value保存的)。一级缓存的作用范围是SqlSession范围的,当一个SqlSession中执行两次相同的sql第一次执行完就会将数据库查询到的数据写进缓存,第二次查询时直接去缓存中查找,从而提高数据库的效率。
(2)如果SqlSession执行DML(insert,update,delete)操作,并且提交到数据库,Mybatis会清空SqlSession的一级缓存,这样做的目的是为了保存最新的数据,避免出现脏读的现象。当Mybatis会清空SqlSession的一级缓存(生命周期结束)
(3)SqlSession对象中会有一个Executor对象,Executor对象中持有一个PerpetualCache对象,见下面代码。当会话结束时,SqlSession对象及其内部的Executor对象还有PerpetualCache对象也一并释放掉。

a.如果SqlSession调用了close()方法,会释放掉以及缓存PerpetualCache,一级缓存将不可用;
b.如果SqlSession调用了clearCache(),会清空PerpetualCache对象中的数据,但是该对象仍可使用;
c.SqlSession中执行了任何一个更新操作,例如:update、delete、insert ,都会清空PerpetualCache对象的数据,但是该对象可以继续使用;
3.2 二级缓存有过期时间,但是没有后台线程检测
(1)二级缓存是mapper级别的缓存,使用二级缓存时,多个SqlSession使用同一个mapper的sql语句去操作数据库,得到的数据存在二级缓存区域。同样是使用hashMap进行存储。
(2)相比于一级缓存,二级缓存的范围更大,多个SqlSession可以共用二级缓存,二级缓存时跨SqlSession的。
(3)二级缓存是多个SqlSession共享的,其作用域是SqlSession的namespace。Mybatis一级缓存是默认开启的,二级缓存没有默认开启,需要在setting全局配置中配置开启二级缓存。

二级缓存有过期时间,并不是key-value的过期时间,而是这个cache的过期时间,是flushInterval,意味着整个清空缓存cache,所以不需要后台线程去定时检测。
4、关于@CacheNamespace的说明:
当使用该注解的时候,你去查询的时候,使用到的查询能被缓存起来,但是,加入你这个查询SQL调用的是xml文件里面的,此时,是不会被缓存的,因为我们有时候会使用很多注解或者tk.mapper,这个时候是不会走xml,所以这个缓存我们用不上,此时存在一个解决方案,在xml文件里面也用上缓存,如下就代表开启使用缓存了,这个方式进行的话存在同上面类似的问题

这个存在的问题是xml里面走的SQL是可以被缓存的,但是你接口层的注解之类的SQL是不会被缓存,那有的人就说了,我把@CacheNamespace注解和cache标签一起使用不就行了嘛,但实际上是不行的。

那么如何同时去满足这个问题呢
@CacheNamespaceRef 来解决这个问题,也就是在接口上使用这个注解,把接口上的@CacheNamespace注解替换成@CacheNamespaceRef ,同时的话xml文件里面使用cache标签,
使用@CacheNamespaceRef注解要注意一点,要指明value或者name,如果不指明则会报错

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值