Redis学习篇 (1)SDS(Simple Dynamic String 简单动态字符串)

redis

注:截止目前 2021年03月 redis 最新大版本 6.0 下面的学习也是基于最新版本学习。

Redis是用C写的, 鉴于C没有提供一般的字符串处理方式,导致对字符串的各种处理都很麻烦,所以 Redis 自身实现了一个SDS(简单动态字符串)对象,用于操作所有的字符串,同时为了跟 C 兼容,所以 SDS 在实现的时候保证了它能直接适用于标准库 提供的各种 strxxx 函数。

SDS在Redis中也是存在最广泛的数据结构,它也是很多其他数据结构的基础,所以先选择介绍SDS。 SDS也兼容部分C字符串API(strcmp,strlen),它如何兼容C字符串我觉得也是有个很sao的操作。在开始正式内容前,先抛出几个问题:

  • C语言中已经支持String了,为什么Redis还要自己封装一个?
  • 或者说,redis 为什么把简单的字符串设计成 SDS?
  • SDS中的D(dynamic)到底是什么含义?
  • SDS的数据结构是啥样的?为什么要那么设计?
  • SDS是如何兼容C字符串的?

Redis中sds相关的源码都在src/sds.csrc/sds.h 中(链接可以直接跳转redis源码),其中sds.h中定义了所有SDS的api,当然也实现了部分几个api,比如sds长度、sds剩余可用空间……,我们先看下SDS的数据结构,看完后再看代码为什么那么写,我们就一目了然了。

一、sdshdr数据结构

redis提供了sdshdr5 ,sdshdr8, sdshdr16, sdshdr32, sdshdr64这几种sds的实现,其中除了sdshdr5比较特殊外,其他几种sdshdr差不只在于两个字段的类型差别。我就拿 sdshdr8sdshdr16来举例,源码所在文件(src/sds.h ),其struct定义分别如下:

struct __attribute__ ((__packed__)) sdshdr5 {
	/* 实际上这个类型redis不会被使用。他的内部结构也与其他sdshdr不同,直接看sdshdr8就好。*/
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length *//* 一共8位,低3位用来存放真实的flags(类型),高5位用来存放len(长度)。*/
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* used *//*表示当前sds的长度(单位是字节)*/
    uint8_t alloc; /* excluding the header and null terminator */ /*表示已为sds分配的内存大小(单位是字节)*/
    unsigned char flags; /* 3 lsb of type, 5 unused bits *//* 用一个字节表示当前sdshdr的类型,因为有sdshdr有五种类型,所以至少需要3位来表示。000:sdshdr5,001:sdshdr8,010:sdshdr16,011:sdshdr32,100:sdshdr64。高5位用不到所以都为0。*/
    char buf[]; /* sds实际存放的位置 */
};
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* used */
    uint16_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};

msb: Most Significant Bit, 最高有效位;lsb: Least Significant Bit, 最低有效位。


使用 __attribute__ ((__packed__))声明结构体的作用:让编译器以紧凑模式来分配内存,如果不声明该属性,编译器可能会为结构体成员做内存对齐优化,在其中填充空字符。这样就不能保证 结构体成员在内存上是紧凑相邻的,也不能通过 buf 向低地址偏移一个字节来获取 flags 字段的值。


char buf[]:一个没有指定长度的字符数组,这是 c 语言定义字符数组的一种特殊写法,称为 flexible array member,只能定义在一个结构体的最后一个成员。buf 起标记作用,表示在 flags 字段后面就是一个字符数组,程序在为 sdshdr 分配内存时,它并不占用内存空间。


还有,要说明之所以sizeof(struct sdshdr8)的大小是len+alloc+flags 是因为这个struct拥有一个柔性数组成员 buf,柔性数组成员是C99之后引入的一个新feature,这里可以通过sizeof整个struct给出buf变量的偏移量,从而确定buf的位置。


参数说明:
len表示sds当前sds的长度(单位是字节),不包括’0’终止符,通过len直接获取字符串长度,不需要扫一遍string,这就是封装sds的理由之一;
alloc表示当前为sds分配的大小(单位是字节)(3.2以前的版本用的free是表示还剩free字节可用空间),不包括’0’终止符;
flags表示当前sdshdr的类型,声明为char 一共有1个字节(8位),仅用低三位就可以表示所有5种sdshdr类型(详见上文代码注释)。
要判断一个sds属于什么类型的sdshdr,只需 flags&SDS_TYPE_MASK和SDS_TYPE_n比较即可(之所以需要SDS_TYPE_MASK是因为有sdshdr5这个特例,它的高5位不一定为0,参考上面sdshdr5定义里的代码注释)。

sdshdr32,sdshdr64也和上面的结构一致,差别只在于lenalloc的数据类型不一样而已。相较于c原生的字符串,sds多了lenallocflags三个字段来存储一些额外的信息,redis考虑到了字符串拼接时带来的巨大损耗,所以每次新建sds的时候会预分配一些空间来应对未来的增长。正是因为预留空间的机制,所以redis需要记录下来已分配和总空间大小,当然可用空间可用直接算出来。
sdshdr数据结构组成

为什么redis费心费力要提供sdshdr5sdshdr64这五种SDS呢?

我觉着这只能说明Redis作者抠内存抠到机制,牺牲了代码的简洁性换取了每个sds省下来的几个字节的内存空间。从sds初始化方法sdsnewsdsnewlen中我们就可以看出,redis在新建sds时需要传如初始化长度,然后根据初始化的长度确定用哪种sdshdr,小于2^8长度的用sdshdr8,这样len和alloc只占用两个字节,比较短字符串可能非常多,所以节省下来的内存还是非常可观的,知道了sds的数据结构和设计原理,sdsnewlen的代码就非常好懂了,源码所在文件(src/sds.c ),如下:

/* Create a new sds string with the content specified by the 'init' pointer
 * and 'initlen'.
 * If NULL is used for 'init' the string is initialized with zero bytes.
 * If SDS_NOINIT is used, the buffer is left uninitialized;
 *
 * The string is always null-termined (all the sds strings are, always) so
 * even if you create an sds string with:
 *
 * mystring = sdsnewlen("abc",3);
 *
 * You can print the string with printf() as there is an implicit \0 at the
 * end of the string. However the string is binary safe and can contain
 * \0 characters in the middle, as the length is stored in the sds header. */
 
sds _sdsnewlen(const void *init, size_t initlen, int trymalloc) {
    void *sh;
    sds s;
    
    /* 【注】:根据初始化的长度确定用哪种sdshdr
    * sdsReqType是一个内部函数,会根据申请的字符串的长度确定对应的sds类型
    *(因为sds5只支持长度为2^5的字符串,sds8只支持长度为2^8的字符串,以此类推 */
    
    char type = sdsReqType(initlen); 
    /* Empty strings are usually created in order to append. Use type 8
     * since type 5 is not good at this. */
 
    /* 【注】:这里是一个优化,当initlen为0的时候,意味着要申请一个空sds,
    * 空字符串大概率之后会append,但sdshdr5不适合用来append,
    * 这个时候sds8会在这方面表现得更好
    */ 
    
    if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8;
    int hdrlen = sdsHdrSize(type);
    unsigned char *fp; /* flags pointer. */
    size_t usable;

    assert(initlen + hdrlen + 1 > initlen); /* Catch size_t overflow */
    /* 【注】
    * s_trymalloc_usable()尝试分配内存,如果失败,则返回NULL。 如果非NULL,则将'* usable'设置为可用大小。
    * s_malloc_usable()分配内存或紧急/异常情况。 如果非NULL,则将'* usable'设置为可用大小。 
    * 具体函数 请查看 (https://github.com/redis/redis/blob/unstable/src/zmalloc.c)*/
    sh = trymalloc?
        s_trymalloc_usable(hdrlen+initlen+1, &usable) :
        s_malloc_usable(hdrlen+initlen+1, &usable);
    if (sh == NULL) return NULL;
    // SDS_NOINIT=="SDS_NOINIT",如果init是这个字符串,则不对sds进行初始化
    if (init==SDS_NOINIT)
        init = NULL;
    else if (!init)
        memset(sh, 0, hdrlen+initlen+1);
        
 	/* 【**注意**】:返回的s并不是直接指向sds的指针,而是指向sds中字符串的指针,
 	 * sds的指针还需要根据s和hdrlen计算出来,
     * s为sds中buf的起始位置*/
     * 
    s = (char*)sh+hdrlen;
    
    /* fp是sds中flags的指针 */
    
    fp = ((unsigned char*)s)-1;
    usable = usable-hdrlen-1;
    if (usable > sdsTypeMaxSize(type))
        usable = sdsTypeMaxSize(type);
    switch(type) {
    
    /* 这个是由sds5的规定确定的,sds5中flags低三位是类型,高五位是字符串长度。
    * 所以将initlen偏移三位后,initlen就会移到高五位,
    * 再和type或一下,低三位就是type(目前也只有sds5是这样用,所以低三位就只会是0了) */
    
        case SDS_TYPE_5: {
            *fp = type | (initlen << SDS_TYPE_BITS);
            break;
        }
        case SDS_TYPE_8: {
            SDS_HDR_VAR(8,s);
            sh->len = initlen;
            sh->alloc = usable;
            *fp = type;
            break;
        }
        case SDS_TYPE_16: {
            SDS_HDR_VAR(16,s);
            sh->len = initlen;
            sh->alloc = usable;
            *fp = type;
            break;
        }
        case SDS_TYPE_32: {
            SDS_HDR_VAR(32,s);
            sh->len = initlen;
            sh->alloc = usable;
            *fp = type;
            break;
        }
        case SDS_TYPE_64: {
            SDS_HDR_VAR(64,s);
            sh->len = initlen;
            sh->alloc = usable;
            *fp = type;
            break;
        }
    }
    /* 将init字符串的initlen个字节赋值给sds底层char */
    if (initlen && init)
        memcpy(s, init, initlen);
    s[initlen] = '\0';
    return s;
}
/* 对 _sdsnewlen 的上层封装。*/
sds sdsnewlen(const void *init, size_t initlen) {
    return _sdsnewlen(init, initlen, 0);
}
/* 对 _sdsnewlen 的上层封装。和 sdsnewlen() 的区别是:
* sdstrynewlen() 如果分配内存失败,则返回 null
* sdsnewlen(): 如果分配内存失败,则抛出异常 :zmalloc:尝试分配%zu 字节时内存不足
* 
* */
sds sdstrynewlen(const void *init, size_t initlen) {
    return _sdsnewlen(init, initlen, 1);
}


流程如下:

  • 根据sds的长度判断需要选用sdshdr的类型
  • 根据sdshdr的类型用sdsHdrSize函数得到hdrlen(其实就是sizeof(struct sdshdr))
  • 为sdshdr分配一个hdrlen+initlen+1大小的堆内存(+1是为了放置’\0’,这个’\0’不计入alloc或len)
  • 按参数填充成员变量len、alloc和type
  • 用memcpy给sds赋值,并在尾部加上’\0’

其他函数说明,具体可参照 sds 常用API

/* Create an empty (zero length) sds string. Even in this case the string
 * always has an implicit null term. */

/* 创建一个空的(零长度)sds字符串。 即使在这种情况下,字符串总是有一个隐含的空项。*/

sds sdsempty(void) {
    return sdsnewlen("",0);
}

/* Create a new sds string starting from a null terminated C string. */

/* 创建一个新的SDS字符串从空开始终止的C字符串 */

sds sdsnew(const char *init) {
    size_t initlen = (init == NULL) ? 0 : strlen(init);
    return sdsnewlen(init, initlen);
}

/* Duplicate an sds string. */
/* 复制一个sds字符串。*/
sds sdsdup(const sds s) {
    return sdsnewlen(s, sdslen(s));
}

/* Free an sds string. No operation is performed if 's' is NULL. */
/* 释放一个SDS字符串。 如果“s”为NULL,则不执行任何操作。 */
void sdsfree(sds s) {s
    if (s == NULL) return;
    s_free((char*)s-sdsHdrSize(s[-1]));
}

二、SDS的使用

上面代码中特意标注了一个【**注意**】_sdsnewlen()返回的sds指针并不是直接指向sdshdr的地址,而是直接指向了sdshdr中buf的地址。

这样做有啥好处?

好处就是这样可以兼容c原生字符串。buf其实就是C 原生字符串+部分空余空间,中间是特殊符号’0’隔开,‘0’有是标识C字符串末尾的符号,这样就实现了和C原生字符串的兼容,部分C字符串的API也就可以直接使用了。 当然这也有坏处,这样就没法直接拿到len和alloc的具体值了,但是也不是没有办法。

当我们拿到一个sds,假设这个sds就叫s吧,其实一开始我们对这个sds一无所知,连他是sdshdr几都不知道,这时候可以看下s的前面一个字节,我们已经知道sdshdr的数据结构了,前一个字节就是flag,根据flag具体的值我们就可以推断出s具体是哪个sdshdr,也可以推断出sds的真正地址,相应的就知道了它的len和alloc,知道了这点,下面这些有点晦涩的代码就很好理解了。


static inline size_t sdslen(const sds s) {
    unsigned char flags = s[-1]; /* -1 相当于获取到了sdshdr中的flag字段 */
    switch(flags&SDS_TYPE_MASK) {
        case SDS_TYPE_5:
            return SDS_TYPE_5_LEN(flags);
        case SDS_TYPE_8:
            return SDS_HDR(8,s)->len;
        case SDS_TYPE_16:
            return SDS_HDR(16,s)->len;
        case SDS_TYPE_32:
            return SDS_HDR(32,s)->len;
        case SDS_TYPE_64:
            return SDS_HDR(64,s)->len;
    }
    return 0;
}

static inline size_t sdsavail(const sds s) {
    unsigned char flags = s[-1];
    switch(flags&SDS_TYPE_MASK) {
        case SDS_TYPE_5: {
            return 0;
        }
        case SDS_TYPE_8: {
            SDS_HDR_VAR(8,s);
            return sh->alloc - sh->len; /* 宏替换获取到sdshdr中的len */
        }
        case SDS_TYPE_16: {
            SDS_HDR_VAR(16,s);
            return sh->alloc - sh->len;
        }
        case SDS_TYPE_32: {
            SDS_HDR_VAR(32,s);
            return sh->alloc - sh->len;
        }
        case SDS_TYPE_64: {
            SDS_HDR_VAR(64,s);
            return sh->alloc - sh->len;
        }
    }
    return 0;
}

sdslen函数里第一行出现了s[-1],看起来感觉会是一个undefined behavior,其实不是,这是一种正常又正确的使用方式,它就等同于*(s-1)。The definition of the subscript operator [] is that E1[E2] is identical to (((E1)+(E2))). --C99。又因为s是一个sds(char)所以s指向的类型是char,-1就是-1*sizeof(char),由于sdshdr结构体内禁用了内存对齐,所以这也刚好是一个flags(unsigned char)的地址,所以通过s[-1]我们可以获得sds所属的sdshdr的成员变量flags。

三、SDS的扩容

在做字符串接的时候,sds可能剩余的可用空间不足,这个时候需要扩容,什么时候该扩容,又该怎么扩? 这是不得不考虑的问题。Java中很多数据结构都有动态扩容的机制,比如和sds很类似的StringBuffer,HashMap,他们都会在使用过程中动态判断是否空间充足,而且基本上都采用了先指数扩容,然后到一定大小限制后才开始线性扩容的方式,Redis也不例外,Redis在10241024以内都是2倍的方式扩容,只要不超出1021024都是先额外申请200%的空间,但一旦总长度超过10241024字节,那每次最多只会扩容10241024字节。 Redis中sds扩容的代码是在·sdsMakeRoomFor()·,可以看到很多字符串变更的API开头都直接或者间接调用这个。 和Java中StringBuffer扩容不同的是,Redis这里还需要考虑不同字符串长度时sdshdr类型的变化,具体代码如下:

/* Enlarge the free space at the end of the sds string so that the caller
 * is sure that after calling this function can overwrite up to addlen
 * bytes after the end of the string, plus one more byte for nul term.
 *
 * Note: this does not change the *length* of the sds string as returned
 * by sdslen(), but only the free buffer space we have. */
 * 
 /* 扩大sds字符串末尾的可用空间,以便调用者确保在调用此函数后可以覆盖字符串末尾的addlen字节,再为null项再加上一个字节。
 * 注意:这不会更改sdslen()返回的sds字符串的* length *,而只会更改我们拥有的可用缓冲区空间。*/
  
sds sdsMakeRoomFor(sds s, size_t addlen) {
    void *sh, *newsh;
    size_t avail = sdsavail(s);
    size_t len, newlen;
    char type, oldtype = s[-1] & SDS_TYPE_MASK;
    int hdrlen;
    size_t usable;

    /* Return ASAP if there is enough space left. */
    if (avail >= addlen) return s;

    len = sdslen(s);
    sh = (char*)s-sdsHdrSize(oldtype);
    newlen = (len+addlen);
    assert(newlen > len);   /* Catch size_t overflow */
    if (newlen < SDS_MAX_PREALLOC)
        newlen *= 2;
    else
        newlen += SDS_MAX_PREALLOC;

    type = sdsReqType(newlen);

    /* Don't use type 5: the user is appending to the string and type 5 is
     * not able to remember empty space, so sdsMakeRoomFor() must be called
     * at every appending operation. */
    if (type == SDS_TYPE_5) type = SDS_TYPE_8;

    hdrlen = sdsHdrSize(type);
    assert(hdrlen + newlen + 1 > len);  /* Catch size_t overflow */
    if (oldtype==type) {
        newsh = s_realloc_usable(sh, hdrlen+newlen+1, &usable);
        if (newsh == NULL) return NULL;
        s = (char*)newsh+hdrlen;
    } else {
        /* Since the header size changes, need to move the string forward,
         * and can't use realloc */
        newsh = s_malloc_usable(hdrlen+newlen+1, &usable);
        if (newsh == NULL) return NULL;
        memcpy((char*)newsh+hdrlen, s, len+1);
        s_free(sh);
        s = (char*)newsh+hdrlen;
        s[-1] = type;
        sdssetlen(s, len);
    }
    usable = usable-hdrlen-1;
    if (usable > sdsTypeMaxSize(type))
        usable = sdsTypeMaxSize(type);
    sdssetalloc(s, usable);
    return s;
}

四、sds 常用API

函数名称作用复杂度
sdsnewlen创建一个指定长度的 sds ,接受一个 C 字符串作为初始化值O(N)
sdstrynewlen创建一个指定长度可捕获异常的的 sds ,接受一个 C 字符串作为初始化值O(N)
sdsempty创建一个只包含空字符串””的sdsO(N)
sdsnew根据给定的C字符串,创建一个相应的sdsO(N)
sdsdup复制给定的sdsO(N)
sdsfree释放给定的sdsO(1)
sdsupdatelen更新给定sds所对应的sdshdr的free与len值O(1)
sdsclear清除给定 sds 的内容,将它初始化为 “”O(1)
sdsMakeRoomFor对给定sds对应sdshdr的buf进行扩展O(N)
sdsRemoveFreeSpace在不改动 buf 的情况下,将 buf 内多余的空间释放出去,这样就节省了每次sds缩减长度而导致的内存释放开销。O(N)
sdsAllocSize计算给定 sds 的 buf 所占用的内存总数。包括:1)指针之前的sds标头。2)字符串。3)最后的空闲缓冲区(如果有)。4)隐式空项。O(1)
sdsIncrLen对 sds 的 buf 的右端进行扩展(expand)或修剪(trim)O(1)
sdsgrowzero将给定 sds 的 buf 扩展至指定长度,无内容的部分用 \0 来填充O(N)
sdscatlen将一个C字符串追加到给定的sds对应sdshdr的bufO(N)
sdscat将一个 C 字符串追加到 sds 末尾O(N)
sdscatsds将一个 sds 追加到另一个 sds 末尾O(N)
sdscpylen将一个C字符串复制到sds中,需要依据sds的总长度来判断是否需要扩展O(N)
sdscpy将一个 C 字符串复制到 sdsO(N)
sdscatprintf通过格式化输出形式,来追加到给定的sdsO(N)
sdscatfmt此函数与sdscatprintf相似,但速度更快,因为它不依赖于libc实现的sprintf()系列函数,而这些函数通常非常慢。 此外,在连接新数据时直接处理sds字符串可以提高性能,但是此函数只能处理与printf-alike不兼容的子集O(N)
sdstrim对给定sds,删除前端/后端在给定的C字符串中的字符O(N)
sdsrange截取给定sds,[start,end]字符串O(N)
sdstolowers串转成小写O(N)
sdstouppers串转成大写O(N)
sdscmp比较两个sds的大小O(N)
sdssplitlen对给定的字符串s按照给定的sep分隔字符串来进行切割O(N)
sdscatrepr在sds字符串“ s”后面附加一个转义的字符串表示形式,其中所有不可打印的字符(通过isprint()测试)都以“ \ n \ r \ a …”或“ \ x < hex-number>“。调用之后,修改后的sds字符串将不再有效,并且所有引用都必须替换为调用返回的新指针。O(N)
sdsmapchars字符替换。将字符串 s 中,所有在 from 中出现的字符,替换成 to 中的字符。比如调用 sdsmapchars(mystring, “ho”, “01”, 2) 就会将 “hello” 转换为 “0ell1”O(N^2)
sdsjoin把c字符串数组按指定的sep连接符连接起来O(N)
sdsjoinsds类似于sdsjoin,但是连接SDS字符串数组。O(N)

五、总结一下

1、sds简单动态字符串的优点

(1)、可以常数复杂度获取字符串长度

通过len属性直接获取字符串实际长度,不包括结尾的’\0’. 时间复杂度O(1)

(2)、防止缓冲区溢出

strcat()函数不能保证目的内存是足够的。SDS的空间分配策略完全杜绝了发生缓冲区溢出的可能性:当SDS API需要对SDS进行修改时,API会先检查SDS的空间是否满足修改所需的要求,如果不满足的话,API会自动将SDS的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用SDS既不需要手动修改SDS的空间大小,也不会出现前面所说的缓冲区溢出问题。

(3)、减少修改字符串导致内存重分配的次数

空间预分配策略,Redis可以减少连续执行字符串增长所需的内存重分配次数。在SDS中,buf数组的长度不一定就是字符数量加一,数组里面可以包含未使用的字节,而这些字节的数量就由SDS的free属性记录。通过未使用空间,SDS实现了空间预分配和惰性空间释放两种优化策略。①空间预分配;②惰性空间释放。

(4)、二进制安全

C字符串中的字符必须符合某种编码(比如ASCII),并且除了字符串的末尾之外,字符串里面不能包含空字符,否则最先被程序读入的空字符误认为是字符串结尾,这些限制使得C字符串只能保存文本数据,而不能保存像图片、音频、视频、压缩文件这样的二进制数据。
为了确保Redis可以适用于各种不同的使用场景,SDS的API都是二进制安全的(binary-safe),所有SDS API都会以处理二进制的方式来处理SDS存放在buf数组里的数据,程序不会对其中的数据做任何限制、过滤、或者假设,数据在写入时是什么样的,它被读取时就是什么样。
C语言中的字符串以’\0’结尾,而Redis由于使用len记录数据长度,而不是使用空字符判断字符串是否结束,所以简单动态字符串可以存储包含空字符的数据。

2、C 字符串和 SDS 之间的区别

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

下一节:Redis学习篇 (2)linkedlist(双端链表)



END

如有问题请在下方留言。

或关注我的公众号“孙三苗”,输入“联系方式”。获得进一步帮助。

在这里插入图片描述

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值