MyBatis 源码分析 - 缓存原理,java毕业设计题目给个思路

// 存储缓存项

delegate.putObject(key, value);

} finally {

// 释放锁

releaseLock(key);

}

}

@Override

public Object getObject(Object key) {

// 请 // 请求锁

acquireLock(key);

Object value = delegate.getObject(key);

// 若缓存命中,则释放锁。需要注意的是,未命中则不释放锁

if (value != null) {

// 释放锁

releaseLock(key);

}

return value;

}

@Override

public Object removeObject(Object key) {

// 释放锁

releaseLock(key);

return null;

}

private ReentrantLock getLockForKey(Object key) {

ReentrantLock lock = new ReentrantLock();

// 存储 <key, Lock> 键值对到 locks 中

ReentrantLock previous = locks.putIfAbsent(key, lock);

return previous == null ? lock : previous;

}

private void acquireLock(Object key) {

Lock lock = getLockForKey(key);

if (timeout > 0) {

try {

// 尝试加锁

boolean acquired = lock.tryLock(timeout, TimeUnit.MILLISECONDS);

if (!acquired) {

throw new CacheException(“…”);

}

} catch (InterruptedException e) {

throw new CacheException(“…”);

}

} else {

// 加锁

lock.lock();

}

}

private void releaseLock(Object key) {

// 获取与当前 key 对应的锁

ReentrantLock lock = locks.get(key);

if (lock.isHeldByCurrentThread()) {

// 释放锁

lock.unlock();

}

}

// 省略部分代码

}

如上,查询缓存时,getObject 方法会先获取与 key 对应的锁,并加锁。若缓存命中,getObject 方法会释放锁,否则将一直锁定。getObject 方法若返回 null,表示缓存未命中。此时 MyBatis 会进行数据库查询,并调用 putObject 方法存储查询结果。同时,putObject 方法会将指定 key 对应的锁进行解锁,这样被阻塞的线程即可恢复运行。

上面的描述有点啰嗦,倒是 BlockingCache 类的注释说到比较简单明了。这里引用一下:

It sets a lock over a cache key when the element is not found in cache.

This way, other threads will wait until this element is filled instead of hitting the database.

这段话的意思是,当指定 key 对应元素不存在于缓存中时,BlockingCache 会根据 lock 进行加锁。此时,其他线程将会进入等待状态,直到与 key 对应的元素被填充到缓存中。而不是让所有线程都去访问数据库。

在上面代码中,removeObject 方法的逻辑很奇怪,仅调用了 releaseLock 方法释放锁,却没有调用被装饰类的 removeObject 方法移除指定缓存项。这样做是为什么呢?大家可以先思考,答案将在分析二级缓存的相关逻辑时分析。

3. CacheKey


在 MyBatis 中,引入缓存的目的是为提高查询效率,降低数据库压力。既然 MyBatis 引入了缓存,那么大家思考过缓存中的 key 和 value 的值分别是什么吗?大家可能很容易能回答出 value 的内容,不就是 SQL 的查询结果吗。那 key 是什么呢?是字符串,还是其他什么对象?如果是字符串的话,那么大家首先能想到的是用 SQL 语句作为 key。但这是不对的,比如:

SELECT * FROM author where id > ?

id > 1 和 id > 10 查出来的结果可能是不同的,所以我们不能简单的使用 SQL 语句作为 key。从这里可以看出来,运行时参数将会影响查询结果,因此我们的 key 应该涵盖运行时参数。除此之外呢,如果进行分页查询也会导致查询结果不同,因此 key 也应该涵盖分页参数。综上,我们不能使用简单的 SQL 语句作为 key。应该考虑使用一种复合对象,能涵盖可影响查询结果的因子。在 MyBatis 中,这种复合对象就是 CacheKey。下面来看一下它的定义。

public class CacheKey implements Cloneable, Serializable {

private static final int DEFAULT_MULTIPLYER = 37;

private static final int DEFAULT_HASHCODE = 17;

// 乘子,默认为37

private final int multiplier;

// CacheKey 的 hashCode,综合了各种影响因子

private int hashcode;

// 校验和

private long checksum;

// 影响因子个数

private int count;

// 影响因子集合

private List updateList;

public CacheKey() {

this.hashcode = DEFAULT_HASHCODE;

this.multiplier = DEFAULT_MULTIPLYER;

this.count = 0;

this.updateList = new ArrayList();

}

// 省略其他方法

}

如上,除了 multiplier 是恒定不变的 ,其他变量将在更新操作中被修改。下面看一下更新操作的代码。

/** 每当执行更新操作时,表示有新的影响因子参与计算 */

public void update(Object object) {

int baseHashCode = object == null ? 1 : ArrayUtil.hashCode(object);

// 自增 count

count++;

// 计算校验和

checksum += baseHashCode;

// 更新 baseHashCode

baseHashCode *= count;

// 计算 hashCode

hashcode = multiplier * hashcode + baseHashCode;

// 保存影响因子

updateList.add(object);

}

当不断有新的影响因子参与计算时,hashcode 和 checksum 将会变得愈发复杂和随机。这样可降低冲突率,使 CacheKey 可在缓存中更均匀的分布。CacheKey 最终要作为键存入 HashMap,因此它需要覆盖 equals 和 hashCode 方法。下面我们来看一下这两个方法的实现。

public boolean equals(Object object) {

// 检测是否为同一个对象

if (this == object) {

return true;

}

// 检测 object 是否为 CacheKey

if (!(object instanceof CacheKey)) {

return false;

}

final CacheKey cacheKey = (CacheKey) object;

// 检测 hashCode 是否相等

if (hashcode != cacheKey.hashcode) {

return false;

}

// 检测校验和是否相同

if (checksum != cacheKey.checksum) {

return false;

}

// 检测 coutn 是否相同

if (count != cacheKey.count) {

return false;

}

// 如果上面的检测都通过了,下面分别对每个影响因子进行比较

for (int i = 0; i < updateList.size(); i++) {

Object thisObject = updateList.get(i);

Object thatObject = cacheKey.updateList.get(i);

if (!ArrayUtil.equals(thisObject, thatObject)) {

return false;

}

}

return true;

}

public int hashCode() {

// 返回 hashcode 变量

return hashcode;

}

equals 方法的检测逻辑比较严格,对 CacheKey 中多个成员变量进行了检测,已保证两者相等。hashCode 方法比较简单,返回 hashcode 变量即可。

关于 CacheKey 就先分析到这,CacheKey 在一二级缓存中会被用到,接下来还会看到它的身影。

4.一级缓存


在进行数据库查询之前,MyBatis 首先会检查以及缓存中是否有相应的记录,若有的话直接返回即可。一级缓存是数据库的最后一道防护,若一级缓存未命中,查询请求将落到数据库上。一级缓存是在 BaseExecutor 被初始化的,下面我们来看一下相关的初始化逻辑:

public abstract class BaseExecutor implements Executor {

protected PerpetualCache localCache;

// 省略其他字段

protected BaseExecutor(Configuration configuration, Transaction transaction) {

this.localCache = new PerpetualCache(“LocalCache”);

// 省略其他字段初始化方法

}

}

如上,一级缓存的类型为 PerpetualCache,没有被其他缓存类装饰过。一级缓存所存储从查询结果会在 MyBatis 执行更新操作(INSERT/UPDATE/DELETE),以及提交和回滚事务时被清空。下面我们来看一下查询一级缓存的逻辑。

public List query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {

BoundSql boundSql = ms.getBoundSql(parameter);

// 创建 CacheKey

CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql);

return query(ms, parameter, rowBounds, resultHandler, key, boundSql);

}

public List query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {

// 省略部分代码

List list;

try {

queryStack++;

// 查询一级缓存

list = resultHandler == null ? (List) localCache.getObject(key) : null;

if (list != null) {

// 存储过程相关逻辑,忽略

handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);

} else {

// 缓存未命中,则从数据库中查询

list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);

}

} finally {

queryStack–;

}

// 省略部分代码

return list;

}

如上,在访问一级缓存之前,MyBatis 首先会调用 createCacheKey 方法创建 CacheKey。下面我们来看一下 createCacheKey 方法的逻辑:

public CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql) {

if (closed) {

throw new ExecutorException(“Executor was closed.”);

}

// 创建 CacheKey 对象

CacheKey cacheKey = new CacheKey();

// 将 MappedStatement 的 id 作为影响因子进行计算

cacheKey.update(ms.getId());

// RowBounds 用于分页查询,下面将它的两个字段作为影响因子进行计算

cacheKey.update(rowBounds.getOffset());

cacheKey.update(rowBounds.getLimit());

// 获取 sql 语句,并进行计算

cacheKey.update(boundSql.getSql());

List parameterMappings = boundSql.getParameterMappings();

TypeHandlerRegistry typeHandlerRegistry = ms.getConfiguration().getTypeHandlerRegistry();

for (ParameterMapping parameterMapping : parameterMappings) {

if (parameterMapping.getMode() != ParameterMode.OUT) {

Object value; // 运行时参数

// 当前大段代码用于获取 SQL 中的占位符 #{xxx} 对应的运行时参数,

// 前文有类似分析,这里忽略了

String propertyName = parameterMapping.getProperty();

if (boundSql.hasAdditionalParameter(propertyName)) {

value = boundSql.getAdditionalParameter(propertyName);

} else if (parameterObject == null) {

value = null;

} else if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {

value = parameterObject;

} else {

MetaObject metaObject = configuration.newMetaObject(parameterObject);

value = metaObject.getValue(propertyName);

}

// 让运行时参数参与计算

cacheKey.update(value);

}

}

if (configuration.getEnvironment() != null) {

// 获取 Environment id 遍历,并让其参与计算

cacheKey.update(configuration.getEnvironment().getId());

}

return cacheKey;

}

如上,在计算 CacheKey 的过程中,有很多影响因子参与了计算。比如 MappedStatement 的 id 字段,SQL 语句,分页参数,运行时变量,Environment 的 id 字段等。通过让这些影响因子参与计算,可以很好的区分不同查询请求。所以,我们可以简单的把 CacheKey 看做是一个查询请求的 id。有了 CacheKey,我们就可以使用它读写缓存了。在上面代码中,若一级缓存为命中,BaseExecutor 会调用 queryFromDatabase 查询数据库,并将查询结果写入缓存中。下面看一下 queryFromDatabase 的逻辑。

private List queryFromDatabase(MappedStatement ms, Object parameter, RowBounds rowBounds,ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {

List 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;

}

到此,关于一级缓存相关的逻辑就差不多分析完了。一级缓存的逻辑比较简单,大家可以简单过一遍。接下来分析二级缓存。

5.二级缓存


二级缓存构建在一级缓存之上,在收到查询请求时,MyBatis 首先会查询二级缓存。若二级缓存未命中,再去查询一级缓存。与一级缓存不同,二级缓存和具体的命名空间绑定,一级缓存则是和 SqlSession 绑定。在按照 MyBatis 规范使用 SqlSession 的情况下,一级缓存不存在并发问题。二级缓存则不然,二级缓存可在多个命名空间间共享。这种情况下,会存在并发问题,因此需要针对性去处理。除了并发问题,二级缓存还存在事务问题,相关问题将在接下来进行分析。下面首先来看一下访问二级缓存的逻辑。

// -☆- CachingExecutor

public List query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {

BoundSql boundSql = ms.getBoundSql(parameterObject);

// 创建 CacheKey

CacheKey key = createCacheKey(ms, parameterObject, rowBounds, boundSql);

return query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);

}

public List query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)

throws SQLException {

// 从 MappedStatement 中获取 Cache,注意这里的 Cache 并非是在 CachingExecutor 中创建的

Cache cache = ms.getCache();

// 如果配置文件中没有配置 ,则 cache 为空

if (cache != null) {

flushCacheIfRequired(ms);

if (ms.isUseCache() && resultHandler == null) {

ensureNoOutParams(ms, boundSql);

// 访问二级缓存

List list = (List) tcm.getObject(cache, key);

// 缓存未命中

if (list == null) {

// 向一级缓存或者数据库进行查询

list = delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);

// 缓存查询结果

tcm.putObject(cache, key, list);

}

return list;

}

}

return delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);

}

如上,注意二级缓存是从 MappedStatement 中获取的,而非由 CachingExecutor 创建。由于 MappedStatement 存在于全局配置中,可以多个 CachingExecutor 获取到,这样就会出现线程安全问题。除此之外,若不加以控制,多个事务共用一个缓存实例,会导致脏读问题。线程安全问题可以通过 SynchronizedCache 装饰类解决,该装饰类会在 Cache 实例构造期间被添加上。相关过程可以参考我之前写的文章 MyBatis-源码分析-映射文件解析过程,这里就不多说了。至于脏读问题,需要借助其他类来处理,也就是上面代码中 tcm 变量对应的类型。下面分析一下。

/** 事务缓存管理器 */

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) {

return getTransactionalCache(cache).getObject(key);

}

public void putObject(Cache cache, CacheKey key, Object value) {

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 增加事务功能

txCache = new TransactionalCache(cache);

transactionalCaches.put(cache, txCache);

}

return txCache;

}

}

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

public class TransactionalCache implements Cache {

private final Cache delegate;

private boolean clearOnCommit;

// 在事务被提交前,所有从数据库中查询的结果将缓存在此集合中

private final Map<Object, Object> entriesToAddOnCommit;

// 在事务被提交前,当缓存未命中时,CacheKey 将会被存储在此集合中

private final Set 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 中,而非 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 集合,TransactionalCache 中的很多方法都会与这个集合打交道。该集合用于存储从查询的结果,那为什么要将结果保存在该集合中,而非 delegate 所表示的缓存中呢?主要是因为直接存到 delegate 会导致脏数据问题。下面通过一张图演示一下脏数据问题发生的过程,假设两个线程开启两个不同的事务,它们的执行过程如下:

img

如上图,时刻2,事务 A 对记录 A 进行了更新。时刻3,事务 A 从数据库查询记录 A,并将记录 A 写入缓存中。时刻4,事务 B 查询记录 A,由于缓存中存在记录 A,事务 B 直接从缓存中取数据。这个时候,脏数据问题就发生了。事务 B 在事务 A 未提交情况下,读取到了事务 A 所修改的记录。为了解决这个问题,我们可以为每个事务引入一个独立的缓存。查询数据时,仍从 delegate 缓存(以下统称为共享缓存)中查询。若缓存未命中,则查询数据库。存储查询结果时,并不直接存储查询结果到共享缓存中,而是先存储到事务缓存中,也就是 entriesToAddOnCommit 集合。当事务提交时,再将事务缓存中的缓存项转存到共享缓存中。这样,事务 B 只能在事务 A 提交后,才能读取到事务 A 所做的修改,解决了脏读问题。整个过程大致如下:

img

如上,时刻2,事务 A 和 B 同时查询记录 A。此时共享缓存中还没没有数据,所以两个事务均会向数据库发起查询请求,并将查询结果存储到各自的事务缓存中。时刻3,事务 A 更新记录 A,这里把更新后的记录 A 记为 A′。时刻4,两个事务再次进行查询。此时,事务 A 读取到的记录为修改后的值,而事务 B 读取到的记录仍为原值。时刻5,事务 A 被提交,并将事务缓存 A 中的内容转存到共享缓存中。时刻6,事务 B 再次查询记录 A,由于共享缓存中有相应的数据,所以直接取缓存数据即可。因此得到记录 A′,而非记录 A。但由于事务 A 已经提交,所以事务 B 读取到的记录 A′ 并非是脏数据。MyBatis 引入事务缓存解决了脏读问题,事务间只能读取到其他事务提交后的内容,这相当于事务隔离级别中的“读已提交(Read Committed)”。但需要注意的时,MyBatis 缓存事务机制只能解决脏读问题,并不能解决“不可重复读”问题。再回到上图,事务 B 在被提交前进行了三次查询。前两次查询得到的结果为记录 A,最后一次查询得到的结果为 A′。最有一次的查询结果与前两次不同,这就会导致“不可重复读”的问题。MyBatis 的缓存事务机制最高只支持“读已提交”,并不能解决“不可重复读”问题。即使数据库使用了更高的隔离级别解决了这个问题,但因 MyBatis 缓存事务机制级别较低。此时仍然会导致“不可重复读”问题的发生,这个在日常开发中需要注意一下。

下面写点测试代码验证 MyBatis 所导致的“不可重复读”问题,首先看一下实体类:

public class Student {

private Integer id;

private String name;

private Integer age;

// 省略 getter/setter

}

对应的数据表如下:

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数同学面临毕业设计项目选题时,很多人都会感到无从下手,尤其是对于计算机专业的学生来说,选择一个合适的题目尤为重要。因为毕业设计不仅是我们在大学四年学习的一个总结,更是展示自己能力的重要机会。

因此收集整理了一份《2024年计算机毕业设计项目大全》,初衷也很简单,就是希望能够帮助提高效率,同时减轻大家的负担。
img
img
img

既有Java、Web、PHP、也有C、小程序、Python等项目供你选择,真正体系化!

由于项目比较多,这里只是将部分目录截图出来,每个节点里面都包含素材文档、项目源码、讲解视频

如果你觉得这些内容对你有帮助,可以添加VX:vip1024c (备注项目大全获取)
img

取到其他事务提交后的内容,这相当于事务隔离级别中的“读已提交(Read Committed)”。但需要注意的时,MyBatis 缓存事务机制只能解决脏读问题,并不能解决“不可重复读”问题。再回到上图,事务 B 在被提交前进行了三次查询。前两次查询得到的结果为记录 A,最后一次查询得到的结果为 A′。最有一次的查询结果与前两次不同,这就会导致“不可重复读”的问题。MyBatis 的缓存事务机制最高只支持“读已提交”,并不能解决“不可重复读”问题。即使数据库使用了更高的隔离级别解决了这个问题,但因 MyBatis 缓存事务机制级别较低。此时仍然会导致“不可重复读”问题的发生,这个在日常开发中需要注意一下。

下面写点测试代码验证 MyBatis 所导致的“不可重复读”问题,首先看一下实体类:

public class Student {

private Integer id;

private String name;

private Integer age;

// 省略 getter/setter

}

对应的数据表如下:

自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数同学面临毕业设计项目选题时,很多人都会感到无从下手,尤其是对于计算机专业的学生来说,选择一个合适的题目尤为重要。因为毕业设计不仅是我们在大学四年学习的一个总结,更是展示自己能力的重要机会。

因此收集整理了一份《2024年计算机毕业设计项目大全》,初衷也很简单,就是希望能够帮助提高效率,同时减轻大家的负担。
[外链图片转存中…(img-YAj2sZ7K-1712528909566)]
[外链图片转存中…(img-VaPj5SgB-1712528909567)]
[外链图片转存中…(img-0qSKjoTY-1712528909567)]

既有Java、Web、PHP、也有C、小程序、Python等项目供你选择,真正体系化!

由于项目比较多,这里只是将部分目录截图出来,每个节点里面都包含素材文档、项目源码、讲解视频

如果你觉得这些内容对你有帮助,可以添加VX:vip1024c (备注项目大全获取)
[外链图片转存中…(img-duebOEwU-1712528909568)]

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值