当前位置:   article > 正文

cmu15445fall2022学习笔记(完结撒花)_2022 15445 p0

2022 15445 p0

如果你还没开始写cmu15445,那么我更推荐最新年份的。

可以转去知乎看,如果有遗漏之处会在那里更新:

cmu15445fall2022笔记(完结撒花) - 知乎 (zhihu.com)icon-default.png?t=N7T8https://zhuanlan.zhihu.com/p/690608079?

#p0

day 1  2024.2.25:

        学习了gdb调试,第一节课,在Ubuntu虚拟机中配置好了环境(vscode编写代码,cmake编译,gdb调试,git本地版本控制(因为fall2022早就更新完了,所以没有在github新建仓库,直接在本地建立了一个仓库。),cppreference和effective cpp语法参考),准备第二天开始#p0。

day 2 2024.2.26:

        完成了#p0,打了100分,算是有资格继续了。

        因为之前手写过Trie(01Trie树 以及 m进制Trie 用于平衡树问题洛谷P3369 桶排洛谷P1177-CSDN博客),所以今天遇见的问题比起数据结构的逻辑问题,更多的是语法问题和环境问题(痛苦死了,核心代码在下午三点左右已经完成,但是找语法问题和环境问题找到了六点多):

         1.语法问题:

                万能引用&&的问题:因为有的地方需要移动拷贝构造,所以需要注意。

                unique_ptr智能指针:独占原始指针,虽然get()可以得到原始指针,但尽量让它只是一个临时值,不要赋给变量;reset()会释放,clang推荐用 =std::unique_ptr<T>(args) 代替reset;unique_ptr不能赋值,只能移动。有一个比较有趣的问题,Trie的析构函数为defualt默认的,但是申请的TrieNode却没有手动释放,思考了一下发现,Trie中有一个root_,他是unique_ptr,析构的时候unique_ptr会自动释放它的空间,然后被他所指的TrieNode也会释放,同时因为TrieNode的析构函数是virtual的,所以不用担心子类不会完全释放,这就形成了一个递归,最后完成释放。

                virtual问题,不熟练,在写代码的时候反复思考了好几次类型为TrieNode的指向 TrieNodeWithValue 的智能指针释放空间的时候应该怎么办,直到想起来TrieNode的析构函数为virtual并且TrieNodeWithValue的析构函数是override(强制检查基类的虚函数是否重写)的,所以不用担心。

                派生类初始化父类问题,不知道派生类怎么初始化父类,其实只需要在列表构造哪里加上父类的名字即可,比如:class A; class B : public A;  只需要 B() : A(args) { }  即可带有参数的初始化父类。

                dynamic_cast问题,不会用dynamic_cast<T>(arg);作用:沿继承层级向上、向下及侧向,安全地转换到其他类的指针和引用。如果转换成功,那么 dynamic_cast 就会返回目标类型 类型的值。如果转换失败且目标类型 是指针类型,那么它会返回该类型的空指针。如果转换失败且目标类型 是引用类型,那么它会抛出与类型 std::bad_cast 的处理块匹配的异常。可以当做dynamic_cast<T1*>(T2*),如果T2和T1在一个继承链上,那么成功,并返回T1*指针,如果失败返回nullptr。

                模板TrieNodeWithValue和非模板Trie搭配可以把任何类型当做value值问题;首先Trie不是个模板类,但是它有模板成员函数,这让它可以完成这件事情。Trie中存储unique_ptr<Trie>但是,里面的指针可以是由TireNodeWithValue类型转化而来的,只需要在用的时候转化回去就可以,但这需要在GetValue中把类型当做参数传入,比如 : auto tmp = GetValue<int>(args)。我们才能知道要把这个指针转化成什么类型,这个时候我们就做到了Trie中存储多个类型。

                多线程的锁的问题,暂时还是只知道使用bustub提供的读写锁。

          

        2.环境问题:

                cmake,gdb不怎么会用。还有,在检测的时候已经要把测试样例的DISABLE_给去掉,不然测试没用。

                语法格式问题,因为要遵守goole clang的格式,有以下问题:if语句就算只有一句话也要加{ };;避免隐式类型转化,哪怕是从int到bool的转化;;变量声明应该一行只有一个;;用std::make_unique()代替unique_ptr();;注释 // 需要加一个空格再进行comment;;if,函数之后的 { } 的 { 应该在同一行并且空一个空格;;对unsigned int类型>=0是没有意义的。

                语言问题,因为我英语一般,需要时时查看翻译,应该多记住一些专业术语。、

                出错的时候认真一点,仔细阅读出错信息,里面真的很清楚了。

#p0 成功通过。git branch "#p1" git switch "#p1" 明天开始继续学习。

#p1

       day 3 2024.2.27:

                稍微又调整了一下环境,用vscode ssh连接上了虚拟机的Ubuntu但是不太好用,同时配置了vscode的格式化文档为Google。今天玩了一天,唔(),看了新番,排位上点分,玩会yozusoft,看会直播()。

PS:因课程要求,不能直接提供源代码,可以进行交流。

        day 4 2024.2.28:

                听了第二节课SQL,准备写HOMEWORK1;vscode ssh连接到Ubuntu不太好用,没有智能提示,最后决定在share文件夹上放数据,Windows上仅编写代码,Unbuntu做其他事情。收拾东西准备返校。

                day 5 2024.2.29:        

                          吃KFC了。

                day 6 2024.3.1:

                           高铁没赶上。

                 day 7 2024.3.2:

                          返校。

        day8 2024.3.3 - day 12 2024.3.7:

                p1终于通过。太痛苦了,漫无止境的debug。因为上的大锁,效率不佳,总的运行时间为3.12s,在leaderboard上排大概300名左右。

                写代码之前,一定一定要先翻译一遍要求,然后弄清楚弄明白每个类,每个函数,每个变量是干什么用的。谁都不想写的一坨史,然后改来改去,这是非常痛苦的。

                前置知识:gdb,shared_lock,shared_mutex,scoped_lock,mutex,可拓展哈希表,lru算法,lruk算法,BufferPool基本知识,也就是lecture的内容,一定不要操纵野指针。scoped_lock一定要学习,是一个防死锁的STL库锁,当然不是不会再出现死锁,而是出现死锁的时候会直接abort(),出现deadlock avoided abort()。这个可比等着运行半天再gdb才发现死锁了强多了。多多参考cppreference.com    可以帮助理解代码。

                Auzdora.-CSDN博客  感谢Auzdora的关于project1的博客,让我更清晰的理解了这些部分和内容。

                DEBUG记录:

                        可拓展哈希表部分:

                                说实话哈希表还好,因为平常就是用哈希表,比较能够理解它的构造。有的函数不需要写锁,只需要读锁,所以可以使用shared_mutex进行区分,可以提高一点效率,虽然只有一点。

                                1.private成员不需要加锁,因为外界不会访问;不要在一个函数中访问另一个会上锁的函数,会出现死锁;多用结构体绑定,别再用pair的时候一直first,second,那很难受。

                                2.在dir_倍增的时候,关于怎么增加更有效率,这里有一个比较好的讨论:c++ - Nice way to append a vector to itself - Stack Overflow

                                3.在分桶的时候,其实不需要新建两个桶,只需要新建一个桶,然后把其中一部分指针转向新桶即可,另一部分指针不需要转向,因为使用了list,可以使用list的splice方法进行剖分,不需要发生拷贝操作,效率更高。在分桶的时候,i每次+=1<<local_depth。

                                4.一个bug,或者说理解错误:我本来以为分桶的时候其实从hash(key)&(local_mask-1)开始的后面只需要一半的指针进行转向即可,所以每次都只挑选前半部分转向,但是这只在global_depth==local_depth的时候成立,如果local_depth<global_depth的时候,会导致一部分指针转向错误。原因和二进制有关,当local_depth<global_depth的时候,应该检查第local_depth位是否为1。 -->这个错误非常非常隐蔽,非常难察觉,我gdb了半天才发现错误。

                        LRU-K部分:

                                开始痛苦了,虽然之前做过LRU缓存(LeetCode)  但是对函数,变量的理解一直不到位,导致出现了问题,但是有一说一这个难度其实是比可拓展哈希表低的。

                                1.网上和群友大部分都是通过STL里的list搭配unordered_map实现的,但是这样的Evict函数时间复杂度为O(n),经过查找之后发现有的方法是使用map,让Evict函数的时间复杂度变为了O(logn),但是其他函数的时间复杂度也变成了O(logn),当然在BufferPool之后就会发现调用最多的就是Evict函数,但那样实现有些复杂。考虑到在初始化的时候就确定了整个replacer的大小,也就是我们只需要管理那么多就好了,不需要去删除,去增加,所以我选择了vector和list的搭配使用,并且vector只有一个,虽然Evict的时间复杂度还是O(n),但是包括Evict函数在内的时间复杂度常数都会很低而且实现也会简单不少,因为不在涉及erase。

                                2.Evict函数中,因为把evictable和unevictable的frame都放在了一个vector中,所以需要两个临时变量,最后确定该驱逐的frame。

                                3.初始化的时候,默认所有frame都是不可驱逐的,这样才和初始时size==0相符合。注意evictable修改的时机,只有Evict,Remove和Set的时候,RecordAccess不会改变evictable!!!

                                4.合理利用assert断言,会方便debug很多,但是断言的使用别忘了,assert(expression,string),当前面为false的时候才会abort()。我把Remove中的断言弄反了,导致subprocess abort() 错误,一直没发现为什么。用来debug的东西却需要被debug,小溪了。

                                5.注意LRU-K指的是,有k个记录之后使用LRU-K,没有k的时候就是普通的LRU,而且记录与记录之间是FIFO的关系。

                BufferPoolManager部分:

                                这个真的痛苦死我了,因为没能充分理解各个函数和变量的作用与意义,英语也不是非常好,没能充分理解含义,网上都说这个只是简单的拼接一下,但是我快难受死了。但不得不说,写完这个部分之后,对缓冲池的理解真的比较深了,也很能理解为什么不使用OS来帮忙缓冲。

                                1.区分frame和page的概念:frame是缓冲池中的东西,page是物理磁盘上的东西,把page读入到BufferPool之后它就改叫frame了,frame_id是pages_数组的下标,page_id是标记页面的唯一标志。

                                2.区分pin_count_和is_dirty:功能类似,但是作用不同,is_dirty标记页面是否为脏,如果脏的时候,那么在被Evict、DeletePgImp和Flush的时候需要写入磁盘,并修改为false,且默认为false;pin_count_相当于reference_count,当NewPg的时候应该标记为1,当FetchPgImp的时候应该加1,当pin_count_为0的时候不代表需要从replacer_中Remove。free_list_中的页面应当是,pin_count_=0,is_dirty=false并且不在page_table和replacer中的。

                               3.pages_,在flush之后应该改is_dirty为false,需要时时注意pages_的pin_count_是否需要改变,is_dirty是否需要修改,如果脏了是否需要写回磁盘,还需要注意pages_的下标frame_id和page_id的绑定,这也需要在page_table中体现。别忘了在函数中Read,Write和Reset。

                                4.page_table中的元素 = replacer中的元素 = 不在free_list_中的元素。page_table记录page_id到frame_id的映射,当页面被Evict、Delete的时候需要时时注意把原来的page_id删除,把新来的页面插入。page_table就这些作用。

                                5.replacer : 在Remove之前一定先SetEvitable(frame_id,true),谁都不想用来debug的断言出现subprocess abort()。NewPgImp、FetchPgImp之后一定要SetEvictable(frame_id,false)。在UnpinPgImp的时候如果pin_count_==0的时候在SetEvictable true,因为这个时候相当于没有被“pin”了(文档中所说),但这个时候不能Remove它并把它放进free_list_,它应该还是保留在replacer直到有东西给它驱逐。

                                6.page_table_、replacer管理在缓冲池中的,而且这两个管理的元素是一样的:pin和unpin的,free_list_里面是纯净的没有使用的page。

                                7.虽然事后想起来好像难度不太大,但是自己一个bug一个bug的de是真的恶心。向p2冲,冲,冲刺。

贴一个通过的图片嘿嘿,希望大家可以少de几个bug。

        day 13 2024.3.8:

                lru-k优化

                就像在2024.3.7中所说的,我用fork了一个分支,定义了一个结构体。用set实现了logn时间的Evict函数,但是时间却反而超过了3s,小溪了,反向优化。

这是结构体:

        day 14 2024.3.9:

                配置环境

                VMware虚拟机气死我了,又慢还老崩溃。润WSL了,WSL为什么我之前没有用,真的薄纱VMware,太方便了。

#p2

        day 15 2024.3.10 - day 19 2024.3.15 :

                完成p2的taks1,task2,task3,通过#p2 checkpoint1,为了检查iterator,给树上了大锁,结果直接通过了#p2 checkpoint2,甚至成绩还很好。推荐先学习B+树,因为之前写过B树,所以对我来说难度会少一些,这是我的关于平衡树的代码还有推荐网站:手搓平衡树:一颗B-树,一颗AVL树,一颗RB树,一颗Splay树,一颗带旋Treap,一颗无旋fhq-Treap,一颗01Trie_01trie 平衡树-CSDN博客icon-default.png?t=N7T8https://blog.csdn.net/qq_16762209/article/details/134431094?spm=1001.2014.3001.5501

Data Structure Visualization (usfca.edu)icon-default.png?t=N7T8https://www.cs.usfca.edu/~galles/visualization/Algorithms.htmlB+ 树 - OI Wikiicon-default.png?t=N7T8https://oi.wiki/ds/bplus-tree/

                单线程:总计四天

                        task1:

                                并不难,理解好要求就像,需要注意的是别把INVALID_PAGE_ID打成别的,因为可能值不同,区别rootpage和其他page的方法是parent是否是INVALID_PAGE_ID。

                        Getvalue:

                                没啥好说的,小试牛刀而已。用好bpm(buffer_pool_manager),reinterpret_cast。但是千万别忘了unpin,不然会很诡异得出现,有时候出错,有时候没问题的现象,这是因为缓冲池在大数据下爆满但因为没有unpin导致永远申请不到page。在这个时候先参考stl的二分写了二分查找UpperBound和LowerBound。

                        Insert,Remove:

                                插入和删除我使用了同一个框架,即先查找,然后在叶子结点判断,并通过传递bool值指引是否继续调整,最后unpin即可。

              

                       

在写插入和删除的时候,一定要善于debug,合理使用项目中给的b_plus_tree_printer,当最后debug结束之后,看着自己写的b+树构建起来真的很有成就感。、

                debug记录:

                        1.缓冲池出现问题,发现有时候读到脏数据,通过单步调试发现,这个bug困惑我了整整一天多,当时心态快崩了,发现是缓冲池在unpin的时候对脏标记的处理策略是直接覆盖,这是错误的,应该是原本为true则不进行修改,原本为false就进行修改。

                        2.还是缓冲池问题,unpin的时候,只要进行修改就应该立刻修改脏标记,而不是pin_count_==0才进行修改。所以一定要好好检查缓冲池的逻辑,不然bug是非常隐蔽难找的。

                        3.关于页面大小问题,首先,要先理解B+树的构造,在运行的时候,DBMS不会把整个B+树载入内存,这太耗费内存了,载入内存的实际上只有B+树这一个壳,它保存着B+树相关信息,还有root_page_id_,在使用的时候,根据root_page_id_去向缓冲池申请diskmanager读入页面,这样页面才进入节点。为什么B+树用于外盘就是因为,B+树一个节点就是一个页面,方便读取。

                        4.节点大小需要注意,我是这么定义的,节点中的数据个数应该 [min,max) 左闭右开,因为有的时候存在页面大小等于max的时候,比如:向父亲传递节点,这个时候父亲个数会达到max,为了不让页面超过max,所以设定小于max,即使暂时出现等于max的情况紧接着也会去处理。

                        5.在写完插入之后,在测试中使用大数据同时设置最大节点数据个数为2,3,缓冲池为5,发现错误,因为有的地方忘记unpin,导致缓冲池不够用,因为单线程下同时只需要三个页面即可,又给header一个页面,共四个,余一个为备用,检测缓冲池是否存在问题,善用std::random_shuffle

                        6.在删除和插入的时候,debug主要靠数据可视化的网页提供参考,b_plus_tree_printer提供图形化,最后单步调试定位问题。惊奇的发现自己的错误非常之多,但是这些错误比较容易定位。

                        7.在插入的时候有重复数据会进入到错误的叶子结点,锁定发现是,在寻找叶子结点的过程中应该使用UpperBound而不是LowerBound,因为UpperBound是大于号,而LowerBound是大于等于号。

                        8.多封装几个函数供使用,这些函数可能就几行,但是真的很有用。

                        9.为了task3迭代器的begin方便,考虑:split时总是向右分裂,merge时总是从右边合并到左边,这样保证了在空树时创建的root_page_id_一直都是begin_page_id_,修改begin_page_id_的时机只有树被删除至空或者空树插入的时候,比较优秀。

                task3:

                        迭代器的实现其实很简单,因为本质上是单项迭代器,而且不用考虑上锁的问题。唯一的难点在于怎么确定迭代器的私有成员,因为有哪些部分能让迭代器正常工作。我考虑了两种方法:

                         一个是只拿着页面id和当前位置pos,每次使用的时候都向缓冲池申请页面,然后在拿到数据之后unpin页面,但是感觉这样对缓冲池不太友好。

                        所以我选择了方法二:拿着页面指针和pos,在迭代器的生存周期中始终pin住页面,析构时向缓冲池unpin页面。当然这个方法也有问题,即如果缓冲池早于迭代器析构怎么办?我选择先判断是否是空指针,然后吞下异常(参考effect c++)。然后剩下几个函数也不难实现。

        day 20 2024.3.16 - day 26 2024.3.22 :

                电脑不慎进水了,维修,花了800块钱,气死我了。本来想着四月份之前写完这个项目的也泡汤了。这段时间只能用手机看看视频,惊奇的发现没了电脑我的学习至少被影响了百分之七十,因为我上课本来就没书,全靠电脑上看PPT。

        day 27 2024.3.23 - day 28 2024.3.24:

                多线程(crab,乐观锁):

                          其实crab思路一点都不难,思想很简单,就是及时把用不到的节点的锁给释放了。但是难点在于怎么让页面上的锁和缓冲池的pin保持一致,因为锁既不能少锁也不能多锁,少锁会多线程数据竞争,多锁会死锁;同时释放锁也是,少释放锁死锁,多释放锁会让这个线程把那个线程的锁给释放了。还需要注意的一点是:root_page_id_需要锁保护,不然在split和merge的时候会出问题,这会导致幻读。因为基本思想很简单,所以直接说debug。

                        Debug:

                                1.因为多线程下不再是“最多三个页面”了,所以有的时候某个线程可能申请不到页面,按理说这个时候策略有三个,一个是kill当前任务让他重新启动,一个是sleep等待别的线程,一个是wait无限循环不断尝试。因为本人菜狗,所以选择了第三种,即使当前线程会占用缓冲池不归还导致时间比较慢,甚至产生诡异的缓冲池死锁(线程A等待线程B释放缓冲池,线程B等待线程A释放缓冲池)。实际上我觉得最合理的应该是第一个,释放缓冲池之后然后killself可以让别的线程加快速度,代价仅仅是重新启动而已。

                                2.即使b_plus_tree.h中贴心的添加了头文件queue,但是我觉得queue是一个陷阱,虽然在查找的过程中queue确实合理,因为需要把父亲节点的锁给释放,但是在向上回溯的时候,因为其实我们要及时释放孩子节点,所以我们要pop_back,因此deque才是正确的选择

                                3.注意unpin和锁释放的统一,unpin的同时应该锁释放。比如,我们应该把所有上锁的页面放进deuqe,在函数退出的时候释放他们;在处理父亲之后不在管孩子的问题就释放孩子的锁。这里有一个debug的技巧:在buffer_pool_manager中添加一个方法用于打印当前缓冲池中上锁的情况和pin_count_的情况,当然,也要在page.h中添加一个方法,trylock,这样很方便的知道了当前缓冲池的情况。debug的时候时刻注意缓冲池的情况,就能知道有没有及时unpin和锁释放。其实这个时候我就差不多写完了crab,但是有的问题没有及时发现。

                        4.发现缓冲池太过于拥挤,测试的时候发现非常慢,提交oj之后发现超时的非常多。猜想是缓冲池的问题,考虑到crab已经想不到优化空间,所以写了乐观锁。发现速度快了不好,在oj上不会超时。

                        5.写了乐观锁之后,oj不会超时,但是会出现答案错误的情况。这个时候我百思不得其解,思考了非常久的时间,因为在单线程下始终找不到原因。最后,我发现在leaf_max_ = 255的情况下,插入数据小于等于250的时候,多线程是没有任何问题的。但是大于500的时候出现问题,具体表现为节点重复,相邻节点跨幅大。如下图(建议下载之后放大看):

上面这一小行就是图片,仔细看数据就会发现问题。最后猜想为split时出现问题,但因为单线程下没问题,推测为多线程引起的问题。

首先怀疑乐观锁中,我对叶子结点使用了锁升级(先把R锁解锁,然后申请W锁),但是因为父亲节点拿到了锁,仔细思考之后认为并非这个问题。

最后怀疑到split中在有时会分裂根节点,那个地方为了root_page_id_的安全,我上了锁,但是在最开始查找叶子结点的部分我上了读锁,并在随后解锁,乐观锁也是一样的策略。假设:线程A乐观锁读取root_page_id_之后解锁,在然后寻找叶子结点,而线程B这个时候对根节点进行了split,那么线程A就读到了脏数据

这是一个显然很简单的幻读,但是在实际上发现问题是非常困难的,当时我在群里询问,我表示对这一点表示怀疑,随后群友给我提出了非常有用的解决方案:建立一个头结点

之前因为我把对于root_page_id_的锁直接放在了B+树的定义中,导致了root_page_id_和锁的分离,不容易管理它的锁,而建立一个头结点,用某个数据表示root_page_id_某个数据表示begin_page_id_就实现了锁和数据结合,便于管理锁,到这个地方,我终于理解为什么锁总是放在数据结构的定义中了,这是有很大意义的。

随后定义一个头结点,把parent_page_id_表示根节点,page_size_表示begin_page_id_。

为什么不用page_id_表示begin_page_id_?

因为我们在unpin的时候头结点因为不在缓冲池中,所以在Unpin头结点应该总是无用功,但是如果存在page_id_的话,那么begin会被多次unpin,甚至导致非常隐蔽的错误:在使用begin页的时候,突然读到了脏数据(因为begin页被刷新了。)

为了杜绝这种情况,选择一个不可能用到的数据:page_size_。至此,这个bug结束,非常隐蔽,非常难找,甚至在解决的过程中还有坑。其实这个bug就是我程序的最后一个bug,写完这个bug之后就通过了p2 checkpoint2.

                        6.unpin不完全问题,因为在寻找子节点的过程中需要FetchPage,所以pin_count_+1,但是在split或者merge向上的时候,还需要Fetch页面,这个时候pin_count_+2,被pin了两次,因此要小心而慎重的既不可多unpin,也不可少Unpin,可以借助 3. 来debug。

                优化:

                        优化主要考虑怎么减少锁的申请和减少缓冲池的占用。

                        1.split和merge中,在改变孩子节点的parenti_page_id_时不要申请锁,因为这个时候线程是一定安全的。

                        2.在向上split和merge回溯的时候,及时unpin。

                        3.尽可能快的找到第一个不需要split或merge的节点,及时释放父亲及其之上的锁(有点类似于红黑树、AVL树的“最小不平衡子树”)。

p2结语:

虽然还没写p3,p4,但是据群友说,p2难度是最大的,还好之前写过B树,难度稍微减低一些。“思路清晰,目标明确。富有耐心,有想象力。”是我觉得写p2需要的,前两者是对于前期写代码的时候;后两者是对于中后期debug的时候(debug真的很需要“猜”到底错到了哪里)。

在寻找单线程的bug中因为有不少方法,尚能忍受,并且每次打印出结果总是让人振奋;但是寻找多线程bug的无限地狱中,差点不想写了,因为我写的大锁已经过了,我就算写好多线程也不一定有那个成绩好。还好坚持了下来,让增加了不少关于多线程、锁的认识。在多线程通过之后,我把b_plus_tree_contention_test的数据加大,发现多线程还是有意义的,速度比大锁快不少,虽然排名不高就是了。

写了crab之后发现多线程和单线程有一个常数的问题:即多线程可能确实可以更加合理的利用CPU和磁盘IO,但是上锁和维护锁也需要时间,而单线程虽然没法充分利用CPU和磁盘IO但是,上锁少,缓冲池用的少。这之间有一个权衡的问题。p1的时候也是,使用set理论O(logn)上时间复杂更优,但是最终却不如O(n),也是一个常数的原因;回忆起stl的sort,在n<=16的时候也是选择插入排序O(n^2),而不是堆排序O(nlogn),想来也是常数的原因。大概这就是kiss的优雅之处。

贴一下成绩:这个是大锁(写的crab排名是79名)。

吐槽:写p2这段时间霉运不少:3.15电脑进水被迫停工;3.20号洗澡发现身上略有些红点本以为没事直到3.24发现红点遍布全身,密集的吓人,赶紧看了医生;推魔女夜宴想进没轱辘线却进了消失线,气死我了;3.22电脑官方店才告知进水不保修,维修店维修花了800块钱,给我钱包吸干了才在3.23修好电脑,明明买的时候才四千。真是祸不单行。

我写这个博客的时候是3.25,正好是我(阴历)和高中好兄弟(阳历)同一天生日,生日快乐!

(这个蛋糕是真好吃啊,还是甜食最能治愈人。)

#p3:

        day29 2024.3.25:

                今天先歇一天。

        day30 2024.3.26 - day32 2024.3.28:

                p3完成。p3最神奇的地方在于写的时候感觉很懵很难懂。但是写完之后又觉得思路很清晰很简单。推荐的文章:

                做个数据库:2022 CMU15-445 Project3 Query Execution - 知乎 (zhihu.com)icon-default.png?t=N7T8https://zhuanlan.zhihu.com/p/587566135BusTub 养成记:从课程项目到 SQL 数据库 - 知乎 (zhihu.com)icon-default.png?t=N7T8https://zhuanlan.zhihu.com/p/570917775        光看项目p3文档的话,其实根本不知道要做什么,要怎么做,这就是p3的难点,读代码读到读懂要干什么。

        有一说一,这个图做的是真的好。当然在看懂之后才明白这个图在干什么。先看存储数据

        1. Value,数据的基本单位,在头文件的定义中可以看到它的实现方式。非常巧妙,初始化的时候通过指定typeid和类型重载实现,同一个类却在初始化中初始化成不同类。且看代码:

        当然,不同类型的数据存储在同一个类可能有些浪费,因此数据存储方式为union,有一说一,我这是第一次在除了语法课之外见union(是我代码读的少了)。通过把若干类当做友元方便访问,实际上用的是工厂模式(见 ValueFactory)。

          之后Value有一堆成员函数包括Add,Max,Min,IsNull,这都是之后会用到的,因为在操作Value的时候不会直接使用 + , * 等等运算符,而是调用这些函数。

        2.tuple用来存储一行的元素 与 schema是解释tuple的类。我觉得这两个的关系是密不可分的,但同时又不能把他们两个合并成一个类。schema解释tuple的组成和有用的列,也可以通过schema获得tuple的各个列的typeid,在使用的时候schema主要用来组装tuple;

         3.plannode,用来指示怎么执行计划。因为不同的执行器有不同的plannode,不能一概而论。

        4.在实际中,child_executor_,expression_一般都是抽象的基类,所以建议先打印出来(T哦String)看看到底是怎么个事,再去浏览对应的实现代码,辅助使用bustub的explain解释执行过程,理解整个执行。

        踩坑:

        1.SeqScanExecutor:刚开始的时候,不知道怎么操作,看了别人说的才知道,exe_ctx中保存着执行引擎需要的所有东西,先去plan要即将扫描的tableid然后问catalog获取对应的table_info,最后用着已经实现的迭代器,进行迭代即可。因为扫描的时候一般已经是执行计划的叶子节点了,所以不需要child_executor。

        2.InsertExecutor:这个时候开始需要child_executor,在Init中先初始它。

                坑点一:不管怎么样,即使不需要继续插入,第一次Next总需要返回true,因为需要返回插入的个数,之后才可以返回false。这一点在别的算子也是一样的。我的方法是一次性全部插入,设置一个bool标志是否已经调用Next。

                坑点二:正如文档上所说,记得更新对应的索引,但是一定是插入成功之后再更新对应索引。索引通过拿着table_info中的name问catalog的GetTableIndexs要。

                坑点三:在向索引插入的时候,不能直接插入tuple,需要调用tuple的KeyFromTuple(返回值是tuple)方法获取需要的key,传入的参数是原tuple的schema,index的key_schema和index的Attrs。

                坑点四:这里第一次涉及了tuple的构造,因为是这个tuple只需要包含一个整型表示插入的个数(当然这个时候输出的schema就是和输出的tuple的解释一直),这里展示一下代码:

        3.DeleteExecutor:有一说一,和Insert一样。

        4.IndexScanExecutor:这里一定要保证B+树的iterator实现正确。而获得B+树索引的方法,在文档中有,不再多说,需要注意的是这里的plan没有给tableid,但是B+树上存储的是RID,没有tuple,所以还是需要向table要。这个时候理应想到索引应该会存着对应表的有关信息,果不其然,tree->GetMetadata()中可以得到tablename,然后找catalog获取即可,Next中用iterator获取RID,用table获取tuple。

        5.AggregationExecutor:这个地方是pipeline breaker,即破坏了火山模型的一次一次地返回。在这里需要先把所有信息存在哈希表中,然后在Next中进行进行tuple的拼接。

                坑点一:在Init中,先把所有元组给插入哈希表中该怎么插入。

                坑点二:构造tuple时,我们需要把对应Value先插入std::vector<Value>中,再用vector构造tuple,这个地方一定要Key在前,Val在后。Key和Val都在迭代器的函数中。可以去看迭代器的实现。

                坑点三:我们需要自己去写聚合函数,需要注意处理空值,给一个示例:

 基本就是先判断是否为空,然后调用Value对应的函数即可。只是如果没有示例的话会有点一头雾水,并不难。

        坑点四:在前面三点过之后,其实已经可以执行了。但是有个莫名其妙的地方:空表。显然,如果只是单纯的:

那么在 count(*) 的时候就会什么也不输出,可是文档要求输出 0 ,那么这个时候我们需要特判。用一个bool值表示是否使用过Next,只要调用过Next,那么这个bool值就进行修改,那么就和迭代器和这个bool值,我们就能判断是否是一个空表,这个时候我们再根据plan中的AggregationType拼接一个tuple,如果是CountStar就是0,否则为对应类型的空值即可。

                坑点五:上面坑点四的处理不完全,如果在使用聚合的时候有group by,那么空表就是输出空表而不再输出任何值。那么这个时候我们就需要在这个算子中判断语句中是否有group by。怎么判断?答案在plan的GetGroupBys(),如果不为空,说明含有group by。那么在判断出空表的时候,如果有group by,我们直接返回false,不在输出任何东西。不然按照坑点四处理。(这里我建议输出空的时候,输出的是对应的value类型,不是直接整型的空),即:

        6.NstedLoopJoinExecutor:有一说一,这个算子是需要一些算法思维的,而Aggregation只是单纯的熟悉了之后就能写。不过关于算法部分那个blog讲的非常仔细了(我看到这个部分的时候也想到了yield,不过是Python的yield)。

                坑点一:怎么判断是否match,用plan里面的为谓词,这个谓词实际上是AbstractExpression,可以打印一下看看到底是那种表达式,看一下实现。调用EvaluateJoin,传入两边的tuple即可。这点在别的已经实现了的算子中可以看到。

                坑点二:拼接tuple,实际上就是把左右两边的tuple给拼出来,方法实际上就是一个一个的调用tuple的GetValueAt得到value,然后装入vector,最后构造返回的tuple即可。

                坑点三:InnerJoin中,如果当前left tuple没有match到right tuple,那么应该继续尝试下一个left tuple,也就是应该是一个循环直到match或者返回false。

                 坑点四:LeftJoin中,如果一个left tuple没有match到,仍然应该返回只不过right tuple部分为空。考虑到我们match到right tuple就返回,所以需要判断left tuple是否是一个都没有match到,我的方法是保存初始right tuple进行到的下标,如果终止时下标为n,起始时下标为0,说明没有match到。

        7.NestIndexJoinExecutor:有一说一,这个是最坑的,因为傻傻不清那个tuple时left,right,inner,outer还有这个执行过程到底是怎么样的。执行过程:child_executor吐出left tuple,之后再index中查找right tuple,然后不需要评估,直接连接两方tuple即可。

                坑点一:left tuple应该从child_executor获取,而非table,left tuple对应的schema在child executor中;right tuple通过index获取rid之后,通过和IndexScanExecutor一样获取table,在table中获取right tuple,对应的schema在plan的InnerSchema中。

                坑点二:这个时候通过left tuple查找right tuple不是通过KeyFromTuple,因为索引是在列上面建立的,对应的tuple只有一列。需要使用plan中的key_predicate的evaluate获取需要的那一列,然后再用index的schema表示解释方法构建出查找key,然后在B+索引ScanKey获取rid。之后因为已经知道了左右两边的schema,之后就是直接拼接即可。InnerJoin和leftjoin的坑根据之前的算子就可以知道。

        8.SortExecutor:这个和Aggregation类似,也是pipeline breaker。需要在Init的时候就排好序。排序只需要调用std::sort,然后自定义排序规则即可。

                坑点一:比较方式,因为plan中的order_by是一个pair,获取second发现是一个AbstractExpression,建议打印出来到底是个怎么事,然后查看实现代码。我本来以为调用的是调用它的EvaluateJoin,因为这个会传入两个tuple,但是后来才想起来这样的话没有自定义规则,当然是错误的。实际上使用expr->Evaluate获取左右两边的需要比较的Value,然后调用Value中的比较函数即可。需要注意怎么判断两个值相等?这个我就不说了,然后这个lamaba函数传入sort的参数即可。

        9.LimitExecutor:有一说一,最简单的一个。

        10.TopNExecutor:写了前面好几个算子,这个其实已经很清晰了,唯一的难点就是怎么用stl的堆自定义排序规则。

                坑点一:自定义排序。lamaba函数其实和sort的一样,这个不说。读堆的实现代码,发现模版参数中有一个是Comp,这个是比较函数的类型,所以直接delctype(comp)即可。这个时候会提示无法初始化,查看堆的构造函数,发现默认构造函数为 comp(),显然肯定没法初始化,就算能初始化我们用lamaba捕捉的有关信息也会丢失,发现有构造函数可以传入比较函数,所以定义堆的时候直接在()中传入即可。

        11.OptimizeSortLimitAsTopN:最后一个,终于不再是算子。应该是为了leaderboard开个头。可以先看看别的optimizer是怎么实现的。因为这个真的不难,也没什么坑点。所以放这不说了。

        p3结语:

           虽然刚开始的时候非常懵逼,但是事后复盘会觉得很简单,这是p3最神奇的地方。通过天数也可以看出,p3实际上真的不难,当然这是因为我还没写leaderboard,我准备留在之后再写,这段时候有点小忙,先把p4写完再说。

        过关记录(没写优化,所以成绩很一般,118名):

        看见这绿色的,真让人满心欢喜。越来越熟练了,就趁着就这劲头尽快写完p4!!

#p4

        day33 2024.3.29 - day38 2024.4.3:

                p4完结,有一说一,不是说p4简单一些吗?可是我觉得比p3还难,有几个bug快给我整崩溃了。p4是我这四个项目中写的最晕的。因为前期意图锁没听太懂,导致整个lab都写的晕晕的,真是很感谢大佬们写的博客才让我找到门路。感谢:

做个数据库:2022 CMU15-445 Project4 Concurrency Control - 知乎 (zhihu.com)icon-default.png?t=N7T8https://zhuanlan.zhihu.com/p/592700870CMU15445-2022 P4 Concurrency Control - 知乎 (zhihu.com)icon-default.png?t=N7T8https://zhuanlan.zhihu.com/p/600001968比较有意思的一点是:刚开始的时候看不太懂大佬们在讲什么,在写出来问题之后找bug,找出来bug之后才恍然明白大佬的意思。建议先看这两位大佬的博客,这只是踩坑记录。

        踩坑记录:

        task1:

        1.request_queue在lock的时候可能为空,需要先新建一个(建议把request_queue里的裸指针改成std::shared_ptr保证内存安全,谁都不想手动delete。)

        2.request_queue在获取之后需要上锁,需要注意的是,上锁应该在释放table_lock_map_/row_lock_map的锁之前,不然可能数据竞争。

        3.在检查锁升级的时候,确定锁需要升级,需要先释放原来的锁,怎么释放?不是直接调用Unlock函数,方法是直接删除事务中锁集合中的对应oid然后再request_queue中删除对应节点,记得使用断言,如果找到了事务id一样而grant_为false,这不应该出现。然后把upgrading_改为当前事务id。

        4.GrantLock中需要先检查是否和grant_ == true的兼容(是否兼容可以看课程有一个相容图,可以写一个函数专门判断。),然后检查正在wait是否依次和前面的锁兼容,也就是说这是个二重循环(应该有算法可以优化,但是我当时心态崩了,就没想),这是我倒数几个bug,是真的难找。感谢 知乎大佬xiao 提供的测试样例,才让我发现这个问题:

GitHub - wuxiao889/bustub_test

        5.锁在grant之后,如果是锁升级,需要把队列的upgrading_改成INVALID_TXN_ID,也别忘了把队列中对应节点的grant_改成true。最后锁进行分配,其实就是在事务中对应锁集合添加table/row的id。

        6.只有S/X锁会导致收缩,所有的意图锁解锁都不会导致收缩!!!(我被卡了好久)

        7.解锁之前需要先判断有没有锁,不然要抛出异常。解锁的时候也是要先申请队列的锁再解开map的锁。table解锁的时候要检查row的锁是否全部解锁,只需要判断事务对应锁集合的oid下row的锁是否为空,这是我最后一个bug,我写成了检查所有table下所有row的锁,因为是很前期的代码,我一直没发现错误。解锁之后可能需要对事务进行状态调整,这个地方需要考虑commited的状态,因为TranscatinManager会在事务提交之后,依次释放锁。

        8.因为解锁的时候其实事务可能处于commited状态,所以要做好判断。释放对应资源其实就是,在队列中删除节点,然后在事务的锁集合中erase掉。最后别忘了用条件变量唤醒所有线程。

        9.在给Row上锁的时候,记得检查对应表上是否有锁。如果是S锁,那么表上至少应该有IS锁;如果是X锁,那么表上至少应该有IX锁。剩下的基本和Table一致。

        10.SIX锁实际上是IS锁+IX锁。

        11.关于找bug:可以定义一个抛出异常的函数(参数中可以添加一个行数表示是哪一行在抛出异常),所有异常由他抛出,可以打印出异常信息来调试;如果存在某个测试样例莫名因为抛出异常abort,可以尝试在Lock()和unLock()函数中写try catch,让打印异常信息的函数打印出来信息。

        12.task1的坑大概就是这么多,但是我记得写的时候我特别痛苦啊,为什么回忆的时候想不起来了,这是什么好了伤疤忘了疼吗?

        task2:

        1.寻找环可以使用dfs算法,不如说我推荐dfs算法,不推荐top排序。因为dfs算法可以找到环上有哪些点,而top排序只能找到环上没有哪些点,有些麻烦。

        2.dfs的时候需要从事务id比较小的开始搜索,至于为什么,其实我是不清楚的,这一点我确实没有明白。因为我的理解是有向图中只要有环,不管从哪个点出发,都能找到这些环上的点,这里存疑。所以这个部分我相当于只是在照搬博客大佬们。因为维护升序比较麻烦,所以我的图的定义:

       使用了map和set,让底层的红黑树帮我保持顺序。

        3.死锁检测的过程大概是:建图->寻找环->找点->设置abort->在图中删除相关边并在这个过程中唤醒对应线程->等到abort的线程醒来->让它在队列中删除自己->释放自己的锁->再次唤醒所有线程。 其中,建图是一个在等待队列上的二重循环,等待的向使用的增加一条边,记得队列要上锁,选择的点是事务id最大的点。删除点的时候不要直接删除队列中的对应点,让线程苏醒之后自己去删,删除点这个过程只删除图的边和唤醒线程,也就是说唤醒线程有两次,这是因为我们无法控制唤醒线程的顺序(由操作系统控制而不是DBMS控制)。大概过程如图:

        

        4.其实有一说一,wait_for_latch_是不需要的,因为只有一个线程会访问wait_for_graph_。

        5.经过死锁检测之后,获得锁的while循环就需要进行更改了:

        6.在写这个的过程中,我想到,如果上锁都是以一定顺序上锁的,比如对于表a,b,c,总是按照a,b,c依次上锁,如事务1需要a,b锁,事务2需要b,a锁,修改顺序之后都变成先申请a,再申请b,那么就不会出现死锁了。不过思考之后就会发现,事务是用户控制的,所以我们没办法控制。

        7.死锁检测其实难度不大,主要是task1,bug真的快难受死我了。

        task3:

        因为我没有写leaderboard,所以这个不难,看一下 十一 大佬的知乎博客就能过。leaderboard等我再有时间了再写。

最后一张通关记录:

        吐槽:        

        在写p4的过程终于装上了clangd,语法和风格检查真是严格。看着一堆爆红感觉浑身爬满了蚂蚁一样。需要反思的是,因为清明节我必须要开始复习学校的课程了,导致p4我写的越来越烦躁,甚至有面向测试编程之嫌,在通关之后才恍然明白这是在暴殄天物。之后有时间一定会再优化一下和写一下leaderboard。(自我吐槽:为什么没有吐槽我的名字Dog_Du实际上是函数名的驼峰法和变量名的下划线写法的混用啊。)

结语:

整个15445可能花了150-170个小时,p0 1天提交9次,p1 6天提交75次,p2 7天(去除中间电脑进水)提交77次 p3 3天提交10次 p4 6天提交61次,写了一个多月时间。

个人觉得难度是: p4 > p2 > p1 > p3..

因为我之前写过不少平衡树,p2对于我来说难度小一些,而且有b_plus_tree_printer比较好debug;p4可能是心态过于烦躁导致我觉得难度很大;p1、p3的话则是难度在于看懂干什么。

有一说一,这一个月是真不容易,除去电脑进水那几天和因故没法写代码的几天,每天写代码爬在电脑前面保守估计有五六个小时以上,真是有很多不经历痛苦就无法解决的bug。这一个月学校上课也没听课,清明节还要补课。

感谢Andy、迟策先生还有为这个项目的TAs慷慨的分享和开源,让我有机会写这一个优秀的lab。真的是收获不少,从配环境,到查文档搜资料,到多线程基本功,再到数据库的实现细节包括C++语言的基础知识本身都收获不少,让我切实感受到C++是一门很强大的语言。这篇笔记本意是为了复盘实现的过程和帮助自己更好的理解知识,如果在此之外还能给其他人一点点微弱的帮助真是再好不过了。

感谢群里大佬的答疑解惑:

在写p2的时候让我很担心的湿疹终于消失了,到最后也不知道原因是什么。都四月了,一月番都完结了,沈阳才刚刚升温,将将零上而且风还不小,所以什么时候空调能装好在寝室穿着短袖吹空调喝可乐啊。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/小蓝xlanll/article/detail/561471
推荐阅读
相关标签
  

闽ICP备14008679号