【SZU DB内核课程笔记】 #01 - Storage

以下内容根据课件自行翻译总结,如有谬误,烦请指教~


介绍

DBMS的存储管理目标

  • 提供数据的集合的view
  • 把数据库对象映射到磁盘文件
  • 管理数据从/到磁盘的传输
  • 使用buffers减少磁盘/内存传输
  • 将加载的数据解释为元组/记录
  • basis for file structures used by access methods

数据在查询中的过程

image-20220925102326004
  • Query Evaluation 要扫描一个表,调用关系代数层扫描表的元组

  • 关系代数层:扫描表的元组,调用访问方法层扫描表的页

  • 访问方法层:扫描表的页,请求缓存管理层把数据块读出来

  • 缓存管理层:需要文件管理层读出数据块

  • 文件管理层:根据表和文件的映射关系,读取出数据块给到缓存管理层

  • 缓存管理层:把数据块放入缓存中,让访问方法层可以扫描块中的表

  • 访问方法层:对页逐行扫描,把结果给到关系代数层

  • 关系代数层:返回结果给Query Evaluation

文件管理的主要问题

  • 磁盘和文件管理:性能问题和磁盘文件的组织问题
  • 缓存管理:使用缓存来提高DBMS系统的吞吐量
  • 元组和页的管理:如何在磁盘页中表示元组
  • 数据库对象的管理: tables/views/functions/types这些怎么表示 —— 使用Catalog

文件管理

文件管理的目标

  • 组织数据在文件系统中的布局
  • 处理从数据库ID到文件地址的映射
  • 在缓冲区池和文件系统之间传输数据块
  • 还将尝试处理文件访问错误问题

在OS的文件操作上再进行一层封装

操作系统提供的文件操作:

fd = open(fileName,mode) 打开文件
close(fd) 关闭文件
nread = read(fd, buf, nbytes) 读文件数据到缓存
nwriten = write(fd, buf, nbytes) 写缓存数据到文件中
lseek(fd, offset, seek_type) 移动文件指针
fsync(fd) 刷盘

不同的DBMS有不同的文件管理方式选择(按照文件粒度)

  • 绕过文件系统,直接使用原始的磁盘分区
  • 一个大文件包含所有数据库的数据
  • 使用几个大文件
  • 每个表一个文件
  • 每个表多个文件

单文件 DBMS

介绍
  • 一个文件包含整个数据库,每个对象被分配单独的区域(段)
  • 例如:SQLite

image-20220925142348671

如果一个对象太长了,溢出了当初分配的空间怎么办?对象被删除了会发生什么?

结构案例
  • 以下图的一个简易单文件DBMS布局为例(注意以页/块为基本单位)

image-20220925143425872

  • SpaceMap 作为第一段,划分了每一段的开始和长度,以及是否被使用
    例:SpaceMap = [ (0,10,U), (10,10,U), (20,600,U), (620,100,U), (720,20,F) ]
  • TableMap 作为第二段,记录了所有表所处页的开始位置和长度
    例:TableMap = [ (”employee”,20,500), (”project”,620,40) ]

每个分段都是由固定大小的块组成的

#define PAGESIZE 2048 // 每页字节大小,一般是1024/2048/4096/8192
typedef long PageId; // PageId(long类型)是块索引,pageOffset=PageId*PAGESIZE
typedef char *Page; // 指向页或者缓存块的指针

打开的数据库和表的数据结构

// 数据库
typedef struct DBrec {
    char *dbname; // 复制的数据库名
    int fd; // 数据库的文件描述符
    SpaceMap map; // 记录 空F/已使用U的区域
    NameTable names; // 从表名name到(offset,sizes)的映射(参考上方的布局图)
} *DB;

// 表
typedef struct Relrec {
    char *relname; // 复制的表名 copy of table name
    int start; // 表数据的起始页码 page index of start of table data
    int npages; // 表数据的页数  number of pages of table data
...
} *Rel;
实例:扫描表

对于 SQL查询 select name from Employee 的实现

// 打开指定数据库和表,得到表在数据库文件中的起始位置和大小
DB db = openDatabase(”myDB”);  
Rel r = openRelation(db,”Employee”);  
// 分配一页缓存
Page buffer = malloc(PAGESIZE*sizeof(char));
// 遍历表包含的所有页
for (int i = 0; i < r->npages; i++) {
    PageId pid = r->start+i;
    get_page(db, pid, buffer);  // 把pid页读到缓存中
    // 遍历该页中的所有元组
    for each tuple in buffer {
        get tuple data and extract name
        add (name) to result tuples
    } 
}

打开和关闭数据库

// start using DB, buffer meta-data
// 创建新的DBrec结构体,主要打开数据库文件和把基本的map和names读出来
DB openDatabase(char *name) {
    DB db = new(struct DBrec);  
    db->dbname = strdup(name);
    db->fd = open(name,O_RDWR);
    db->map = readSpaceTable(db->fd);
    db->names = readNameTable(db->fd);
    return db;
}
// stop using DB and update all meta-data
// 主要把在内存中修改了的map和names重新写回磁盘,最后释放空间
void closeDatabase(DB db) {
    writeSpaceTable(db->fd,db->map);
    writeNameTable(db->fd,db->map);
    fsync(db->fd);
    close(db->fd);
    free(db->dbname);
    free(db);
}

打开和关闭表

// set up struct describing relation
// 创建新的Rel结构体,主要从数据库读出该表的起始位置和页大小
Rel openRelation(DB db, char *rname) {
    Rel r = new(struct Relrec);
    r->relname = strdup(rname);
    // get relation data from map tables
    r->start = ...;
    r->npages = ...;
    return r;
}
// stop using a relation
// 关闭时释放空间
void closeRelation(Rel r) {
    free(r->relname);
    free(r);
}

页的读写(到这有了要读或写的页号,就可以直接调用OS的接口了

// assume that Page = byte[PageSize]
// assume that PageId = block number in file
// read page from file into memory buffer
void get_page(DB db, PageId p, Page buf) {
    lseek(db->fd, p*PAGESIZE, SEEK_SET);
    read(db->fd, buf, PAGESIZE);
}
// write page from memory buffer to file
void put_page(Db db, PageId p, Page buf) {
    lseek(db->fd, p*PAGESIZE, SEEK_SET);
    write(db->fd, buf, PAGESIZE);
}

管理空间映射表可能有点复杂:创建n页和删除p开始的n页:

// assume an array of (offset,length,status) records

// 分配n页
PageId allocate_pages(int n) {
	 // 如果在map中已经没有空闲且足够大的段了,则在文件末尾增加n块;否则选择”worst fit”的块,分配出去后剩下的作为新的空闲段
    if (no existing free chunks are large enough) {
        int endfile = lseek(db->fd, 0, SEEK_END);
        addNewEntry(db->map, endfile, n);
    } else {
        grab ”worst fit” chunk
        split off unused section as new chunk
    }
    // note that file itself is not changed
}

// 删除从p开始的n页
void deallocate_pages(PageId p, int n) {
    // 如果没有相邻的空闲段,则直接在map中将该段标记为空闲;如果有则合并空闲段,并压缩map
    if (no adjacent free chunks) {
        markUnused(db->map, p, n);
    } else {
        merge adjacent free chunks
        compress mapping table
    }
    // note that file itself is not changed
}

// 更改在closeDatabase()的时候生效(要写回map)

多文件磁盘管理器


介绍

大多数DBMS不是用一个单独的大文件存储所有数据的。一般把DB级别的对象映射到物理分区或逻辑分区的多个文件。具体的文件结构决定于DBMS。

使用一表一文件的多文件管理方式对于某些操作会更加简单,比如:创建一个表,扩展一张表的大小,计算在一个表里的页偏移量

结构案例

比较单文件和多文件的数据库,单文件数据库无法直接计算得到某个表的某一页的偏移量(必须先通过map获得该表的起始页号),而多文件数据库可以直接计算得到。

image-20220925194154848

PageId 的结构在不同的多文件系统里也不相同

  • 如果是一表一文件,则PageId包含 表标识符+页号(哪张表的第几页)
  • 如果是一表多文件(如PostgreSQL),则PageId包含 表标识符+文件标识符+页号(哪张表的第几个文件的第几页)


PostgreSQL的存储管理

PostgreSQL存储管理的几个子系统:

  • 把表映射到文件 mapping from relations to files ( RelFileNode )

  • 打开表池的抽象 abstraction for open relation pool ( storage/smgr )

    Note: smgr designed for many storage devices; only disk handler provided

    smgr是为很多存储设备设计的,但只提供了对disk的设计(应该是这么理解)

  • 文件管理方法 functions for managing files ( storage/smgr/md.c )

  • 文件描述符池 file-descriptor pool ( storage/file )

两种基本文件类型:

  • 堆文件:包含数据(元组)
  • 索引文件:包含索引条目

把表当做多个文件 Relations as Files


PostgreSQL通过OID来区分表的文件,其中的核心结构是 RelFileNode (表文件节点)

typedef struct RelFileNode {
    Oid spcNode; // tablespace 所属空间
    Oid dbNode; // database 所属的数据库
    Oid relNode; // relation 所属的表
} RelFileNode;
// 如果是Global (shared)的表(如pg_database),则spcNode == GLOBALTABLESPACE_OID,dbNode == 0

relpath 函数把 RelFileNode 映射到 文件

// 获取RelFileNode的真实路径
char *relpath(RelFileNode r) // 简化版本
{
    char *path = malloc(ENOUGH_SPACE);
    
    if (r.spcNode == GLOBALTABLESPACE_OID) {
        /* Shared system relations live in PGDATA/global */
        Assert(r.dbNode == 0);
        // path=PGDATA/global/r.relNode
        sprintf(path,%s/global/%u”, DataDir, r.relNode); 
    }
    else if (r.spcNode == DEFAULTTABLESPACE_OID) {
        /* The default tablespace is PGDATA/base */
        // path=PGDATA/base/r.dbNode/r.relNode
        sprintf(path,%s/base/%u/%u”,DataDir, r.dbNode, r.relNode);
    }
    else {
        /* All other tablespaces accessed via symlinks */
        // path=PGDATA/pg_tblspc/r.spcNode/r.dbNode/r.relNode
        sprintf(path,%s/pg_tblspc/%u/%u/%u”, DataDir,r.spcNode, r.dbNode, r.relNode);
    }
    return path;
}

结合结构图理解更佳

image-20220925201109842


文件描述符池 File Descriptor Pool


简介

Unix限制了同时打开文件的最大数量,因此PostgreSQL维护了一个文件描述符池去向上一层隐藏了这个限制,并且减少了文件的**open()**操作

文件名就是个字符串:typedef char *FileName

打开的文件时通过File(int类型)来引用的:typedef int File,但其实这个File只是虚拟文件描述符表的一个索引(应该是理解为虚拟fd到操作系统fd的映射)

文件描述符池提供的接口
// open a file in the database directory ($PGDATA/base/...) 打开base下的文件的
File FileNameOpenFile(FileName fileName, int fileFlags, int fileMode);
// 打开临时文件; interXact: 是否在事务结束后关闭
File OpenTemporaryFile(bool interXact);
void FileClose(File file);
void FileUnlink(File file);
int FileRead(File file, char *buffer, int amount);
int FileWrite(File file, char *buffer, int amount);
int FileSync(File file);
long FileSeek(File file, long offset, int whence);
int FileTruncate(File file, long offset);
// 以上都可以类比unix的 open() , close(int fd) , read() , write() , lseek()
虚拟文件描述符

虚拟文件描述符(Vfd)在物理上存储在一个动态扩展的数组里面,里面包含了实际的文件描述符fd和文件路径pos

image-20220925205033281

另外,虚拟文件描述符也用链表连接起来组成 recency-of-use(近期使用过的Vfd)

image-20220925205302952

所以在VfdCache的数据结构里面还包含了头尾指针

以下是简化的Vfd的结构

typedef struct vfd
{
    s_short fd; // current FD, or VFD_CLOSED if none 当前真实的fd,或者没有标记为VFD_CLOSED标记
    u_short fdstate; // bitflags for VFD’s state 标记是否空闲
    File nextFree; // link to next free VFD, if in freelist 下个空闲vfd的index
    File lruMoreRecently; // doubly linked recency-of-use list lru的双指针
    File lruLessRecently;
    long seekPos; // current logical file position 当前文件的seekpos
    char *fileName; // name of file, or NULL for unused VFD 文件名,空闲为NULL
    // NB: fileName is malloc’d, and must be free’d when closing the VFD
    int fileFlags; // open(2) flags for (re)opening the file
    int fileMode; // mode to pass to open(2)
} Vfd;

文件管理器 File Manager


简介

在PostgreSQL里保存了每张表的信息

  • 可以在PGDATA/base/里看到有许多文件夹,每个目录是一个数据库,目录名就是数据库的Oid

    lzh@iZwz98qdx9tvkkfsuhp66eZ:~/postgresql/pgdata/base$ ls
    1  12367  12368  16384
    
  • 进入其中一个目录中,可以看到有很多文件。

    image-20220925231531505

  • 其中每个表由多个文件组成,被称为forks(分支,应该是指多个数据文件),同一张表的文件以该表的Oid为前缀

    • Oid 存储表的数据 table data pages
    • Oid.1 Oid.2 也是存储表的数据,前面的就会创建这些文件继续装
    • Oid_fsm 存储 free space map 空闲空间映射
    • Oid_vm 存储 visibility map

数据文件 (Oid, Oid.1, Oid.2 …):

  • 由一系列固定大小的块/页组成(典型值为8KB)
  • 每个页面都包含 tuple data 元组数据和 admin data 管理数据
  • 数据文件的最大大小为1GB(由Unix系统限制)

image-20220925233047862

Free space map ( Oid _fsm ):

  • 指示数据页中空闲空间的位置

  • 每当PostgreSQL数据库中的表中的行被更新或删除时,死亡行会被遗留下来。VACUUM则会把它们除去来使空间能被重新利用。VACUUM后才会记为free

    VACUUM命令只可以移除这些不再被需要的行版本(也被称为元组)。如果被删除事务的事务ID(存储在xmax系统列中)比仍然活跃在PostgreSQL数据库(或者共享表的整个集群)中最老的事务(xmin界限)更老,那么这个元组将不再被需要(不会再被访问)。

    (像MySQL中的DB_TRX_ID < m_up_limit_id的意思)

Visibility map ( Oid _vm ):

  • 指示其中哪些页面的所有元组都是 “可见的” (所有当前活跃的事务都可以访问)
  • 这些页面可以被VACUUM忽略(不可以移除删除的行版本,当前事务可能还会用到)
磁盘文件管理器 ( storage/smgr/md.c )
  • 管理它自己的打开的文件描述符池 (vfd’s)
  • 可能使用多个vfd去访问数据,如果由多文件组成的话(forks)
  • 管理 PageId文件+偏移 的映射

PostgreSQL中的 PageID 的数据结构

typedef struct
{
    RelFileNode rnode; // 哪个文件(.../Oid)
    ForkNumber forkNum; // 哪个 fork (.../Oid.n)
    BlockNumber blockNum; // 哪一页/块 (.../Oid.n + offset)
} BufferTag;

访问(磁盘中的)一块/页数据的大致过程:

getBlock(BufferTag pageID, Buffer buf)
{
    Vfd vf; off_t offset;
    (vf, offset) = findBlock(pageID)  // 找到pageID所处的文件vfd和offset
    lseek(vf.fd, offset, SEEK_SET)  // 猜测和FileSeek差不多,从vfd拿出真正fd调用lseek
    vf.seekPos = offset;
    nread = read(vf.fd, buf, BLOCKSIZE)
    if (nread < BLOCKSIZE) ... we have a problem
}

findBlock(BufferTag pageID) returns (Vfd, off_t)
{
    offset = pageID.blockNum * BLOCKSIZE  // 偏移 = 块号 * 块大小
    // RelFileNode得到.../Oid,再和ForkNumber拼接得到pageID所处的文件
    fileName = relpath(pageID.rnode) 
    if (pageID.forkNum > 0)
        fileName = fileName+.+pageID.forkNum	
    // 用文件名获得文件的vfd
    if (fileName is not in Vfd pool)
        fd = allocate new Vfd for fileName
    else
        fd = use Vfd from pool  // File FileNameOpenFile(..)
    // offset已经比文件大了,则分配下一个fork并重新计算块在新文件内的偏移
    if (offset > fd.fileSize) {
        fd = allocate new Vfd for next fork
        offset = offset - fd.fileSize
    }
    return (fd, offset)
}

缓存池 Buffer Pool


简介

缓存池保存了读出来的数据库文件页,因为它们可能再次被用到。被读写数据块的access methods 用到(如顺序扫描、索引检索、哈希)。

磁盘里叫页Page,内存中叫块Block,知道差不多是同一个东西就行

在上一层请求页时会先在缓存池中找有没有缓存到这个页,如果有就不用去disk找了

image-20220926093607072

缓存池操作都是使用PageID作为唯一参数:如request_page(pid) , release_page(pid) , …

在某种程度上,request_page(pid) 代替了getBlock(), release_page(pid)代替了putBlock()

这一部分在学过操作系统的页面缓存机制后会更好理解

数据结构
  • Page frames [NBUFS] :frames线性表保存的是缓存页的数据

    • Page类型是 字节数组 byte[BUFSIZE]

    frame也就相当于一个页或块了

  • FrameData directory [NBUFS] : directory线性表保存的是各个frames的信息

image-20220926094259044

FrameData保存了哪些信息?

  • PageID :保存的是哪一页,或者是空的
  • dirty bit :是否修改过
  • pin count : 当前有多少事务在使用这个页
  • 最近使用时间:LRU替换用
有无缓存的区别

如果没有缓存池,我们每次扫描同样的N个页都要从磁盘读N个页(ps:读磁盘时操作系统也会读一大块到操作系统的缓存中,两个是不一样的)

Buffer buf;
int N = numberOfBlocks(Rel);
    for (i = 0; i < N; i++) {
    pageID = makePageID(db,Rel,i);
    getBlock(pageID, buf);
    for (j = 0; j < nTuples(buf); j++)
    	process(buf, j)	
}

有了缓存池之后,使用request_page,只有第一次扫描时需要从磁盘读N个页,后续先在缓存中找,读的页数<=N(可能被替换出去0~N块)

Buffer buf;
int N = numberOfBlocks(Rel);
for (i = 0; i < N; i++) {
    pageID = makePageID(db,Rel,i);
    bufID = request_page(pageID);  // 代替getBlock,先找缓存
    buf = frames[bufID]
    for (j = 0; j < nTuples(buf); j++)
        process(buf, j)	
    release_page(pageID);  // 释放(我用完了这个page)
}

来看一下request_page(pageID)是怎么实现的

int request_page(PageID pid)
{
    // 现在缓存池里找,找不到再读磁盘
    if (pid in Pool)
        bufID = index for pid in Pool
    else {
        // 如果缓存池没有空位了就要把一个frame“驱赶”出去
        if (no free frames in Pool)
            evict a page (free a frame)
        bufID = allocate free frame
        // 感觉这里少了个getBlock
        directory[bufID].page = pid
        directory[bufID].pin_count = 0
        directory[bufID].dirty_bit = 0
    }
    directory[bufID].pin_count++ // 使用的事务数++
    return bufID
}

其他的操作:

  • release_page(pid) :当前事务用完了,使用事务数–
  • mark_page(pid):设置脏位,说明这个页被改过了还没写回磁盘
  • flush_page(pid):把特定页写回磁盘(使用 write_page)
替换规则

什么页可以替换:

  • 使用最安全的
    • pin count = 0 :现在没有事务在用着它
    • dirty bit = 0 :没被修改过,也就不用写回
  • 如果选择的frame修改过,就要 flush_page 写回去,然后标记为空

那么怎么选择要换出的frame呢,常用的有以下几种:

  • Least Recently Used (LRU) 选最久未用的
  • Most Recently Used (MRU) 选最近被使用的(与LRU相反)
  • First in First Out (FIFO) 选择最早进来的
  • Random 随机

LRU 和 MRU 都要记录最近使用的时间,或使用计数器,详细可百度

如何挑选最好的替换策略?

  • 因素一:可用的frames数
  • 因素二:看页面的访问模式

假设有n个frames,表包含b个pages,参考下面几种情况:

  • 情况一:顺序扫描,n >= b,采用LRUMRU:第一次读b页,且都能缓存到,后续访问不用读
  • 情况二:顺序扫描,n < b, 采用MRU:第一次读b页,只能缓存n个页,后续访问都需要读b-n次
  • 情况三:顺序扫描,n < b,采用LRU:每次读都需要读b次,因为每次顺序读读到后面时前面的都被替换出去了,命中率为0。被称作 sequential flooding

(MRU其实我自己理解有点模糊,可以自行了解。总之不用情况用不同替换策略效果可能差别很大)

来考虑实际情况:SELECT c.name FROM Customer c, Employee e WHERE c.ssn = e.ssn;,采用嵌套循环的方法,在不同层面到看的情况如下:

// table-level
for each tuple t1 in Customer {
    for each tuple t2 in Employee {
        if (t1.ssn == t2.ssn)
            append (t1.name) to result set
        } 
}

// page-level
// 先打开两个表
Rel rC = openRelation(”Customer”);
Rel rE = openRelation(”Employee”);
for (int i = 0; i < nPages(rC); i++) {
    // 对表C的每一页,遍历表E的所有页
    PageID pid1 = makePageID(db,rC,i);
    Page p1 = request_page(pid1);
    for (int j = 0; j < nPages(rE); j++) {
        PageID pid2 = makePageID(db,rE,j);
        Page p2 = request_page(pid2);
        // compare all pairs of tuples from p1,p2
        // construct solution set from matching pairs
        release_page(pid2);
    }
    release_page(pid1);
}

PostgreSQL 的缓存管理器

postgresql提供了一个所有backends共享的内存缓存池,所有从磁盘获取数据的访问方法都需要经过缓存管理器。缓冲区位于共享内存的一个大区域中。

定义:src/include/storage/buf*.h

函数:src/backend/storage/buffer/*.c

Buffer代码同时也被需要私有缓存池的backend使用

缓存池的组成:

  • BufferDescriptors:共享的fix的/padding的BufferDesc数组(大小为NBuffers) // 信息
  • BufferBlocks:共享的fix的8KB frames数组(大小为NBuffers) // 数据
  • Buffer:索引值(int),全局buffer是1~NBuffers,本地buffer是负数

缓存池的大小可以在postgresql.conf文件中设置,如shared_buffers = 16MB

image-20220926144353520

源码导航:

  • include/storage/buf.h
    定义buffer manager的基本数据类型 (e.g. Buffer )
  • include/storage/bufmgr.h
    定义buffer manager的函数接口
    (i.e. functions that other parts of the system call to use buffer manager)
  • include/storage/buf_internals.h
    定义buffer manager的内部构件 (e.g. BufferDesc )
  • Code: backend/storage/buffer/*.c

简单总结


DBMS的存储管理系统,文中主要举例介绍了单文件DBMS(如SQLite)和单表多文件的DBMS(如PostgreSQL),以及PostgreSQL的Storage Management。这个Storage Management主要做了以下的事:

  1. Relations as Files:把表分成多个文件,通过 RelFileNoderelpath 方法可以得到文件所在的路径
  2. File Descriptor Pool:用了一个文件描述符池来解决Unix不能同时打开过多文件的问题,它可以自动管理和分配 vfd ,封装起来,提供了一系列通过vfd访问文件的接口
  3. File Manager:一个表由多个文件组成(forks),每个文件包含多个页,元组数据就包含在这些页中。要标志一个页就要用到PageId,而这个PageId不是简单的id,而是 BufferTag 数据结构,包含了这个页属于哪个表的哪个fork文件里的哪一页的信息(FileName + offset)。要取出某个页时就需要magnetic disk storage manager 去用PageId获得 FileName和offset,使用FileName去 File Descriptor Pool 去得文件的 vfd,有了vfd 和 offset 就可以调用 File Descriptor Pool 提供的函数接口去访问对应的页
  4. Buffer Pool:它对访问磁盘的过程进行了优化,提供接口给Access Method用。有了缓存可以减少访问disk的次数。在不使用buffer pool前每次都要找 File Manager 去取一个块出来,使用buffer pool后,可以现去buffer pool找,找不到再去问 File Manager 要。

整个查询流程:在 Relational Operators 层要扫描一个表的 tuples,就要 Access Methods 把这个表的 pages 取出来,它先用 PageId/BufferTagBuffer Pool 找,如果找不到再去管 File Manager 要。

image-20220925102326004


本章结束啦~

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

LNZH_酱油

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

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

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

打赏作者

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

抵扣说明:

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

余额充值