Redis跳表数据结构

Redis跳跃表数据结构

跳跃表数据结构

在redis3.x版本中定义在redis.h头文件中

在redis6.x版本中定义在server.h头文件中

/*
 * 跳跃表
 */
/**
 * header :指向跳跃表的表头节点。
 * tail :指向跳跃表的表尾节点。
 * level :记录目前跳跃表内,层数最大的那个节点的层数(表头节点的层数不计算在内)。
 * length :记录跳跃表的长度,也即是,跳跃表目前包含节点的数量(表头节点不计算在内)。
 */ 
typedef struct zskiplist {

    // 表头节点和表尾节点 // 头节点,尾节点  注意在创建zskiplist的时候默认有创建一个头节点,见zslCreate
    struct zskiplistNode *header, *tail;

    // 表中节点的数量
    unsigned long length;

    // 目前表内节点的最大层数 level 属性则用于在 O(1) 复杂度内获取跳跃表中层高最大的那个节点的层数量, 注意表头节点的层高并不计算在内。
    //创建zskiplist的时候zslCreate中默认置1
    int level; //创建新的zskiplistNode节点的时候,level层数数组[]大小时随机产生的,见zslInsert->zslRandomLevel

} zskiplist;

跳跃表节点

在redis3.x版本中定义在redis.h头文件中

在redis6.x版本中定义在server.h头文件中

/**
 * 跳跃表节点
 * 在同一个跳跃表中, 各个节点保存的成员对象必须是唯一的, 但是多个节点保存的分值却可以是相同的: 
 * 分值相同的节点将按照成员
 * 对象在字典序中的大小来进行排序, 成员对象较小的节点会排在前面(靠近表头的方向),
 * 而成员对象较大的节点则会排在后面(靠近表尾的方向)
 */ 
typedef struct zskiplistNode {
    // 表头节点也有后退指针、分值和成员对象, 不过表头节点的这些属性都不会被用到
    // member 对象      节点所保存的成员对象。
    robj *obj;

    // 分值 在跳跃表中,节点按各自所保存的分值从小到大排列
    // 跳跃表中的所有zskiplistNode节点都按分值从小到大来排序
    double score;

    // 后退指针
    struct zskiplistNode *backward;

    // 层
    /*
     节点中用 L1 、 L2 、 L3 等字样标记节点的各个层, L1 代表第一层, L2 代表第二层,以此类推。每个层都带有两个属性:前进指针和跨度。

     跳跃表节点的 level 数组可以包含多个元素, 每个元素都包含一个指向其他节点的指针, 程序可以通过这些层来加快访问其他节点的
     速度, 一般来说, 层的数量越多, 访问其他节点的速度就越快。
     
     每次创建一个新跳跃表节点的时候, 程序都根据幂次定律 (power law,越大的数出现的概率越小) 随机生成一个介于 1 和 32 之间
     的值作为 level 数组的大小, 这个大小就是层的“高度”。
     */
    struct zskiplistLevel {

        // 前进指针
        // 前进指针 前进指针用于访问位于表尾方向的其他节点  当程序从表头向表尾进行遍历时,访问会沿着层的前进指针进行。
        struct zskiplistNode *forward;

        // 跨度
        // 这个层跨越的节点数量  跨度则记录了前进指针所指向节点和当前节点的距离。
        unsigned int span;

    } level[]; //创建新的zskiplistNode节点的时候,level层数数组[]大小时随机产生的,见zslInsert->zslRandomLevel

} zskiplistNode; //存储在zskiplist跳跃表结构中

Redis跳跃表是用来实现有序集合(zset)的,zset就是集合里每一个成员都有一个对应的评分,成员是按评分从低到高存储的。因此,在redis跳跃表里,节点也是按分值从低到高排列的,而不是按对象本身的大小。

再解释一下后退指针,一个节点的后退指针会指向它的上一个节点,为什么需要后退指针呢?因为zset支持分数以从高到低的顺序返回集合元素,这个时候就会用到后退指针。

level数组是我们的重点,它有两个成员,forward和span。

如下图:
redis_zsl

forward是指节点在这一层对应的下一个节点,换句话说,一个节点,在每一层都有不同的forward指针,就拿上图中的节点8来说 ,在Level0,节点8的forward就是节点9,在Level1,节点8的forward就是节点12,在Level2,节点8的forward是16。

span呢,是指节点在这一层距离下一个节点的距离,这个变量可以用来快速的确定节点的排名,拿上图中的节点8来说 ,在Level0,节点8的span就是1,在Level1,节点8的span就是4,在Level2,节点8的span是8。

补充:16个节点插入以后跳跃表后并不会像上图那样这么规整,节点在插入时,层数是随机的,对于一个节点,层数是n的概率是高于n+1的,所以当节点多了以后,会发现层数越高,节点越少。

跳跃表相关API

函数实现在t_zset.c文件中

zslCreate

创建并返回一个新的跳跃表,时间复杂度O(1)

zskiplist *zslCreate(void) {
    int j;
    zskiplist *zsl;

    // 分配空间
    zsl = zmalloc(sizeof(*zsl));

    // 设置高度和起始层数
    zsl->level = 1;
    zsl->length = 0;

    // 初始化表头节点
    // T = O(1)
    zsl->header = zslCreateNode(ZSKIPLIST_MAXLEVEL,0,NULL);
    for (j = 0; j < ZSKIPLIST_MAXLEVEL; j++) {
        zsl->header->level[j].forward = NULL;
        zsl->header->level[j].span = 0;
    }
    zsl->header->backward = NULL;

    // 设置表尾
    zsl->tail = NULL;

    return zsl;
}

其中:

  • ZSKIPLIST_MAXLEVEL,这个是跳跃表的最大层数,源码里通过宏定义设置为了32,也就是说,节点再多,也不会超过32层。
  • 初始化头节点
/**
 * 创建一个层数为 level 的跳跃表节点,
 * 并将节点的成员对象设置为 obj ,分值设置为 score 。
 *
 * 返回值为新创建的跳跃表节点
 *
 * T = O(1)
 */
zskiplistNode *zslCreateNode(int level, double score, robj *obj) {
    
    // 分配空间
    zskiplistNode *zn = zmalloc(sizeof(*zn)+level*sizeof(struct zskiplistLevel));

    // 设置属性
    zn->score = score;
    zn->obj = obj;

    return zn;
}

zslFree

释放给定跳跃表,以及表中的所有节点,时间复杂度O(n)

void zslFree(zskiplist *zsl) {

    zskiplistNode *node = zsl->header->level[0].forward, *next;

    // 释放表头
    zfree(zsl->header);

    // 释放表中所有节点
    // T = O(N)
    while(node) {

        next = node->level[0].forward;

        zslFreeNode(node);

        node = next;
    }
    
    // 释放跳跃表结构
    zfree(zsl);
}
  • 释放表头
  • 释放表中的所有节点
/**
 * 释放给定的跳跃表节点
 *
 * T = O(1)
 */
void zslFreeNode(zskiplistNode *node) {

    decrRefCount(node->obj);

    zfree(node);
}

zslInsert

创建一个成员为 obj ,分值为 score 的新节点,并将这个新节点插入到跳跃表 zsl 中。

函数的返回值为新节点。

T_wrost = O(N^2), T_avg = O(N log N)

zskiplistNode *zslInsert(zskiplist *zsl, double score, robj *obj) {
    zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
    unsigned int rank[ZSKIPLIST_MAXLEVEL];
    int i, level;

    redisAssert(!isnan(score));

    // 在各个层查找节点的插入位置
    // T_wrost = O(N^2), T_avg = O(N log N)
    x = zsl->header;
    for (i = zsl->level-1; i >= 0; i--) {

        /* store rank that is crossed to reach the insert position */
        // 如果 i 不是 zsl->level-1 层
        // 那么 i 层的起始 rank 值为 i+1 层的 rank 值
        // 各个层的 rank 值一层层累积
        // 最终 rank[0] 的值加一就是新节点的前置节点的排位
        // rank[0] 会在后面成为计算 span 值和 rank 值的基础
        rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];

        // 沿着前进指针遍历跳跃表
        // T_wrost = O(N^2), T_avg = O(N log N)
        while (x->level[i].forward &&
            (x->level[i].forward->score < score ||
                // 比对分值
                (x->level[i].forward->score == score &&
                // 比对成员, T = O(N)
                compareStringObjects(x->level[i].forward->obj,obj) < 0))) {

            // 记录沿途跨越了多少个节点
            rank[i] += x->level[i].span;

            // 移动至下一指针
            x = x->level[i].forward;
        }
        // 记录将要和新节点相连接的节点
        update[i] = x;
    }

    /* we assume the key is not already inside, since we allow duplicated
     * scores, and the re-insertion of score and redis object should never
     * happen since the caller of zslInsert() should test in the hash table
     * if the element is already inside or not. 
     *
     * zslInsert() 的调用者会确保同分值且同成员的元素不会出现,
     * 所以这里不需要进一步进行检查,可以直接创建新元素。
     */

    // 获取一个随机值作为新节点的层数
    // T = O(N)
    level = zslRandomLevel();

    // 如果新节点的层数比表中其他节点的层数都要大
    // 那么初始化表头节点中未使用的层,并将它们记录到 update 数组中
    // 将来也指向新节点
    if (level > zsl->level) {

        // 初始化未使用层
        // T = O(1)
        for (i = zsl->level; i < level; i++) {
            rank[i] = 0;
            update[i] = zsl->header;
            update[i]->level[i].span = zsl->length;
        }

        // 更新表中节点最大层数
        zsl->level = level;
    }

    // 创建新节点
    x = zslCreateNode(level,score,obj);

    // 将前面记录的指针指向新节点,并做相应的设置
    // T = O(1)
    for (i = 0; i < level; i++) {
        
        // 设置新节点的 forward 指针
        x->level[i].forward = update[i]->level[i].forward;
        
        // 将沿途记录的各个节点的 forward 指针指向新节点
        update[i]->level[i].forward = x;

        /* update span covered by update[i] as x is inserted here */
        // 计算新节点跨越的节点数量
        x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);

        // 更新新节点插入之后,沿途节点的 span 值
        // 其中的 +1 计算的是新节点
        update[i]->level[i].span = (rank[0] - rank[i]) + 1;
    }

    /* increment span for untouched levels */
    // 未接触的节点的 span 值也需要增一,这些节点直接从表头指向新节点
    // T = O(1)
    for (i = level; i < zsl->level; i++) {
        update[i]->level[i].span++;
    }

    // 设置新节点的后退指针
    x->backward = (update[0] == zsl->header) ? NULL : update[0];
    if (x->level[0].forward)
        x->level[0].forward->backward = x;
    else
        zsl->tail = x;

    // 跳跃表的节点计数增一
    zsl->length++;

    return x;
}
思路

首先上面说过,新加入的节点,层数是随机的,我们先不管怎么随机,就假设要在上面那个图上插入一个节点,分值是6.5,且随机生成的层数是3,那么插入这个节点之后,跳跃表应该是这样的:
zsl_insert

如果要实现这个效果,我们要分两步去做:

  • 找到新节点在每一层的上一个节点(即:对于Level0,应该先找到节点6;对于Level1,应该先找到节点4)
  • 将新节点插入到每一层的上一个节点和下一个节点之间

除此之外,不要忘了每一层还有一个span变量,在插入新节点之后,我们需要计算新节点在每一层的span,另外,在插入新节点之后,新节点的上一个节点的span也会发生变化,需要我们更新。

那么问题来了,这个span怎么来计算呢?

首先看底层,底层其实是没有这个问题的,因为底层的步长是1。所有节点在底层都是直接相邻的,所以span都是1。

问题在于高层,比如Level1,我们要想办法确定插入节点6.5之后,节点4到节点6.5之间的距离,和节点6.5到节点8之间的距离,实际上这两个确定一个就可以,因为这两个距离之和就是原来Level1层节点4的span+1(+1是因为插入了节点6.5),现在问题转变为我们就想办法确定节点4到节点6.5之间的距离。

节点4到节点6.5之间的距离又该怎么算呢?我们可以将这段距离转化为Level1的节点4到Level0的节点6之间的距离+1,也就是下面这段:
zsl_insert_rank

而这一段又该怎么算呢?可以把它当做下面两段距离的差:

delta len = len1 - len2

zsl_insert_delta

len1(len2)其实就是当前节点的排名(节点6就是第6个节点)。

遍历,记录update和rank
  • 首先,这里创建了两个数组,数组大小都是最大层数,其中:
    • update数组用来记录新节点在每一层的上一个节点,也就是新节点要插到哪个节点后面;
    • rank数组用来记录update节点的排名,也就是在这一层,update节点到头节点的距离,这个上一节说过,是为了用来计算span。store rank that is crossed to reach the insert position。
    x = zsl->header;
    for (i = zsl->level-1; i >= 0; i--) {

        /* store rank that is crossed to reach the insert position */
        // 如果 i 不是 zsl->level-1 层
        // 那么 i 层的起始 rank 值为 i+1 层的 rank 值
        // 各个层的 rank 值一层层累积
        // 最终 rank[0] 的值加一就是新节点的前置节点的排位
        // rank[0] 会在后面成为计算 span 值和 rank 值的基础
        rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];

        // 沿着前进指针遍历跳跃表
        // T_wrost = O(N^2), T_avg = O(N log N)
        while (x->level[i].forward &&
            (x->level[i].forward->score < score ||
                // 比对分值
                (x->level[i].forward->score == score &&
                // 比对成员, T = O(N)
                compareStringObjects(x->level[i].forward->obj,obj) < 0))) {

            // 记录沿途跨越了多少个节点
            rank[i] += x->level[i].span;

            // 移动至下一指针
            x = x->level[i].forward;
        }
        // 记录将要和新节点相连接的节点
        update[i] = x;
    }

update和rank数组的值可以通过一次逐层的遍历确定。

遍历之前,定义一个x指向头节点。

遍历的过程可以参照这个图来看,还是假设要插入6.5,只不过这时候我们还不知道随机生成的层数是多少,也就是说每一层都有可能插入,因此从跳跃表当前的最大层数开始遍历,遍历到最底层为止。
zsl_insert_detail

遍历过程中,如果x的forward指针指向的节点(也就是x的下一个节点)的评分低于插入节点的评分,那么插入节点应当插入x的下一个节点的右侧,所以这时rank应当加上x节点的span(也就是x到x下一个节点的距离),然后再将x指向x的下一个节点。这就是下面这段代码的含义。

 while (x->level[i].forward &&
            (x->level[i].forward->score < score ||
                // 比对分值
                (x->level[i].forward->score == score &&
                // 比对成员, T = O(N)
                compareStringObjects(x->level[i].forward->obj,obj) < 0))) {

            // 记录沿途跨越了多少个节点
            rank[i] += x->level[i].span;

            // 移动至下一指针
            x = x->level[i].forward;
        }

上面这段循环的退出条件有两个,一个是x节点的下一个节点是空,也就是走到结尾了;另一个是x节点的下一个节点的评分大于插入节点的评分。在这两种条件下,都说明,新节点就是要插入在x节点后面,所以此时应将这一层的update节点记录为当前的x节点。

// 记录将要和新节点相连接的节点
update[i] = x;

第一次遍历,就是Level2,找到的update节点是头节点,对应的rank是0

第二次遍历,注意这里不是再重头开始遍历了,因为上一次遍历完,x已经移到了Level2的update节点,而在level1,update节点一定在x当前的位置之后(>=),所以对于rank的计算,也可以直接在上一层的rank的基础上继续计算,这就是下面这行代码的含义。

找到的update节点是节点4,对应的rank是此时x节点的span,也就是头节点在Level1的span。x这时也移动到了节点4。

rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];

生成随机层数

redis的跳跃表在插入节点时,会随机生成节点的层数,通过控制每一层的概率,控制每一层的节点个数,也就是保证第一层的节点个数,之后逐层增加,下面给出随机层数的生成代码:

int zslRandomLevel(void) {
    int level = 1;

    while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
        level += 1;

    return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}

这里面有一个宏定义ZSKIPLIST_P ,在源码中定义为了0.25,所以,上面这段代码,生成n+1的概率是生成n的概率的4倍。

如果生成的层数比当前跳跃表层数大

所以如果生成了一个比当前最大层数大的数,那么多出来的那些层也需要插入新的节点,而上面的那次遍历是从当前跳跃表最大层数开始的,也就是多出来这些层的update节点和rank还没有获取,因此需要通过下面这段程序,给多出来的这些层写入对应的rank和update节点。这部分很简单,因为这些层还没有节点,所以这些层的update节点只能是头节点,rank也都是0(头节点到头节点),而span则是节点个数(本身该层的头节点此时还没有forward节点,也不该有span,但插入节点后新节点需要用这个span计算新节点的span,因此这里需要把span设置为当前跳跃表中的节点个数)。

    // 如果新节点的层数比表中其他节点的层数都要大
    // 那么初始化表头节点中未使用的层,并将它们记录到 update 数组中
    // 将来也指向新节点
    if (level > zsl->level) {

        // 初始化未使用层
        // T = O(1)
        for (i = zsl->level; i < level; i++) {
            rank[i] = 0;
            update[i] = zsl->header;
            update[i]->level[i].span = zsl->length;
        }

        // 更新表中节点最大层数
        zsl->level = level;
    }
插入新节点

前面已经找到插入位置(update),接下来的插入其实就是单链表插入,这个就不说了。

注意span的计算,这里可以对着上面思路那一节来看, (rank[0] - rank[i])就是上面说的两段距离之差。

    // 创建新节点
    x = zslCreateNode(level,score,obj);

    // 将前面记录的指针指向新节点,并做相应的设置
    // T = O(1)
    for (i = 0; i < level; i++) {
        
        // 设置新节点的 forward 指针
        x->level[i].forward = update[i]->level[i].forward;
        
        // 将沿途记录的各个节点的 forward 指针指向新节点
        update[i]->level[i].forward = x;

        /* update span covered by update[i] as x is inserted here */
        // 计算新节点跨越的节点数量
        x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);

        // 更新新节点插入之后,沿途节点的 span 值
        // 其中的 +1 计算的是新节点
        update[i]->level[i].span = (rank[0] - rank[i]) + 1;
    }
更新未涉及到的层

如果随机生成的层数小于之前跳跃表中的层数,那么大于随机生成的层数的那些层在创建新节点的过程中就没有被操作到(创建新节点的时候是从0遍历到随机生成的层数),对于这些没有操作到的层,里面的update节点对应的span应当+1(因为后面插入了一个节点)。

    /* increment span for untouched levels */
    // 未接触的节点的 span 值也需要增一,这些节点直接从表头指向新节点
    // T = O(1)
    for (i = level; i < zsl->level; i++) {
        update[i]->level[i].span++;
    }
设置后继指针

针对每一层的调整到这里已经全部完成了,也就是level数组已经搞定,接下来,处理一下backward指针,首先新节点的backward要指向前一个节点,然后,新节点的下一个节点要将backward指向新节点。

   // 设置新节点的后退指针
    x->backward = (update[0] == zsl->header) ? NULL : update[0];
    if (x->level[0].forward)
        x->level[0].forward->backward = x;
    else
        zsl->tail = x;
更新跳跃表节点个数
   // 跳跃表的节点计数增一
    zsl->length++;

    return x;

zslDelete

删除跳跃表中包含给定成和分值的节点.

思路:

  • 遍历所有层,找到比要删除节点小的前一个节点
  • 判断它的分值和对象是否都相同,都相同才删除,否则返回0表示not found
/* Delete an element with matching score/object from the skiplist. 
 *
 * 从跳跃表 zsl 中删除包含给定节点 score 并且带有指定对象 obj 的节点。
 *
 * T_wrost = O(N^2), T_avg = O(N log N)
 */
int zslDelete(zskiplist *zsl, double score, robj *obj) {
    zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
    int i;

    // 遍历跳跃表,查找目标节点,并记录所有沿途节点
    // T_wrost = O(N^2), T_avg = O(N log N)
    x = zsl->header;
    for (i = zsl->level-1; i >= 0; i--) {

        // 遍历跳跃表的复杂度为 T_wrost = O(N), T_avg = O(log N)
        while (x->level[i].forward &&
            (x->level[i].forward->score < score ||
                // 比对分值
                (x->level[i].forward->score == score &&
                // 比对对象,T = O(N)
                compareStringObjects(x->level[i].forward->obj,obj) < 0)))

            // 沿着前进指针移动
            x = x->level[i].forward;

        // 记录沿途节点
        update[i] = x;
    }

    /* We may have multiple elements with the same score, what we need
     * is to find the element with both the right score and object. 
     *
     * 检查找到的元素 x ,只有在它的分值和对象都相同时,才将它删除。
     */
    x = x->level[0].forward;
    if (x && score == x->score && equalStringObjects(x->obj,obj)) {
        // T = O(1)
        zslDeleteNode(zsl, x, update);
        // T = O(1)
        zslFreeNode(x);
        return 1;
    } else {
        return 0; /* not found */
    }

    return 0; /* not found */
}

删除节点的逻辑

/**
 * Internal function used by zslDelete, zslDeleteByScore and zslDeleteByRank 
 * 
 * 内部删除函数,
 * 被 zslDelete  zslDeleteRangeByScore 和 zslDeleteByRank 等函数调用。
 *
 * T = O(1)
 */
void zslDeleteNode(zskiplist *zsl, zskiplistNode *x, zskiplistNode **update) {
    int i;

    // 更新所有和被删除节点 x 有关的节点的指针,解除它们之间的关系
    // T = O(1)
    for (i = 0; i < zsl->level; i++) {
        if (update[i]->level[i].forward == x) {
            update[i]->level[i].span += x->level[i].span - 1;
            update[i]->level[i].forward = x->level[i].forward;
        } else {
            update[i]->level[i].span -= 1;
        }
    }

    // 更新被删除节点 x 的前进和后退指针
    if (x->level[0].forward) {
        x->level[0].forward->backward = x->backward;
    } else {
        zsl->tail = x->backward;
    }

    // 更新跳跃表最大层数(只在被删除节点是跳跃表中最高的节点时才执行)
    // T = O(1)
    while(zsl->level > 1 && zsl->header->level[zsl->level-1].forward == NULL)
        zsl->level--;

    // 跳跃表节点计数器减一
    zsl->length--;
}
思路

如下图的跳跃表,加入我们需要删除8号节点:
zsl_delete1

如果要实现这个效果,我们要分两步去做:

  • 遍历跳跃表,查找目标节点,并记录所有沿途节点.(记录目标删除点的前一个点)
  • 连接目标节点的前驱和后继指针

除此之外,不要忘了每一层还有一个span变量,在删除目标节点之后,我们需要计算前驱指针的span。
zsl_delete2

更新前驱指针的span
 for (i = 0; i < zsl->level; i++) {
        if (update[i]->level[i].forward == x) {
            update[i]->level[i].span += x->level[i].span - 1;
            update[i]->level[i].forward = x->level[i].forward;
        } else {
            update[i]->level[i].span -= 1;
        }
    }
更新被删除节点 x 的前进和后退指针
    // 更新被删除节点 x 的前进和后退指针
    if (x->level[0].forward) {
        x->level[0].forward->backward = x->backward;
    } else {
        zsl->tail = x->backward;
    }
更新跳跃表最大层数

只在被删除节点是跳跃表中最高的节点时才执行

   while(zsl->level > 1 && zsl->header->level[zsl->level-1].forward == NULL)
        zsl->level--;

zsl_delete_finish

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

Erice_s

你的鼓励将是我创作的最大动力

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

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

打赏作者

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

抵扣说明:

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

余额充值