cmu15-445课程笔记

0、前期准备

主要是找各种渠道了解课程,收集课程的信息,然后搭建环境和IDE。
1、下载安装Clion,安装C++编译环境(后面直接用服务器的编译环境了)
2、服务器装依赖包、Cmake、GDB

cmake:将 CMakeLists.txt 文件中的配置指令解析为适用于所选构建系统的构建文件
make: 在生成了构建文件后,编译项目的源代码并生成最终的可执行文件。
总结起来,cmake … 用于配置并生成构建文件,而 make 用于实际执行构建,根据构建文件中的规则进行编译和链接。

3、克隆项目到开发环境
4、Clion远程连接服务器

https://blog.csdn.net/jiuri1005/article/details/128431491
我们在本机的IDE上编写代码,然后在服务器上编译和运行代码。

1、关系型数据库、缓冲池

数据库管理系统(DBMS)是用来管理数据库的软件。
在1969年,Codd提出了关系模型。
关系:关系是包含表示实体的属性关系的无序集合。由于关系是无序的,因此DBMS可以以任何需要的方式存储它们,从而允许进行优化。关系的主键唯一地标识单个元组。
非过程性查询:查询只指定需要的数据,而不指定如何找到它,让DBMS决定要采取哪些步骤。
数据操作语言(DML) : SELECT、 INSERT、 UPDATE 和 DELETE 语句。
数据定义语言(DDL) : 表、索引、视图和其他对象的架构定义。
数据控制语言(DCL) : 安全、访问控制。
数据库管理系统的组件管理数据在非易失性和挥发性记忆体之间的移动。
磁盘上的随机访问通常比循序存取慢得多,因此数据库管理系统希望最大化循序存取。
易失性存储器支持具有字节可寻址位置的快速随机访问(即内存)。
非易失性存储传统上更适合顺序访问(磁盘),它也是块/页可寻址的。这意味着为了读取特定偏移量处的值,程序首先必须将保存程序想要读取的值的4 KB页面加载到内存中。

:数据库全部在磁盘上,数据库文件中的数据被组织成页,第一个页是目录页。为了对数据进行操作,DBMS需要将数据放入内存。它通过一个缓冲池来管理在磁盘和内存之间来回移动的数据来实现这一点。DBMS有一个执行引擎来执行查询,执行引擎将向缓冲池请求特定的页面,缓冲池将负责将该页放入内存,并向执行引擎提供指向内存中该页的指针。缓冲池管理器确保在执行引擎对该部分内存进行操作时该页在那里。

DBMS的一个高级设计目标是支持超出可用内存量的数据库。

问题1 : DBMS 如何在磁盘上的文件中表示数据库

DBMS把一个数据库作为一个或多个文件存储在磁盘上,操作系统对这些文件的内容一无所知,只有DBMS知道如何解密它们的内容,因为它是用特定于DBMS的方式编码的。DBMS的存储管理器负责管理数据库的文件,它将文件表示为页面的集合。它还跟踪哪些数据被读取和写入到页面,以及这些页面中有多少空闲空间。页是一个固定大小的数据块。它可以包含元组、元数据、索引、日志记录…每一页都有一个唯一标识符。DBMS 使用间接层将页 ID 映射到物理位置。
在数据库管理系统中有三种不同的“页面”概念: →硬件页面(通常为4KB)→操作系统页面(通常为4KB)→数据库页面(512B-16KB)。

每个页面都包含一个关于页面内容的元数据头。
在页面中布局数据有两种主要方法:(1)开槽页面和(2)日志结构化。
槽页:页将槽映射到偏移量。
也是目前dbms中最常用的方法。Header跟踪使用的槽的数量,最后一个使用槽的起始位置的偏移量,以及一个槽数组,它跟踪每个元组的起始位置。
添加元组时,槽数组将从开始到结束增长,元组的数据将从结束到开始增长。当槽数组和元组数据相遇时,页面被认为是满的。

元组:元组本质上是一个字节序列。DBMS 的工作是将这些字节解释为属性类型和值。
每个元组前面都有一个包含元数据的标头。

插入一个新的元组: 检查页面目录以查找带有空闲插槽的页面。从磁盘中检索页面(如果不在内存中)。检查槽数组,找到页面中适合的空白区域。
根据记录 id更新已存在的元组: →检查页面目录来查找页面的位置。→从磁盘中检索页面(如果不在内存中)。→使用插槽数组查找页中的偏移量。改写现有数据(如果有新数据)。

大多数 DBMS 不允许元组超过单个页面的大小。为了存储大于页的值,DBMS 使用单独的溢出存储分页

日志结构化存储:DBMS只存储日志记录,而不是存储元组。

•记录包含元组的唯一标识符,操作类型(PUT/DELETE),以及对于PUT操作,元组的内容。
•为了读取一条记录,DBMS从最新到最旧的顺序扫描日志文件,以找到元组中最近的内容。
•写速度快,读速度可能慢。磁盘写入是顺序的,现有页面是不可变的,这导致减少随机磁盘I/O。适用于仅追加存储。
•为了避免长时间的读取,DBMS可以有索引来允许它跳转到日志中的特定位置。
•最终会变得相当大。DBMS可以定期压缩日志,在多个页面上只获取每个元组最近的更改。
•压缩后,不再需要排序,因为每个元组只有一个,因此DBMS可以按id排序以加快查找速度。这些被称为排序字符串表(sstable)。

•在通用压缩中,任何日志文件都可以压缩在一起。在级别压缩中,最小的文件是级别0。0级文件可以被压缩成更大的1级文件,1级文件可以被压缩成2级文件,等等。
缺点是压缩成本高,并且会导致写入放大(它会一次又一次地重写相同的数据)。

缓存池:经常使用的数据和用户写更新的数据
缓存设备对比:

L1缓存:1S
L2缓存:15S
DRAM:200S
SSD: 3天
HDD:30周

缓存流程:数据库系统中的程序在需要从磁盘获取块时向缓冲区管理器发出请求(即调用)。如果该块已经在缓冲区中,则缓冲区管理器将主内存中块的地址传递给请求者。如果该块不在缓冲区中,则缓冲区管理器首先在缓冲区中为该块分配空间,并在必要时抛出其他块,为新块腾出空间。抛出的块只有在最近一次写入磁盘后被修改时才会被写回磁盘。然后,缓冲区管理器将请求的块从磁盘读入缓冲区,并将主内存中块的地址传递给请求者。缓冲区管理器的内部操作对发出磁盘块请求的程序是透明的。

固定pin:一旦将一个块放入缓冲区,数据库进程就可以从缓冲区内存中读取该块的内容。然而,当读取块时,如果并发进程驱逐块并用不同的块替换它,读取旧块内容的读取器将看到不正确的数据;如果该块在被驱逐时正在被写入,那么写入器最终会损坏替换块的内容。
因此,在进程从缓冲块中读取数据之前,确保该块不会被驱逐是很重要的。为此,进程在块上执行一个引脚操作;缓冲区管理器永远不会驱逐固定块。当它完成读取数据时,进程应该执行一个unpin操作,允许在需要时驱逐块。应该仔细编写数据库代码,以避免固定太多的块:如果缓冲区中的所有块都被固定,则不能驱逐任何块,也不能将任何其他块带入缓冲区。如果发生这种情况,数据库将无法进行任何进一步的处理!多个进程可以从缓冲区中的块中读取数据。它们在访问数据之前都需要执行一个引脚操作,在完成访问后需要执行一个解引脚操作。在所有执行了pin的进程都执行了unpin操作之前,该块不能被驱逐。确保此属性的一个简单方法是为每个缓冲块保留一个引脚计数。每个引脚操作增加计数,而取消引脚操作减少计数。只有当引脚计数等于0时,页面才能被驱逐。

页表是一个内存中的哈希表,它跟踪当前在内存中的页面。它将页id映射到缓冲池中的帧位置。由于缓冲池中的页面顺序不一定反映磁盘上的顺序,因此这个额外的间接层允许识别池中的页面位置。
注意:不要将页表与页目录混淆,页目录是数据库文件中从页id到页位置的映射。对页目录的所有更改必须记录在磁盘上,以便DBMS在重新启动时能够找到。页表还为每个页维护额外的元数据,一个脏标志和一个引脚/引用计数器。当线程修改页面时,它会设置dirty标志。这表明存储管理器必须将该页写回磁盘。引脚/引用计数器跟踪当前正在访问该页(读取或修改该页)的线程数量。线程必须在访问该页之前增加计数器。如果页的引脚计数大于零,则不允许存储管理器从内存中驱逐该页。固定不会阻止其他事务并发地访问该页。

缓存池优化策略
1.多缓存池,有的页存索引、有的存数据、有的存元数据,发类为多个缓冲池,以此降低锁冲突
2.预读取缓存池,
3.共享扫描缓存池
4.bypass缓存池 数据只用一次,临时数据,不进缓存池

project 1 缓冲池管理器

这一部分主要是和Bustub的缓冲池相关,分为两个部分LRU-K Replacer 和 Buffer Pool Manager。其中LRU-K Replacer 是 Buffer Pool Manager 内部的组件,而 Buffer Pool Manager 是向系统提供了获取 page 的接口。系统拿着一个 page_id 就可以向 Buffer Pool Manager 索要对应的 page,而不关心这个 page 具体是在memory上还是disk上,这些操作都交给 Buffer Pool Manager 完成,这整个内部操作对于调用者来说是透明的。

task 1 LRU-K Replacer

设计

LRU-K Replacer 主要是用于存储 buffer pool 中 page 被引用的记录,并且在 buffer pool 满时可以选择合适的page去驱逐。LRU-K 算法是LRU算法的变种,在普通的 LRU 算法里,我们只记录 page 最近一次被引用的时间,在驱逐时,直接选择最近一次引用时间最早的 page就行了。而在 LRU-K 中,我们记录的是 page 最近 K 次被引用的时间,它会比LRU算法多维护一个队列,当元素的访问次数达到K次的时候,再把这个元素放入缓存里。当需要淘汰元素时,LRU-K会优先淘汰第K次访问时间距当前时间最大的元素。它主要是为了解决LRU缓存污染的问题,也就是本来是比较热门的数据但是被替换出去,导致性能下降。

实现

LRU算法
传统的LRU算法实现是基于双链表+哈希表实现的,因为链表是支持在任意位置快速插入和删除的,但无法按照索引快速访问某一个位置的元素,所以需要用到哈希表通过 key 快速映射到任意一个链表节点然后进行操作。
具体来说

双向链表里的节点是按照元素最近被使用的顺序存储了这些元素和元素的key,靠近头部的是最近使用的,而靠近尾部的是最久没有使用的。然后还有一个哈希表page_table_,通过元素的key映射到它在双向链表中的具体位置。每次读取时需要先在page_table_里通过key找到具体的链表node,然后更新node位置到头节点,最后再返回元素。添加元素时如果超出容量,就把链表里的尾节点也就是最久没有访问过的节点驱逐掉,再更新key和链表node的哈希映射。

LRU-K算法
LRU-K算法相比于LRU算法的实现,它多了一个历史队列。(https://www.jianshu.com/p/c4e4d55706ff)
具体来说

数据首先会进入到历史队列里,如果size满了就先进先出,当有数据访问次数达到K=4时,把该数据移动到缓存队列里。
缓存队列执行的其实也就是LRU策略,只不过size满了之后首先会从历史队列淘汰元素,历史队列没有了才会在缓存队列淘汰。它主要是为了解决LRU缓存污染的问题,即本来是热门数据的产品B被替换出去,导致性能下降。

需要注意:LRU-K Replacer 中的 page 有一个evictable 驱逐属性,当一个 page 的 evicitable 为 false 时,就不能驱逐这个页面,这里主要是为了上层调用者可以 pin 住一个 page,对其进行一些读写操作,此时需要保证 page 驻留在内存中。

tack 2 Buffer Pool Manager

Buffer Pool Manager这部分是有两个比较重要的功能,一个是fetch page一个是new page。 fetch page就是上层调用者给定一个 page id,Buffer Pool Manager 返回对应的 page 指针。new page是上层调用者希望新建一个 page,比如当应用程序执行插入操作,往数据库中添加新的数据记录时,可能需要在缓存池中分配一个新的页并分配新的 page_id来存储这些新数据。
缓存池Page = 内存Page + 元数据(page_id、pin_count、is_dirty、rwlatch);
fecch page

  • 在数据库启动时,会分配一部分内存作为缓存池,缓存池块被划分为多个缓存块frames帧。每个缓存块与一个磁盘页相对应。
  • 当上层调用者需要访问数据库中的数据时,它会提供一个 page_id,来指示需要访问哪个数据页。数据库系统首先会检查缓存池的哈希映射unordered_map<page_id_t, frame_id_t> page_table_有没有这个page_id。
    • 如果数据库在缓存池中找到了对应的缓存块,那么发生了缓存命中,数据库读取对应的 frame_id 来找到缓存块,把这个缓存块的pin_count_++代表这个缓存块在内存中被固定的次数又多了一次,再做一个替换器位置的更新操作因为它是最新读的,最后return给应用程序
    • 如果数据库在缓存池中未找到对应的缓存块,那就要去磁盘读取相应的数据页。
      • 如果缓存池的空闲链表list<frame_id_t> free_list_不为空,就从里面拿一个frame_id出来,并且free_list里面弹出这个frame_id。
      • 如果缓存池已经满了,那就要用缓存替换策略(如 LRU)选择一个合适的缓存块进行替换,替换之前还要做一个IsDirty()脏页判断,如果是脏页就先刷盘,然后把这个帧的is_dirty_ = false,移除掉旧的page_id和 frame_id 之间的映射,再取出这个frame_id。
  • 接着在page_table_里更新相应 page_id 和 frame_id 的映射关系,把pin_count_ = 1,再做一个替换器位置的更新操作因为它是最新读的,从磁盘里读数据,最后把这个缓存块的数据返回给应用程序。

NewPage
总体来说后续流程和fetchpage差不多,主要是newpage后这个时候只在缓冲区里有这个页,新的page_id也要作为参数返回给调用者。而且在NewPage里面需要把当前分配好的 frame 的 evictable 属性设为 false。因为上层调用者拿到 page 后可能需要对其进行读写操作,此时 page 必须驻留在内存中不能被驱逐。

分配page_id: int new_page_id = AllocatePage();

project 1 的代码不是很难,主要是需要理清各个函数的作用和关系。

优化

运行时长为 8s,使用perf进行优化

project 2 B+Tree Index B+树索引

task 1 B+Tree pages

这里需要使用我们在 Project 1 中实现的 buffer pool manager 来获取 page。在Bustub 索引 B+ 树里面,所有的节点都是 page,需要先实现B+树里面的各种page,这个过程比较简单。
这里给的page一共有三种
1、B_PLUS_TREE_PAGE
b_plus_tree_page 是另外两个 page 的父类,也就是 B+树中 tree page 的抽象。
page里的属性和数据基本可以分为两类:一个是data存放具体数据的地方,另一个是page的metadata,存放着pageid、pincount、isdirty、rwlatch等元数据。然后data部分一共是4KB,里面有24字节用于存放page的header,比如页类型、parent_page_id等等,剩余部分就是具体的KV键值对了。

2、B_PLUS_TREE_INTERNAL_PAGE
INTERNAL_PAGE继承于Tree Page,它对应于B+树的内部节点。相比于它继承的tree page,它唯一的成员就是一个可变长度的数组 MappingType array_[1]。这个可变长度的数组其实也就是flexible array,它是C语言里的一个概念,它必须是类中的最后一个成员,并且仅能有一个。在为对象分配内存时,flexible array 会自动填充,占用还没有被其他变量使用的内存。就比如我们定义了一个类,这个类里有一个int类型变量A和一个int类型的数组B,我们初始化它的一个实例并且给它分配24字节的内存。那么首先int 类型的A会占用4个字节,还剩下20字节就会自动分配给数组B,因为数组B是int类型的,所以这个数组的大小就会被填充为5。
所以在INTERNAL_PAGE里这个array会用来存放KV键值对,K是能够比较大小的索引,V是page id用来指向下一层的节点。然后在这里它的第一个key是空的,但value不为空,主要是因为n个key用来比较大小,会把value划分为n+1个区域。

所以内部page用key去查找的方法Lookup里key是从1开始,而叶子page是从0开始。
在内部page用key去比对查找方法Lookup里用了二分法

3、B_PLUS_TREE_LEAF_PAGE
leaf page 和 internal page 的内存布局基本一样,只是 leaf page 多了一个成员变量 next_page_id,它指向下一个 leaf page,所以 leaf page 的 header 大小为 28 Byte。leaf page 的 KV 对中,K 是实际的索引,V 是 record id。record id 用于识别表中的某一条数据。所以在Bustub 里的 B+ 树索引,无论是主键索引还是二级索引都是非聚簇索引。

  • 在聚簇索引里,leaf page 的 value 为表中一条数据的某几个字段或所有字段,一定包含主键字段。而非聚簇索引 leaf page 的 value 是 record id,也就是指向一条数据的指针。 在使用聚簇索引时,主键索引的 leaf page 包含所有字段,二级索引的 leaf page 包含主键和索引字段。当使用主键查询时,查询到 leaf page 即可获得整条数据。当使用二级索引查询时,若查询字段包含在索引内,可以直接得到结果,但如果查询字段不包含在索引内,则需使用得到的主键字段在主键索引中再次查询,以得到所有的字段,进而得到需要查询的字段,这就是回表的过程。 在使用非聚簇索引时,无论是使用主键查询还是二级索引查询,最终得到的结果都是 record id,需要使用 record id 去查询真正对应的整条记录。 聚簇索引的优点是,整条记录直接存放在 leaf page,无需二次查询,且缓存命中率高,在使用主键查询时性能比较好。缺点则是二级索引可能需要回表,且由于整条数据存放在 leaf page,更新索引的代价很高,页分裂、合并等情况开销比较大。 非聚簇索引的优点是,由于 leaf page 仅存放 record id,更新的代价较低,二级索引的性能和主键索引几乎相同。缺点是查询时均需使用 record id 进行二次查询。

Task2 B+Tree Data Structure (Insertion, Deletion, Point Search)

Search

  • 当拿到一个 key 需要查找对应的 value 时,首先需要经过 internal page 递归地向下查找,最终找到 key 所在的 leaf page。这个过程在project里是函数 Findleaf()
  • Findleaf() 会从 root page 开始查找,当查找到 leaf page 时直接返回,否则根据 key 在当前 internal page 中找到对应的 child page id,递归地调用 Findleaf。根据 key 查找对应 child时,由于 key 是有序的,可以直接进行二分搜索。
  • 拿到 child page id 后调用 Project 1 中实现的 buffer pool 的 FetchPage() 函数Page *page = buffer_pool_manager_->FetchPage(page_id); 来获得对应的指针,这就拿到了page。然后通过 reinterpret_cast 把这个字节数组强制转换为我们要使用的类型比如 leaf page:auto leaf_page = reinterpret_cast<B_PLUS_TREE_LEAF_PAGE_TYPE *>(page->GetData())
  • 找到 leaf page 后,同样是二分查找 key,来找到对应的 record id。

一个重要的问题就是:在使用完 page 之后,需要将 page unpin 掉,否则最终会导致 buffer pool 中的所有 page 都被 pin 住,无法从 disk 读取其他的 page。 加读锁,每次给child加锁然后释放当前锁。
root_page_id_latch_.RLock();
...
child_page->RLatch();
page->RUnlatch();
buffer_pool_manager_->UnpinPage(page->GetPageId(), false);

Insert

Insert第一步同样是调用 Findleaf(), 根据 key 找到需要插入的 leaf page,然后同样使用二分法搜索到合适位置进行插入。

  • 插入后,如果当前leaf page size < max size,那就结束了,return
  • 插入后,如果当前leaf page size = max size,那就要进行leaf page分裂。在右侧新建一个page,把原page的右半部分移动到新page,因为是两个叶子节点,所以需要更新两个page的next page id,再把新page的第一个key向上插到父节点里。
    • 如果parent page size< max size,插入结束,return
    • 如果parent page size= max size,分裂父节点:新建一个空的 page,把原page的一般数据转移给新page,然后把新page一分为二。把新page第一个key删除,然后把key插入到其父节点,往复循环递归。如果遇到根节点也需要分裂,则除了需要新建一个节点用来容纳原根节点一半的 KV 对,还需要新建一个新的根节点

假如我们有一棵 5 阶的 B+ 树。5 阶只是一种常用的说法,代表 B+ 树节点最多能容纳五个 KV 对。对于 leaf page 来说,当 B+ 树处于稳定状态时(插入、删除等操作已经完全结束),最多只能有 4 个 KV 对。对于 internal page,最多有 4 个 key,5 个 value,可以看成是有 5 个 KV 对。
在 leaf page 分裂时,向父节点插入 key 时是复制后插入。
而 internal page 分裂时,向父节点插入 key 是删除后插入,有点像把 key 上推。

Delete

从根开始找到包含Key的页节点N,从N里删除Key和Value。

  • 如果节点N是根节点且删除kv后只剩一个子节点了,让N的子节点成为新根,删除节点N,return结束
  • 如果删除后N节点内的key还是满足半满约束,return结束
  • 如果删除后N节点内的key不满足半满约束,此时也就是半满-1,M 为N的兄弟节点,假设M 在前:
    • 如果兄弟节点M 有富余的 KV 对,则从M的末尾偷取一个:
      • 如果 N 是叶子节点,把M 的末尾直接移动到 N 的开头,更新父节点。
      • 如果 N 是非叶节点,就把其父节点落下到N开头,再把M 的最后一项放到N前,更新key。
    • 如果兄弟节点M 也没有有富余的 KV 对,就需要合并,也就是把N内容放到M中 :
      • 如果N 不是叶子节点,把N的父节点的key落下来,再合并到M。
      • 如果N 是叶子节点,直接把N 中的Key-Value合并到M,然后删除节点N,从父节点中删除对应的索引,并递归处理父节点的删除。

和 Insert 类似,Delete 过程也是先向下递归查询 leaf page,不满足 min size 后先尝试偷取,无法偷取则合并,并向上递归地检查是否满足 min size。

task 3 遍历索引

task 4 Concurrent Index 并发索引

逻辑上整棵树使用一把锁也是可以的,但是性能肯定不怎么好。所以可以使用 latch crabbing 加锁方式,顾名思义,就像螃蟹一样,移动一只脚放下,移动另一只脚再放下。

基本思想: 1. 先锁住 parent page。 2. 再锁住 child page。 3. 如果 child page 是安全的,再释放 parent page 的锁。安全指当前 page 在当前操作下一定不会发生 split/steal/merge。同时,安全对不同操作的定义是不同的,Search 时,任何节点都安全;Insert 时,判断 max size;Delete 时,判断 min size。
这样当 page 为安全的时候,操作仅可能改变当前 page 和它的 child page 的值,因此可以提前释放掉其祖先的锁来提高并发性能。

Search
Search 时,从 root page 开始,先给 parent 上读锁,再给 child page 上读锁,然后释放 parent page 的锁。如此向下递归。

Insert
Insert 时,从 root page 开始,先给 parent 上写锁,再给 child page 上写锁。假如 child page 安全,则释放所有祖先的锁;否则不释放锁,继续向下递归。

如何记录哪些 page 当前持有锁?这里就要用到在 Checkpoint1 里的一个参数,transaction。transaction 就是 Bustub 里的事务,可以把它看作当前在对 B+ 树进行操作的线程。调用 transaction 的 AddIntoPageSet() 方法,来跟踪当前线程获取的 page 锁。在发现一个安全的 child page 后,将 transaction 中记录的 page 锁全部释放掉。

Delete
和 Insert 基本一样。仅是判断是否安全的方法不同(检测 min size)。需要另外注意的是,当需要 steal/merge sibling 时,也需要对 sibling 加锁,然后在完成 steal/merge 后马上释放。这里是为了避免其他线程正在对 sibling 进行 Search/Insert 操作,从而发生 data race。这里的加锁就不需要在 transaction 里记录了,只是临时使用。

project 3 Query Execution 查询执行引擎

sql 执行流程

一条 sql 语句,首先经过 解析器Parser 生成一棵抽象语法树 AST;
得到 AST 后,通过Binder把这些词语绑定到数据库实体上;得到一棵 Bustub 可以直接理解的树。把它叫做 Bustub AST;
得到 Bustub AST 后,使用Planner 遍历这棵树,生成初步的查询计划;
再将查询计划交给 Optimizer 进行修改优化,生成优化过后的最终查询计划;
有了 Optimizer 生成的具体的查询计划后,就可以生成真正执行查询计划的一系列算子了,而算子也是我们在 Project 3 中需要实现的主要内容。也就是需要遍历查询计划树,将树上的 PlanNode 替换成对应的 Executor。

在SeqScan 算子中,需要遍历 table,首先通过数据库的 catalog 找到对应的 table,一个 table 由许多 page 组成,在访问 page 时,就用到了 Buffer Pool。
在 Optimizer 中,假如发现 Sort 算子在对 indexed attribute 排序,会将 Sort 算子优化为 IndexScan 算子,这样就用到了 B+Tree Index。

涉及的函数

程序执行入口是shell.cpp,里面有main函数,启动后会创建一个BustubInstance实例,也就是一个对话框,可以在里面输入一些sql来执行;
这个实例的构造函数里会初始化DiskManagerLockManagerTransactionManagerCatalogExecutionEngine这些后面可能会用到的对象;
然后调用ExecuteSql函数来执行sql;ExecuteSql中就会依次执行parser、binder、planner、optimizer,最后走到executors。
executors里会先执行Execute函数,这是执行的主函数,它的参数有:优化后的执行计划、最后要返回结果集vector<Tuple> resule_set,当前的事务等等。它会根据当前的执行计划确定使用哪一种算子,其实也就是用了switch case去选择哪一种类型,比如SeqScan、Insert、Delete等等。
拿到对应的之后会进行初始化executor->Init();
然后执行PollExecutor,内部是每次调用executor->Next,然后把每次获取到的tuple放在result_set里,最后返回。

前置知识

系统已经提供了一个TableIterator,需要读取给定 table 中的所有 tuple.

  • 在Bustub 中有一个 Catalog系统表,它用来维护数据库里比较重要的元数据信息,比如 table id 和 table info 的映射关系。
  • table info 里包含了一个 table 的 metadata,有 schema、name、id 和指向 table heap 的指针。也就是访问一张 table 时,需要先使用 id 从 Catalog 中得到 table info,再去访问对应 table info 中的 table heap。
  • table heap 是管理 table 数据的结构,包含 InsertTuple()MarkDelete() 等 table 操作。table heap 本身并不直接存储 tuple 数据,一个table heap 可能由多个 table page 组成,第一个table page的指针就放在table heap里,tuple 数据都存放在 table page 中,这些table page 互相连成一个双链表。需要访问某个 table page 时,通过 page id 由 buffer pool 来访问。
  • table page 是实际存储 table 数据的结构,相比于它的父类 page,table page 多了一些新的方法。table page 在 data 的开头存放了 next page id、prev page id 等信息,把多个 table page 连成一个双向链表,便于整张 table 的遍历操作。table page 在低地址存放 header,在高地址也就是尾部开始插入tuple。当需要新增 tuple 时,table heap 会找到当前属于自己的最后一张 table page进行插入,如果最后一张 table page 满了,就新建一张 table page 插入 tuple。
  • 而tuple里也分为header和data,其中rid等都在header中。

tuple 对应数据表中的一行数据。每个 tuple 都由 RID 唯一标识。RID 由 page id + slot num 构成。tuple 由 value 组成,value 的个数和类型由 table info 中的 schema 指定。

所有要用到的系统资源,例如 Catalog,Buffer Pool 等,都由 ExecutorContext 提供。

task 1 Access Method Executors

Task 1 包含 4 个算子,SeqScan、Insert、Delete 和 IndexScan。

SeqScan 顺序扫描

目标:遍历一张表,将表里的数据依次返回给调用者。
相关文件:
execution/executors/seq_scan_executor.h​ 、 execution/executors/seq_scan_executor.cpp​
参数
exec_ctx上下文信息​、seq_scan_plan_node计划节点
实现思路:
找到要操作的表​以及表上的迭代器​
每次移动迭代器获取对应的值tuple/rid​

我们传入一个表id,就可以从Catalog中拿到对应的表信息,然后访问对应的table heap,找到对应的table page双链表,从前往后依次遍历,每次偏移的粒度是一个tuple,最终返回结果。
现有表

Init() {
	table_info = exec_ctx->GetCatalog()->GetTable(plan_->table_oid);
	iter = make_unique<TableIterator>(table_info->table->MakeIterator());
}
auto Next (Tuple *tuple, RID *rid) -> bool {
	while (!iter = end()) {
		auto [meta, tuple_] = iter->GetTuple();
		*tuple = tuple_;
		*rid = iter->GetRID();
		++(*iter);
		return true;
	}
	return false;
}

Insert & Delete

Insert 时,直接将 tuple 追加至 table 尾部tableInfo->table_->InsertTuple。然后从执行的上下文->GetCatalog()->GetTableIndexes(tableInfo->name)更新索引。

auto InsertExecutor::Next(Tuple *tuple, RID *rid) -> bool {
	int count = 0;
	while (children->Next(tuple, rid)) {
		if (tableInfo->table->InsertTuple(*tuple, rid, exec_ctx->GetTransaction())) {
			++count;
			// 更新索引,只更新部分,因为可能插入的这个tuple只有部分列是索引
			auto indexes = exec_ctx->GetCatalog()->GetTableIndexes(tableInfo->name);
			for (auto index_info : indexes) {
				auto key = tuple->keyFromTuple(..., index_info->index->GetKeyAttrs());
				index_info->index->InsertEntry(key, rid, exe_ctx->GetTransation());
			}
		}
	}
}

Delete 时,并不是直接删除,而是将 tuple 标记为删除状态,也就是逻辑删除tableInfo->table_->MarkDelete(*rid,事务)。更新索引index_info->index->DeleteEntry(key, rid,事务);
在事务提交后,再进行物理删除,Project 3 中无需实现。
Insert & Delete 的 Next() 只会返回一个包含一个 integer value 的 tuple,表示 table 中有多少行受到了影响。

IndexScan

使用我们在 Project 2 中实现的 B+Tree Index Iterator,遍历 B+ 树叶子节点。由于我们实现的是非聚簇索引,在叶子节点只能获取到 RID,需要拿着 RID 去 table 查询对应的 tuple。

根据查询计划里的index_oid拿到对应index_info,获取index_info里的索引,然后获取index->GetEndIterator()。

SELECT FROM <table> ORDER BY <index column> 如果index column是索引列,那么优化器就会自动走索引扫描,如果不是,就要用到后面task3里的sort操作。
目标:按照索引顺序,依次输出所有tuple

先获取索引的迭代器​
然后从迭代器中的rid​
通过rid,拿到对应的tuple数据​
返回tuple和rid

task2

aggregation 聚集

目标:先分组,再聚集,实现 min()、max()、sum()、count(列)、 count(*)这几个算子,根据测试用例来编程。
group by 值相等的划分为一组,然后按要求sum(列)、count(列) ...,也就是最后有几组就输出几个记录,记录里填充的元素就是sum列等等
eg:select sum(age) from table group by grade;
已经提供了一个哈希表SimpleAggregationHashTable来辅助记录,key为group by后面的若干分组列(grade),value为剩下需要的元素(age)

步骤:
首先初始化哈希表,它需要两个vector参数,抽象表达式类型(Integer等等)和聚集类型(CountStarAggregate等等),可以通过调试来看:plan->GetAggregates()[0]、plan->GetAggregateTypes()
Init():
不断从子节点获取tuple和rid,每次拿到tuple后调用MakeAggregateKey(&tuple)来生成key,调用MakeAggregateValue(&tuple)来获得value,然后插入到哈希表中,如果哈希表中已经有数据了,就按照group的类别进行聚集。
Next(Tuple *tuple, RID *rid) -> bool
遍历哈希表的迭代器,输出每组已经聚合后的vecter类型的value。每次用vaules和scheam封装成一个tuple然后返回。

Schema schema(plan->OutputSchema());
*tuple = {value, &schema};
return true;

特殊情况是当为空表时,只有countstar返回0,其余情况返回null。

嵌套循环连接NestedLoopJoin

inner join(等值联接):只返回两个表中联接字段相等的记录。
left join(左联接):返回左表中的所有记录以及和右表中的联接字段相等的记录。​如果右表没有任何行匹配上,则需要填NULL。

NestedLoopJoin 算法本身并不难,但比较容易掉进坑里。伪代码大概是这样:
for outer_tuple in outer_table:
    for inner_tuple in inner_table:
        if inner_tuple matched outer_tuple:
            emit

第一版:

while (left_child->Next(&left_tuple)){
    while (right_child->Next(&right_tuple)){
        if (left_tuple matches right_tuple){
            *tuple = ...;   // assemble left & right together
            return true;
        }
    }
}
return false;
一开始看起来似乎没什么问题。然而很快可以发现有一个严重的错误,right child 在 left child 的第一次循环中就被消耗完了,之后只会返回 false。解决方法很简单,在 Init() 里先把 right child 里的所有 tuple 取出来暂存在一个数组里就好,之后直接访问这个数组。
// 先把右边的tuples缓存起来,放在一个vector中
while (right_executor->Next(&tuple, &rid)) {
	right_tuples.push_back(tuple);
}

第二版

while (left_child->Next(&left_tuple)){
    for (auto right_tuple : right_tuples){
        if (left_tuple matches right_tuple){
            *tuple = ...;   // assemble left & right together
            return true;
        }
    }
}
return false;
看起来好像又没什么问题。然而,同一列是可能存在 duplicate value 的。在上层算子每次调用 NestedLoopJoin 的 Next() 时,NestedLoopJoin 都会向下层算子请求新的 left tuple。但有可能上一个 left tuple 还没有和 right child 中所有能匹配的 tuple 匹配完(只匹配了第一个)。
t1          t2
---------   ---------
|   x   |   |   x   |
---------   ---------
|   1   |   |   1   |
|   2   |   |   1   |
|   3   |   |   2   |
---------   ---------
SELECT * FROM t1 INNER JOIN t2 ON t1.x = t2.x;
t1 中的 1 只会和 t2 的第一个 1 匹配,产生一行输出。
再下一次调用 Next() 时,左边会直接选取 2 开始尝试匹配。

解决方法:在算子里暂存 left tuple,每次调用 Next() 时,先用暂存的 left tuple 尝试匹配。并且要用变量index_记录上一次右表匹配到的位置,不要每次都直接从右表第一行开始匹配。右表遍历完还没有匹配结果,再去找左表要下一个 tuple。实际上就是注意迭代器要保存上下文信息。最后把两个记录拼接起来然后返回给上一层。

构造函数中:
is_inner = (plan_->GetJoinType() == JoinType::INNER); 判断是什么类型
bool is_match{true;} 当是left join时,如果右表没有匹配的数据就要注意处理空值

NestedIndexJoin

在进行 equi-join 时,如果发现 JOIN ON 右边的字段上建了 index,则 Optimizer 会将 NestedLoopJoin 优化为 NestedIndexJoin。具体实现和 NestedLoopJoin 差不多,只是在尝试匹配右表 tuple 时,会拿 join key 去 B+Tree Index 里进行查询。如果查询到结果,就拿着查到的 RID 去右表获取 tuple 然后装配成结果输出。其他的就不再多说了。

task 3

Task 3 中要实现 3 个算子,Sort、Limit 和 TopN,以及将 Limit + Sort 在 Optimizer 中优化为 TopN。

Sort

在 Init() 中读取所有下层算子的 tuple,并按 ORDER BY 的字段升序或降序排序。Sort 算子主要需要自定义 std::sort()。std::sort() 的第三个参数可以传入自定义的比较函数。直接传入一个 lambda 匿名函数。由于要访问成员 plan_ 来获取排序的字段,lambda 需要捕获 this 指针。另外,排序字段可以有多个,按先后顺序比较。第一个不相等,直接得到结果;如果相等,就继续迭代到第二个列比较第二个。不会出现所有字段全部相等的情况。

// 定义一个vector, 用来拿到全部的tuple
vector<Tuple> sorted_tuples; 
while (child_>Next)(&tuple, rid) {
	sorted_tuples.push_back(tuple);
}
// 然后开始自定义排序
std::sort(sorted_tuples_.begin(), sorted_tuples_.end(), [this](const Tuple &a, const Tuple &b) {
	// 排序类型(升/降),抽象表达式引用(针对哪一列进行计算)
    for (auto [order_by_type, expr] : plan_->GetOrderBy()) {
      // compare and return ...
    }
    UNREACHABLE("doesn't support duplicate key");
});

Limit

和 SeqScan 基本一模一样,只不过在内部维护一个 count,记录已经输出了多少 tuple。当下层算子空了或 count 达到规定上限后,不再返回新的 tuple。

TopN

explain select * from __mock_table_1 ORDER BY colA LIMIT 10;
仅需返回最大/最小的 n 个 tuple。直接用 std::priority_queue 加自定义比较函数,然后在 Init() 中遍历下层算子所有 tuple,全部塞进优先队列后截取前 n 个,在 Next() 里一个一个输出。和 Limit + Sort 没什么区别,都是 O(nlogn)。

Sort + Limit As TopN

之前实现TopN的做法是先对表里的全部数据排序,然后取最前面若干个元素,但是这样效率太低,因为我可能只需要前十个元素,却不得不把全部的一万条数据做排序。一个比较智能的解决方法是动态跟踪到目前为止最小的十个元素,这也就用到了priority_queue数据结构。

比较:
sort + limit:先把所有数据排序,然后取有序数组中的前N个。​
优先队列:使用大根堆动态维护前N个数据,超过N个时,弹出多余的大元素。

目标文件:topn_executor.cpp、sort_limit_as_topn.cpp
不是实现算子了,而是在 Optimizer 里增加一条规则,将 Sort + Limit 优化为 TopN。先看看 Optimizer 是如何执行优化规则的:

auto Optimizer::OptimizeCustom(const AbstractPlanNodeRef &plan) -> AbstractPlanNodeRef {
  auto p = plan;
  p = OptimizeMergeProjection(p);
  p = OptimizeMergeFilterNLJ(p);
  p = OptimizeNLJAsIndexJoin(p);
  p = OptimizeNLJAsHashJoin(p);  // Enable this rule after you have implemented hash join.
  p = OptimizeOrderByAsIndexScan(p);
  p = OptimizeSortLimitAsTopN(p);  // what we should add
  return p;
}

可以看到,让未经优化的原始 plan 树依次经历多条规则,来生成优化过的 plan。我们的任务就是新增一条规则。看看其他规则是怎么实现的,例如 NLJAsIndexJoin:

auto Optimizer::OptimizeNLJAsIndexJoin(const AbstractPlanNodeRef &plan) -> AbstractPlanNodeRef {
  std::vector<AbstractPlanNodeRef> children;
  for (const auto &child : plan->GetChildren()) {
    children.emplace_back(OptimizeNLJAsIndexJoin(child));
  }
  auto optimized_plan = plan->CloneWithChildren(std::move(children));

  if (optimized_plan->GetType() == PlanType::NestedLoopJoin) {
    // apply the rule and return
  }

  return optimized_plan;
}

可以看到,实际上就是对 plan tree 进行后序遍历,自底向上地适用规则,改写节点。遍历到某个节点时,通过 if 语句来判断当前节点的类型是否符合我们要优化的类型,若符合则进行优化。大致了解如何对 plan 进行优化后,就可以开始写我们的优化规则了。需要特别注意的是,能优化为一个 TopN 算子的形式是,上层节点为 Limit,下层节点为 Sort,不能反过来。同样,我们对 plan tree 进行后续遍历,在遇到 Limit 时,判断其下层节点是否为 Sort,若为 Sort,则将这两个节点替换为一个 TopN。还是比较好实现的,只是代码看起来可能有点复杂。

// 创建一个优先队列,传入自定义比较器cmp
priority_queue<Tuple, vector<Tuple>>, decltype(cmp)> pq(cmp);
while (chile_->Next(&child_tuple, &child_rid)) {
	pq.push(child_tuple);
	// 大根堆,如果元素个数超过限定的值,就把堆顶的大元素pop()弹出
	if (pq.size() > plan_GetN()) {
		pq.pop();
	}
}
// 用栈来保存,本来在pq里输出的元素为 3 2 1,放到栈里是 3 2 1,输出就是1 2 3
while (!pq.empty()) {
	child_tuples_.push(pq.top());
	pq.pop();
}

两阶段锁

锁有两种基本类型:•共享锁(S-LOCK):一种允许多个事务同时读取同一个对象的共享锁。
•排他锁(Exclusive Lock, X-LOCK)

事务必须从锁管理器请求锁(或升级)。锁管理器根据其他事务当前持有的锁授予或阻塞请求。事务必须在不再需要锁时释放锁来释放对象。DBMS的锁表不需要是持久的,因为当DBMS崩溃时,任何活动(即仍在运行)的事务都会自动中止。锁需要一个并发控制协议来补充。

2PL

两阶段锁定(Two-Phase locking, 2PL)是一种悲观并发控制协议,它使用锁来确定是否允许事务动态访问数据库中的对象。协议不需要提前知道事务将要执行的所有查询。

阶段#1—增长:在增长阶段,每个事务向DBMS的锁管理器请求它所需的锁。锁管理器授予/拒绝这些锁请求。
阶段2 -收缩:事务在释放第一个锁后立即进入收缩阶段。在收缩阶段,只允许事务释放锁。他们不允许获得新的。

就其本身而言,2PL足以保证冲突的可序列化性。它生成优先级图为非循环的调度。但是它很容易受到级联中止的影响,即当一个事务中止时,现在必须回滚另一个事务,这会导致浪费工作。2PL仍然可能有脏读,也可能导致死锁.
强严格2PL(也称为严格2PL)是2PL的一种变体,其中事务仅在提交时释放锁。
这种方法的优点是DBMS不会导致级联中止。DBMS还可以通过恢复修改元组的原始值来逆转中断的事务的更改。然而,严格的2PL生成了更谨慎/悲观的调度,限制了并发性。

project 4 锁管理器

task 1 锁管理器 Lock Manager

锁管理器,利用 2PL 实现并发控制。大部分需要注意的内容都在 lock_manager.h 的NOTE注释里。事务在不同的隔离级别中能获取到的锁不同。

支持三种隔离级别 :REPEATABLE_READ、READ_COMMITTED 和 READ_UNCOMMITTED
支持五种锁: SHARED、EXCLUSIVE、INTENTION_SHARED、INTENTION_EXCLUSIVE 和 SHARED_INTENTION_EXCLUSIVE
支持两种锁粒度: table 和 row,支持锁升级。

读未提交隔离级别下,GROWING 状态时只允许获取 IX、X 锁,SHRINKING 状态时所有锁不行。
读已提交,GROWING 状态时所有锁都允许,SHRINKING 状态时只允许使用 IS、S 锁。
可重复读,GROWING 状态时所有锁都允许, 在 SHRINKING 状态下任何锁都不允许。

编码:lock_manager.hlock_manager.cpp文件,里面主要实现四个函数:
LockTable(Transaction, LockMode, TableOID)​
UnlockTable(Transction, TableOID)​
LockRow(Transaction, LockMode, TableOID, RID)​
UnlockRow(Transaction, TableOID, RID)

LockTable(Transaction, LockMode, TableOID)
第一步:保证锁请求、事务状态、事务隔离级别之间的兼容性。首先判断需要的LockMode在当前隔离级别&growing/shrinking状态下是否允许被加上。如果不允许,就要抛出异常。

第二步:获取锁。从 table_lock_map_ 中获取 table 对应的锁请求队列,注意需要对 map 加锁table_lock_map_latch_.lock()因为会有并发操作。而且为了提高并发性,在获取到 queue 之后table_lock_map_.find(oid)->second,要把队列先锁起来lock_request_queue->latch_.lock(),因为它也是个临界资源,并且立即释放 map 的锁_unlock()。

第三步:判断这个锁请求是否是锁升级

  • 我们遍历队列看有没有和当前事务 id相同的请求。如果已经有了,就代表当前事务在此前之前已经得到了这个资源上的一把锁,那现在又发加锁请求,那可能就是重复请求或是要锁升级。
    • 如果当前请求锁的类型和已经有的锁类型相同,那就是重复请求,没有什么意义,直接返回。
    • 如果不同,就是锁升级。需要判断在当前资源上有没有另一个事务正在尝试升级。
      • 如果有其他事务升级,就终止当前事务,抛出 UPGRADE_CONFLICT 异常throw TransactionAbortException(txn->GetTransactionId(), AbortReason::UPGRADE_CONFLICT)。因为不允许多个事务在同一资源上同时尝试锁升级。
      • 如果没有,进行锁升级。判断当前lock_mode是否满足升级条件。如果满足,在锁升级时,需要先释放此前持有的锁,先把原来的事务从队列里移除掉,然后新建一个锁请求并且遍历,插入到第一个未分配锁的位置(因为升级优先级高所以插队了),把资源标记为正在锁升级lock_request_queue->upgrading_ = txn->GetTransactionId()

第四步,尝试获取锁(条件变量阻塞模型)
这是最后的一步了,也是最核心的一步,体现了 Lock Manager 的执行模型,也就是条件变量等待模型。

// 条件变量并不是某一个特定语言中的概念,而是操作系统中线程同步的一种机制
std::unique_lock<std::mutex> lock(latch);
while (!resource) {
    cv.wait(lock);
}

条件变量与互斥锁配合使用。首先需要持有锁,并查看是否能够获取资源。这个锁和资源绑定,是用来保护资源的锁。
若暂时无法获取资源,则调用条件变量的 wait 函数。
调用 wait 函数后,latch 将自动释放,然后当前线程会被挂起,以节省资源。
这就是阻塞的过程。此外,允许有多个线程在 wait 同一个 latch。

当有其他线程的活动使得资源状态发生改变时,需要调用条件遍历的 cv.notify_all() 函数。
notify_all() 可以看作一次广播,会唤醒所有正在这个条件变量上阻塞的线程。
在线程被唤醒后,它仍处于 wait 函数中,会在 wait 函数中尝试获取 latch。
在成功获取 latch 后,就退出 wait 函数,进入循环的判断条件,检查是否能获取资源。
若仍不能获取资源,就继续进入 wait 阻塞,释放锁,挂起线程。
若能获取资源,则退出循环。这样就实现了阻塞等待资源的模型。条件变量中的条件指的就是满足某个条件,在这里即能够获取资源。
std::unique_lock<std::mutex> lock(queue->latch_);
while (!GrantLock(...)) {
    queue->cv_.wait(lock);
}

在 GrantLock() 中,Lock Manager 会判断是否可以满足当前锁请求。若可以满足,则返回 true,事务成功获取锁,并退出循环。若不能满足,则返回 false,事务暂时无法获取锁,在 wait 处阻塞,等待资源状态变化时被唤醒并再次判断是否能够获取锁。
这里的资源状态变化指的就是其他事务释放了锁。

GrantLock()执行流程:

  • 判断兼容性。遍历请求队列,查看当前锁请求是否与所有的已经 granted 的请求兼容。
  • 判断优先级。锁请求会严格的执行先进先出顺序。只有当前请求为请求队列中优先级最高的请求时,才允许授予锁。
    • 比如如果当前队列里有锁升级请求,这个锁升级请求还是当前请求,那当前请求的优先级最高。否则那个锁升级请求的优先级高于当前请求。
    • 如果队列中不存在锁升级请求,就要遍历队列。如果当前请求是第一个 waiting 状态的请求,则代表优先级最高。如果当前请求前面还存在其他 waiting 请求,就要判断当前请求是否和前面的 waiting 请求兼容。若兼容,则当前的可以视为优先级最高。若存在不兼容的请求,则优先级就不是最高。
  • 如果当前请求既兼容又有最高优先级,那就可以给它授予锁。授予锁的方式是将 granted_ 置为true lock_request->granted_ = true。并返回 true。假如这是一次升级请求,则代表升级完成,还要记得将 upgrading_ 置为 INVALID_TXN_ID。

row lock 与 table lock 几乎相同,仅多了一个检查步骤。
在接收到 row lock 请求后,需要检查是否持有 row 对应的 table lock。必须先持有 table lock 再持有 row lock。

解锁表

有在事务没有在该表上持有任何行的锁时,才允许解锁表
解锁 S 或 X 锁才会更改事务状态。

锁表中是否存在这个表
行锁S和X分别是否为空,如果行锁不为空,就不能解表锁
事务ID是否在请求队列中,如果找到对应queue且他的granted是true,就可以尝试解锁。

  • 从队列中删除请求
  • 更新事务状态txn->SetState(TransactionState::SHRINKING);
    • 读未提交隔离级别下,解锁 X 锁应将事务状态设置为SHRINKING,不允许解S锁,因为根本没有。​
    • 读已提交隔离级别下,解锁 X 锁应将事务状态设置为SHRINKING,解锁 S 锁不影响事务状态。
    • 可重复读隔离级别下,解锁S/X锁都要把事务状态设置为SHRINKING。
  • 通知其他事务,调用 cv_.notify_all() 唤醒所有阻塞在此 table 上的事务,检查能够获取锁。
  • 从表集合删除事务

task 2

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值