mybatis源码--缓存机制

2 篇文章 0 订阅
2 篇文章 0 订阅

缓存概述

MyBatis提供在执行过程中查询缓存,如果缓存中有数据,则直接从缓存中获取,没有则从数据库查询,用以减轻数据压力,提高系统性能。

MyBatis的缓存分为一级缓存和二级缓存。默认情况下一级缓存和二级缓存都是默认开启的。

一级缓存是SqlSession级别的,与SqlSession生命周期是相同的。
二级缓存是全局的,可以跨线程,不同的会话之间是可以共享的。

mybatis缓存结构

在这里插入图片描述

一级缓存

概要

第一级缓存,也叫会话级缓存。
指的是在同一会话内如果有两次相同的查询(Sql和参数均相同),那么第二次就会命中缓存。一级缓存通过会话进行存储(其底层用hashMap存储),当会话关闭,缓存也就没有了。此外如果会话进行了修改(增删改) 操作,缓存也会被清空。

结构
在这里插入图片描述

源码

在这里插入图片描述

一级缓存命中的条件

以下数据必须全部相同才可以命中缓存(而且两次必须相邻的):
1.相同的statement id
2.相同的Session
3.相同的Sql与参数
4.返回行范围相同

1.示例代码

 @Test
    public void yiji() throws IOException {
        InputStream inputStream = Resources.getResourceAsStream("mybats.xml");
        SqlSessionFactory sessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
        SqlSession sqlSession = sessionFactory.openSession();
        UserMapper mapper = sqlSession.getMapper(UserMapper.class);
        User user = mapper.selectByPrimaryKey(1);
        User user2 = mapper.selectByPrimaryKey(1);
        System.out.println(user==user2);

    }

1.1 结果

DEBUG 08-13 00:47:03,712 ==>  Preparing: select id, user_name, pass_word, email, td_id from t_user where id = ?  (BaseJdbcLogger.java:137) 
DEBUG 08-13 00:47:03,760 ==> Parameters: 1(Integer)  (BaseJdbcLogger.java:137) 
DEBUG 08-13 00:47:03,795 <==      Total: 1  (BaseJdbcLogger.java:137) 
**true**

1.2我们debug查看源码中放入hashMap的数据(两次是一样的):
在这里插入图片描述
2.不同的mappedStatementId不能命中缓存,即使sql与参数一致

<select id="findById" resultType="com.lxy.entity.User" parameterType="Integer">
    SELECT * FROM user WHERE id=#{id}
</select>
<select id="findById2" resultType="com.lxy.entity.User" parameterType="Integer">
    SELECT * FROM user WHERE id=#{id} 
</select>
----------------------------------
@Test
public void baseCacheTest(){
    SqlSession session = factory.openSession(true);
    UserDao mapper = session.getMapper(UserDao.class);
    User user1 = mapper.findById(4);
    User user2 = mapper.findById2(4);
    System.out.println(user1 == user2);
}

2.1 运行结果:false

3.RowBounds必须相同,否则不会命中缓存

@Test
public void baseCacheTest(){
    SqlSession session = factory.openSession(true);
    UserDao mapper = session.getMapper(UserDao.class);
    RowBounds rowBounds = new RowBounds(0,10);//返回前10行
    User user1 = mapper.findById(4);
    List<User> list = session.selectList("com.lxy.dao.UserDao.findById",4, rowBounds);
    System.out.println(user1 == list.get(0));
}

3.1运行结果:false;

4.不同session不会命中缓存

@Test
public void baseCacheTest() {
    SqlSession session = factory.openSession(true);
    UserDao mapper = session.getMapper(UserDao.class);
    SqlSession session2 = factory.openSession(true);
    UserDao mapper2 = session2.getMapper(UserDao.class);
    User user1 = mapper.findById(4);
    User user2 = mapper2.findById(4);
    System.out.println(user1 == user2);
}

4.1运行结果:false

一级缓存失效的情况

缓存失效的情况:
1.手动清空缓存
2.配置flushCache=true
3.执行update
4.设置作用域为statement

1、手动清空缓存
1.1、测试代码

@Test
    public void yiji() throws IOException {
        InputStream inputStream = Resources.getResourceAsStream("mybats.xml");
        SqlSessionFactory sessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
        SqlSession sqlSession = sessionFactory.openSession();
        UserMapper mapper = sqlSession.getMapper(UserMapper.class);
        User user = mapper.selectByPrimaryKey(1);
        //清空
        sqlSession.clearCache();
        
        User user2 = mapper.selectByPrimaryKey(1);
        System.out.println(user==user2);

    }

1.2、结果

DEBUG 08-13 01:00:12,170 ==>  Preparing: select id, user_name, pass_word, email, td_id from t_user where id = ?  (BaseJdbcLogger.java:137) 
DEBUG 08-13 01:00:12,217 ==> Parameters: 1(Integer)  (BaseJdbcLogger.java:137) 
DEBUG 08-13 01:00:12,246 <==      Total: 1  (BaseJdbcLogger.java:137) 
DEBUG 08-13 01:00:12,248 ==> Parameters: 1(Integer)  (BaseJdbcLogger.java:137) 
DEBUG 08-13 01:00:12,250 <==      Total: 1  (BaseJdbcLogger.java:137) 
false

2、更新
2.1、测试代码

  @Test
    public void yiji() throws IOException {
        InputStream inputStream = Resources.getResourceAsStream("mybats.xml");
        SqlSessionFactory sessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
        SqlSession sqlSession = sessionFactory.openSession();
        UserMapper mapper = sqlSession.getMapper(UserMapper.class);
        User user = mapper.selectByPrimaryKey(1);
        //更新
        mapper.updateByPrimaryKey(new User(6, "dfgtb", "fsdfsf", null, null));
        
        User user2 = mapper.selectByPrimaryKey(1);
        System.out.println(user==user2);

    }

2.2、结果

DEBUG 08-13 01:00:49,224 ==>  Preparing: select id, user_name, pass_word, email, td_id from t_user where id = ?  (BaseJdbcLogger.java:137) 
DEBUG 08-13 01:00:49,287 ==> Parameters: 1(Integer)  (BaseJdbcLogger.java:137) 
DEBUG 08-13 01:00:49,338 <==      Total: 1  (BaseJdbcLogger.java:137) 
DEBUG 08-13 01:00:49,342 ==>  Preparing: update t_user set user_name = ?, pass_word = ?, email = ?, td_id = ? where id = ?  (BaseJdbcLogger.java:137) 
DEBUG 08-13 01:00:49,346 ==> Parameters: dfgtb(String), fsdfsf(String), null, null, 6(Integer)  (BaseJdbcLogger.java:137) 
DEBUG 08-13 01:00:49,373 <==    Updates: 1  (BaseJdbcLogger.java:137) 
DEBUG 08-13 01:00:49,374 ==> Parameters: 1(Integer)  (BaseJdbcLogger.java:137) 
DEBUG 08-13 01:00:49,381 <==      Total: 1  (BaseJdbcLogger.java:137) 
false

3、配置flushCache=true
3.1、测试代码


mapper.xml中
<select id="selectByPrimaryKey" parameterType="java.lang.Integer" resultMap="BaseResultMap" flushCache="true">
    select id, user_name, pass_word, email, td_id
    from t_user
    where id = #{id,jdbcType=INTEGER}
  </select>
  @Test
    public void yiji() throws IOException {
        InputStream inputStream = Resources.getResourceAsStream("mybats.xml");
        SqlSessionFactory sessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
        SqlSession sqlSession = sessionFactory.openSession();
        UserMapper mapper = sqlSession.getMapper(UserMapper.class);
        User user = mapper.selectByPrimaryKey(1);
        User user2 = mapper.selectByPrimaryKey(1);
        System.out.println(user==user2);

    }

3.2、结果

DEBUG 08-13 01:05:07,610 ==>  Preparing: select id, user_name, pass_word, email, td_id from t_user where id = ?  (BaseJdbcLogger.java:137) 
DEBUG 08-13 01:05:07,656 ==> Parameters: 1(Integer)  (BaseJdbcLogger.java:137) 
DEBUG 08-13 01:05:07,694 <==      Total: 1  (BaseJdbcLogger.java:137) 
DEBUG 08-13 01:05:07,697 ==> Parameters: 1(Integer)  (BaseJdbcLogger.java:137) 
DEBUG 08-13 01:05:07,699 <==      Total: 1  (BaseJdbcLogger.java:137) 
false

4、设置作用域为statement
4.1、测试代码

mybatis配置文件中
 <settings>

        <setting name="mapUnderscoreToCamelCase" value="true"/>
        <setting name="lazyLoadingEnabled" value="true"/>
        <setting name="cacheEnabled" value="true"/>
        <setting name="defaultExecutorType" value="REUSE"/>
        <setting name="localCacheScope" value="STATEMENT"/>

    </settings>
  @Test
    public void yiji() throws IOException {
        InputStream inputStream = Resources.getResourceAsStream("mybats.xml");
        SqlSessionFactory sessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
        SqlSession sqlSession = sessionFactory.openSession();
        UserMapper mapper = sqlSession.getMapper(UserMapper.class);
        User user = mapper.selectByPrimaryKey(1);
        User user2 = mapper.selectByPrimaryKey(1);
        System.out.println(user==user2);

    }

4.2、结果

DEBUG 08-13 01:07:27,022 ==>  Preparing: select id, user_name, pass_word, email, td_id from t_user where id = ?  (BaseJdbcLogger.java:137) 
DEBUG 08-13 01:07:27,075 ==> Parameters: 1(Integer)  (BaseJdbcLogger.java:137) 
DEBUG 08-13 01:07:27,111 <==      Total: 1  (BaseJdbcLogger.java:137) 
DEBUG 08-13 01:07:27,113 ==> Parameters: 1(Integer)  (BaseJdbcLogger.java:137) 
DEBUG 08-13 01:07:27,114 <==      Total: 1  (BaseJdbcLogger.java:137) 
false

查看源码中失效情况的位置

查看BaseExecutor源码
1.1更新的位置
在这里插入图片描述

1.2配置的
在这里插入图片描述
1.2.1queryStack
在这里插入图片描述
1.3配置statement
在这里插入图片描述
1.4手动清空
在这里插入图片描述

一级缓存性能

1、Session级别的一级缓存设计比较简单,只使用了HashMap来维护,并没有对HashMap的容量和大小进行限制。
2、 SqlSession的生存时间很短。使用一个SqlSession对象执行的操作不会太多,执行完就会消亡;
3、对于某一个SqlSession对象而言,只要执行commit操作(update、insert、delete),都会将这个SqlSession对象中对应的一级缓存清空掉,所以一般情况下不会出现缓存过大,影响JVM内存空间的问题;

作者:云芈山人
链接:https://www.jianshu.com/p/196c50e7f322
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

一级缓存执行的过程(源码)

执行流程

在这里插入图片描述

1.会话(DefaultSQLSession)内执行器(BaseExecutor)执行对应的查询、更新等
2.执行器对于查询,首先会查看一级缓存是否有对应的结果,否则就交给statementHandler进行数据库查询,保存到一级缓存中。(在执行更新时,方法内部会清空一级缓存。)

源码分析

1.DefaultSqlSession类中执行selectList方法
在这里插入图片描述
2.BaseExecutor类中执行query方法
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
3.看看update方法
在这里插入图片描述

spring-mybatis整合后一级缓存失效的分析

示例

1.示例

@Test
    public void test(){
        ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext("conf/spring.xml");
        UserDaoMapper mapper = context.getBean(UserDaoMapper.class);

        User user = mapper.getUser(1); //每次都会构造一个新的会话
        User user2 =mapper.getUser(1);
        System.out.println(user==user2);
    }

结果:false
2.用事务包装后便可

  @Test
    public void test(){
        ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext("conf/spring.xml");
        UserDaoMapper mapper = context.getBean(UserDaoMapper.class);
        //手动获取事务管理器
        DataSourceTransactionManager manager =(DataSourceTransactionManager) context.getBean("dataSourceTransactionManager");
        TransactionStatus transaction = manager.getTransaction(new DefaultTransactionDefinition());
        User user = mapper.getUser(1); //每次都会构造一个新的会话
        User user2 =mapper.getUser(1);
        System.out.println(user==user2);
    }

结果:true

查看源码

1.整合后访问数据库的流程(mybatis中我们可以发现默认是执行defaultSqlsession,里面是有Executor的属性,直接调用Executor,而整合后它会调用sqlSessionTemplate这个类,这里面使用了jdk的动态代理【可以查看->jdk动态代理】生成SqlsessionProxy类来间接执行defaultSqlSession中的方法。)
在这里插入图片描述
2.源码

public class SqlSessionTemplate implements SqlSession, DisposableBean {
    private final SqlSessionFactory sqlSessionFactory;
    private final ExecutorType executorType;
    //SqlSession动态代理对象
    private final SqlSession sqlSessionProxy;
    private final PersistenceExceptionTranslator exceptionTranslator;

    public SqlSessionTemplate(SqlSessionFactory sqlSessionFactory) {
        this(sqlSessionFactory, sqlSessionFactory.getConfiguration().getDefaultExecutorType());
    }

    public SqlSessionTemplate(SqlSessionFactory sqlSessionFactory, ExecutorType executorType) {
        this(sqlSessionFactory, executorType, new MyBatisExceptionTranslator(sqlSessionFactory.getConfiguration().getEnvironment().getDataSource(), true));
    }

    public SqlSessionTemplate(SqlSessionFactory sqlSessionFactory, ExecutorType executorType, PersistenceExceptionTranslator exceptionTranslator) {
        Assert.notNull(sqlSessionFactory, "Property 'sqlSessionFactory' is required");
        Assert.notNull(executorType, "Property 'executorType' is required");
        this.sqlSessionFactory = sqlSessionFactory;
        this.executorType = executorType;
        this.exceptionTranslator = exceptionTranslator;
        //jdk动态代理的调用,我们可以看到下面的增删改查都是调用this.sqlSessionProxy的方法
        /*
        jdk的动态代理生成SqlsessionProxy类来间接执行defaultSqlSession中的方法
        如果这里不使用动态代理的话,它需要再下面的每个方法都生成defaultSqlSession,然后调用其方法
        所以这里使用动态代理代理当前这个类中的所有方法,通过InvocationHandler的invoke执行这些中的每个方法
         */
        this.sqlSessionProxy = (SqlSession) Proxy.newProxyInstance(SqlSessionFactory.class.getClassLoader(),
                new Class[]{SqlSession.class}, new SqlSessionTemplate.SqlSessionInterceptor());
    }

    public SqlSessionFactory getSqlSessionFactory() {
        return this.sqlSessionFactory;
    }

    public ExecutorType getExecutorType() {
        return this.executorType;
    }

    public PersistenceExceptionTranslator getPersistenceExceptionTranslator() {
        return this.exceptionTranslator;
    }

    public <T> T selectOne(String statement) {
        return this.sqlSessionProxy.selectOne(statement);
    }

    public <T> T selectOne(String statement, Object parameter) {
        return this.sqlSessionProxy.selectOne(statement, parameter);
    }

   
    .........
    

    private class SqlSessionInterceptor implements InvocationHandler {
        private SqlSessionInterceptor() {
        }

        public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
            //每次执行方法都会新获得一个SqlSession对象,这就是为什么一级缓存在整合中会失效
            SqlSession sqlSession = SqlSessionUtils.getSqlSession(SqlSessionTemplate.this.sqlSessionFactory, SqlSessionTemplate.this.executorType, SqlSessionTemplate.this.exceptionTranslator);

            Object unwrapped;
            try {
                //执行目标方法(当前类中的每个方法)
                Object result = method.invoke(sqlSession, args);
                if (!SqlSessionUtils.isSqlSessionTransactional(sqlSession, SqlSessionTemplate.this.sqlSessionFactory)) {
                    sqlSession.commit(true);
                }

                unwrapped = result;
            } catch (Throwable var11) {
                unwrapped = ExceptionUtil.unwrapThrowable(var11);
                if (SqlSessionTemplate.this.exceptionTranslator != null && unwrapped instanceof PersistenceException) {
                    SqlSessionUtils.closeSqlSession(sqlSession, SqlSessionTemplate.this.sqlSessionFactory);
                    sqlSession = null;
                    Throwable translated = SqlSessionTemplate.this.exceptionTranslator.translateExceptionIfPossible((PersistenceException)unwrapped);
                    if (translated != null) {
                        unwrapped = translated;
                    }
                }

                throw (Throwable)unwrapped;
            } finally {
                if (sqlSession != null) {
                    SqlSessionUtils.closeSqlSession(sqlSession, SqlSessionTemplate.this.sqlSessionFactory);
                }

            }

            return unwrapped;
        }
    }
}


二级缓存

概要

二级缓存是应用级的缓存,即作用于整个应用的生命的周期。可以跨线程使用,相对一级缓存会有更高的命中率。所以在顺序上是先访问二级然后在是一级和数据库。
由于生命周期长,跨会话访问的因素所以二级在使用上要更谨慎,如果用的不好就会造成脏读。
在这里插入图片描述

二级缓存命中的条件

MyBatis一二级缓存的CacheKey是一至的,必须满足以条件才可以命中缓:
1.相同的statement id
2.相同的Sql与参数
3.返回行范围相同

缓存更新与关闭

二级缓存的数据默认是存在很久的,那怎么保证数据的一至性?有以下几种方式:
默认的update操作会清空该namespace下的缓存(可设定flushCache=false 来禁止)。
设定缓存的失效时间(默认一小时)。
将指定查询的缓存关闭即设置useCache=false。
为指定Statement设定 flushCache=true清空缓存

二级缓存划分

  1. 为每一个mapper分配一个缓存对象(对于每一个mapper.xml,在其中使用 节点)。

  2. 在这里插入图片描述

  3. 多个mapper共用一个缓存对象(使用节点,来指定你的这个Mapper使用到了哪一个Mapper的Cache缓存)。
    在这里插入图片描述

二级缓存为什么必须提交后才能命中

1.示例:
开启二级缓存

@Test
    public void twst() throws IOException {
        InputStream inputStream = Resources.getResourceAsStream("mybats.xml");
        SqlSessionFactory sessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
		//开启两个会话
        SqlSession sqlSession = sessionFactory.openSession();
        UserMapper mapper = sqlSession.getMapper(UserMapper.class);
        User user = mapper.selectByPrimaryKey(1);
		System.out.println(user);
        SqlSession sqlSession2 = sessionFactory.openSession();
        UserMapper mapper2 = sqlSession.getMapper(UserMapper.class);
        User user2 = mapper2.selectByPrimaryKey(1);
		System.out.println(user2);
    }

1.2.结果:


DEBUG 08-13 21:57:32,993 ==>  Preparing: select id, user_name, pass_word, email, td_id from t_user where id = ?  (BaseJdbcLogger.java:137) 
DEBUG 08-13 21:57:33,027 ==> Parameters: 1(Integer)  (BaseJdbcLogger.java:137) 
DEBUG 08-13 21:57:33,060 <==      Total: 1  (BaseJdbcLogger.java:137) 
User{id=1, userName='cheyuan', passWord='123456', email='dafa@qq.com', tdId=1}
//命中为0,没有命中
DEBUG 08-13 21:57:33,080 Cache Hit Ratio [com.me.dao.UserMapper]: 0.0  (LoggingCache.java:60) 
User{id=1, userName='cheyuan', passWord='123456', email='dafa@qq.com', tdId=1}

2.手动提交

 @Test
    public void twst() throws IOException {
        InputStream inputStream = Resources.getResourceAsStream("mybats.xml");
        SqlSessionFactory sessionFactory = new SqlSessionFactoryBuilder().build(inputStream);

        SqlSession sqlSession = sessionFactory.openSession();
        UserMapper mapper = sqlSession.getMapper(UserMapper.class);
        User user = mapper.selectByPrimaryKey(1);
        System.out.println(user);

        sqlSession.commit();
        SqlSession sqlSession2 = sessionFactory.openSession();
        UserMapper mapper2 = sqlSession.getMapper(UserMapper.class);
        User user2 = mapper2.selectByPrimaryKey(1);
        System.out.println(user2);

    }

2.2结果:

DEBUG 08-13 21:59:20,729 ==>  Preparing: select id, user_name, pass_word, email, td_id from t_user where id = ?  (BaseJdbcLogger.java:137) 
DEBUG 08-13 21:59:20,775 ==> Parameters: 1(Integer)  (BaseJdbcLogger.java:137) 
DEBUG 08-13 21:59:20,823 <==      Total: 1  (BaseJdbcLogger.java:137) 
User{id=1, userName='cheyuan', passWord='123456', email='dafa@qq.com', tdId=1}
WARN  08-13 21:59:20,859 As you are using functionality that deserializes object streams, it is recommended to define the JEP-290 serial filter. Please refer to https://docs.oracle.com/pls/topic/lookup?ctx=javase15&id=GUID-8296D8E8-2B93-4B9A-856E-0A65AF9B8C66  (SerialFilterChecker.java:46) 
//命中 50%
DEBUG 08-13 21:59:20,864 Cache Hit Ratio [com.me.dao.UserMapper]: 0.5  (LoggingCache.java:60) 
User{id=1, userName='cheyuan', passWord='123456', email='dafa@qq.com', tdId=1}

3.原因:

两个会话,第二次查询时,先执行修改,而此时二级缓存中还是上次保存的数据,并不是修改后的,这样机会造成脏读。这里的update只是临时清空的,只有提交后,这个操作才会到二级缓存,清空二级缓存。
在这里插入图片描述
如图所示,在增删改查操作都是暂存在暂存区的,暂存区和sqlsession生命周期是相同的,只有在提交后,才能将暂存区的内容取出,放入二级缓存中,所以只有在提交后,才会命中。

二级缓存的设计思想

1.实现
MyBatis 对于二级缓存的实现非常灵活,自己内部实现了Cache的一系列的实现类,并提供了各种缓存刷新策略LRU、FIFO等。同时它也允许自定义Cache接口实现,需实现org.apache.ibatis.cache.Cache接口,然后将Cache的实现类配置在节点的type属性上。此外,它也支持与第三方缓存库如Memecached的集成。
2.二级缓存采用了装饰器模式和责任链模式
2.1不同的功能由不同缓存装饰器实现

装饰器描述
SynchronizedCache同步锁,用于保证对指定缓存区的操作都是同步的
LoggingCache统计器,记录缓存命中率
BlockingCache阻塞器,基于key加锁,防止缓存穿透
ScheduledCache时效检查,用于验证缓存有效器,并清除无效数据
LruCache溢出算法,淘汰闲置最久的缓存。
FifoCache溢出算法,淘汰加入时间最久的缓存
WeakCache溢出算法,基于java弱引用规则淘汰缓存
SoftCache溢出算法,基于java软引用规则淘汰缓存
PerpetualCache实际存储,内部采用HashMap进行存储。

这些装饰器是如何组织起来的呢?查看源码可得知,每个装饰器都会通过属性引用下一个装饰器,从而组成一个链条。缓存逻辑基于链条进行传递。

在这里插入图片描述

二级缓存的执行流程

执行流程

在这里插入图片描述

源码

CachingExecutor中
在这里插入图片描述

在这里插入图片描述

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

霓乤

谢谢支持,菜鸟会继续努力..

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值