大白话解析LevelDB:LRUCache

LRUCache是一个基于LRU(Least Recently Used)算法实现的Cache

Cache满了之后,再插入新的缓存项时,会将Cache中访问时间最早的缓存项移除,为新的缓存项腾出空间。

LRUCache 的实现思路

LRUCache由 3 个数据结构组成,2 个链表和 1 个哈希表:

class LRUCache {
   
private:
    // ...

    // LRU 链表的 Dummy Head 节点。
    // LRU 链表中存放 refs == 1 && in_cache == true 的缓存项。
    // LRU 链表中,最新的缓存项是尾节点,最老的是头节点。
    LRUHandle lru_ GUARDED_BY(mutex_);

    // in_use 链表的 Dummy Head 节点。
    // in_use 链表中的缓存项是正在被客户端使用的,它们的引用次数 >= 2,in_cache==true。
    LRUHandle in_use_ GUARDED_BY(mutex_);

    // Cache 中所有缓存项的 Hash 表,用于快速查找缓存项。
    HandleTable table_ GUARDED_BY(mutex_);
};

我们先来看lru_链表和table_哈希表,这两个数据结构是LRUCache的核心,in_use_链表稍后再说。

lru_ 链表

lru_链表是一个双向链表,用于存放Cache中的缓存项。

链表节点用LRUHandle来表示(感觉如果叫做LRUNode的话会更好理解),lru链表的示意图如下:

   Older
<-----------------------------------------------------------------------------------------------+


+------------+             +------------+              +------------+              +------------+              +------------+
|            | +---------> |            |  +---------> |            |  +---------> |            |  +---------> |            |
|  LRUHandle |             |  LRUHandle |              |  LRUHandle |              |  LRUHandle |              |  LRUHandle |
|            | <---------+ |            |  <---------+ |            |  <---------+ |            |  <---------+ |            |
+------------+             +------------+              +------------+              +------------+              +------------+
                                                                                                                    lru_
+------------------------------------------------------------------------------------------------>
                                                                                      Younger

lru_链表的头节点是一个 Dummy Head 节点,不存放任何数据,只是用来简化链表的操作。

lru_链表的头部的节点,是访问时间最新的节点,而越靠近尾部的节点,访问时间越早。

当需要往Cache中插入新节点的时候,会使用头插法将该节点插入到lru_链表的头部。

如果Cache满了,需要移除一些老节点为新节点腾出空间,就会从lru_链表的尾部开始移除节点,直到空间足够插入新节点位置。

这样一来,往Cache中插入新节点就解决了,只需要 O ( 1 ) O(1) O(1)的时间往lru_链表的头部插入即可。

那么怎么从Cache中快速查找一个缓存项呢?这就需要用到table_哈希表了。

table_ 哈希表

table_哈希表是一个HandleTable,用于快速查找Cache中的缓存项。

LevelDB 设计的LRUHandle很巧妙,LRUHandle中存储了该缓存项的keyvaluehash等信息。

其中key是这个缓存项的唯一标识,hashkey的哈希值,value是缓存项的值。

使用table_.Lookup(key, hash)可以在 O ( 1 ) O(1) O(1)的时间复杂度内查找到key对应的缓存项在lru_链表中的位置。

也就是说,往Cache中插入一个Key-Value时,会构建出一个LRUHandle插入到lru_链表的头部,同时会在table_哈希表中插入{key, LRUHandle}

这样在查找key对应的缓存项时,只需要在table_哈希表中查找即可,不需要遍历整个lru_链表。

如果要从Cache中删除某个key对应的缓存项,也只需要在table_哈希表中查找到key对应的LRUHandle所在位置,然后从lru_链表中移除即可。

如此一来,通过table_哈希表和lru_链表的相互配合,就已经可以实现一个高效的LRUCache了,其增删改查的时间复杂度都是 O ( 1 ) O(1) O(1)

那么in_use_链表是干什么的呢?

in_use_ 链表

lru_链表一样,in_use_链表也是一个双向链表,用于存放Cache中的缓存项。

什么样的缓存项会被放到in_use_链表中呢?

我们来看这样一个场景:

LRUCache* cache里有{LRUHandle_1, LRUHandle_2, LRUHandle_3}三个缓存项,他们的Key分别为"key1", "key2", "key3",且引用计数都为1

此时客户端从cache里获取了key1key2的缓存项:

LRUHandle* lruhandle_1 = cache->Lookup("key1");
LRUHandle* lruhandle_2 = cache->Lookup("key2");

// lruhandle_1 和 lruhandle_2 正在被客户端使用..

会让lruhandle_1lruhandle_2的引用计数加一,此时lruhandle_1lruhandle_2的引用计数都变为了2,那么他们就会从lru_链表中移出来,放到in_use_链表中。

lruhandle_1lruhandle_2被客户端使用完毕后,通过LRUCache::Release(lruhandle)方法将他们的引用计数减一。

cache->Release(lruhandle_1);
cache->Release(lruhandle_2);

此时lruhandle_1lruhandle_2的引用计数都变回为了1,会从in_use_链表中移出来,又放回到lru_链表中。

in_use_链表的作用是让我们能清晰的知道哪些缓存项是正在被客户端使用的,哪些是在Cache中但是没有正在被使用,这样可以实现更精细的缓存策略。

比如在LRUCache::Prune()方法中,可以将所有没有正在被使用的缓存项从Cache中移除。

LRUCache 的代码实现

LRUCache 的定义

我们先来看下LRUCache的定义,都有哪些公共接口:

class LRUCache {
   public:
    LRUCache();
    ~LRUCache();

    // 设置 Cache 的容量。
    // 当插入一条缓存项使得 Cache 的总大小超过容量时,会将最老(访问时间最早)的缓存项移除。
    void SetCapacity(size_t capacity) { capacity_ = capacity; }

    // 插入一个缓存项到 Cache 中,同时注册该缓存项的销毁回调函数。
    // key: 缓存项的 key
    // hash: key 的 hash 值,需要客户端自己计算
    // value: 缓存数据的指针
    // charge: 缓存项的大小,需要客户端自己计算,因为缓存项里只存储了缓存数据的指针
    // deleter: 缓存项的销毁回调函数
    Cache::Handle* Insert(const Slice& key, uint32_t hash, void* value, size_t charge,
                          void (*deleter)(const Slice& key, void* value));
    
    // 根据 key 和 hash 查找缓存项。
    Cache::Handle* Lookup(const Slice& key, uint32_t hash);

    // 将缓存项的引用次数减一。
    void Release(Cache::Handle* handle);

    // 将缓存项从 Cache 中移除。
    void Erase(const Slice& key, uint32_t hash);

    // 移除 Cache 中所有没有正在被使用的缓存项,也就是引用计数为 1 的那些。
    void Prune();

    // 返回 Cache 里所有缓存项的总大小,也就是 Cache 的占用的内存空间。
    size_t TotalCharge() const {
        MutexLock l(&mutex_);
        return usage_;
    } 

   private:
    // LRUCache 的 3 个核心数据结构:

    // LRU 链表的 Dummy Head 节点。
    // LRU 链表中存放 refs == 1 && in_cache == true 的缓存项。
    // LRU 链表中,最新的缓存项是尾节点,最老的是头节点。
    LRUHandle lru_ GUARDED_BY(mutex_);

    // in_use 链表的 Dummy Head 节点。
    // in_use 链表中的缓存项是正在被客户端使用的,它们的引用次数 >= 2,in_cache==true。
    LRUHandle in_use_ GUARDED_BY(mutex_);

    // Cache 中所有缓存项的 Hash 表,用于快速查找缓存项。
    HandleTable table_ GUARDED_BY(mutex_);
};

LRUHandle

LRUHandleLRUCache的核心数据结构,用于表示Cache中的缓存项,把它叫做LRUNode可能更好理解一些,lru_链表和in_use_链表都是由若干个LRUHandle节点组成的。

忘记lru_链表长什么样的同学可以回头看下 lru_ 链表的示意图

在往下看LRUCache各个接口的实现之前,我们先来看下LRUHandle的定义:

struct LRUHandle {
    void* value;
    void (*deleter)(const Slice&, void* value);
    LRUHandle* next_hash; // 如果两个缓存项的 hash 值相同,那么它们会被放到一个 hash 桶中,next_hash 就是桶里的下一个缓存项
    LRUHandle* next; // LRU 链表中的下一个(更新的)缓存项
    LRUHandle* prev; // LRU 链表中的上一个(更旧的)缓存项
    size_t charge;  // 该缓存项的大小
    size_t key_length; // key 的长度
    bool in_cache;     // 该缓存项是否还在 Cache 中
    uint32_t refs;     // 引用次数 
    uint32_t hash;     // key 的 hash 值
    char key_data[1];  // key

    Slice key() const {
        // next_ is only equal to this if the LRU handle is the list head of an
        // empty list. List heads never have meaningful keys.
        assert(next != this);

        return Slice(key_data, key_length);
    }
};
LRUHandle::key, LRUHandle::hash, LRUHandle::value

LRUHandle::key是该缓存项的KeyLRUHandle::hashkey的哈希值,而LRUHandle::value是是缓存数据的指针。

LRUHandle::valuevoid*类型的,可以存储任意类型的数据,客户端需要自己管理它的生命周期。

LRUHandle::next_hash

LRUHandle::next_hash可能会有同学还没搞懂,它是用来解决哈希冲突的。

前面我们讲到,LRUCachetable_哈希表和lru_链表组成。

当我们往LRUCache中插入一个缓存项LRUHandle时,会将该LRUHandlelru_链表里插入,同时也会往table_哈希表里插入{key, LRUHandle}

示意图如下:

+-----------------------------------------------------------------------------------+
|   +----------------+  +----------------+ +----------------+ +----------------+    |
|   |    Bucket1     |  |    Bucket2     | |    Bucket3     | |    Bucket4     |    |
|   |                |  |                | |                | |                |    |
|   | +------------+ |  | +------------+ | | +------------+ | | +------------+ |    |
|   | | LRUHandle1 | |  | | LRUHandle4 | | | | LRUHandle5 | | | | LRUHandle6 | |    |
|   | +------------+ |  | +------------+ | | +------------+ | | +------------+ |    |
|   |       |next_hash  |                | |                | |                |    |
|   |       |        |  |                | |                | |                |    |
|   | +-----v------+ |  |                | |                | |                |    |
|   | | LRUHandle2 | |  |                | |                | |                |    |
|   | +------------+ |  |                | |                | |                |    |
|   |       |next_hash  |                | |                | |                |    |
|   |       |        |  |                | |                | |                |    |
|   | +-----v------+ |  |                | |                | |                |    |
|   | | LRUHandle3 | |  |                | |                | |                |    |
|   | +------------+ |  |                | |                | |                |    |
|   +----------------+  +----------------+ +----------------+ +----------------+    |
|                                                                                   |
|                                  LRUCache::table_                                 |
+-----------------------------------------------------------------------------------+

假设LRUHandle1LRUHandle2LRUHandle3Key互不相同,分别为key1, key2, key3,但它们的哈希值恰好都是1,那么它们会被一起放到Bucket1的链表中,然后用next_hash依次连起来。

当我们要在table_寻找Keykey2LRUHandle时,会先计算key2的哈希值,找到对应的Bucket,也就是Bucket1

LRUHandle::next, LRUHandle::prev

LRUHandle::nextLRUHandle::prevLRUHandle的双向链表指针,用于构成lru_链表,这应该比较好理解,见下图。

+------------+      next    +------------+
|            |  +---------> |            |
|  LRUHandle |              |  LRUHandle |
|            |  <---------+ |            |
+------------+      prev    +------------+
LRUHandle::charge

由于LRUHandle中只存储了value的指针,无法自己计算出value的大小,所以需要客户端自己计算出value的大小,然后记录到LRUHandle::charge中。

LRUHandle::in_cache

LRUHandle::in_cachetrue,则表示该缓存项还在Cache中,可能在lru_链表中,也可能在in_use_链表中。

LRUHandle::refs

该缓存项的引用次数,当引用次数为1时,表示该缓存项还在Cache中,但是没有正在被客户端使用。

当引用次数大于1时,表示该缓存项正在被客户端使用。

LRUHandle::deleter

当该缓存项被移出Cache时,会查看下该缓存项的引用计数是否为1,如果是的话,会调用LRUHandle::deleter来销毁缓存项中的缓存数据,也就是value

如果移出时引用计数不为1,那么暂时先不调用LRUHandle::deleter来将value销毁,因为还有客户端在使用这个缓存项。

当客户端使用完毕后,会调用LRUCache::Release(LRUHandle*)来将引用计数减一,当引用计数减为0了,则调用LRUHandle::deleter来销毁value

LRUHandle::key_data, LRUHandle::key_length

以数组的方式存储keyLRUHandle::key_data存储key的内容,LRUHandle::key_length存储key的长度。

但为什么key_data[1]的长度只有1呢?这是 C/C++ 的一个常用技巧,感兴趣的可以移步翻看柔性数组

HandleTable

了解完LRUHandle之后,在看LRUCache的实现之前,我们还需要来看下HandleTable的定义。

前面我们说过,LRUCache的核心数据结构是lru_链表和table_哈希表,table_哈希表是由HandleTable实现的。

HanldeTable是一个用数组实现的哈希表,数组里存放的是LRUHandle*LRUHandle的指针。

class HandleTable {
    // ...
private:
    uint32_t length_;   // 哈希表数组 list_[] 的大小
    uint32_t elems_;    // 哈希表中存放的元素个数
    LRUHandle** list_; // 哈希表的数组 list_[]
};

为什么哈希表中装的是LRUHandle*而不是LRUHandle呢?

节省空间呀,lru_链表已经存了LRUHandle了,table_哈希表只需要存LRUHandle的指针就行了。

我们来看下 HandleTable 的核心接口:

class HandleTable {
   public:
    HandleTable() : length_(0), elems_(0), list_(nullptr) { Resize(); }
    ~HandleTable() { delete[] list_; }

    LRUHandle* Lookup(const Slice& key, uint32_t hash);

    // 插入一个新的 LRUHandle, 返回一个和这个新 LRUHandle 相同 Key 的老 LRUHandle,
    // 如果存在的话。
    LRUHandle* Insert(LRUHandle* h);

    // 从哈希表中移除一个指定 Key 的 LRUHandle。
    LRUHandle* Remove(const Slice& key, uint32_t hash);
};
HandleTable::Insert(LRUHandle* h)

先查找待插入项h在哈希表中的待插入位置,然后对该位置使用反引用赋值。

假设h应该插入到list_[i]的位置,通过LRUHandle** ptr = FindPointer(h->key(), h->hash)获取到list_[i]的地址,然后对*ptr进行赋值。

*ptr = h相当于list_[i] = h,这样就完成了h的插入。

LRUHandle* Insert(LRUHandle* h) {
    // 找到 key 对应的 LRUHandle* 在 Hash 表中的位置。
    // 如果哈希表中存在相同 key 的缓存项,那么返回老的 LRUHandle* 
    // 在 Hash 表中的位置。
    // 如果哈希表中不存在相同 key 的缓存项,那么返回新的 LRUHandle*
    // 需要插入到 Hash 表中的位置。
    LRUHandle** ptr = FindPointer(h->key(), h->hash);
    
    // 先把老的 LRUHandle* 保存下来,最后返回给客户端。
    LRUHandle* old = *ptr;
    // 如果 old 存在,就用新的 LRUHandle* 替换掉 old。
    h->next_hash = (old == nullptr ? nullptr : old->next_hash);
    *ptr = h;
    if (old == nullptr) {
        /// 如果 old 不存在,表示哈希表中需要新插入一个 LRUHandle*。
        // 此时需要更新哈希表的元素个数,如果元素个数超过了哈希表的长度,
        // 则需要对哈希表进行扩容。
        ++elems_;
        if (elems_ > length_) {
            Resize();
        }
    }
    return old;
}
HandleTable::Lookup(const Slice& key, uint32_t hash)

使用FindPointer来查找key对应的LRUHandle*在哈希表中的位置。

LRUHandle* Lookup(const Slice& key, uint32_t hash) { return *FindPointer(key, hash); }
HandleTable::Remove(const Slice& key, uint32_t hash)
LRUHandle* Remove(const Slice& key, uint32_t hash) {
    // 找到 key 对应的 LRUHandle* 在 Hash 表中的位置。
    LRUHandle** ptr = FindPointer(key, hash);
    LRUHandle* result = *ptr;
    if (result != nullptr) {
        // 如果找到了,那么需要将该 LRUHandle* 从 Hash 表中移除,
        // 并且更新哈希表的元素个数。
        *ptr = result->next_hash;
        --elems_;
    }
    return result;
}
HandleTable::FindPointer(const Slice& key, uint32_t hash)

FindPointerHandleTable的核心方法,用于查找key对应的LRUHandle*在哈希表中的位置。

LRUHandle** FindPointer(const Slice& key, uint32_t hash) {
    // key 的 hash 值模上哈希表的长度,得到 key 在哈希表中的位置。
    // 这个位置其实是哈希冲突链表的头节点,遍历这个冲突链表,找到
    // key 对应的 LRUHandle*。
    LRUHandle** ptr = &list_[hash & (length_ - 1)];
    while (*ptr != nullptr && ((*ptr)->hash != hash || key != (*ptr)->key())) {
        ptr = &(*ptr)->next_hash;
    }
    return ptr;
}
HandleTable::Resize()

往哈希表中插入新元素后,如果哈希表的元素个数超过了哈希表的长度,那么需要对哈希表进行扩容。

创建一个新哈希表,大小是老哈希表的两倍,然后将老哈希表中的所有元素逐一 hash 到新哈希表中。最后销毁掉老哈希表,用新哈希表替换掉老哈希表。

void Resize() {
    // 哈希表扩容后的最小长度是 4
    uint32_t new_length = 4;
    // 将哈希表的长度以指数增长的方式扩大,
    // 一直扩大到可以容纳下哈希表里的所有
    // 元素为止。 
    while (new_length < elems_) {
        new_length *= 2;
    }

    // 创建一张新哈希表,将老哈希表里的所有元素逐一 hash 到新哈希表中。
    LRUHandle** new_list = new LRUHandle*[new_length];
    memset(new_list, 0, sizeof(new_list[0]) * new_length);
    uint32_t count = 0;
    for (uint32_t i = 0; i < length_; i++) {
        LRUHandle* h = list_[i];
        while (h != nullptr) {
            // 如果存在 hash 冲突,那么将冲突的 LRUHandle* 插入到冲突链表的尾部。
            LRUHandle* next = h->next_hash;
            uint32_t hash = h->hash;
            LRUHandle** ptr = &new_list[hash & (new_length - 1)];
            h->next_hash = *ptr;
            *ptr = h;
            h = next;
            count++;
        }
    }
    assert(elems_ == count);
    // 销毁老哈希表,用新哈希表替换掉老哈希表。
    delete[] list_;
    list_ = new_list;
    length_ = new_length;
}

LRUCache::Insert(const Slice& key, uint32_t hash, void* value, size_t charge, void (*deleter)(const Slice& key, void* value))

Cache::Handle* LRUCache::Insert(const Slice& key, uint32_t hash, void* value, size_t charge,
                                void (*deleter)(const Slice& key, void* value)) {
    MutexLock l(&mutex_);

    // 构造一个 LRUHandle 节点
    LRUHandle* e = reinterpret_cast<LRUHandle*>(malloc(sizeof(LRUHandle) - 1 + key.size()));
    e->value = value;
    e->deleter = deleter;
    e->charge = charge;
    e->key_length = key.size();
    e->hash = hash;
    e->in_cache = false;
    // 提前把引用计数先加一,因为 Insert 结束后需要把创建出来的 LRUHandle 地址
    // 返回给客户端,客户端对该 LRUHandle 的引用需要加一。
    e->refs = 1;  // 
    std::memcpy(e->key_data, key.data(), key.size());

    // 如果打开数据库时配置了禁止使用 Cache,则创建出来的 Cache Capacity 就会是 0。
    if (capacity_ > 0) {
        // 这里的引用计数加一表示该 LRUHandle 在 Cache 中,是 Cache 对 LRUHandle
        // 的引用。
        e->refs++;  // 
        e->in_cache = true;
        // 把 LRUHandle 节点按照 LRU 的策略插入到 in_use_ 链表中。
        LRU_Append(&in_use_, e);
        usage_ += charge;
        // 把 LRUHandle 节点插入到 Hash 表中。
        // 如果存在相同 key 的缓存项,那么`table_.Insert(e)`会返回老的缓存项。
        // 如果存在老的缓存项,那么需要将老的缓存项从 Cache 中移除。
        FinishErase(table_.Insert(e));
    } else {
        // capacity_ == 0 表示禁止使用 Cache,所以这里不需要把 LRUHandle 节点插入到
        // 链表中。
        e->next = nullptr;
    }

    // 如果插入新的 LRUHandle 节点后,Cache 的总大小超过了容量,那么需要将最老的
    // LRUHandle 节点移除,直到 Cache 的总大小不溢出容量。
    while (usage_ > capacity_ && lru_.next != &lru_) {
        // +->oldest <-> youngest <-> lru_<-+
        // +--------------------------------+
        LRUHandle* old = lru_.next;
        assert(old->refs == 1);
        bool erased = FinishErase(table_.Remove(old->key(), old->hash));
        if (!erased) {  // 防止编译报 Warning: unused variable
            assert(erased);
        }
    }

    return reinterpret_cast<Cache::Handle*>(e);
}

LRUCache::Lookup(const Slice& key, uint32_t hash)

Cache::Handle* LRUCache::Lookup(const Slice& key, uint32_t hash) {
    MutexLock l(&mutex_);
    // 到 Hash 表中查找 key 对应的缓存项指针。
    LRUHandle* e = table_.Lookup(key, hash);

    // 如果找到了缓存项,那么需要将缓存项的引用次数加一,
    // 然后返回该缓存项指针。
    if (e != nullptr) {
        Ref(e);
    }
    return reinterpret_cast<Cache::Handle*>(e);
}

LRUCache::Release(Cache::Handle* handle)

void LRUCache::Release(Cache::Handle* handle) {
    MutexLock l(&mutex_);
    // 将缓存项的引用次数减一。
    Unref(reinterpret_cast<LRUHandle*>(handle));
}

Unref(LRUHandle*)的实现如下:

void LRUCache::Unref(LRUHandle* e) {
    assert(e->refs > 0);
    // 将缓存项的引用次数减一。
    e->refs--;
    if (e->refs == 0) {  // Deallocate.
        // 如果引用计数减少后为 0,调用 deleter 销毁该缓存项。
        assert(!e->in_cache);
        (*e->deleter)(e->key(), e->value);
        free(e);
    } else if (e->in_cache && e->refs == 1) {
        // No longer in use; move to lru_ list.
        //
        // 如果引用计数减少后为 1,表示该缓存项已经没有正在使用的客户端了,
        // 那么需要将该缓存项从 in_use_ 链表中移除,然后插入回 lru_ 链表中。
        LRU_Remove(e);
        LRU_Append(&lru_, e);
    }
}

LRU_Remove(e)的含义是把e从所在链表移除。如果ein_use_链表中,那么就从in_use_链表中移除,如果elru_链表中,那么就从lru_链表中移除。

LRUCache::Erase(const Slice& key, uint32_t hash)

void LRUCache::Erase(const Slice& key, uint32_t hash) {
    MutexLock l(&mutex_);
    // 先从 Hash 表中移除 key 对应的缓存项,然后调用 FinishErase
    // 将缓存项从 Cache 中移除。
    FinishErase(table_.Remove(key, hash));
}

FinishErase(LRUHandle*)的实现如下:

bool LRUCache::FinishErase(LRUHandle* e) {
    if (e != nullptr) {
        assert(e->in_cache);
        // 将缓存项 e 从 in_use_ 或 lru_ 链表中移除。
        LRU_Remove(e);
        e->in_cache = false;
        usage_ -= e->charge;
        // 将引用计数减一,如果减一后为零,则销毁该缓存项。
        Unref(e);
    }
    return e != nullptr;
}

LRUCache::FinishErase(e)LRUCache::Erase(key, hash)的不同之处是:

  • LRUCache::FinishErase(e)不负责将 e 从 table_ 中移除,
  • LRUCache::Erase(key, hash)负责。

LRUCache::Prune()

void LRUCache::Prune() {
    MutexLock l(&mutex_);
    // 遍历 lru_ 链表,将该链表上的所有缓存项从 Cache 中移除。
    while (lru_.next != &lru_) {
        LRUHandle* e = lru_.next;
        assert(e->refs == 1);
        bool erased = FinishErase(table_.Remove(e->key(), e->hash));
        if (!erased) {
            assert(erased);
        }
    }
}
  • 18
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值