【起】Redis 基础篇——基本数据结构之String,Hash

27 篇文章 0 订阅
18 篇文章 1 订阅

前言

距离过年那会闲在家更新的 MySQL 系列已经过去一段时间了,这段时间一直在忙其他的,所以博客的更新也就搁置了,但是一直在想着要更新啥内容比较好,刚好朋友给了我一本 Redis 的书籍,我就打算看完结合官方的文档总结一下,分享给大家,如果有什么不对的地方请指正。

Redis 系列,我想以“起承转合”的形式来更新,不过不一定是四篇噢,因为篇幅有限,太长怕你们没有耐心看完,可能《起》篇就分为几篇博文来叙述了,我也会对其进行规整,方便大家看完能更好的吸收,毕竟写文章的我能得到各位观看我的文章,是我的荣幸,我必须得对大家负责的嘛~

话归正题,Redis 应该很多人都有用过(没用过应该看这篇也能看得懂,但是一些基本理论就得自己上网百度啦)。至于 Redis 是什么,有什么好处,怎么用,那就继续往下看吧~本文会侧重于让大家对Redis 基本数据类型的操作命令,底层存储结构以及其应用场景得到一定的认知。

附上基础篇的脑图(上传平台有压缩,有兴趣可以到我的公众号【6曦轩】领取原图)
在这里插入图片描述
今天这一篇章给大家介绍一下 Redis 的基本数据类型(高阶的暂不介绍)。

篇幅有点长,我把要介绍的数据类型分为了三篇章的长度来介绍,大家可以有选择性地看。

正文

Redis 基本数据类型

最基本也是最常用的数据类型就是 String。

set 和 get 命令就是 String 的操作命令。

为什么叫 Binary-safe strings 呢?

Let’s see.

String 字符串
存储类型

可以用来存储字符串、整数、浮点数。

操作命令

设置多个值(批量操作,原子性)

mset port 2673 zk 666

设置值,如果 key 存在,则不成功

setnx port

基于此可实现分布式锁,用del key释放锁。

但如果释放锁的操作失败了,导致其他节点永远获取不到锁,怎么办?

加过期时间。

单独用 expire 加过期,也失败了,无法保证原子性,怎么办?

多参数

set key value [expiration EX seconds|PX milliseconds][NX|XX]

使用参数的方式

set lock1 1 EX 10 NX

(整数)值递增

incr port
incrby port 100

(整数)值递减

decr port
decrby port 100

浮点数增量

set f 2.6
incrbyfloat f 7.3

获取多个值

mget port jack

获取值长度

strlen port

字符串追加内容

append port good

获取指定范围的字符

getrange port 0 8
存储(实现)原理

数据模型

set hello word为例,因为 Redis 是 KV 的数据库,它是通过 hashtable 实现的(我们把这个叫做外层的哈希)。
所以每个键值对都会有一个 dictEntry(源码位置:dict.h),里面指向了 key 和 value 的指针。
next 指向下一个 dictEntry。

typedef struct dictEntry {
	void *key; /* key 关键字定义 */
	union {
		void *val;	uint64_t u64; /* value 定义 */
		int64_t s64;	double d;
	} v;
	struct dictEntry *next; /* 指向下一个键值对节点 */ 
} dictEntry;

在这里插入图片描述

  1. key 是字符串,但是 Redis 没有直接使用 C 的字符数组,而是存储在自定义的 SDS 中。
  2. value 既不是直接作为字符串存储,也不是直接存储在 SDS 中,而是存储在redisObject 中。
  3. 实际上五种常用的数据类型的任何一种,都是通过 redisObject 来存储的。
  • redisObject

redisObject 定义在 src/server.h 文件中。

typedef struct redisObject {
	unsigned type:4; /* 对象的类型,包括:OBJ_STRING、OBJ_LIST、OBJ_HASH、OBJ_SET、OBJ_ZSET */
	unsigned encoding:4; /* 具体的数据结构 */
	unsigned lru:LRU_BITS; /* 24 位,对象最后一次被命令程序访问的时间,与内存回收有关 */
	int refcount; /* 引用计数。当 refcount 为 0 的时候,表示该对象已经不被任何对象引用,则可以进行垃圾回收了 */
	void *ptr; /* 指向对象实际的数据结构 */
} robj;

可以使用 type 命令来查看对外的类型。

127.0.0.1:6379> type qs
string

内部编码
在这里插入图片描述

127.0.0.1:6379> set number 1
OK
127.0.0.1:6379> set zk "'s default port is 2181"
OK
127.0.0.1:6379> set redis nosql
OK
127.0.0.1:6379> object encoding number
"int"
127.0.0.1:6379> object encoding redis
"embstr"
127.0.0.1:6379> object encoding zk
"raw"

字符串类型的内部编码有三种:

  1. int,存储 8 个字节的长整型(long,2^63-1)。
  2. embstr,代表 embstr 格式的 SDS(Simple Dynamic String 简单动态字符串),存储小于 44 个字节的字符串。
  3. raw,存储大于 44 个字节的字符串(3.2 版本之前是 39 字节)。为什么是 39?
/* object.c */
#define OBJ_ENCODING_EMBSTR_SIZE_LIMIT 44
  • 问题 1、什么是 SDS?

Redis 中字符串的实现。
在 3.2 以后的版本中,SDS 又有多种结构(sds.h):sdshdr5、sdshdr8、sdshdr16、sdshdr32 、 sdshdr64 ,用于存储不同的长度的字符串,分别代表 2^5=32byte ,2 ^ 8=256byte,2 ^ 16=65536byte=64KB,2^32byte=4GB。

/* sds.h */
struct __attribute__ ((__packed__)) sdshdr8 {
	uint8_t len; /* 当前字符数组的长度 */
	uint8_t alloc; /*当前字符数组总共分配的内存大小 */
	unsigned char flags; /* 当前字符数组的属性、用来标识到底是 sdshdr8 还是 >sdshdr16 等 */ 
	char buf[]; /* 字符串真正的值 */
};
  • 问题 2、为什么 Redis 要用 SDS 实现字符串?

我们知道,C 语言本身没有字符串类型(只能用字符数组 char[]实现)。

  1. 使用字符数组必须先给目标变量分配足够的空间,否则可能会溢出;
  2. 如果要获取字符长度,必须遍历字符数组,时间复杂度是 O(n);
  3. C 字符串长度的变更会对字符数组做内存重分配;
  4. 通过从字符串开始到结尾碰到的第一个’\0’来标记字符串的结束,因此不能保存图片、音频、视频、压缩文件等二进制(bytes)保存的内容,二进制不安全。

SDS 的特点:

  1. 不用担心内存溢出问题,如果需要会对 SDS 进行扩容;
  2. 获取字符串长度时间复杂度为 O(1),因为定义了 len 属性;
  3. 通过“空间预分配”( sdsMakeRoomFor)和“惰性空间释放”,防止多次重分配内存;
  4. 判断是否结束的标志是 len 属性(它同样以’\0’结尾是因为这样就可以使用 C 语言中函数库操作字符串的函数了),可以包含’\0’。

C 字符串 SDS

  • 获取字符串长度的复杂度为 O(N) 获取字符串长度的复杂度为 O(1) ;
  • API 是不安全的,可能会造成缓冲区溢出 API 是安全的,不会造成个缓冲区溢出;
  • 修改字符串长度 N 次必然需要执行 N 次内存重分配 修改字符串长度 N 次最多需要执行 N 次内存重分配;
  • 只能保存文本数据 可以保存文本或者二进制数据 ;
    -可以使用所有<string.h>库中的函数 可以使用一部分<string.h>库中的函数 ;
  • 问题 3、embstr 和 raw 的区别?

embstr 的使用只分配一次内存空间(因为 RedisObject 和 SDS 是连续的),而 raw 需要分配两次内存空间(分别为 RedisObject 和 SDS 分配空间)。

因此与 raw 相比,embstr 的好处在于创建时少分配一次空间,删除时少释放一次空间,以及对象的所有数据连在一起,寻找方便。

而 embstr 的坏处也很明显,如果字符串的长度增加需要重新分配内存时,整个 RedisObject 和 SDS 都需要重新分配空间,因此 Redis 中的 embstr 实现为只读。

  • 问题 4:int 和 embstr 什么时候转化为 raw?

当 int 数 据 不 再 是 整 数 , 或 大 小 超 过 了 long 的 范 围(2^63-1=9223372036854775807)时,自动转化为 embstr。

127.0.0.1:6379> set k1 1
OK
127.0.0.1:6379> append k1 a
(integer) 2
127.0.0.1:6379> object encoding k1
"raw"
  • 问题 5:明明没有超过阈值,为什么变成 raw 了?
127.0.0.1:6379> set k2 a
OK
127.0.0.1:6379> object encoding k2
"embstr"
127.0.0.1:6379> append k2 b
(integer) 2
127.0.0.1:6379> object encoding k2
"raw"

对于 embstr,由于其实现是只读的,因此在对 embstr 对象进行修改时,都会先转化为 raw 再进行修改。

因此,只要是修改 embstr 对象,修改后的对象一定是 raw 的,无论是否达到了 44 个字节。

  • 问题 6:当长度小于阈值时,会还原吗?

关于 Redis 内部编码的转换,都符合以下规律:编码转换在 Redis 写入数据时完成,且转换过程不可逆,只能从小内存编码向大内存编码转换(但是不包括重新 set)。

  • 问题 7:为什么要对底层的数据结构进行一层包装呢?

通过封装,可以根据对象的类型动态地选择存储结构和可以使用的命令,实现节省空间和优化查询速度。

应用场景
  • 缓存

String 类型

例如:

热点数据缓存(例如报表,明星出轨),对象缓存,全页缓存。

可以提升热点数据的访问速度。

数据共享分布式

STRING 类型,因为 Redis 是分布式的独立服务,可以在多个应用之间共享例如:

  • 分布式 Session
<dependency>
	<groupId>org.springframework.session</groupId>
	<artifactId>spring-session-data-redis</artifactId>
</dependency>
  • 分布式锁

STRING 类型 setnx 方法,只有不存在时才能添加成功,返回 true。

http://redisdoc.com/string/set.html 建议用参数的形式

public Boolean getLock(Object lockObject){
	jedisUtil = getJedisConnetion();
	boolean flag = jedisUtil.setNX(lockObj, 1);
	if(flag){
		expire(locakObj,10);
	}
	return flag;
}

public void releaseLock(Object lockObject){
	del(lockObj);
}
  • 全局 ID

INT 类型,INCRBY,利用原子性

incrby userid 1000

(分库分表的场景,一次性拿一段)

  • 计数器

INT 类型,INCR 方法

例如:文章的阅读量,微博点赞数,允许一定的延迟,先写入 Redis 再定时同步到数据库。

  • 限流

INT 类型,INCR 方法

以访问者的 IP 和其他信息作为 key,访问一次增加一次计数,超过次数则返回 false。

  • 位统计

String 类型的 BITCOUNT(1.6.6 的 bitmap 数据结构介绍)。

字符是以 8 位二进制存储的。

set k1 a
setbit k1 6 1
setbit k1 7 0
get k1

a 对应的 ASCII 码是 97,转换为二进制数据是 01100001 b 对应的 ASCII 码是 98,转换为二进制数据是 01100010

因为 bit 非常节省空间(1 MB=8388608 bit),可以用来做大数据量的统计。

例如:在线用户统计,留存用户统计

setbit onlineusers 0 1
setbit onlineusers 1 1
setbit onlineusers 2 0

支持按位与、按位或等等操作。

BITOP AND destkey key [key ...]  ,对一个或多个 key 求逻辑并,并将结果保存到 destkey 。

BITOP OR destkey key [key ...] ,对一个或多个 key 求逻辑或,并将结果保存到 destkey 。

BITOP XOR destkey key [key ...] ,对一个或多个 key 求逻辑异或,并将结果保存到 destkey 。

BITOP NOT destkey key ,对给定 key 求逻辑非,并将结果保存到 destkey 。

#计算出 7 天都在线的用户
BITOP "AND" "7_days_both_online_users" "day_1_online_users" "day_2_online_users" ... "day_7_online_users"
  • 如果一个对象的 value 有多个值的时候,怎么存储?
  1. 例如用一个 key 存储一张表的数据。
    在这里插入图片描述

  2. 序列化?
    例如 JSON/Protobuf/XML,会增加序列化和反序列化的开销,并且不能单独获取、修改一个值。

  3. 可以通过 key 分层的方式来实现,例如:

mset student:1:sno CK16666 student:1:sname  沐风 student:1:company 腾讯
  1. 获取值的时候一次获取多个值:
mget student:1:sno student:1:sname student:1:company

缺点:key 太长,占用的空间太多。有没有更好的方式?

Hash 哈希

在这里插入图片描述

存储类型

包含键值对的无序散列表。value 只能是字符串,不能嵌套其他类型。

同样是存储字符串,Hash 与 String 的主要区别?

  1. 把所有相关的值聚集到一个 key 中,节省内存空间;

  2. 只使用一个 key,减少 key 冲突;

  3. 当需要批量获取值的时候,只需要使用一个命令,减少内存/IO/CPU 的消耗;

Hash 不适合的场景:

  1. Field 不能单独设置过期时间;

  2. 没有 bit 操作;

  3. 需要考虑数据量分布的问题(value 值非常大的时候,无法分布到多个节点);

操作命令
hset h1 f 6

hset h1 e 5

hmset h1 a 1 b 2 c 3 d 4
hget h1 a

hmget h1 a b c d

hkeys h1

hvals h1

hgetall h1

key 操作

hget exists h1

hdel h1

hlen h1

存储(实现)原理

Redis 的 Hash 本身也是一个 KV 的结构,类似于 Java 中的 HashMap。

外层的哈希(Redis KV 的实现)只用到了 hashtable。当存储 hash 数据类型时,我们把它叫做内层的哈希。内层的哈希底层可以使用两种数据结构实现:

ziplist:OBJ_ENCODING_ZIPLIST(压缩列表)
hashtable:OBJ_ENCODING_HT(哈希表)
127.0.0.1:6379> hset h2 f aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa (integer) 1
127.0.0.1:6379> hset h3 f aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa (integer) 1
127.0.0.1:6379> object encoding h2
"ziplist"
127.0.0.1:6379> object encoding h3
"hashtable"

ziplist 压缩列表

ziplist 压缩列表是什么?

/* ziplist.c 源码头部注释 */
The ziplist is a specially encoded dually linked list that is designed to be very memory efficient. It stores both strings and integer values, where integers are encoded as actual integers instead of a series of characters. It allows push and pop operations on either side of the list in O(1) time. However, because every operation requires a reallocation of the memory used by the ziplist, the actual complexity is related to the amount of memory used by the ziplist.

ziplist 是一个经过特殊编码的双向链表,它不存储指向上一个链表节点和指向下一个链表节点的指针,而是存储上一个节点长度和当前节点长度,通过牺牲部分读写性能,来换取高效的内存空间利用率,是一种时间换空间的思想。只用在字段个数少,字段值小的场景里面。

ziplist 的内部结构?

ziplist.c 源码第 16 行的注释:

 <zlbytes> <zltail> <zllen> <entry> <entry> ... <entry> <zlend>

在这里插入图片描述

typedef struct zlentry {
	unsigned int prevrawlensize; /* 上一个链表节点占用的长度 */
	unsigned int prevrawlen;	/* 存储上一个链表节点的长度数值所需要的字节数 */
	unsigned int lensize;	/* 存储当前链表节点长度数值所需要的字节数 */
	unsigned int len;	/* 当前链表节点占用的长度 */
	unsigned int headersize;	/* 当前链表节点的头部大小(prevrawlensize + lensize),即非数据域的大小 */
	unsigned char encoding;	/* 编码方式 */
	unsigned char *p;	/* 压缩链表以字符串的形式保存,该指针指向当前节点起始位置 */
} zlentry;

编码 encoding(ziplist.c 源码第 204 行)

#define ZIP_STR_06B (0 << 6) //长度小于等于 63 字节
#define ZIP_STR_14B (1 << 6) //长度小于等于 16383 字节 
#define ZIP_STR_32B (2 << 6) //长度小于等于 4294967295 字节

在这里插入图片描述

  • 问题:什么时候使用 ziplist 存储?

当 hash 对象同时满足以下两个条件的时候,使用 ziplist 编码:
1)所有的键值对的健和值的字符串长度都小于等于 64byte(一个英文字母一个字节);
2)哈希对象保存的键值对数量小于 512 个。

/* src/redis.conf 配置 */

hash-max-ziplist-value 64 // ziplist 中最大能存放的值长度 
hash-max-ziplist-entries 512 // ziplist 中最多能存放的 entry 节点数量

/* 源码位置:t_hash.c ,当达字段个数超过阈值,使用 HT 作为编码 */

if (hashTypeLength(o) > server.hash_max_ziplist_entries)
hashTypeConvert(o, OBJ_ENCODING_HT);

/*源码位置: t_hash.c,当字段值长度过大,转为 HT */

for (i = start; i <= end; i++) {
	if (sdsEncodedObject(argv[i]) &&sdslen(argv[i]->ptr) > server.hash_max_ziplist_value){
		hashTypeConvert(o, OBJ_ENCODING_HT);
		break;
	}
}

一个哈希对象超过配置的阈值(键和值的长度有>64byte,键值对个数>512 个)时,会转换成哈希表(hashtable)。

hashtable(dict)

在 Redis 中,hashtable 被称为字典(dictionary),它是一个数组+链表的结构。

源码位置:dict.h

前面我们知道了,Redis 的 KV 结构是通过一个 dictEntry 来实现的。

Redis 又对 dictEntry 进行了多层的封装。

typedef struct dictEntry {
	void *key; /* key 关键字定义 */
	union {
		void *val;	
		uint64_t u64; /* value 定义 */
		int64_t s64;	
		double d;
	} v;
	struct dictEntry *next; /* 指向下一个键值对节点 */ 
} dictEntry;

dictEntry 放到了 dictht(hashtable 里面):

/* This is our hash table structure. Every dictionary has two of this as we
*implement incremental rehashing, for the old to the new table. */ 
typedef struct dictht {
	dictEntry **table; /* 哈希表数组 */ 
	unsigned long size; /* 哈希表大小 */
	unsigned long sizemask; /* 掩码大小,用于计算索引值。总是等于 size-1 */ 
	unsigned long used; /* 已有节点数 */
} dictht;

ht 放到了 dict 里面:

typedef struct dict {
	dictType *type; /* 字典类型 */
	void *privdata; /* 私有数据 */
	dictht ht[2]; /* 一个字典有两个哈希表 */
	long rehashidx; /* rehash 索引 */
	unsigned long iterators; /* 当前正在使用的迭代器数量 */
} dict;

从最底层到最高层

dictEntry——dictht——dict——OBJ_ENCODING_HT

总结:哈希的存储结构
在这里插入图片描述
注意:

  1. dictht 后面是 NULL 说明第二个 ht 还没用到。
  2. dictEntry*后面是 NULL 说明没有 hash 到这个地址。
  3. dictEntry 后面是 NULL 说明没有发生哈希冲突。
  • 问题:为什么要定义两个哈希表呢?ht[2]

redis 的 hash 默认使用的是 ht[0],ht[1]不会初始化和分配空间。

哈希表 dictht 是用链地址法来解决碰撞问题的。在这种情况下,哈希表的性能取决于它的大小(size 属性)和它所保存的节点的数量(used 属性)之间的比率:

  • 比率在 1:1 时(一个哈希表 ht 只存储一个节点 entry),哈希表的性能最好;
  • 如果节点数量比哈希表的大小要大很多的话(这个比例用 ratio 表示,5 表示平均一个 ht 存储 5 个 entry),那么哈希表就会退化成多个链表,哈希表本身的性能优势就不再存在。

在这种情况下需要扩容。
Redis 里面的这种操作叫做 rehash。

rehash 的步骤:

  1. 为字符 ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及 ht[0]当前包含的键值对的数量。
    扩展:ht[1]的大小为第一个大于等于 ht[0].used*2。
  2. 将所有的 ht[0]上的节点 rehash 到 ht[1]上,重新计算 hash 值和索引,然后放入指定的位置。
  3. 当 ht[0]全部迁移到了 ht[1]之后,释放 ht[0]的空间,将 ht[1]设置为 ht[0]表,并创建新的 ht[1],为下次 rehash 做准备。

问题:什么时候触发扩容?

负载因子(源码位置:dict.c):

static int dict_can_resize = 1;
static unsigned int dict_force_resize_ratio = 5;

ratio = used / size,已使用节点与字典大小的比例
dict_can_resize 为 1 并且 dict_force_resize_ratio 已使用节点数和字典大小之间的比率超过 1:5,触发扩容

扩容判断 _dictExpandIfNeeded(源码 dict.c)

if (d->ht[0].used >= d->ht[0].size &&(dict_can_resize || d->ht[0].used/d->ht[0].size > dict_force_resize_ratio)){
	return dictExpand(d, d->ht[0].used*2);
}
return DICT_OK;

扩容方法 dictExpand(源码 dict.c)

static int dictExpand(dict *ht, unsigned long size) {
	dict n; /* the new hashtable */
	unsigned long realsize = _dictNextPower(size), i;
	/* the size is invalid if it is smaller than the number of
	*elements already inside the hashtable */ 
	if (ht->used > size)
		return DICT_ERR;
	_dictInit(&n, ht->type, ht->privdata);
	n.size = realsize;
	n.sizemask = realsize-1;
	n.table = calloc(realsize,sizeof(dictEntry*));
	
	/* Copy all the elements from the old to the new table:
	*note that if the old hash table is empty ht->size is zero,
	*so dictExpand just creates an hash table. */
	n.used = ht->used;
	for (i = 0; i < ht->size && ht->used > 0; i++) {
		dictEntry *he, *nextHe;
		if (ht->table[i] == NULL) continue;
		/* For each hash entry on this slot... */
		he = ht->table[i];
		while(he) {
			unsigned int h;
			nextHe = he->next;
			/* Get the new element index */
			h= dictHashKey(ht, he->key) & n.sizemask; he->next = n.table[h];
			n.table[h] = he; ht->used--;
			/* Pass to the next element */
			he = nextHe;
		}
	}
	assert(ht->used == 0);
	free(ht->table);
	/* Remap the new hashtable in the old */
	*ht = n;
	return DICT_OK;
}

缩容:server.c

int htNeedsResize(dict *dict) {
	long long size, used;
	size = dictSlots(dict);
	used = dictSize(dict);
	return (size > DICT_HT_INITIAL_SIZE &&(used*100/size < HASHTABLE_MIN_FILL));
}
应用场景
  • String

String 可以做的事情,Hash 都可以做。

  • 存储对象类型的数据

比如对象或者一张表的数据,比 String 节省了更多 key 的空间,也更加便于集中管理。

  • 购物车
    在这里插入图片描述

key:用户 id;field:商品 id;value:商品数量。

+1:hincr。-1:hdecr。删除:hdel。全选:hgetall。商品数:hlen。

By the way

有问题?可以给我留言或私聊
有收获?那就顺手点个赞呗~

当然,也可以到我的公众号下「6曦轩」,

回复“学习”,即可领取一份
【Java工程师进阶架构师的视频教程】~

回复“面试”,可以获得:
【本人呕心沥血整理的 Java 面试题】

回复“MySQL脑图”,可以获得
【MySQL 知识点梳理高清脑图】

还有【阿里云】【腾讯云】的购买优惠噢~具体请联系我

曦轩我是科班出身的程序员,php,Android以及硬件方面都做过,不过最后还是选择专注于做 Java,所以有啥问题可以到公众号提问讨论(技术情感倾诉都可以哈哈哈),看到的话会尽快回复,希望可以跟大家共同学习进步,关于服务端架构,Java 核心知识解析,职业生涯,面试总结等文章会不定期坚持推送输出,欢迎大家关注~~~
在这里插入图片描述

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值