缓存问题解决方案及Redis批量操作优化

目录

 

1.1 缓存穿透

1.1.1 缓存空数据

1.1.2 谷歌BloomFilter(布隆过滤器)

1.1.3 Redis实现布隆过滤器

1.2 缓存击穿

1.2.1 互斥锁

1.2.3 热点数据永不过期

1.3 缓存雪崩

2、批量操作优化

2.1 批量命令(multi)

2.2 管道(pipelining)

2.3 事务(transaction)

2.4 LUA脚本

2.4.1 LUA简介

2.4.2 lua 安装和helloworld

2.4.3 lua批量查询

2.5 各模式区别


1.1 缓存穿透

缓存没有,数据库也没有,业务系统访问压根就不存在的数据,导致每次访问都将压力挂到了数据库服务器上导致服务崩溃,一般来说都是恶意访问导致

解决方案:

1.1.1 缓存空数据

第一,空值做了缓存,意味着缓存层中存了更多的键,需要更多的内存空间 ( 如果是攻击,问题更严重 ),比较有效的方法是针对这类数据设置一个较短的过期时间,让其自动剔除。 第二,缓存层和存储层的数据会有一段时间窗口的不一致,可能会对业务有一定影响。例如过期时间设置为 5 分钟,如果此时存储层添加了这个数据,那此段时间就会出现缓存层和存储层数据的不一致,此时可以利用消息系统或者其他方式清除掉缓存层中的空对象。

1.1.2 谷歌BloomFilter(布隆过滤器)

在缓存之前再加一道屏障,里面存储目前redis数据库中存在的所有key,在讲布隆过滤器之前我们思考下:

有些同学可能会在想,我使用集合行不行呢:比如在海量元素中(例如 10 亿无序、不定长、不重复) 快速判断一个元素是否存在?好,我们最简单的想法就是把这么多数据放到数据结构里去,比如List、Map、Tree,一搜不就出来了吗,比如map.get(),我们假设一个元素有1个字节长度的字段,那么10亿的数据大概需要 900G 的内存空间,这个对于普通的服务器来说是承受不了的,当然面试官也不希望听到你这个答案,因为太笨了吧,我们肯定是要用一种好的方法,巧妙的方法来解决,这里引入一种节省空间的数据结构 --- 位数组 ,它是一个有序的数组,只有两个值,0 和 1。0代表不存在,1代表存在。

20210203234700659.png

有了这个厉害的东西,现在我们还需要一个映射关系,你总得知道某个元素在哪个置上吧,然后在去看这个位置上是0还是1,怎么解决这个问题呢,那就要用到哈希函数,用哈希函数有两个好处,第一是哈希函数无论输入值的长度是多少,得到的输出值长度是固定的,第二是他的分布是均匀的,如果全挤的一块去那还怎么区分,比如MD5、SHA-1这些就是常见的哈希算法。

watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2h1eGlhbmcxOTg1MTExNA==,size_16,color_FFFFFF,t_70

布隆过滤器原理:

watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2h1eGlhbmcxOTg1MTExNA==,size_16,color_FFFFFF,t_70

集合里面有3个元素, 要把它存到布隆过滤器里面去,应该怎么做呢?首先是a元素,,这里我们用3次计算,b、c元素也是一样.

元素都存进去以后,现在我要来判断一个元素在这个容器中是否存在,就要使用同样的三个函数(这个地方三个函数只是我们约定的,当然也可能是四个,五个。。。)进行计算。

比如d元素,我用第一个函数f1 计算,发现这个位置上是1,没问题, 第二个位置也是1,第三个位置上也是1。

如果经过三次计算得到的下标位置值都是1,这种情况下, 能不能确定d元素一定在这个容器里面呢? 实际上是不能的. 比如这张图里面,这三个位置分别是把a、b、c 存进去的时候置成1, 所以即使d元素之前没有存进去, 也会得到三个1,判断返回true

所以 这个是布隆过滤器的一个很重要的特性,因为哈希碰撞是不可避免的,所以它会存在一定的误判率。这种把本来不存在布隆过滤器中的元素误判为存在的情况,我们把它叫做假阳性(False Positive Probability,FPP)

我们再来看另一个元素e, 我们要判断它在容器中是否存在, 一样的要用这三个函数去计算,第一个位置是1,第二个位置是1,第三个位置是0

e元素是不是一定不在这个容器里面呢?可以确定一定不存在,如果说当时已经把e元素存到布隆过滤器里面去了,那么这三个位置肯定都是1,不可能会出现0。

布隆过滤器的特点,从布隆过滤器容器的角度来说:

  • 如果布隆过滤器判断元素在集合中存在, 不一定存在.

  • 如果布隆过滤器判断不存在, 则一定不存在.

从元素的角度来说:

  • 如果元素实际存在, 布隆过滤器一定判断存在

  • 如果元素实际不存在,布隆过滤器可能判断存在

利用第二个特性, 我们是不是就可以解决持续从数据库查询不存在的值的问题呢?

布隆过滤器:适用于数据命中不高,数据相对固定实时性低(通常是数据集较大)的应用场景,代码维护较为复杂,但是缓存空间占用少

总结:

布隆过滤器添加元素

  • 将要添加的元素给k个哈希函数

  • 得到对应于位数组上的k个位置

  • 将这k个位置设为1

布隆过滤器查询元素

  • 将要查询的元素给k个哈希函数

  • 得到对应于位数组上的k个位置

  • 如果k个位置有一个为0,则肯定不在集合中

  • 如果k个位置全部为1,则可能在集合中

Guava实现布隆过滤器案例:

@Test
    public void testBloomFilter() {
        //插入多少数据(模仿100W个非法请求参数)
        int insertions = 1000000;
        //期望的误判率百分之3:依据:数据库最大并发》误判率*最高估算并发访问量
        double fpp = 0.03;
        long start = System.currentTimeMillis();
        //初始化一个存储string数据的布隆过滤器,默认误判率是0.03
        BloomFilter<String> bf
                = BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8), insertions, fpp);
​
        //用于存放所有实际存在的key,用于比较,实际开发中完全不需要
        List<String> lists = new ArrayList<String>(insertions);
        //插入随机字符串
        for (int i = 0; i < insertions; i++) {
            String uuid = UUID.randomUUID().toString();
            bf.put(uuid);
            lists.add(uuid);
        }
        int rightNum = 0;//正确数
        int wrongNum = 0;//误判数
        for (int i = 0; i < 10000; i++) {
            // 0-10000之间,可以被100整除的数有100个(100的倍数)
            String data = i % 100 == 0 ? lists.get(i / 100) : UUID.randomUUID().toString();//凭证:车钥匙
            //这里用了might,看上去不是很自信,所以如果布隆过滤器判断存在了,我们还要去sets中验证下
            if (bf.mightContain(data)) {//判断是否有凭证
                if (lists.contains(data)) {
                    rightNum++;
                    continue;
                }
                wrongNum++;
            }
        }
        long end = System.currentTimeMillis();
        System.out.println("the general total time is:" + (end - start));
        BigDecimal percent = new BigDecimal(wrongNum).divide(new BigDecimal(9900), 2, RoundingMode.HALF_UP);
        BigDecimal bingo = new BigDecimal(9900 - wrongNum).divide(new BigDecimal(9900), 2, RoundingMode.HALF_UP);
        System.out.println("在100W个元素中,判断100个实际存在的元素,布隆过滤器认为可能存在的个数:" + rightNum);
        System.out.println("在100W个元素中,判断9900个实际不存在的元素,误认为存在的个数:" + wrongNum + ",命中率:" + bingo + ",误判率:" + percent);
    }

以下为误判率系数表:

watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2h1eGlhbmcxOTg1MTExNA==,size_16,color_FFFFFF,t_70

一般用默认的误判率(0.03,7位标志)即可,兼顾内存和性能

1.1.3 Redis实现布隆过滤器

我们看了谷歌布隆过滤器,知道是将Redis数据放在内存中,虽然布隆过滤器所占空间比集合要小很多,但是我们反转一想,Redis数据本来就是放在内存中,而且是一个专业级别的内存容器,我们能不能直接使用这个空间呢?答案是肯定的,Redis本身就提供了这部分功能

我们知道计算机是以二进制位作为底层存储的基础单位,一个字节等于8位。

比如“big”字符串是由三个字符组成的,这三个字符对应的ASCII码分为是98、105、103

watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2h1eGlhbmcxOTg1MTExNA==,size_16,color_FFFFFF,t_70

对应的二进制存储如下

20210203234757150.png

在Redis中,Bitmaps 提供了一套命令用来操作类似上面字符串中的每一个位。

  • 设置值命令

#setbit key offset value
# 我们知道"b"的二进制表示为0110 0010,我们将第8位(从0开始)设置为1,那0110 0011 表示的就是字符“c”,所以最后的字符 “big”变成了“cig”。
127.0.0.1:6379> set type big
OK
127.0.0.1:6379> get type
"big"
127.0.0.1:6379> setbit type 7 1
(integer) 0
127.0.0.1:6379> get type
"cig"
​
  • 获取值命令

# gitbit key offset 获取每一个比特位值 如下组合起来就是b的二进制:0110 0010
127.0.0.1:6379> GETBIT type 0
(integer) 0
127.0.0.1:6379> GETBIT type 1
(integer) 1
127.0.0.1:6379> GETBIT type 2
(integer) 1
127.0.0.1:6379> GETBIT type 3
(integer) 0
127.0.0.1:6379> GETBIT type 4
(integer) 0
127.0.0.1:6379> GETBIT type 5
(integer) 0
127.0.0.1:6379> GETBIT type 6
(integer) 1
127.0.0.1:6379> GETBIT type 7
(integer) 0
  • 统计位图指定范围值为1的个数

#bitcount key [start end] 如果不指定,那就是获取全部值为1的个数。
#注意:start和end指定的是字节的起始,而不是位数组下标。
​
127.0.0.1:6379> set type big
OK
127.0.0.1:6379> BITCOUNT type 0 0 #标识第一个字节 "b"
(integer) 3
127.0.0.1:6379> BITCOUNT type 0 1 #前两个字节 "bi"
(integer) 7
127.0.0.1:6379> BITCOUNT type 0 2 #前第三个字节 "big"
(integer) 12
​
#big对应的二进制总共12个 “1” 位标 

 

  • Redis 实现布隆过滤器的底层就是通过 bitmap 这种数据结构,至于如何实现,这里就不重复造轮子了,介绍业界比较好用的一个客户端工具——Redisson

@Test
public void testRBloomFilter(){
    Config config = new Config();
    //使用单节点Redis服务
    config.useSingleServer().setAddress("redis://192.168.223.128:6379");
    /*如果数据量比较大,期望的误差率又很低,那单节点所提供的内存是无法满足的,这时候可以使用分布式布隆过滤器
    config.useClusterServers().addNodeAddress("redis://192.168.223.128:6379","redis://192.168.223.128:6379"......)*/
​
    //构造Redisson
    RedissonClient redisson = Redisson.create(config);
    //插入多少数据(模仿1W个非法请求参数)
    int insertions = 10000;
    RBloomFilter<String> bloomFilter = redisson.getBloomFilter("BloomFilterList");
    //初始化布隆过滤器:预计元素为1W,误差率为3%
    bloomFilter.tryInit(insertions,0.003);
    //用于存放所有实际存在的key,用于取出
    List<String> lists = new ArrayList<String>(insertions);
    //将数据插入到布隆过滤器中
    for (int i = 0; i < insertions ; i++) {
        bloomFilter.add(i+"");
        lists.add(i+"");
    }
    int rightNum = 0;//正确数
    int wrongNum = 0;//误判数
    long start = System.currentTimeMillis();
    for (int i = 0; i < 1000; i++) {
        // 0-1000之间,可以被10整除的数有100个(10的倍数)
        String data = i % 10 == 0 ? lists.get(i / 10) : UUID.randomUUID().toString();//凭证:车钥匙
        //这里用了might,看上去不是很自信,所以如果布隆过滤器判断存在了,我们还要去lists中验证下
        if (bloomFilter.contains(data)) {//判断是否有凭证
            if (lists.contains(data)) {
                rightNum++;
                continue;
            }
            wrongNum++;
        }
    }
    long end = System.currentTimeMillis();
    System.out.println("the general total time is:" + (end - start));
    BigDecimal percent = new BigDecimal(wrongNum).divide(new BigDecimal(900), 2, RoundingMode.HALF_UP);
    BigDecimal bingo = new BigDecimal(900 - wrongNum).divide(new BigDecimal(900), 2, RoundingMode.HALF_UP);
    System.out.println("在1W个元素中,判断100个实际存在的元素,布隆过滤器认为可能存在的个数:" + rightNum);
    System.out.println("在1W个元素中,判断900个实际不存在的元素,误认为存在的个数:" + wrongNum + ",命中率:" + bingo + ",误判率:" + percent);
    jedis.del("BloomFilterList");
    bloomFilter.delete();
}

1.2 缓存击穿

主要体现在:热点数据过了有效时间,此刻有大量请求会落在数据库上,从而可能会导致数据库崩溃

解决方案:

1.2.1 互斥锁

只允许一个线程重建缓存,其他线程等待重建缓存的线程执行完,重新从缓存获取数据---》可能存在死锁

@Test
    public void testLock() {
        for (int i = 0; i < 100; i++) {
            get("001");
        }
    }
​
    private String get(String key) {
        String key_mutex = "lock_";
        JedisPool jedisPool = (JedisPool) context.getBean("jedisPool");
        Jedis jedis = jedisPool.getResource();
        String value = jedis.get(key);
        System.out.println("redis的值" + value);
        if (value == null) { //代表缓存值过期
            //设置一个临时key_mutex,用于阻塞相同的请求!设置10S的超时,防止del操作失败的时候,下次缓存过期一直不能load db
            if (jedis.setnx(key_mutex + key, key_mutex + key) == 1) {  //代表设置成功
                jedis.expire(key_mutex + key, 10);
                value = "去数据库查询出来的值";
                jedis.set(key, value);
                jedis.expire(key, 5 * 60);
                jedis.del(key_mutex);
            } else {  //这个时候代表同时候的其他线程已经load ,并且第一个大爷db并回设到缓存了,这时候重试获取缓存值即可
                try {
                    Thread.sleep(50);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                get(key);  //重试
            }
        } else {
            return value;
        }
        return "";
    }

无非是使用setNx阻塞相同的请求!

1.2.3 热点数据永不过期

无非就是对该数据不设置过期时间,但是一定要注意该数据更新的同时,必须对缓存数据进行更新,这种问题在于热点数据过多的话会一致占据内存

 

1.3 缓存雪崩

因某种原因发生了宕机或者数据在同一时间批量失效,那么原本被缓存抵挡的海量查询请求就会像疯狗一样涌向数据库。此时数据库如果抵挡不了这巨大的压力,它就会崩溃。

解决方案:

1、如果缓存数据库是分布式部署,将热点数据均匀分布在不同得缓存数据库中---Redis Cluster

2、尽可能使缓存数据不在同一时间过期,比如使用随机时间

3、热点数据永不过期

4、最后没办法的情况下,使用服务熔断降级、隔离限流等手段,比如采用netflix的hystrix

最后:无论是缓存穿透,缓存击穿还是缓存雪崩,都建议使用队列来排队、拒绝大量请求涌入和分布式互斥锁来避免后端数据服务被冲击,防止已有的数据出现问题

2、批量操作优化

经常会有这样一种业务逻辑,就是需要根据Redis中Key的规则,模糊查询对应的数据,当数据量少时,利用常规的命令也能满足需求,但是数据量大时,就会导致堵塞,就算是采用不堵塞的函数,如果数据需要显示的话,显示结果的时间也比较慢,用户体验不好。

几种常见的批量操作方式

  • 批量命令(multi)

  • 管道(pipelining)

  • 事务(transaction)

  • lua脚本

2.1 批量命令(multi)

每个数据类型都对应着几个批量操作的命令,例如mset/mget/hmset/hmget...,这种的一次可以对多个key进行操作,相比于所有姿势这个是最快的,因为这里面的对多个key进行一次性操作,是一个命令,注意,是一个命令。不是把很多命令打包,也不是缓存了很多命令最后一起执行。这是最快的方式。一次连接,一个命令。并且这个是原子操作。但是缺点是并不是所有命令都支持,只支持一小部分基本的命令,所以最终结论是,能用这个就一定用这个,不能的话在用其他批量处理的姿势。

127.0.0.1:6379> MSET name laohu age 18
OK
127.0.0.1:6379> mget name age
1) "laohu"
2) "18"
​

2.2 管道(pipelining)

管道的话这么理解,有9个任务,我们直接扔在一个管道里了(过大的话会被自动分批发送),可能会变成3截。每截3个。每次发送一截。这样不用建立9次连接发送9个。3次就可以了,这个就是管道的原理。同时一定要注意,管道的批量操作是建立在协议上的优化,就是就是依靠协议进行分批操作。同时一定一定记住,管道不是原子操作

 @Test
    public void testPipelineQuery(){
        /**
         * 数据库操作
         */
        final long start = System.currentTimeMillis();
        JdbcTemplate jdbcTemplate = (JdbcTemplate) context.getBean("jdbcTemplate");
        NamedParameterJdbcTemplate namedParameterJdbcTemplate =
                new NamedParameterJdbcTemplate(jdbcTemplate);
        Object[] args = new Object[10000];
        for (int i = 0; i < 10000; i++) {
            args[i] = i;
        }
        MapSqlParameterSource parameters = new MapSqlParameterSource();
        parameters.addValue("ids",Arrays.asList(args));
        List<Map<String, Object>> maps
                = namedParameterJdbcTemplate.queryForList("select * from redis_batch where id in (:ids)",parameters);
        System.out.println(maps);
        long end = System.currentTimeMillis();
        System.out.println("the general total time is:" + (end-start));
​
        /**
         * 普通redis查询(反而比不上数据库的批量查询)
         */
        long start0 = System.currentTimeMillis();
        for (int i = 0; i < 10000; i++) {
            jedis.get(String.valueOf(i));
        }
        long end0 = System.currentTimeMillis();
        System.out.println("the general total time is:" + (end0 - start0));
​
        /**
         * 查询所有的value
         */
        Pipeline pipe = jedis.pipelined(); // 先创建一个 pipeline 的链接对象(管道,替换了jedis的单步操作)
        long start1 = System.currentTimeMillis();
        for (int i = 0; i < 10000; i++) {
            pipe.get(String.valueOf(i));
        }
        List<Object> list = pipe.syncAndReturnAll();
        long end1 = System.currentTimeMillis();
        System.out.println("the general total time is:" + (end1 - start1));
​
        /**
         * 查询所有的key/value
         */
        Map<String,Response<String>> map = new HashMap();
        long start2 = System.currentTimeMillis();
        for (int i = 0; i < 10000; i++) {
            map.put(String.valueOf(i),pipe.get(String.valueOf(i)));
        }
        pipe.sync();
        long end2 = System.currentTimeMillis();
        System.out.println("the general total time is:" + (end2 - start2));
        for (Map.Entry<String, Response<String>> responseEntry : map.entrySet()) {
            Response<String> sResponse=(Response<String>)responseEntry.getValue();
            /*System.out.println(new String(responseEntry.getKey())
                    +"-----"+new String(sResponse.get()).toString());*/
        }
    }

2.3 事务(transaction)

这么理解,先喊一声 准备,然后把所有任务都扔在车里(此时已经陆续的传给操作端了),然后再喊开始,所有被传过去的任务才开始执行。就是分三部分,准备好了、上任务、干活。是不是感觉这东西可能会比管道慢点,因为管道是 仍一批过去、干活 再扔一批过去、干活 不用等都到了再开始统一干活。没错。实际测试结果也是事务慢于管道一点点,但是重点是这东西是原子操作。

@Test
public void  testMaxmemory(){
    Transaction transaction = jedis.multi();
    for (int i = 0; i < 100000; i++) {
        transaction.set("" + i,"value is " + i);
    }
    transaction.exec();
}

2.4 LUA脚本

2.4.1 LUA简介

Lua 是一种轻量小巧的脚本语言,用标准C语言编写并以源代码形式开放, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。

Redis中使用Lua的好处

  • 减少网络开销。可以将多个请求通过脚本的形式一次发送,减少网络时延

  • 原子操作。redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。因此在编写脚本的过程中无需担心会出现竞态条件,无需使用事务。

  • 复用。客户端发送的脚步会永久存在redis中,这样,其他客户端可以复用这一脚本而不需要使用代码完成相同的逻辑。

2.4.2 lua 安装和helloworld

curl -R -O http://www.lua.org/ftp/lua-5.3.0.tar.gz
tar zxf lua-5.3.0.tar.gz
cd lua-5.3.0
make linux test
make install
​
​
-------------------------
[root@ydt1 lua-5.3.0]# lua
Lua 5.1.4  Copyright (C) 1994-2008 Lua.org, PUC-Rio
> print("Hello World!")
Hello World!
​

2.4.3 lua批量查询

可惜lua脚本长度有限制!

 @Test
    public void test() {
        /*jedis.eval("redis.call('set','lua','hello lua')");*/
        /*System.out.println(jedis.eval("return redis.call('get','lua')"));*/
        long start0 = System.currentTimeMillis();
        StringBuffer luaScript = new StringBuffer("return redis.call('mget'");
        List<String> keys = new ArrayList<String>();
        for (int i = 0; i < 247; i++) {
            keys.add(i + "");
            luaScript.append(",KEYS[" + (i + 1) + "]");
        }
        luaScript.append(")");
        List<String> values = new ArrayList<String>();
        Object getResult = null;
        for (int i = 0; i < 40; i++) {
            getResult = jedis.eval(luaScript.toString(), keys, values);
        }
        long end0 = System.currentTimeMillis();
        System.out.println("the general total time is:" + (end0 - start0));
        List<String> retList = (List<String>) getResult;
        for (String ret : retList) {
            System.out.println(ret);
        }
    }

2.5 各模式区别

redis pipeline

pipeline引入,降低了多次命令-应答之间的网络交换次数,并不能缩小redis对每个命令的处理时间

lua

Redis在2.6版引入了对Lua的支持

  • 使用Lua可以非常明显的提升Redis的效率。

  • 只要脚本所对应的函数曾经在 Lua 里面定义过, 那么即使用户不知道脚本的内容本身, 也可以直接通过脚本的 SHA1 校验和来调用脚本所对应的函数, 从而达到执行脚本的目的 —— 这就是 EVALSHA 命令的实现原理

  • 当 Lua 脚本里本身有调用 Redis 命令时(执行 redis.call 或者 redis.pcall ), Redis 和 Lua 脚本之间的数据交互会更复杂一些。

什么时候使用pipeline,什么时候使用lua

  • 当多个redis命令之间没有依赖、顺序关系(例如第二条命令依赖第一条命令的结果)时,建议使用pipline;

  • 如果命令之间有依赖或顺序关系时,pipline就无法使用,此时可以考虑采用lua脚本的方式来使用。

redis执行lua脚本好处

  • 减少网络开销,本来多次网络请求的操作,可以用一个请求完成,原来多次请求的逻辑均放在redis服务器上完成。使用lua,减少了网络往返时延;

  • 原子操作:redis会将整个脚本作为一个整体执行,不会被其他命令插入。

  • 复用:客户端发送的脚本会永久存储在redis中,意味着其他客户端可以复用这一脚本而无需使用代码完成同样逻辑。

所以最终总结:

能批量就批量,不能的话如果必须原子操作就lua,如果lua不熟悉就事务,如果不需要原子性就管道。

 

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值