Redis BigKey发现定位与删除

  在Redis中,每个key都有一个对应的value,如果某个key的value过大,就会导致Redis的性能下降或者崩溃。因为Redis需要将大key全部加载到内存中,这会占用大量的内存空间,会降低Redis的响应速度,这个问题被称为BigKey问题。不要小看这个问题,它可是能让你的Redis瞬间变成“乌龟”。由于Redis单线程的特性,操作Big Key的通常比较耗时,也就意味着阻塞Redis可能性越大,这样会造成客户端阻塞或者引起故障切换,有可能导致“慢查询”。

  一般而言,下面这两种情况被称为大 key:String类型的 key 对应的value超过10 KBlistsethashzset等集合类型,集合元素个数超过 5000个。

  判断标准并不唯一,在实际业务开发中,需要根据具体的使用场景做判断。比如操作某个 key 导致请求响应时间变慢,那么这个 key 就可以判定成 Big Key。

1、危害

🚫内存不均,集群迁移困难:集群模式下,无法做到负载均衡,导致请求倾斜到某个实例上,而这个实例的QPS会比较大,内存占用也较多;对于Redis单线程模型又容易出现CPU瓶颈,当内存出现瓶颈时,只能进行纵向库容,使用更牛逼的服务器。

🚫网络流量阻塞:涉及到大key的操作,尤其是使用hgetalllrange 0 -1gethmget等操作时,网卡可能会成为瓶颈,也会到导致堵塞其它操作,QPS就有可能出现突降或者突升的情况,趋势上看起来十分不平滑,严重时会导致应用程序连不上,实例或者集群在某些时间段内不可用的状态。

🚫超时删除:如果直接对BigKey进行del操作,被操作的实例会被Block住,导致无法响应应用的请求,而这个Block的时间会随着key的变大而变长。

2、定位bigKey

  为了避免对线上 Redis 带来卡顿,需要用到scan指令扫描出的每一个key,使用type指令获得key的类型,然后使用相应数据结构的size或者len方法来得到它的大小,对于每一种类型,保留大小的前 N 名作为扫描结果展示出来。上面这样的过程需要编写脚本,比较繁琐。不过Redis官方已经在redis-cli指令中提供了该扫描功能,可以直接使用。

2.1 bigkeys

  Redis自带的BIGKEYS命令可以查询当前Redis中六大数据类型(String、hash、list、set、zset、stream)key的信息,对整个数据库中的键值对大小情况进行统计分析,比如统计每种数据类型的键值对个数以及平均大小,还会输出每种数据类型中最大bigkey的信息(String:输出最大 bigkey 的字节长度;集合:会输出最大 bigkey 的元素个数)。

  BIGKEYS命令会扫描整个数据库,这个命令本身会阻塞Redis,找出所有的大键,并将其以一个列表的形式返回给客户端。所以在执行该命令,需确保Redis实例有足够的资源来处理它,建议在从节点执行,命令格式及回复格式如下:

$ redis-cli --bigkeys -a abc123
# Scanning the entire keyspace to find biggest keys as well as
# average sizes per key type.  You can use -i 0.1 to sleep 0.1 sec
# per 100 SCAN commands (not usually needed).

[00.00%] Biggest string found so far 'a' with 3 bytes
[05.14%] Biggest list   found so far 'b' with 100004 items
[35.77%] Biggest string found so far 'c' with 6 bytes
[73.91%] Biggest hash   found so far 'd' with 3 fields

-------- summary -------

Sampled 506 keys in the keyspace!
Total key length in bytes is 3452 (avg len 6.82)

Biggest string found 'c' has 6 bytes
Biggest   list found 'b' has 100004 items
Biggest   hash found 'd' has 3 fields

504 strings with 1403 bytes (99.60% of keys, avg size 2.78)
1 lists with 100004 items (00.20% of keys, avg size 100004.00)
0 sets with 0 members (00.00% of keys, avg size 0.00)
0 streams with 0 entries (00.00% of keys, avg size 0.00)
1 hashs with 3 fields (00.20% of keys, avg size 3.00)
0 zsets with 0 members (00.00% of keys, avg size 0.00)

优化: 若担心该指令会大幅抬升 Redis 的ops导致线上报警,还可以增加一个-i休眠参数。下面的指令每隔100scan指令就会休眠0.1sops就不会剧烈抬升,但是扫描的时间会变长。

redis-cli -h 127.0.0.1 -p 7001 –-bigkeys -i 0.1

2.2 Debug Object | memory usage 查询大小

​  想查询大于10kb的所有key,则光靠--bigkeys不行了,还需要用到memory usage来计算每个键值的字节数对其进行进一步的分析。在Redis4.0之前,只能通过DEBUG OBJECT命令估算key的内存使用(字段serializedlength),但DEBUG OBJECT命令是有误差的。4.0版本及以上,可使用memory usage命令,如果当前key存在,则返回key的value实际使用内存估算值;如果key不存在,则返回nil

​  不太推荐的命令:debug object key查看某key的详细信息,包括该key的value大小等,当 key 不存在时,返回一个错误。还可通过SCAN+debug object key 得到当前实例所有key的大小。

#1. debug object key
#serializedlength表示key对应的value序列化之后的字节数
127.0.0.1:6379> DEBUG OBJECT key
Value at:0xb6838d20 refcount:1 encoding:raw serializedlength:9 lru:283790 lru_seconds_idle:150

redis 127.0.0.1:6379> DEBUG OBJECT key
(error) ERR no such key

#2.memory usage
127.0.0.1:6379> set k1 value1
OK
127.0.0.1:6379> memory usage k1    #这里k1 value占用57字节内存
(integer) 57
127.0.0.1:6379> memory usage aaa   #aaa键不存在,返回nil.
(nil)

  对于除String类型之外的类型,memory usage命令采用抽样的方式,默认抽样5个元素,所以计算是近似值,我们也可以指定抽样的个数。要想获取key较精确的内存值,就指定更大抽样个数。但是抽样个数越大,占用cpu时间分片就越大,若需要抽取所有的元素,则指定:SAMPLES 0

#比如一个100w个字段的hash键:hkey,每字段的value长度是从1~1024字节的随机值。
127.0.0.1:6379> hlen hkey    #hkey有100w个字段,每个字段的value长度介于1~1024个字节
(integer) 1000000
127.0.0.1:6379> MEMORY usage hkey   #默认SAMPLES为5,分析hkey键内存占用521588753字节
(integer) 521588753
127.0.0.1:6379> MEMORY usage hkey SAMPLES  1000 #指定SAMPLES为1000,分析hkey键内存占用617977753字节
(integer) 617977753
127.0.0.1:6379> MEMORY usage hkey SAMPLES  10000 #指定SAMPLES为10000,分析hkey键内存占用624950853字节
(integer) 624950853

2.3 redis-rdb-tools

  在redis实例上执行bgsave,然后对rdb快照文件进行分析,找到其中的BigKeyredis-rdb-tools是一个 python 的解析 rdb 文件的工具,将rdb快照文件生成CSVJSON文件,也可以导入到MySQL生成报表来分析。

#通过pip安装rdbtools
pip install rdbtools

#将快照文件生成csv文件
rdb -c memory dump.rdb > memory.csv

#生成的 CSV 文件中有以下几列:
- `database` key在Redis的db
- `type` key类型
- `key` key值
- `size_in_bytes` key的内存大小
- `encoding` value的存储编码形式
- `num_elements` key中的value的个数
- `len_largest_element` key中的value的长度

​  可以在MySQL中新建表然后导入csv文件到数据库,然后可以直接通过SQL语句进行数据的查询分析 :

CREATE TABLE `memory` (
    `database` int(128) DEFAULT NULL,
    `type` varchar(128) DEFAULT NULL,
    `KEY` varchar(128),
    `size_in_bytes` bigint(20) DEFAULT NULL,
    `encoding` varchar(128) DEFAULT NULL,
    `num_elements` bigint(20) DEFAULT NULL,
    `len_largest_element` varchar(128) DEFAULT NULL,
    PRIMARY KEY (`KEY`)
 );
 
 #导入csv文件到数据库
load data local infile 'file_path' # 文件路径
into table daily_price             # 表名
character set utf8                 # 编码
fields terminated by ','           # 分隔符
lines terminated by '\n'           # 换行符,windows下是
ignore 1 lines;                    # 忽略第一行,因为表头已建好
 
 
 #例子:查询内存占用最高的3个 key
 mysql> SELECT * FROM memory ORDER BY size_in_bytes DESC LIMIT 3;
+----------+------+-----+---------------+-----------+--------------+---------------------+
| database | type | key | size_in_bytes | encoding  | num_elements | len_largest_element |
+----------+------+-----+---------------+-----------+--------------+---------------------+
|        0 | set  | k1  |        624550 | hashtable |        50000 | 10                  |
|        0 | set  | k2  |        420191 | hashtable |        46000 | 10                  |
|        0 | set  | k3  |        325465 | hashtable |        38000 | 10                  |
+----------+------+-----+---------------+-----------+--------------+---------------------+
3 rows in set (0.12 sec)

3、规避危险指令

3.1 rename-command

  生产上需限制keys */flushdb/flushall等危险命令以防止误用,因为这很可能造成Redis锁住,CPU飙升,并引起调用链路超时并卡住,锁住的那几秒时间里,所有请求流量全部击中到RDS,使得数据库雪崩并宕机。可在配置文件中通过rename-command(Deprecated)移除,可以在客户端输入指令尝试,可以看到redis服务器已经不能识别这些指令了:

rename-command KEYS ""         #必禁命令,线上用这种查询方式绝对是不对的
rename-command FLUSHALL "" #必禁命令,谁会清除数据呢!!!
rename-command FLUSHDB ""  #必禁命令,谁会清除数据呢!!!
rename-command CONFIG ""   #可以考虑重命名下

在这里插入图片描述

注意: 若改变一些会被记录到AOF文件或者传输到从节点的(写)命令的名字,那么在写入AOF文件后可能无法正确地恢复数据或者传输给副本时报错。所以需要谨慎操作,并保证AOF文件和副本也能识别新的命令名称。

​  推荐使用ACL来将默认用户中的命令某些命令删除,而仅对创建的某些管理用户开放这些命令。而且从Redis 6.2开始,还可以使用ACL规则管理对Pub/Sub频道的访问。

3.2 ACL

  在Redis 6.0之前的版本中,登陆只需要输入密码,而密码明文配置到配置文件中(requirepass),所有的客户端连接都使用该密码和全部的权限,风险极高。在Redis 6.0之后实现了ACL(Access Control List)访问控制列表,限制连接可执行的命令和可访问的key,可以按照不同的需求设置相关的用户和权限。Redis ACL向后兼容,用户名默认为default,并通过requirepass配置密码,有2种方式使用方式:

#旧版本的使用方式,默认用户。兼容旧版本Redis的支持
AUTH <password>
#新方式,还需要验证用户名
AUTH <username> <password>

#redis-cli登录参数
--user <username>  验证用户名
--pass <password>  验证密码,是参数-a的别名;配合--user使用
--askpass          强制用户输入带有STDIN掩码的密码

#ACL指令集可通过acl help参看
127.0.0.1:6379> ACL help
 1) ACL <subcommand> arg arg ... arg. Subcommands are:
 2) LOAD                             -- 从ACL文件中重新载入用户信息.
 3) SAVE                             -- 保存当前的用户配置信息到ACL文件.
 4) LIST                             -- 以配置文件格式显示用户详细信息.
 5) USERS                            -- 列出所有注册的用户名.
 6) SETUSER <username> [attribs ...] -- 创建或则修改一个用户.
 7) GETUSER <username>               -- 得到一个用户的详细信息.
 8) DELUSER <username> [...]         -- 删除列表中的用户.
 9) CAT                              -- 列出可用的命令类别.
10) CAT <category>                   -- 列出指定类别中的命令.
11) GENPASS [<bits>]                 -- 生成一个安全的用户密码.
12) WHOAMI                           -- 返回当前的连接用户.
13) LOG [<count> | RESET]            -- 显示ACL日志条目.

​  通过acl listacl getuser default可查看到一个默认用户定义,包含了完整的用户权限的格式:user后紧跟用户名,on(off)是否启用该用户,是否有密码(nopass),访问所有可能的key~*)和Pub/Sub-channel发布订阅频道(&*),并能够调用所有可能的命令(+@all)。

127.0.0.1:6379> acl list
1) "user default on #6ca13d52ca70c883e0f0bb101e425a89e8624de51db2d2392593af6a84118090 ~* &* +@all"

127.0.0.1:6379> ACL GETUSER default
 1) "flags"
 2) 1) "on"
 3) "passwords"
 4) 1) "6ca13d52ca70c883e0f0bb101e425a89e8624de51db2d2392593af6a84118090"
 5) "commands"
 6) "+@all"
 7) "keys"
 8) "~*"
 9) "channels"
10) "&*"
11) "selectors"
12) (empty array)

⭕️允许和禁止命令

+/-command   #为用户可调用的命令列表添加、删除命令,可与`|`一起用于添加或删除子命令(:-/+config|set)。
+/-@category #为用户添加删除可调用的模块命令,有效类别为`@admin`, `@set`,`@sortedset`,`@read`,`@write`,@all`…(`allcommands`+@all的别名;`nocommands`: -@all的别名。),通过调用`acl cat`命令查看完整列表。

+command|first-arg #允许使用携带`first-arg`参数的被禁用命令(只支持没有子命令的命令,且不支持否定形式)。比如:当select命令被禁用后,可以使用`+SELECT|0`来允许`select 0`命令的执行。

⭕️允许和禁止某些密钥和密钥权限

​  ~pattern添加允许的key的全局匹配模式,可指定多个模式。如~*(allkeys)允许所有键;~foo:*表示以foo:开头的keyRedis 7读写进行分开管理:%RW~pattern~pattern的别名;%R~pattern授予对匹配该模式的Key的读权限;%W~pattern授予对匹配该模式的Key的写权限。

​  resetkeys将重置清空允许的key匹配模式列表。例如,~foo:* ~bar:* resetkeys ~objects:*将只允许客户端访问与objects:*模式匹配的键。

⭕️允许和禁止发布/订阅频道

&pattern     #(Redis 6.2)添加用户可以访问的Pub/Sub频道的全局匹配模式,可以指定多个通道模式。请注意,模式匹配仅针对`PUBLISH`和`SUBSCRIBE`提到的通道进行,而`PSUBSCRIBE`需要其通道模式和用户允许的通道模式之间的文字匹配。

allchannels  #是`&*`的别名,允许用户访问所有`Pub/Sub`频道;`resetchannels`:重置允许的频道模式列表,如果用户的Pub/Sub客户端不再能够访问其各自的频道和/或频道模式,则断开这些客户端的连接。

⭕️为用户配置有效密码

> password  #将此密码添加到用户的有效密码列表中(每个用户都可以拥有任意数量的密码)。`> mypass`会将“mypass”添加到有效密码列表中,并清除`nopass`标志。相反,`< password`:从有效密码列表中删除此密码,若试图删除的密码不存在,则报错。

#/!hash     #将此`SHA-256`哈希值在用户的有效密码列表中添加、删除。`resetpass`重置密码列表并删除`nopass`状态,若之后未添加密码(稍后也**未设置**为`nopass`),就无法进行身份验证。

⭕️为用户配置选择器(略)

3.3 Scan

​  上述为了避免危险指令,禁用了keys *,后续查询键该用什么指令呢?这时候就要scan命令出场了,复杂度和keys命令一样,也是 O(n),但是它是通过游标分步进行的,不会阻塞线程( KEYS或者SMEMBERS等会阻塞服务器),类似于MySQLlimit命令。SCAN命令遍历所有的key,而SSCAN|HSCAN|ZSCAN用于增量遍历keyvalue中的元素。

  • 提供limit参数,可以控制每次返回结果的最大条数,这里是最大条数,而不是等于limit的条数,因为是匹配查询,是在limit的范围内匹配查询
  • 返回的结果可能会有重复
  • 遍历的过程中如果有数据修改,改动后的数据能不能遍历到是不确定的;
  • 单次返回的结果是空的并不意味着遍历结束,而要看返回的游标值是否为零;
`SCAN`命令用于迭代当前数据库中的`key`集合;
`SSCAN`命令用于迭代`SET`集合中的元素;
`HSCAN`命令用于迭代`Hash`类型中的键值对;
`ZSCAN`命令用于迭代`SortSet`集合中的元素和元素对应的分值

​  在Redis当中,所有的key都存储在一个很大的字典中,这个字典结构就是一维数组+二维链表的结构,scan指令返回的游标就是一维数组的位置索引,这个位置索引称为槽 (slot)。遍历并不是从一维数组的第零位一直遍历到末尾,而是采用了高位进位加法,避免字典的扩容和缩容时槽位的遍历重复和遗漏。

在这里插入图片描述

#语法
$ SCAN cursor [MATCH pattern] [COUNT count] [TYPE type]
  • cursorSCAN命令是一个基于游标的迭代器,每次被调用之后返回新的游标作为下次迭代参数,以此来延续之前的迭代过程。使用 0 作为游标,则开始一次新的迭代。
  • Count:指定返回的元素数量,COUNT参数的默认值为 10 。当数据集比较大时,如果没有使用MATCH选项, 那么命令返回的元素数量通常和COUNT选项指定的一样,或者比COUNT选项指定的数量稍多一些。
127.0.0.1:6379> scan 0 count 10
1) "26"   #用于进行下一次迭代的新游标,返回0表示迭代已结束
2)  1) "user:206" #数组, 这个数组中包含了所有被迭代的元素。
    2) "user:109"
    3) "user:101"
    4) "user:205"
    5) "user:106"
    6) "user:103"
    7) "user:105"
    8) "user:100"
    9) "user:104"
   10) "user:201"
  • MATCH:模式参数, 让命令只返回和给定模式相匹配的元素。
redis 127.0.0.1:6379> sadd myset 1 2 3 foo foobar feelsgood
(integer) 6
redis 127.0.0.1:6379> sscan myset 0 match f*
1) "0"
2) 1) "foo"
   2) "feelsgood"
   3) "foobar"

4、删除BigKey

4.1 String类型

  对于String类型的数据,观察下表的删除时间虽然删除时间随数据上升,但不至于产生阻塞,所以一般可直接使用DEL删除,若过于庞大,可使用UNLINK来进行删除。

key 类型512KB1MB2MB5MB10MB
string0.22ms0.31ms0.32ms0.56ms1ms

4.2 Hash | List | Set | Zset

  非字符串类型,元素的个数越多,元素越大,删除时间越长。且该删除时间,足够引起 Redis 阻塞。在不考虑使用lazy free的情况,可以使用渐进式来进行删除。

key 类型10W (8 byte)100W10W (16 byte)100W10W ( byte)100W
hash51ms950ms58ms970ms96ms2000ms
list23ms134ms23ms138ms23ms266ms
set44ms873ms58ms881ms73ms1319ms
zset51ms845ms57ms859ms59ms969ms
  • hashhscan循环遍历获取所有的field-value+hdel删除每个field
public void delBigHash(String host, int port, String password, String bigHashKey) {
    Jedis jedis = new Jedis(host, port);
    if (password != null && !"".equals(password)) jedis.auth(password);
    ScanParams scanParams = new ScanParams().count(100);
    String cursor = "0";
    do {
        ScanResult<Entry<String, String>> scanResult = jedis.hscan(bigHashKey, cursor, scanParams);
        List<String, String> entryList = scanResult.getResult();
        if (entryList != null && !entryList.isEmpty()) {
            for(Entry<String, String> entry : entryList) {
                jedis.hdel(bigHashKey, entry.getKey());
            }
        }
        cursor = scanResult.getStringCursor();
    }while("0".equals(cursor));
    
    // 最终删除bigkey
    jedis.del(bigHashKey);
}
  • list:使用ltrim渐进式逐步删除,直到全部删除
public void delBigList(String host, String host, String password, String bigListKey) {
    Jedis jedis = new Jedis(host, port);
    if(password !=null && !"".equals(password)) jedis.auth(password);
    long llen = jedis.llen(bigListKey);
    int counter = 0;
    int left = 100;
    while( counter < llen) {
        jedis.ltrim(bigListKey, left, llen);
        counter += left;
    }
    
    // 最终删除bigkey
    jedis.del(bigListKey);
}
  • set:使用sscan循环获取部分元素,再使用srem删除每个元素
public void delBigList(String host, String port, String password, String bigSetKey) {
    Jedis jedis = new Jedis(host, port);
    if(password != null && !"".equals(password)) jedis.auth(password);
    ScanParams scanParams = new ScanParams().count(100);
    String cursor = "0";
    do {
        ScanResult<List<String>> scanResult = jedis.sscan(scanParams, cursor, bigSetKey);
        List<String> memberList = scanResult.getResult();
        if(memberList != null && !memberList.isEmpty()) {
            for(String member : memberList) {
                jedis.srem(bigSetList, member);
            }
        }
        cursor = scanResult.getStringCursor();
    }while(!"0".equals(cursor));
    
    jedis.del(bigSetKey);
}
  • zset:使用zscan循环获取部分元素,再使用zremrangebyrank逐步删除
public void delBigZsetKey(String host, String port, String password, String bigZsetKey) {
    Jedis jedis = new Jedis(host, port);
    if(password != null && !"".equals(password)) jedis.auth(password);
    ScanParams scanParams = new ScanParams().count(100);
    String cursor = "0";
    do {
        ScanResult<List<Tuple>> scanResult = jedis.zscan(scanParams, cursor, BigZsetKey);
        List<Tuple> tupleList = scanResult.getResult();
        if(tupList != null && !tupleList.isEmpty()) {
            for(Tuple tuple : tupleList) {
                jedis.zrem(bigZsetKey, tuple.getElement());
            }
        }
        
    }while("0".equals(cursor));
    
    jedis.del(bigZsetKey);
}

4.3 Lazy Freeing异步删除

​  DEL阻塞删除,意味着服务器停止处理新的命令,以同步方式回收与对象相关的所有内存。如果被删除的键占用小,则DEL所需时间非常短,相当于 O(1) 或 O(log_N) 命令。但若键数据量过大,服务器则会长时间阻塞直到完成删除;

​  所以Redis提供了非阻塞删除指令:UNLINK非阻塞删除和 FLUSHALL|FLUSHDB 命令的ASYNC选项,在另一个线程将尽可能快地逐步释放对象,这时操作删除bigkey不会阻塞Redis。在下面四种情况,redis将默认以阻塞方式删除对象,类似del命令,可以通过下面的四个配置项来进行修改默认行为:

#针对redis内存使用达到maxmeory,并设置有淘汰策略maxmemory policy时;在被动淘汰键时,是否采用lazy free机制
#因为此场景开启lazy free, 可能使用淘汰键的内存释放不及时,导致redis内存超用,超过maxmemory的限制。请结合业务测试。
lazyfree-lazy-eviction no
#针对设置有TTL的键,达到过期后,被redis清理删除时是否采用lazy free机制;建议开启,因TTL本身是自适应调整的速度。
lazyfree-lazy-expire no
#针对有些指令在处理已存在的键时,会带有一个隐式的DEL键的操作。RENAME修改key名,会先删除旧键名;同样SUNIONSTORE 或 SORT 将结果存储到某键时,可能会删除原来的键的内容。建议开启,因为若操作的是bigkey,会引入阻塞删除的性能问题。
lazyfree-lazy-server-del no
#针对slave进行全量数据同步,slave在加载master的RDB文件前,会执行flushall来清理内存,该参数决定是否采用异常flush机制。若内存变动不大,建议可开启。可减少全量同步耗时,从而减少主库因输出缓冲区爆涨引起的内存使用增长。
replica-lazy-flush no

  还通过下面配置项可以修改DELFLUSHDB|FLUSHALL|SCRIPT FLUSH|FUNCTION|FLUSH等的默认行为,操作更人性化:

# 修改 DEL 命令的默认行为,使其与 UNLINK 命令完全相同
lazyfree-lazy-user-del no

# FLUSHDB,FLUSHALL,SCRIPT FLUSH 和 FUNCTION FLUSH 命令都支持异步和同步删除,可以通过传递 [SYNC|ASYNC] 标志来控制。#当没有传递任何标志时,该配置将决定是否异步删除数据
lazyfree-lazy-user-flush no
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值