CMU15-445 2021Fal PROJECT#1 - BUFFER POOL 含代码慎用

PROJECT #1 - BUFFER POOL


以下代码均未通过make check-clang-tidy测试慎用,谨取

Task1 LRU REPLACEMENT POLICY

LRUReplacer 的最大页数与缓冲池的大小相同,因为它包含 BufferPoolManager 中所有帧的占位符。 然而,在任何给定时刻,并非所有帧都被认为在 LRUReplacer 中。 LRUReplacer 被初始化为没有帧。 然后,只有 新取消固定(零引用) 的才会被认为在 LRUReplacer 中。

  • Victim(frame_id_t*) :与替换器跟踪的所有其他元素相比,删除最近最少访问的对象,将其内容存储在输出参数中并返回 True。 如果 Replacer 为空,则返回 False。
  • Pin(frame_id_t) :在将页面固定到 BufferPoolManager 中的框架后,应调用此方法。 它应该从 LRUReplacer 中删除包含固定页面的框架。
  • Unpin(frame_id_t) :当页面的 pin_count 变为 0 时应调用此方法。此方法应将包含未固定页面的框架添加到 LRUReplacer。
  • Size() :此方法返回当前在 LRUReplacer 中的帧数。

实施细节由您决定。 您可以使用内置的 STL 容器。 您可以假设不会耗尽内存,但必须确保操作是线程安全的。

比较简单就直接贴代码了

//===----------------------------------------------------------------------===//
//
//                         BusTub
//
// lru_replacer.cpp
//
// Identification: src/buffer/lru_replacer.cpp
//
// Copyright (c) 2015-2019, Carnegie Mellon University Database Group
//
//===----------------------------------------------------------------------===//

#include "buffer/lru_replacer.h"

namespace bustub {

LRUReplacer::LRUReplacer(size_t num_pages):capacity(num_pages){}

LRUReplacer::~LRUReplacer() = default;


//删除最近最少访问的对象
bool LRUReplacer::Victim(frame_id_t *frame_id) {
    latch.lock();
    if (lru_map.empty()) { //如果 Replacer 为空,则返回 False
        frame_id = nullptr;
        latch.unlock();
        return false;
    }
    
    frame_id_t lru_frame = lru_list.back(); //删掉尾部的frame_id
    lru_map.erase(lru_frame);
    lru_list.pop_back();
    
    *frame_id = lru_frame; //将其内容存储在输出参数中并返回 True
    
    latch.unlock();
    return true;
}

//从LRUReplacer中删除被引用的页面。
void LRUReplacer::Pin(frame_id_t frame_id) {
    latch.lock();
    if (lru_map.count(frame_id) != 0) {
        lru_list.erase(lru_map[frame_id]);
        lru_map.erase(frame_id);
    }
    latch.unlock();
}

//当页面的 pin_count 变为 0 时应调用此方法。将包含页面添加到 LRUReplacer。
void LRUReplacer::Unpin(frame_id_t frame_id) {
    if (lru_map.count(frame_id) != 0) return ;

    latch.lock();
    if (lru_map.size() == capacity) {
        frame_id_t fd;
        if(Victim(&fd) == false) return;
    }
    lru_list.push_front(frame_id); //头部插入frame_id
    lru_map.insert({frame_id, lru_list.begin()}); //插入 frame_id 和 迭代器
    latch.unlock();
}
  

size_t LRUReplacer::Size() { return lru_map.size(); }

}  // namespace bustub

Task2 BUFFER POOL MANAGER

接下来,您需要在系统中实现缓冲池管理器(BufferPoolManagerInstance)。BufferPoolManagerInstance负责从DiskManager获取数据库页面并将它们存储在内存中。BufferPoolManage还可以在有要求它这样做时,或者当它需要驱逐一个页以便为新页腾出空间时,将脏页写入磁盘。为了确保您的实现能够正确地与系统的其余部分一起工作,我们将为您提供一些已经填写好的功能。您也不需要实现实际读写数据到磁盘的代码(在我们的实现中称为DiskManager)。我们将为您提供这一功能。

系统中的所有内存页面均由Page对象表示。BufferPoolManagerInstance不需要了解这些页面的内容。 但是,作为系统开发人员,重要的是要了解Page对象只是缓冲池中用于存储内存的容器,因此并不特定于唯一页面。 也就是说,每个Page对象都包含一块内存,DiskManager会将其用作复制从磁盘读取的物理页面内容的位置。 BufferPoolManagerInstance将在将其来回移动到磁盘时重用相同的Page对象来存储数据。 这意味着在系统的整个生命周期中,相同的Page对象可能包含不同的物理页面。Page对象的标识符(page_id)跟踪其包含的物理页面。 如果Page对象不包含物理页面,则必须将其page_id设置为INVALID_PAGE_ID

每个Page对象还维护一个计数器,以显示“固定”该页面的线程数。BufferPoolManagerInstance不允许释放固定的页面。每个Page对象还跟踪它的脏标记。您的工作是判断页面在解绑定之前是否已经被修改(修改则把脏标记置为1)。BufferPoolManagerInstance必须将脏页的内容写回磁盘,然后才能重用该对象。

BufferPoolManagerInstance实现将使用在此分配的前面步骤中创建的LRUReplacer类。它将使用LRUReplacer来跟踪何时访问页对象,以便在必须释放一个帧来为从磁盘复制新的物理页腾出空间时,它可以决定取消哪个页对象

You will need to implement the following functions defined in the header file (src/include/buffer/buffer_pool_manager_instance.h) in the source file (src/buffer/buffer_pool_manager_instance.cpp):

  • FetchPgImp(page_id) Fetch the requested page from the buffer pool.
  • UnpinPgImp(page_id, is_dirty) Unpin the target page from the buffer pool.
  • FlushPgImp(page_id) Flushes the target page to disk.
  • NewPgImp(page_id) Creates a new page in the buffer pool.
  • DeletePgImp(page_id) Deletes a page from the buffer pool.
  • FlushAllPagesImpl() Flushes all the pages in the buffer pool to disk.

先看看构造函数和数据

BufferPoolManagerInstance::BufferPoolManagerInstance(size_t pool_size, uint32_t num_instances, uint32_t instance_index, DiskManager *disk_manager, LogManager *log_manager)
    : pool_size_(pool_size),
      num_instances_(num_instances),
      next_page_id_(instance_index),
      disk_manager_(disk_manager),
      log_manager_(log_manager) {
  BUSTUB_ASSERT(num_instances > 0, "If BPI is not part of a pool, then the pool size should just be 1");
  BUSTUB_ASSERT(instance_index < num_instances,"BPI index cannot be greater than the number of BPIs in the pool. In non-parallel case, index should just be 1.");
  // We allocate a consecutive memory space for the buffer pool.
  pages_ = new Page[pool_size_];
  replacer_ = new LRUReplacer(pool_size);

  // Initially, every page is in the free list.
  for (size_t i = 0; i < pool_size_; ++i) {
    free_list_.emplace_back(static_cast<int>(i));
  }
}

//BufferPoolManager的数据
  /** Array of buffer pool pages. */
  Page *pages_;
  /** Pointer to the disk manager. */
  DiskManager *disk_manager_ __attribute__((__unused__));
  /** Pointer to the log manager. */
  LogManager *log_manager_ __attribute__((__unused__));
  /** Page table for keeping track of buffer pool pages. */
  std::unordered_map<page_id_t, frame_id_t> page_table_;
  /** Replacer to find unpinned pages for replacement. */
  Replacer *replacer_;
  /** List of free pages. */
  std::list<frame_id_t> free_list_;
  /** This latch protects shared data structures. We recommend updating this comment to describe what it protects. */
  std::mutex latch_;


//page的数据
  /** The actual data that is stored within a page. */
  char data_[PAGE_SIZE]{};
  /** The ID of this page. */
  page_id_t page_id_ = INVALID_PAGE_ID;
  /** The pin count of this page. */
  int pin_count_ = 0;
  /** True if the page is dirty, i.e. it is different from its corresponding page on disk. */
  bool is_dirty_ = false;
  /** Page latch. */
  ReaderWriterLatch rwlatch_;

BPM的总体架构

Memory(Buffer Pool Management)
面向磁盘的数据库有一个重要的特性就是其数据不能全部装入内存。
所以只能将将数据部分装入内存,并向上提供全部装入内存的幻象(有点虚拟内存的感觉)。而这就是通过buffer pool management来提供的。

与虚拟内存的设计思想一致,BPM核心的便是将内存中的地址映射到磁盘中的地址(再次通过增加一个中间层解决了问题),向上提供所有数据都装入内存的幻象,数据单位为页。为了区分内存中的页和磁盘中的页,我们做一个定义:

  • frame:buffer中的一页
  • page:磁盘中的一页

BPM结构如下图所示:

image-20210925184656467

其中包含两个数据结构:

  • Buffer Pool:里面包含了一些frame,而这些frame中保存的就是对应page的内容
    我们要完成的Buffer Pool存的free_list,保存的free_frame,要用直接取即可
  • Page Table:保存的是 page id -> frame id的映射。

实际运行的流程如下 (仅为请求) :

  1. 上层任务向BPM请求一个page

  2. BPM查询对应的page是否在buffer pool中,即Page Table中包不包含page id

    1. 如果包含,那么返回对应的frame地址,并将frame 的pin count加1(代表这个frame的引用多了一次)
    2. 如果不包含,BPM需要将Page读如Buffer Pool中的frame中,再返回对应的frame地址(这其中可能包含置换)
  3. 结束

先写有注释的函数

第一步

  • 每个Page对象还维护一个计数器,以显示“固定”该页面的线程数。BufferPoolManagerInstance不允许释放固定的页面。每个Page对象还跟踪它的脏标记。您的工作是判断页面在解绑定之前是否已经被修改(修改则把脏标记置为1)。BufferPoolManagerInstance必须将脏页的内容写回磁盘,然后才能重用该对象。
  • BufferPoolManagerInstance实现将使用在此分配的前面步骤中创建的LRUReplacer类。它将使用LRUReplacer来跟踪何时访问页对象,以便在必须释放一个帧来为从磁盘复制新的物理页腾出空间时,它可以决定取消哪个页对象

这个计数器不为0意味着一直有线程在使用这块内存,所以是不被允许替换的,其次在替换的时候

在必要时(BufferPool满了的时候),根据LRU算法选择替换一个页面

page

image-20210926181451554

BufferPoolManagerInstance

image-20210926183859576

FetchPage 拿一个页

FetchPage(获取page),若在Buffer Pool中,直接返回对象。若不在,则从磁盘中加载后返回对象。

image-20210926191853220

bool BufferPoolManagerInstance::lru_replace(frame_id_t *frame_id) {
	if (!free_list_.empty()) { //直接取出frame_id
    *frame_id = free_list_.front();
    free_list_.pop_front();
    return true;
  }
  
  if (replacer_->Victim(frame_id) == false) { return false; } //buffer_pool的所有页都被pin了
  
  int replace_frame_id = -1;
  for (auto &p : page_table_) { //遍历所有的page_table,查找id为frame_id的frame
  	page_id_t pid = p.first;
    frame_id_t fid = p.second;
    if (fid == *frame_id) {
      replace_frame_id = pid;
      break;
    }
  }
  
  if (replace_frame_id == -1) { return false;} //buffer_pool里没有这个frame

  Page *replace_page = &pages_[*frame_id];
  if (replace_page->is_dirty_) { //脏位
    char *data = pages_[page_table_[replace_page->page_id_]].data_;
    disk_manager_->WritePage(replace_page->page_id_, data);
    replace_page->pin_count_ = 0;  //重置pin_count
  }
  page_table_.erase(replace_page->page_id_); //从映射中删除
  return true;
}

FetchPgImp(page_id) Fetch the requested page from the buffer pool.

从缓冲池中请求一个页面,则该页面

Page *BufferPoolManagerInstance::FetchPgImp(page_id_t page_id) {
  // 1.     Search the page table for the requested page (P).
  // 1.1    If P exists, pin it and return it immediately.
  // 1.2    If P does not exist, find a replacement page (R) from either the free list or the replacer.
  //        Note that pages are always found from the free list first.
  // 2.     If R is dirty, write it back to the disk.
  // 3.     Delete R from the page table and insert P.
  // 4.     Update P's metadata, read in the page content from disk, and then return a pointer to P.
    latch_.lock();
// 1.     Search the page table for the requested page (P).
  auto it = page_table_.find(page_id);
  if (it != page_table_.end()) {
      // 1.1
    frame_id_t frame_id = it->second;
    Page *page = &pages_[frame_id];
    page->pin_count_++;        // pin the page
    replacer_->Pin(frame_id);  // notify replacer

    latch_.unlock();
    return page;
  }
	// 1.2
  frame_id_t replace_fid;
  if (!lru_replace(&replace_fid)) { //替换失败
    latch_.unlock();
    return nullptr;
  }
  Page *replacePage = &pages_[replace_fid];
  
	// 2. 
  if (replacePage->IsDirty()) {
    disk_manager_->WritePage(replacePage->page_id_, replacePage->data_);
  }
  
// 3.     Delete R from the page table and insert P.
  page_table_.erase(replacePage->page_id_);
  page_table_[page_id] = replace_fid;

 // 4.     Update P's metadata, read in the page content from disk, and then return a pointer to P.
  Page *newPage = replacePage; //创建一个新的帧
  disk_manager_->ReadPage(page_id, newPage->data_); //读磁盘到内存中
  newPage->page_id_ = page_id;
  newPage->pin_count_++;
  newPage->is_dirty_ = false;
  replacer_->Pin(replace_fid);
  latch_.unlock();

  return newPage;
}

第二步

NewPageImpl 实现

分配一个新的page。

  1. 利用find_replace函数在我们的缓冲池找到合适的地方建立page_id --> frame_id的映射
  2. 更新 新页的元数据
Page *BufferPoolManager::NewPageImpl(page_id_t *page_id) {
  // 0.   Make sure you call AllocatePage!
  // 1.   If all the pages in the buffer pool are pinned, return nullptr.
  // 2.   Pick a victim page P from either the free list or the replacer. Always pick from the free list first.
  // 3.   Update P's metadata, zero out memory and add P to the page table.
  // 4.   Set the page ID output parameter. Return a pointer to P.
    latch_.lock();
  // 0.
  page_id_t new_page_id = disk_manager_->AllocatePage();
  // 1.
  bool is_all = true;
  for (int i = 0; i < static_cast<int>(pool_size_); i++) {
    if (pages_[i].pin_count_ == 0) {
      is_all = false;
      break;
    }
  }
  if (is_all) {
    latch_.unlock();
    return nullptr;
  }
  // 2.
  frame_id_t victim_fid;
  if (!find_replace(&victim_fid)) {
    latch_.unlock();
    return nullptr;
  }
  // 3.
  Page *victim_page = &pages_[victim_fid];
  victim_page->page_id_ = new_page_id;
  victim_page->pin_count_++;
  replacer_->Pin(victim_fid);
  page_table_[new_page_id] = victim_fid;
  victim_page->is_dirty_ = false;
  *page_id = new_page_id;
   disk_manager_->WritePage(victim_page->GetPageId(), victim_page->GetData());
  latch_.unlock();
  return victim_page;
}

第三步

DeletePageImpl 实现

这里是要我们把缓冲池中的page移出

  1. 如果这个page根本就不在缓冲池则直接返回
  2. 如果这个page 的引用计数大于0(pin_counter>0)表示我们不能返回
  3. 如果这个page被修改过则要写回磁盘
  4. 否则正常移除就好了。(在hash表中erase)
bool BufferPoolManager::DeletePageImpl(page_id_t page_id) {
  // 0.   Make sure you call DeallocatePage!
  // 1.   Search the page table for the requested page (P).
  // 1.   If P does not exist, return true.
  // 2.   If P exists, but has a non-zero pin-count, return false. Someone is using the page.
  // 3.   Otherwise, P can be deleted. Remove P from the page table, reset its metadata and return it to the free list.
   latch_.lock();
  // 1.
  if (page_table_.find(page_id) == page_table_.end()) {
    latch_.unlock();
    return true;
  }
  //2.
  frame_id_t frame_id = page_table_[page_id];
  Page *page = &pages_[frame_id];
  if (page->pin_count_ > 0)  {
    latch_.unlock();
    return false;
  }
// if dirty write to disk
  if (page->is_dirty_) {
    FlushPageImpl(page_id);
  }
  disk_manager_->DeallocatePage(page_id);
  page_table_.erase(page_id);
  // reset metadata
  page->is_dirty_ = false;
  page->pin_count_ = 0;
  page->page_id_ = INVALID_PAGE_ID;
  // return it to the free list
  free_list_.push_back(frame_id);
  latch_.unlock();
  return true;
}

第四步

UnpinPgImp(page_id, is_dirty) Unpin the target page from the buffer pool.
解引用操作

  1. 如果这个页的pin_couter>0我们直接–
  2. 如果这个页的pin _couter==0我们需要给它加到Lru_replacer中。因为没有人引用它。所以它可以成为被替换的候选人
bool BufferPoolManager::UnpinPageImpl(page_id_t page_id, bool is_dirty) {
  latch_.lock();
  // 1. 如果page_table中就没有
  auto iter = page_table_.find(page_id);
  if (iter == page_table_.end()) {
    latch_.unlock();
    return false;
  }
  // 2. 找到要被unpin的page
  frame_id_t unpinned_Fid = iter->second;
  Page *unpinned_page = &pages_[unpinned_Fid];
  if (is_dirty) {
    unpinned_page->is_dirty_ = true;
  }
  // if page的pin_count == 0 则直接return
  if (unpinned_page->pin_count_ == 0) {
    latch_.unlock();
    return false;
  }
  unpinned_page->pin_count_--;
  if (unpinned_page->GetPinCount() == 0) {
    replacer_->Unpin(unpinned_Fid);
  }
  latch_.unlock();
  return true;
}

FlushPageImpl 实现

bool BufferPoolManagerInstance::FlushPgImp(page_id_t page_id) {
  // Make sure you call DiskManager::WritePage!
  auto iter = page_table_.find(page_id);
  if (iter == page_table_.end() || page_id == INVALID_PAGE_ID) {
    latch_.unlock();
    return false;
  }
  frame_id_t flush_fid = iter->second;
  disk_manager_->WritePage(page_id, pages_[flush_fid].data_);
  pages_[flush_fid].is_dirty_ = false;
  return false;
  return false;
}

FlushAllPgsImp实现

void BufferPoolManagerInstance::FlushAllPgsImp() {
  // You can do it!
  for (auto it : page_table_) {
    page_id_t page_id = it.first;
    frame_id_t flush_fid = it.second;
    disk_manager_->WritePage(page_id, pages_[flush_fid].data_);
    pages_[flush_fid].is_dirty_ = false;
  }
}

测试

make buffer_pool_manager_instance_test
./test/buffer_pool_manager_instance_test

Task #3 - Parallel Buffer Pool Manager

正如您在前面的任务中可能注意到的那样,单个缓冲区池管理器实例需要使用闩锁才能确保线程安全。当每个线程在与缓冲池交互时争夺一个闩锁时,这可能会引起很多争用。一个潜在的解决方案是在您的系统中有多个缓冲池,每个缓冲池都有自己的锁存器。

ParallelBufferPoolManager是一个包含多个BufferPoolManagerInstance对于每一次行动,ParallelBufferPoolManager挑一个BufferPoolManagerInstance代表那个例子。

我们使用给定的页面id来确定特定的BufferPoolManagerInstance利用。如果我们有num_instances许多BufferPoolManagerInstance然后,我们需要一些方法将给定的页面id映射到范围[0,num_instents)中的一个数字。对于这个项目,我们将使用模块化操作符,page_id mod num_instances将给定的page_id映射到正确的范围。

ParallelBufferPoolManager首先实例化它的起始索引为0。每次创建新页面时,您都会尝试BufferPoolManagerInstance,从起始指数开始,直到成功为止。然后将起始索引增加一个。

确保在创建个人时BufferPoolManagerInstanceS您使用的构造函数uint32_t num_instancesuint32_t instance_index这样就可以正确地创建页面ID。

您需要实现在头文件中定义的下列函数(Src/include/buffer/parallel_buffer_pool_manager.h)在源文件中(SRC/缓冲器/并行缓冲池_Manager.cpp):

  • ParallelBufferPoolManager(num_instances, pool_size, disk_manager, log_manager)
  • ~ParallelBufferPoolManager()
  • GetPoolSize()
  • GetBufferPoolManager(page_id)
  • FetchPgImp(page_id)
  • UnpinPgImp(page_id, is_dirty)
  • FlushPgImp(page_id)
  • NewPgImp(page_id)
  • DeletePgImp(page_id)
  • FlushAllPagesImpl()
//BufferPoolManagerInstance的数据  
  /** Number of pages in the buffer pool. */
  const size_t pool_size_;
  /** How many instances are in the parallel BPM (if present, otherwise just 1 BPI) */
  const uint32_t num_instances_ = 1;
  /** Index of this BPI in the parallel BPM (if present, otherwise just 0) */
  const uint32_t instance_index_ = 0;
  /** Each BPI maintains its own counter for page_ids to hand out, must ensure they mod back to its instance_index_ */
  std::atomic<page_id_t> next_page_id_ = instance_index_;

第一步

修改 构造函数

BufferPoolManagerInstance::BufferPoolManagerInstance(size_t pool_size, uint32_t num_instances, uint32_t instance_index,
                                                     DiskManager *disk_manager, LogManager *log_manager)
    : pool_size_(pool_size),
      num_instances_(num_instances),
      instance_index_(instance_index), //原本为next_page_id_
      disk_manager_(disk_manager),
      log_manager_(log_manager)
private:
  std::vector<BufferPoolManagerInstance*> bufferPool;

ParallelBufferPoolManager::ParallelBufferPoolManager(size_t num_instances, size_t pool_size, DiskManager *disk_manager,
                                                     LogManager *log_manager) {
  // Allocate and create individual BufferPoolManagerInstances
  for (long unsigned int i = 0; i < num_instances; i++) {
    bufferPool.push_back(new BufferPoolManagerInstance(pool_size, num_instances, i, disk_manager, log_manager));
  }
}

// Update constructor to destruct all BufferPoolManagerInstances and deallocate any associated memory
ParallelBufferPoolManager::~ParallelBufferPoolManager() {
  for (int i = 0; i < bufferPool.size(); i++) {
      for (auto it : page_table_) {
    page_id_t page_id = it.first;
    frame_id_t flush_fid = it.second;
    disk_manager_->WritePage(page_id, pages_[flush_fid].data_);
    pages_[flush_fid].is_dirty_ = false;
  }
      DeallocatePage(page_id);
    delete bufferPool[i];
  }
}

第二步

其他按照指引填写即可

测试

make parallel_buffer_pool_manager_test
./test/parallel_buffer_pool_manager_test

成功完成

21: Test command: /home/knight/Desktop/cmu15-445/build/test/type_test "--gtest_color=yes" "--gtest_output=xml:/home/knight/Desktop/cmu15-445/build/test/type_test.xml"
21: Test timeout computed to be: 10000000
21: Running main() from gmock_main.cc
21: [==========] Running 5 tests from 1 test suite.
21: [----------] Global test environment set-up.
21: [----------] 5 tests from TypeTests
21: [ RUN      ] TypeTests.InvalidTypeTest
21: 
21: Exception Type :: Unknown Type
21: Message :: Unknown type.
21: 
21: Exception Type :: Mismatch Type
21: Message :: Cannot get minimal value.
21: 
21: Exception Type :: Mismatch Type
21: Message :: Cannot get max value.
21: [       OK ] TypeTests.InvalidTypeTest (0 ms)
21: [ RUN      ] TypeTests.GetInstanceTest
21: [       OK ] TypeTests.GetInstanceTest (0 ms)
21: [ RUN      ] TypeTests.MaxValueTest
21: [       OK ] TypeTests.MaxValueTest (0 ms)
21: [ RUN      ] TypeTests.MinValueTest
21: [       OK ] TypeTests.MinValueTest (0 ms)
21: [ RUN      ] TypeTests.TemplateTest
21: size is 48
21: key info32
21: [       OK ] TypeTests.TemplateTest (0 ms)
21: [----------] 5 tests from TypeTests (0 ms total)
21: 
21: [----------] Global test environment tear-down
21: [==========] 5 tests from 1 test suite ran. (0 ms total)
21: [  PASSED  ] 5 tests.
21/21 Test #21: type_test ...........................   Passed    0.02 sec

100% tests passed, 0 tests failed out of 21

Total Test time (real) =   0.53 sec
[100%] Built target check-tests
  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 14
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值