maven清理缓存_mybatis源码初探【二】缓存的实现

本文探讨MyBatis的缓存实现,包括Cache接口、永久缓存PerpetualCache和各种装饰器缓存实现,如BlockingCache、FifoCache、LoggingCache、LruCache、ScheduledCache、SerializedCache、SoftCache、TransactionalCache和WeakCache。MyBatis通过装饰器模式扩展缓存功能,以适应不同的场景需求。
摘要由CSDN通过智能技术生成

前言

上一篇文章中我们从整体出发,对mybatis的配置、启动流程进行了简单的解析说明

这次我们准备深入学习mybaits的缓存原理以及使用

目录
为了提升一丢丢阅读体验,在文章最前面显示这鸡肋的目录(吐槽下微信公众号文章)

1. 缓存
2. mybatis实现的缓存
2.1 Cache接口
2.2 永久缓存实现PerpetualCache
2.3 缓存的装饰器实现
2.3.1 缓存实现列表
2.3.2 BlockingCache 阻塞缓存
2.3.3 FifoCache 先进先出缓存
2.3.4 LoggingCache 日志缓存
2.3.5 LruCache 最近最少使用缓存
2.3.6 ScheduledCache 定时缓存
2.3.7 SerializedCache同步缓存
2.3.8 SoftCache 软引用缓存
2.3.9 TransactionalCache 事务缓存
2.3.10 SerializedCache序列化缓存
2.3.11 WeakCache 软引用缓存

1. 缓存

说起缓存,不知道你们想到的是什么

我理解的缓存就是把数据(info)放在离应用程序或用户(user)更近的地方

缓存都用处非常多

像cdn文件分发、 maven仓库、浏览器缓存、分布式缓存、单机缓存(堆内、堆外)、cpu缓存等等都是缓存使用的场景

可以说,缓存是高并发、高吞吐的保障跟基础,用好缓存,对于任何一个系统都是非常重要

那么,开始我们今天的maybatis缓存之旅吧

2. mybatis实现的缓存

mybatis中为了减少依赖,几乎什么都自己来实现了一份(缓存,事务,连接池)

这样使得mybaits整体上更加和谐(没有多余功能或多余的依赖,不臃肿)

先来看看cache

26612b5d8626334ef7c86649da57bdfe.png

从上一篇文章中我们可以知道,cache包的代码量在910行,占mybatis总代码量4%的篇幅

可想而知,mybatis缓存的实现了一定非常简洁,这对开发者无疑是非常友好的,阅读体验将会大大提升

Cache.java

cache包里面几个类,其中Cache是mybaits的缓存接口

CacheException.java

CacheException缓存异常,继承了PersistenceException持久化异常,提供几种构造器方法

CacheKey.java

CacheKeymybatis缓存的键实体,采用stementId + offset + limit + sql + queryParams + environment规则生成key

NullCacheKey.java

NullCacheKey继承了CacheKey, 用于Null值的key, 但是3.5.5后弃用了

package-info.java

package-info.java是包注释

实现了CloneableSerializable接口,使用Object的clone方法(浅拷贝)

TransactionalCacheManager.java

TransactionalCacheManager是mybatis的事务缓存管理器(即二级缓存),管理所有的事务缓存TransactionalCache, 并提供TransactionalCache的提交、回滚(不是数据库的事务)等方法

decorators 和 impl

还有两个包分别是decorators(装饰器)和impl(实现)

impl是对Cache接口的实现

decorators包下的类也有对Cache实现,但是将Cache的操作委托给内部的Cache实现去执行(使用装饰器模式)

2.1 Cache接口
 1public interface Cache {
2  // 获取缓存id
3  String getId();
4
5  // 设置缓存
6  void putObject(Object key, Object value);
7
8  // 获取缓存
9  Object getObject(Object key);
10
11  // 移除缓存
12  Object removeObject(Object key);
13
14  // 清除所有缓存
15  void clear();
16
17  // 获取缓存数量
18  int getSize();
19
20  // 获取读写锁 3.2.6版本后未再使用
21  default ReadWriteLock getReadWriteLock() {
22    return null;
23  }
24}

可以看到,Cache的键值对都是Object对象,在mybatis中,key为CacheKey,value则为sql查询的结果集

其他几个方法都很常规,不过多说明了

2.2 永久缓存实现PerpetualCache

PerpetualCacheCache的一个实现,这里的永久是指存储在PerpetualCache中的数据会一直保存,直到手动移除

内部使用一个HashMap来存储数据(真的很简单)

 1public class PerpetualCache implements Cache {
2  // 每个PerpetualCache都有一个唯一的id
3  private final String id;
4  // cache是真正存储缓存的地方
5  private final Map cache = new HashMap<>(); 6 7  public PerpetualCache(String id) { 8    this.id = id; 9  }10  @Override11  public void putObject(Object key, Object value) {12    cache.put(key, value);13  }14  @Override15  public Object getObject(Object key) {16    return cache.get(key);17  }18  @Override19  public Object removeObject(Object key) {20    return cache.remove(key);21  }22// 省略部分Cache接口方法23}

可以看到PerpetualCache内部通过一个hashmap来存储和操作数据,

需要注意的是cache才是真正存储缓存的地方

2.3 缓存的装饰器实现

mybatis为了扩展缓存的实现,以及可以实现多种不同实现效果的叠加,使用了装饰器模式来设计

简单说下mybatis的其他缓存实现

2.3.1 缓存实现列表
描述
BlockingCache阻塞缓存,当获取不到缓存时,其他线程会被阻塞直到当前线程填充缓存并释放锁
FifoCache先进先出缓存,当队列满时,最先进队的缓存会出队
LoggingCache实现打印缓存命中率日志的功能,mybatis默认会使用
LruCache最近最少使用缓存,不常用的缓存将被自动移除
ScheduledCache定时清理缓存
SerializedCache序列化缓存
SoftCache软引用缓存,使用SoftReference, 方便GC回收
SynchronizedCache同步缓存,缓存的crud操作都是同步的
TransactionalCache事务缓存
WeakCache弱应用缓存

由于decorators包下所有类都使用了装饰者模式,所有有多一样的代码

在这里展示一次,后续只展示不同代码的地方

由于同步缓存的代码量最少,所以这里展示同步缓存的所有代码

 1public class SynchronizedCache implements Cache {
2  // 缓存的操作委派给delegate, 典型的装饰者模式
3  private final Cache delegate;
4
5  public SynchronizedCache(Cache delegate) {
6    this.delegate = delegate;
7  }
8
9  @Override
10  public String getId() {
11    return delegate.getId();
12  }
13
14  @Override
15  public synchronized int getSize() {
16    return delegate.getSize();
17  }
18
19  @Override
20  public synchronized void putObject(Object key, Object object) {
21    delegate.putObject(key, object);
22  }
23
24  @Override
25  public synchronized Object getObject(Object key) {
26    return delegate.getObject(key);
27  }
28
29  @Override
30  public synchronized Object removeObject(Object key) {
31    return delegate.removeObject(key);
32  }
33
34  @Override
35  public synchronized void clear() {
36    delegate.clear();
37  }
38
39  @Override
40  public int hashCode() {
41    return delegate.hashCode();
42  }
43
44  @Override
45  public boolean equals(Object obj) {
46    return delegate.equals(obj);
47  }
48
49}

SynchronizedCache同步缓存,就是在通过给缓存操作加上同步锁synchronized来实现

可以看到处理方法加锁外,方法体都是直接调用delegate委派缓存的对应方法

下面介绍其他缓存,由于很多方法与此相同,所以只展示部分特别的代码、方法块

2.3.2 BlockingCache 阻塞缓存

BlockingCache有三个属性

1// 请求锁的超时时间,毫秒,如果设置了时间,则会尝试请求锁,否则直接加锁
2private long timeout;
3
4// Cache接口方法委托给delegate去执行
5private final Cache delegate;
6
7// 内部使用线程安全的map来管理锁
8private final ConcurrentHashMap locks;

看看阻塞缓存的getObject方法

 1@Override
2public Object getObject(Object key) {
3      // 尝试获取锁
4    acquireLock(key);
5    Object value = delegate.getObject(key);
6    // 如果值存在,直接释放锁
7    if (value != null) {
8      releaseLock(key);
9    }
10    return value;
11}
12
13private void acquireLock(Object key) {
14    Lock lock = getLockForKey(key);
15    // 如果设置了超时时间, 尝试去获取锁, 否则直接加锁
16    if (timeout > 0) {
17      try {
18        boolean acquired = lock.tryLock(timeout, TimeUnit.MILLISECONDS);
19        if (!acquired) {
20          throw new CacheException("Couldn't get a lock in " + timeout + " for the key " +  key + " at the cache " + delegate.getId());
21        }
22      } catch (InterruptedException e) {
23        throw new CacheException("Got interrupted while trying to acquire lock for key " + key, e);
24      }
25    } else {
26      lock.lock();
27    }
28  }
29
30// 获取锁,如果不存在,new一个
31private ReentrantLock getLockForKey(Object key) {
32    return locks.computeIfAbsent(key, k -> new ReentrantLock());
33}
34// 释放锁
35private void releaseLock(Object key) {
36    ReentrantLock lock = locks.get(key);
37    if (lock.isHeldByCurrentThread()) {
38      lock.unlock();
39    }
40}

当第一个线程调用getObject方法时, BlockingCache会阻塞其他线程的getObject (相同key, 不同的key不会阻塞)

如果此时key没有对应的缓存,mybatis则会去数据库查找,然后填充进去

我们用个小例子试试

我们在bookMapper.xml开启二级缓存, 并使用二级缓存

1<cache2       blocking="true"3 />

使用一个简单的测试方法来验证

 1@Test
2public void getDataByDifferentThread() throws IOException {
3    // 线程1
4    new Thread(() -> {
5        SqlSession sqlSession = sqlSessionFactory.openSession(true);
6        BookMapper mapper = sqlSession.getMapper(BookMapper.class);
7        Book byId = mapper.getById(1);
8        System.out.println("thread1 " + byId);
9        // 为防止线程退出, 主线程休眠5秒钟
10        try {
11            TimeUnit.SECONDS.sleep(5);
12            sqlSession.close();
13            System.out.println("关闭会话");
14        } catch (InterruptedException e) {
15            e.printStackTrace();
16        }
17    }).start();
18
19    // 线程2
20    new Thread(() -> {
21        // 为了保证先执行,这需要休眠1秒钟
22        try {
23            TimeUnit.SECONDS.sleep(1);
24        } catch (InterruptedException e) {
25            e.printStackTrace();
26        }
27        SqlSession sqlSession = sqlSessionFactory.openSession(true);
28        BookMapper mapper = sqlSession.getMapper(BookMapper.class);
29        Book byId = mapper.getById(1);
30        System.out.println("thread2 " + byId);
31//            sqlSession.close();
32    }).start();
33
34    // 阻塞主线程
35    System.in.read();
36}

测试代码开启两个线程执行同样的SQL查询语句,由于我们开启二级缓存,并配置了使用阻塞缓存

所以第一个线程进入之后,获取锁,发现没有缓存,到数据库查找,然后回来填充缓存,但是此时由于我们还没有执行sqlSession.close(),第一个线程并未释放锁, 所以第二个线程会一直阻塞,直到第一个线程释放锁,再获取缓存(此时有值,会直接释放锁)

2.3.3 FifoCache 先进先出缓存

FifoCache是先进先出缓存,在其内部维护一个LinkedList, 结构如下

 1//委派给delegate操作cache
2private final Cache delegate
3// 双向队列来维护队列
4private final Deque keyList; 5// 缓存数量 6private int size; 7 8public FifoCache(Cache delegate) { 9    this.delegate = delegate;10    this.keyList = new LinkedList<>();    // 使用链表的实现11    this.size = 1024;    // 默认102412}

主要来看看putObject设置缓存的操作

 1@Override
2public void putObject(Object key, Object value) {
3    cycleKeyList(key);
4    delegate.putObject(key, value);
5}
6// 维护队列
7private void cycleKeyList(Object key) {
8  // 添加到队尾
9  keyList.addLast(key);
10  // 队列长度对于设定值
11  if (keyList.size() > size) {
12    // 从队头移除
13    Object oldestKey = keyList.removeFirst();
14    // 委派cache也一并删除
15    delegate.removeObject(oldestKey);
16  }
17}

就两个字, 优雅

而且还兼具性能

putObject中添加cycleKeyList操作,要去维护一个队列,需要添加、判断、移除等操作,给人感觉性能不好的亚子

其实不然

由于使用LinkedList实现双向链表, 所以addLast(key)直接在将key连上尾元素,时间复杂度为O(1)

removeFirst()同理,将头指针往后一个元素,时间复杂度也是O(1),至于removeObject(oldestKey),我们知道Cache底层是一个HashMap,所以时间复杂度还是O(1)

当然我们需要付出一个O(N)大小的空间复杂度,默认设置为1024,所以开销并不大

2.3.4 LoggingCache 日志缓存

LoggingCache的目的很明确,就是为了打印日志,记录缓存命中的比率

计算方式如下

缓存命中率 = 请求总数 / 命中缓存的请求数

内部结构如下

 1//mybatis封装的Log接口
2private final Log log;
3//cache操作委派
4private final Cache delegate;
5// 请求总数
6protected int requests = 0;
7// 命中数
8protected int hits = 0;
9
10public LoggingCache(Cache delegate) {
11    this.delegate = delegate;
12    // 通过日志工厂获取日志实现类
13    this.log = LogFactory.getLog(getId());
14}

看看获取缓存的方法

 1@Override
2public Object getObject(Object key) {
3    // 请求数累加
4    requests++;
5    final Object value = delegate.getObject(key);
6    // 如果命中,则累加命中数
7    if (value != null) {
8      hits++;
9    }
10    if (log.isDebugEnabled()) {
11      // 输出缓存命中情况
12      log.debug("Cache Hit Ratio [" + getId() + "]: " + getHitRatio());
13    }
14    return value;
15}
16// 计算缓存命中率
17private double getHitRatio() {
18    return (double) hits / (double) requests;
19}

LoggingCache本身并没什么好说的

不过有一个点可以说一下,就是只要我们的日志配置(无论是什么),只要设置输出DEBUG日志,就会有缓存命中的信息出现

因为mybatis的二级缓存默认是带LoggingCache

 1private Cache setStandardDecorators(Cache cache) {
2    try {
3          //省略部分代码
4          //包装一层 日志
5          cache = new LoggingCache(cache);
6           //包装为 同步缓存
7          cache = new SynchronizedCache(cache);
8           // 如果设置为阻塞缓存,则包装一层
9          if (blocking) {
10            cache = new BlockingCache(cache);
11          }
12          return cache;
13          } catch (Exception e) {
14          throw new CacheException("Error building standard cache decorators.  Cause: " + e, e);
15    }
16}
2.3.5 LruCache 最近最少使用缓存

LruCacheFifoCache 有些类似,都需要在内部维护一个固定大小的容器

但是使用场景不太一样,LruCache 主要使用在那些热点数据访问量较高的场景,如果没有热点数据,也就是数据访问比较平均,那么使用LruCache 意义也不大,FifoCache 暂时想不出有什么比较好的使用(或许这就是佛性?),只知道某些情况下会比lru好一丢丢

LruCache内部通过维护一个LinkedHashMap

并通过改写removeEldestEntry实现移除内部最老的一个cache

 1// 委派
2private final Cache delegate;
3// map接口
4private Map keyMap; 5// 对接的cache key 6private Object eldestKey; 7 8public LruCache(Cache delegate) { 9  this.delegate = delegate;10  setSize(1024);11}12// 设置大小, 并重写removeEldestEntry13public void setSize(final int size) {14    keyMap = new LinkedHashMap(size, .75F, true) {15      private static final long serialVersionUID = 4267176411845948333L;1617      @Override18      protected boolean removeEldestEntry(Map.Entry eldest) {19        //当map大小超过size, 返回true, 让LinkedHashMap移除最老元素20        //同时设置eldestKey为最老元素21        boolean tooBig = size() > size;22        if (tooBig) {23          eldestKey = eldest.getKey();24        }25        return tooBig;26      }27    };28}

因为LinkedHashMap在每次访问或插入元素,都是将元素插入到链表尾部,这样链表头部就是最近最少使用的元素啦

LinkedHashMap也会在每次新增元素调用removeEldestEntry判断是否移除最老元素

接下来

 1@Override
2public void putObject(Object key, Object value) {
3    delegate.putObject(key, value);
4    cycleKeyList(key);
5}
6
7private void cycleKeyList(Object key) {
8    // 先插入元素, 如果溢出,则会移除并设置eldestKey
9    keyMap.put(key, key);
10    // 如果eldestKey不为空,说明keyMap已经移除了最老元素,cache也要一起移除
11    if (eldestKey != null) {
12      delegate.removeObject(eldestKey);
13      eldestKey = null;
14    }
15}

只要在添加元素之后,判断eldestKey的情况即可

这里的设计的确十分巧妙

不过也要感叹jdk十分强大

2.3.6 ScheduledCache 定时缓存

顾名思义,就是定时清理一下缓存

由于实现比较简单,说明一下了(不上代码了)

内部维护了两个变量,一个clearInterval表示清理间隔时间(毫秒单位),一个是lastClear记录上一次清理缓存的时间,那么System.currentTimeMillis() - lastClear > clearInterval便可以得出是否到了清缓存的时间了

在每个操作之间都是计算一下(只是一个判断,消耗不大),如果到时间了,就调用clear方法清理一下缓存

2.3.7 SerializedCache同步缓存

在缓存列表里有简单说明,这里不再说明

2.3.8 SoftCache 软引用缓存

看到软引用,第一个想到的就是GC, 一想到GC,我就想到了垃圾, 一想到垃圾,我就想到了我自己

SoftReference表示某个引用在发生GC垃圾回收时,会被自动回收机制处理的类

所以在发生OOM之前,SoftReference的引用值并不会被回收,生命周期还算比较长的,适合拿来做缓存

看看SoftReference的构造

 1// 强引用, 链表, 用于防止垃圾回收
2private final Deque hardLinksToAvoidGarbageCollection; 3// 软应用队列, 用于垃圾回收 4private final ReferenceQueue queueOfGarbageCollectedEntries; 5// cache操作委派 6private final Cache delegate; 7// 强引用个数 默认 256 8private int numberOfHardLinks; 910public SoftCache(Cache delegate) {11    this.delegate = delegate;12    this.numberOfHardLinks = 256;13    this.hardLinksToAvoidGarbageCollection = new LinkedList<>();14    this.queueOfGarbageCollectedEntries = new ReferenceQueue<>();15}

SoftReferenceSoftReference简单包装了一层

 1private static class SoftEntry extends SoftReference<Object> {
2    //保存 cache 的 key 
3    private final Object key;
4
5    SoftEntry(Object key, Object value, ReferenceQueue garbageCollectionQueue) { 6      // 调用SoftReference的构造器, 设置value为软引用 7      // 并将此软引用与garbageCollectionQueue软引用队列关联 8      // 如果value被GC回收,JVM会把这个软引用加入到garbageCollectionQueue中 9      super(value, garbageCollectionQueue);10      this.key = key;11    }12}

在设置操作putObject, 获取大小getSize, 移除缓存removeObject都调用了removeGarbageCollectedItems清理被GC回收的缓存

1private void removeGarbageCollectedItems() {
2    SoftEntry sv;
3    while ((sv = (SoftEntry) queueOfGarbageCollectedEntries.poll()) != null) {
4      delegate.removeObject(sv.key);
5    }
6}

遍历软引用队列(此时队列中所有的元素已经被GC回收,即value为空,但key还在)

再根据key移除缓存

那之前的硬引用hardLinksToAvoidGarbageCollection又有什么作用呢

答案在getObject

 1@Override
2public Object getObject(Object key) {
3    Object result = null;
4    @SuppressWarnings("unchecked") // assumed delegate cache is totally managed by this cache
5    SoftReference softReference = (SoftReference) delegate.getObject(key); 6    if (softReference != null) { 7      result = softReference.get(); 8      if (result == null) { 9        delegate.removeObject(key);10      } else {11        // See #586 (and #335) modifications need more than a read lock12        synchronized (hardLinksToAvoidGarbageCollection) {13          hardLinksToAvoidGarbageCollection.addFirst(result);14          if (hardLinksToAvoidGarbageCollection.size() > numberOfHardLinks) {15            hardLinksToAvoidGarbageCollection.removeLast();16          }17        }18      }19    }20    return result;21}

由于缓存中保存是一个继承的SoftReferenceSoftEntry

所有需要判断值是否为空(被GC回收)

如果被回收了,则将key也移除掉

如果没有被回收,说明访问次数较多,移动到hardLinksToAvoidGarbageCollection链表中

并维护其固定大小

2.3.9TransactionalCache 事务缓存

TransactionalCache是一个具有事务性的缓存,除了有Cache接口的方法外,还增加了commit,rollback等事务方法

而且在mybatis中,二级缓存(也就是事务性查询缓存)就是通过TransactionalCache实现的

 1private final Cache delegate;
2// 是否在commit时清除缓存
3private boolean clearOnCommit;
4// 先保存一份数据,等commit时再提交到缓存
5private final Map entriesToAddOnCommit; 6// 保存没有命中缓存的key 7private final Set entriesMissedInCache; 8 9public TransactionalCache(Cache delegate) {10  this.delegate = delegate;11  this.clearOnCommit = false;    // 默认commit不清缓存12  this.entriesToAddOnCommit = new HashMap<>();13  this.entriesMissedInCache = new HashSet<>();14}

mybatis通过在缓存之前加了一层(entriesToAddOnCommit), 以此来实现了事务

果然计算机中没有什么事不能通过加一层来解决的,如果有,那就加两层

如果当前事务没提交,则对于其他事务来说,当前事务的操作对于其他事务是不可见的

即缓存不会被其他事务获取到

1@Override
2public void putObject(Object key, Object object) {
3    entriesToAddOnCommit.put(key, object);
4}

所以原先的设置缓存操作,只是将值保存到entriesToAddOnCommit上面

等到commit时

 1public void commit() {
2    if (clearOnCommit) {
3      delegate.clear();
4    }
5    flushPendingEntries();
6    reset();
7}
8
9//刷新待处理的缓存
10private void flushPendingEntries() {
11    for (Map.Entry entry : entriesToAddOnCommit.entrySet()) {12      delegate.putObject(entry.getKey(), entry.getValue());13    }14    // 未命中缓存也要保存一份空对象15    for (Object entry : entriesMissedInCache) {16      if (!entriesToAddOnCommit.containsKey(entry)) {17        delegate.putObject(entry, null);18      }19    }20}21// 重置事务22private void reset() {23    clearOnCommit = false;24    entriesToAddOnCommit.clear();25    entriesMissedInCache.clear();26}

再将值提交到缓存,并将事务重置

2.3.10 SerializedCache序列化缓存

在设置缓存的时候,将对象序列化

等到获取缓存时,将值反序列化成对象,达到节省内存的目的

典型的时间换取空间,但是Java的原生序列化性能并不理想(很差就是了)

简单了解一下设置缓存的序列化过程即可

 1@Override
2public void putObject(Object key, Object object) {
3    // 判断是否可序列化,否则抛出异常
4    if (object == null || object instanceof Serializable) {
5      delegate.putObject(key, serialize((Serializable) object));
6    } else {
7      throw new CacheException("SharedCache failed to make a copy of a non-serializable object: " + object);
8    }
9}
10// 对象序列化成字节数组
11private byte[] serialize(Serializable value) {
12    try (ByteArrayOutputStream bos = new ByteArrayOutputStream();
13        ObjectOutputStream oos = new ObjectOutputStream(bos)) {
14      oos.writeObject(value);
15      oos.flush();
16      return bos.toByteArray();
17    } catch (Exception e) {
18      throw new CacheException("Error serializing object.  Cause: " + e, e);
19    }
20}
2.3.11 WeakCache 软引用缓存

弱引用跟软引用的实现几乎一毛一样

只是把SoftReference换成WeakReference而已

弱引用的生命周期要再短一点,GC的时候,不管会不会发生OOM, WeakReference都会被回收

 1// 只是换成WeakReference, 其他几乎同SoftCache
2private static class WeakEntry extends WeakReference<Object> {
3    private final Object key;
4
5
6    private WeakEntry(Object key, Object value, ReferenceQueue garbageCollectionQueue) {
7      super(value, garbageCollectionQueue);
8      this.key = key;
9    }
10}

最后

微信公众号文章的篇幅实在有限,因此本篇只写到mybatiscache的实现,对于一级缓存,二级缓存的使用以及原理留到下一篇中说明把

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值