前几天突然发现地铁已经修到了学校门前,图书馆上方的钟不知什么时候变成了绿色,我站在那儿,可能立定了一秒钟,毕业季燥热的风携带着四年的短暂片段,抽帧似的呼啸而过。和我设想的一样,淡淡的、白开水般的毕业。乌泱泱一堆的来,各自散开的走。
我在学校完完整整待过的时间不算长,满打满算或许也不到三年,各种实习还有疫情把大学的记忆切割成许多碎片,实验室、传媒中心以及一群随叫随到的兄弟应该就可以全部涵盖,所谓记忆我想就是和这些朋友在一起的欢快时光,当然这也是我最快乐的时光。
我是一直都喜欢毕业的,我喜欢去新的地方,我想一步步脱离『学生 』这个角色,我没觉着大学就一定和青春挂钩。我不是一个『好的学生』,学霸、奖学金、学生干部,我都很少想过,我只想在这个独一无二的四年里,学想学的东西,翘想翘的课,做喜欢做的事,和朋友们一起,学习、娱乐,成为想成为的人。离开时,我希望的不是别人说我很牛,而是我和别人不一样。
或许我会偶尔怀念青春,但我一定不会怀念学生时代,我由衷感激遇到的好老师给过我的启发,我笃定离开校园后的人生完全有可能更加灿烂。毕业,想想就觉得:不错,人生越来越有盼头了。
在高铁上写了这篇文章,但选择发出来还是比较犹豫,每次看都感觉有很多想法难以表述,毕竟来日方长,也并非都后会有期。
最后,也祝你生活愉快。
]]>我最喜欢的季节是冬天,喜欢系上围巾,把手揣在兜里的感觉,然后随着耳机的音乐点头、呼吸,雾气把眼镜朦胧了也没有关系。
我最讨厌夏天,无论是郴州的、徐州的、还是北京的,特别是要下雨的时候,闷热、潮湿,仿佛人都要融化了一般。我的宿舍比较节约,所以我经常跑去活动室吹空调,不过那好像已经是快两年前的事情。
我经常会看之前写的年终总结和随笔,一是它可以帮我回忆那一年大概是个什么样的光景,二是它时刻提醒着我那一年我想成为怎样的人。
失败还是成功?就目前而言我并不能对此下结论。读书时期年轻气盛,大一时我想在 CTF 上有所建树;大二我想成为一名红队;大三我执着于安全研究;但最后我找到了一份 CyberSecurity Construction 的工作。我大概率无法成为我想成为的人,但是我还是在努力让自己不成为讨厌的那个人。
很多次我也会质疑自己的能力,质疑自己的工作,从 Destroy 到 Create 的转变需要一个过程,这一次我告诉自己我会留下,而不是选择逃避。
很多人习惯于把最差和最糟糕的一面留给最熟悉的人,却把宽容和耐心留给陌生人。
很不巧,我刚好踩到了上面这一点。不过与其说是在亲密的人面前展现了糟糕的一面,不如说是把真实的自己无防备的展露出来。在 20 年的总结上我写到:『 我是一个疲于长期维护一段感情的人 』,我比较自负,当我的观点和别人不一致时,我会下意识的否定别人。对朋友而言,我不是一个好的听众,甚至有一些刻薄。
很感谢在 22 年有几位朋友说出了我的缺点,新的一年里我需要学会管理自己的情绪,控制表达自己的情感。
从去年找到工作到现在,我基本上是一篇技术文章都没有输出,明显感到自己对研究技术的精力、甚至兴趣都消减了很多,这让我感到有一些不安。后来看到了 yds 的博客,以及和 mentor 主动 one one 之后我明白了一个道理:『顺势而为』。借用 Jobs 演讲中的一段话:
工作将占据你生命中很大的一部分
Your work is going to fill a large part of your life
只有相信自己所做的是伟大的工作,你才能获得快乐
and the only way to be truly satisfied is to do what you believe is great work
我的工作肯定说不上伟大, 而且也确实消磨了我生活的大部分意志,但无论我如何主观的去思考它,它都不会变得有意义或者失去意义,我能做的通过自己的努力让这份工作变得有意义。顺势,才能有所作为。这是工作教会我的第一课。
今年的总结比较简单,本来已经没有打算写了,但想了想这是大学的最后一篇总结,还是打算补上,有一些仪式感的味道。
最后也祝看到这里的你身体健康,万事如意。
]]>我最初的意向是打算出国读个硕士(主要考虑北欧和西欧),大约在8月初左右结束了暑期实习,一直到9月中旬中秋前一直都在准备留学相关工作(成绩、雅思、文书等等),比较闲还上了国外几门很优秀的公开课(具体可见前面几篇文章),最后因为一些家庭因素暂时终止了这个想法。我也了解今年秋招是属于地狱难度,并且这个时间点也已经过了大多数提前批的截止日期,不过好在我自身也属于一个比较善于规划的人,于是在中秋三天里花了一天整理自己的简历,花了一天收集秋招公司信息,再花了一天海投,最后就是数不清的测评、笔试、面试,一直到十月末才差不多结束 ;-(。回顾我的秋招历程,其实并没有太多时间给我准备,基本上就是边面试边复盘总结的过程,于是有了此文。
不过所谓面试指南,经验、技巧等等,我认为都是建立在面试者已经具备了一定合格的能力的基础上,更何况信息安全本身也是一门重视实操的学科。如果面试者自身基础不扎实,哪怕问到了一些准备过的问题,面试官也很容易通过一些 follow-up 的提问问出面试者的真实水平。
关于面试本身,我觉得这不仅是一场测试,一场选拔,更多的可以把它当作一次充满沟通和交流的谈话,所以我们并不需要畏惧。对于面试官而言,他可能只是希望招到一个能够愉快工作的同事、或者一个值得培养的下属;对于面试者而言,其实这是进入职场前少有的可以和业界大佬们直接交流的机会,所以我非常珍惜每一次面试。整个招聘的过程也是候选人和面试官的双向选择,一次好的面试体验会让我对这家公司好感大增 (^-^) ;反之有的面试官态度傲慢、咄咄逼人也会让我情不自禁的打退堂鼓 ಠ_ಠ(非常少数)。
后续内容为我整个秋招经历的思考、心得与总结,包括面试前的准备、面试中的应对以及未来的一些规划。希望可以帮助到同样找工作的同学。
在秋招之前我已有了两段乙方实习经验,所以个人对乙方的安全产品研发以及红队攻防也有了一定了解。最初我还是向往乙方,因为觉得乙方的工作更加纯粹,并且在某个垂直领域能有比较深入的学习,像一些实验室性质的工作受业务压力影响较少,会有很多时间研究自己感兴趣的东西。产生去甲方的想法是实习一次吃饭时,和华子哥聊到 SAST、以及漏洞挖掘相关话题,在这之前我上过一段时间南大的 程序分析课程 ,但我发现红队向的漏洞挖掘(直白一些就是面向 Pre-GetShell 的审计)更多的还是依靠人工的能力,我也有想过尝试自动化去辅助,但不同项目、业务的代码质量良莠不齐,基本上还是属于审完一套换一套的流水线。这时华子哥和我说如果受限于目前的工作模式,可以考虑去甲方看看。的确在甲方庞大的业务代码下,想只依靠人工直接审计的方式有些不太现实,也衍生出了 SAST、DAST、IAST 等等自动化测试技术以及 DevSecOps 的理念。这一些知识是在乙方环境下比较难去触碰的。
如果是站在甲方的这一点,我们可以考虑的就有非常多了,上到互联网、金融、区块链、最近火热的新能源汽车;下到国企、银行、医疗等等。在甲方公司进行选择的话,抛开物质上的因素(薪资、工作城市等等,虽然这个很重要,不过一般是放在拿到 offer 之后再需要考虑的),我在意的点只有有一个:公司业务是否对安全有真正的诉求,换句话来说就是安全部门是否有真正的话语权。上有喜茶网络安全部门被裁,下有马斯克血洗 Twitter 网安部门,虽然在进入信息安全专业那一天起,我们系主任就嚷嚷着:『没有网络安全就没有国家安全。』但我们不得不承认:安全的确不会带来直接的利益。如果公司对安全没有强诉求,对信息安全建设的重视和投入不足,那么安全部门在公司可能只是一个背锅的存在,在这样的环境下提心吊胆的工作我认为也是一件很痛苦的事情。
我认为简历上最关键的点有以下三项:学历、项目、实习。
除了以上三点,还有一些成果性的荣誉奖项,比如 CTF 比赛、发表的论文、高质量漏洞等等。但对我而言 CTF 基本上面试环节都没有提起过,甚至有些面试官问我 CTF 的全称是什么;漏洞相关也只是大概问一个挖掘思路,不过也可能是因为其并不是属于那种通用框架型漏洞的缘故。
这里的基础我分为三样:算法基础、计算机基础、安全基础。
算法基础是很多同学都容易忽视的一点,包括我在春招找实习时,笔试需要做算法的公司我都一律 pass …后来秋招意识到不对,基本上大多数公司的笔试题都有算法,有的甚至和开发放在一起纯算法笔试,不过我还是没有进行系统的准备,大约就简单温习了 STL 一些数据结构的调用。直到中秋后的第一场面试,整体都比较顺利,快结束时面试官说我们走个流程做一道算法吧,卒,然后面试官换了一道,磕磕碰碰写了出来,但结果不对,我说能不能让我在 Clion 上调试一下,面试官看了看时间让我说思路就行,卒 。之后便找了开发的同学,推荐了 代码随想录,大约花了一周,每天 10 小时左右爆刷,因为时间实在是不够,争取做一道就总结一道,前前后后大概做了 100 出头,少部分见下图(贪心和动态规划以及后面刷的一些题目没有纳入其中):
对我而言 100 多题手撕代码基本上足够了,安全方向的话主要以考察基本数据结构为主,当然时间充裕的话最好还是把代码随想录整体过一遍比较好,大约在 200 左右会更加有底气。
计算机基础基本上为计算机网络和操作系统两块,这里一样是开发同学推荐的 小林coding ,当然不建议像八股文那样去背,我觉得简单过一下就行,和之前说的一样信息安全还是一门重视实操的学科。打个比方面试官很少会直接问你 『 TCP 三次握手和四次挥手的过程 』,但是可能会问 『 NMap 支持哪些端口扫描方式,可以分别说说对应 TCP 三次握手和四次挥手的哪一环节吗?』
安全基础因人而异,web 安全的话至少 OWASP Top 10 得非常熟悉,从漏洞原理到利用到预防。这里推荐 Web安全学习笔记 作为自己查漏补缺的资料,当然不同公司侧重点也会很不一样,就拿最基础的 Sql 注入来说,有的公司可能会对利用这一块要求比较高,比如
xxx 被过滤了如何绕过?
如何判断数据库类型?不同数据库打法上有什么不一样?
不同数据库版本之间的各种tricks
但有的公司可能会对原理上考察更加全面:
sql 注入如何预防?
预编译的原理是什么?在业务中有哪些不能使用预编译的点,为什么?
WAF如何检测sql注入、RASP如何检测、了解AST吗?
基础这一块可以选择多看看面经,慢慢总结完善。不过如果是打 CTF 出身并且基础扎实的同学,我认为还是不用太担心的。
随着各种业务高速发展,安全行业的需求其实也是逐步增长,人的精力终归有限,找到属于自己的领域对无论自我认知还是发展都非常重要。
在大一刚接触安全时,老师把我们简单分为 Web 和二进制两个方向;后来打 CTF 时,我根据 CTF 的题目类型把安全分为 web、逆向、pwn、密码和杂项(取证、隐写)。这样划分终归还是比较片面,如果我们能对自己有一个深刻的认知,找到一个适合自己的方向,进而在简历和面试中凸显出来,至少能告诉面试官:『 我想做这个方向,我觉得我比较合适 』。
单从网络安全来说,不同的人侧重点可能完全不一样:有的人打点很厉害、手握不少 0day;有的人内网漫游、对各类云环境也非常熟悉;有的人擅长漏扫和白盒、对SDL有自己的理解和实践 …安全同时需要广度和深度,如果能在适合自己的领域长期学习和发展,我觉得是一件很快乐和有价值的事情。
在面试中有很大一部分时间都会交给我们自己来说,就我而言以下问题是基本上每次面试都会问到的:
1. 自我介绍
2. 介绍实习
3. 介绍项目
4. 最有印象的一道CTF题目?
5. 最有意思的一次挖洞/渗透经历?
6. 给你一套代码,如何做代码审计?
7. 给你一个网站,如何做渗透测试?
......
上面这些问题的答案,尽可能提前总结记录下来,能比较流畅的介绍,如果条件允许的话可以说给志同道合的朋友。我和室友就经常会互相模拟面试问答 (*^^*) ,这样比较 open 的方式经常会暴露很多自己看不到的问题,也是收获很多。
秋招属实不易,让我最直观的感受就是 个人在大环境下实在渺小,而我很大一部分的焦虑就源自于和别人的比较,后来想开了也就没有那么多的精神内耗。降低预期,减少攀比,顺势而为,毕竟一切都得向前看,不是吗?
受限于我的眼界和水平,文章很多观点可能非常片面和不妥,还请谅解,欢迎批评指正;如果你在阅读之后能有一些收获,那也是我莫大的荣幸。
最后,还要感谢一路上众多师傅以及学长们的指导帮助,
当然,
也要感谢在寒冬下没有放弃的自己。
开启一段新的征途吧。
]]>Exercise 1 是一个 C 语言的 Warm Up,实现几个简易函数即可。
Exercise 2 需要我们使用 GDB,发现并修改程序中的一些逻辑 BUG,这里我使用的是 CGDB,相比于 GDB 来说可以更加方便的在源码上下断点跟进。
注意,如果我们需要用 CGDB 在源码层面上进行调试,需要在编译时指定 -g 参数,gcc 会在编译时做以下额外操作:
下面是CGDB的一些常用指令
空格 断点
run r 运行
start 从第一行开始单步运行
next n 单步调试 F8
step s 进入内部 F10
until u 快速运行完循环、until localtion 运行到目的行
continue 运行到下一个断点 F6
finish 跳出当前栈帧 F7
info locals 打印本地变量
print p 显示变量
display 持续打印某个变量
watch
o 打开文件列表
Exercise 3 还是对 CGDB 的练习,需要我们通过调试,找到并修改链表中的一个 segfaults (段错误)。
Exercise 4 需要我们实现一个链表有环的判断,用双指针即可。
int ll_has_cycle(node *head) {
/* TODO: Implement ll_has_cycle */
node * fast = head;
node * slow = head;
while (fast && fast->next){
slow = slow ->next;
fast = fast->next->next;
if(fast == slow){
return 1;
}
}
return 0;
}
需要我们只通过简单的位运算符实现以下三个函数:
/* Returns the Nth bit of X. Assumes 0 <= N <= 31. */
unsigned get_bit(unsigned x, unsigned n) {
/* YOUR CODE HERE */
return -1; /* UPDATE WITH THE CORRECT RETURN VALUE*/
}
/* Set the nth bit of the value of x to v. Assumes 0 <= N <= 31, and V is 0 or 1 */
void set_bit(unsigned *x, unsigned n, unsigned v) {
/* YOUR CODE HERE */
}
/* Flips the Nth bit in X. Assumes 0 <= N <= 31.*/
void flip_bit(unsigned *x, unsigned n) {
/* YOUR CODE HERE */
}
这一关和 CSAPP 里位运算的那一个 lab 很类似,算是简化版,稍微想一下就能很快写出来。
Valgrind 是一款用于程序调试和分析的开源工具,可以用于检测和追溯内存泄漏。
在编译时,指定 -g 参数,可有助于调试时定位到具体代码行,这里我们以一个创建链表为例:
int main(int argc, char **argv) {
for (int i = 0; i < 5; ++i) {
add_to_front(&head, i);
}
}
运行 valgrind --leak-check=full ./linked_list
输出:
Congrats! All of the test cases passed!
==58781==
==58781== HEAP SUMMARY:
==58781== in use at exit: 80 bytes in 5 blocks
==58781== total heap usage: 6 allocs, 1 frees, 1,104 bytes allocated
==58781==
==58781== 80 (16 direct, 64 indirect) bytes in 1 blocks are definitely lost in loss record 2 of 2
==58781== at 0x4849D8C: malloc (in /usr/lib/aarch64-linux-gnu/valgrind/vgpreload_memcheck-arm64-linux.so)
==58781== by 0x1089BF: create_node (linked_list.c:8)
==58781== by 0x108A67: add_to_front (linked_list.c:35)
==58781== by 0x108C77: main (test_linked_list.c:14)
==58781==
==58781== LEAK SUMMARY:
==58781== definitely lost: 16 bytes in 1 blocks
==58781== indirectly lost: 64 bytes in 4 blocks
==58781== possibly lost: 0 bytes in 0 blocks
==58781== still reachable: 0 bytes in 0 blocks
==58781== suppressed: 0 bytes in 0 blocks
==58781==
==58781== For lists of detected and suppressed errors, rerun with: -s
==58781== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)
从上面信息可以定位到一共有 5 个内存泄漏点,其中 1 个为直接泄漏,即 head 指向的那一个节点;4 个为间接泄漏,即之后四个节点。
下面通过几个案例进一步学习 Valgrind的使用。
int main(void){
int *p;
int a = *p;
}
#include "malloc.h"
int main(void){
int *a = malloc(sizeof(int));
*a = 10;
free(a);
int *b = malloc(sizeof(int));
*a = 20;
}
#include "malloc.h"
int main(void){
int *p = malloc(0x4);
free(p);
free(p);
}
这部分需要我们补充一个缺陷的 vector,非常的基础,可以结合之前所学的 CGDB 调试以及 Valgrind 检测内存情况。
Exercise 1 需要我们配置好 venus 的环境,简单来说就是把虚拟机或者 docker 里的 lab 文件夹映射到某个 web 路径下,然后通过 venus 的网站 进行访问,从而对本地文件进行调试分析。
但是后来发现 vscode 自身就有相关的插件,也还不错,效果如下:
Exercise 2 通过一个斐波那契的例子,熟悉 Venus 对 RISC-V 调试分析。
Exercise 3 还是一个阅读理解,通过给出的 c 和 RISC-V 代码,找到两者的联系。
Exercise 4 需要我们使用 RISC-V 来实现阶乘,算是对之前所学的函数调用以及循环控制的一个练习。
map
这一小节需要我们完善一个链表以及 map
函数,该函数可以穿入一个链表以及函数指针来修改链表的内容。
struct node {
int value;
struct node *next;
};
void map(struct node *head, int (*f)(int))
{
if (!head) { return; }
head->value = f(head->value);
map(head->next,f);
}
这里提一下链表,我们在 C 语言中如果想创建一个9->8->7...->2->1
的链表,一般是按顺序创建结点和修改指向
node_9->data=9;
node_9->next=node_8;
node_8->data=8;
node_8->next=node_7...
node_1->data=1;
node_1->next=NULL;
按照这个思路,我们需要把头节点暂存,然后每次把尾节点指向一个 malloc 分配的空间,重复操作,再返回头节点,这些许有些麻烦。
代码框架是从用从 1 到 9 的创建顺序,这样可以省去很多寄存器的使用,并且最后返回的就是头节点。
li a0, 8 # molloc 8 bytes on heap
jal ra, malloc # Allocate memory for the next node
sw s1, 0(a0) # node->value = i
sw s0, 4(a0) # node->next = last
add s0, a0, x0 # last = node
addi s1, s1, 1 # i++
addi t0, x0, 10
bne s1, t0, loop # ... while i!= 10
Exercise 1 需要我们补充 f
函数,使得调用传入数组和不同的 key ,输出不同的 value。
Exercise 2 需要我们修复 cc_test.s
中的访问错误,主要涉及到 s 寄存器的保存。
Exercise 3 要去我们使用规定的寄存器完成对以下 node 的遍历以及修改。
struct node {
int *arr;
int size;
struct node *next;
};
这一节的坑点还是挺多的,主要是需要灵活的在不同场景使用不同寄存器。
这一节算是给 project3 去做一个铺垫,也是终于进入了硬件部分,实验采用了 logisim 软件模拟和仿真数字电路。
需要我们只用 AND
, OR
,NOT
三种门电路实现NAND、NOR、XOR、MUX,前面几个比较简单,最后一个需要我们实现 4-to-1 Multiplexor,需要我们复用用上一个实现的 2-to-1 Multiplexor。
Exercise 3: Storing State 这一节照着示意图做就可以,让我们用寄存器和加法器去理解存储的这个中间状态。
这一节需要我们实现算术右移 RotRight
,根据输入的 INPUT0 ,将其整体右移 AMOUNT 位。
这里我们可以先分别实现右移1位、2位、4位、8位,再用 MUX 组合起来。
单个的 rot 可以利用 Splitters 组件简单实现 bit 交换。
这一节需要我们生成 S型指令中的立即数,S型指令结构如下图:
最后还需要进行一个 12 位到 32 位的符号拓展:
这一节需要我们根据 B型指令中的 funct3 属性判断 branch 的跳转比较是有符号还是无符号,如下图:
如果是无符号 BrUn 输出1,有符号则输出0,这里直接拿第13位进行:
官网的提示想让我们用比较器,去综合比较 funct3 的值,如下也可以:
Exercise 4 初步引入了 pipeline 的概念,如果一条指令依赖于前一条指令的输出,可以采用流水线的理念分层进行。
这一节需要我们实现 relu
函数,该函数输入一个数组和数组长度,需要把数组内的负数元素置为 0。
loop_start:
addi sp,sp,-4
sw s0,0(sp)
li t0,0
loop:
beq t0,a1,loop_end # if t0 == length go
slli t1 , t0, 2 # offset, t1 = t0 * 4
add s0,a0,t1 # s0 = &a0[t0]
lw t2,0(s0) # t2 = a0[t0]
bge t2,x0,loop_continue # if t2 >= 0 go
sw x0,0(s0) # else a0[t0] = 0
loop_continue:
addi t0,t0,1 # t0 = t0 + 1
j loop
loop_end:
# Epilogue
lw s0,0(sp)
addi sp,sp,4
ret
error:
li a0,36
j exit
argmax
需要我们输入一个数组和数组长度,返回该数组中最大元素的下标。
.globl argmax
.text
# =================================================================
# FUNCTION: Given a int array, return the index of the largest
# element. If there are multiple, return the one
# with the smallest index.
# Arguments:
# a0 (int*) is the pointer to the start of the array
# a1 (int) is the # of elements in the array
# Returns:
# a0 (int) is the first index of the largest element
# Exceptions:
# - If the length of the array is less than 1,
# this function terminates the program with error code 36
# =================================================================
argmax:
# Prologue
li t0,1
blt a1,t0,error
loop_start:
addi sp,sp,-4
sw s0,0(sp)
li t0,1
li t3,0 # max_index = 0
lw t4,0(a0) # max_value = num[0]
loop:
beq t0,a1,loop_end # if t0 == length go
slli t1 , t0, 2 # offset, t1 = t0 * 4
add s0,a0,t1 # s0 = &num[t0]
lw t2,0(s0) # t2 = num[t0]
blt t2,t4,loop_continue # if t2 < max_value go
mv t3,t0 # else max_index = t0
mv t4,t2 # max_value = t4
loop_continue:
addi,t0,t0,1
j loop
loop_end:
# Epilogue
mv a0,t3
lw s0,0(sp)
addi sp,sp,4
ret
error:
li a0,36
j exit
这一节将输入两个数组,每个数组以输入的 stride 遍历 n 个元素,把每轮的两个元素相乘的结果最后相加返回。
.globl dot
.text
# =======================================================
# FUNCTION: Dot product of 2 int arrays
# Arguments:
# a0 (int*) is the pointer to the start of arr0
# a1 (int*) is the pointer to the start of arr1
# a2 (int) is the number of elements to use
# a3 (int) is the stride of arr0
# a4 (int) is the stride of arr1
# Returns:
# a0 (int) is the dot product of arr0 and arr1
# Exceptions:
# - If the length of the array is less than 1,
# this function terminates the program with error code 36
# - If the stride of either array is less than 1,
# this function terminates the program with error code 37
# =======================================================
dot:
li t0,1
blt a2,t0,error1
blt a3,t0,error2
blt a4,t0,error2
loop_start:
addi sp,sp,-8
sw s0,0(sp)
sw s1,4(sp)
mv s0,a0
mv s1,a1
li t0,0 # for loop
li t1,4
mul t1,t1,a3 # offset for a0
li t2 4
mul t2,t2,a4 # offset for a1
li t6,0 # sum
loop:
beq t0,a2,loop_end
lw t3,0(s0)
add s0,s0,t1 # s0 = &a0[t0]
lw t4,0(s1)
add s1,s1,t2 # s1 = &a1[t0]
mul t5,t3,t4
add t6,t6,t5
addi t0,t0,1
j loop
loop_end:
mv a0,t6
lw s0,0(sp)
lw s1,4(sp)
addi sp,sp,8
ret
error1:
li a0,36
j exit
error2:
li a0,37
j exit
这一节需要我们用汇编实现矩阵乘法。
对于一个矩阵而言,我们完全可以把它看作一个数组,只不过是会根据横向和纵向决定其排列顺序。
如上图,我们把矩阵看作一个数组,每轮矩阵相乘可以看作一次 dot 运算(见上一节),其中 a2 为 a0 的列和 a1 的行,a3 为 1,a4 为 a1 的行(说起来有些抽象,画个图能很好理解)。
这样我们可以通过两层循环来进行计算,每轮调用一次 dot
函数,再根据具体情况对数组进行偏移。
需要注意的是在调用外部函数之前,我们需要保存 temp 寄存器和 a 寄存器的一些值以防。
.globl matmul
.text
# =======================================================
# FUNCTION: Matrix Multiplication of 2 integer matrices
# d = matmul(m0, m1)
# Arguments:
# a0 (int*) is the pointer to the start of m0
# a1 (int) is the # of rows (height) of m0
# a2 (int) is the # of columns (width) of m0
# a3 (int*) is the pointer to the start of m1
# a4 (int) is the # of rows (height) of m1
# a5 (int) is the # of columns (width) of m1
# a6 (int*) is the pointer to the the start of d
# Returns:
# None (void), sets d = matmul(m0, m1)
# Exceptions:
# Make sure to check in top to bottom order!
# - If the dimensions of m0 do not make sense,
# this function terminates the program with exit code 38
# - If the dimensions of m1 do not make sense,
# this function terminates the program with exit code 38
# - If the dimensions of m0 and m1 don't match,
# this function terminates the program with exit code 38
# =======================================================
matmul:
# Error checks
li t0,1
blt a1,t0,error
blt a2,t0,error
blt a4,t0,error
blt a5,t0,error
bne a2,a4,error
# Prologue
li t0,0 # t0 for all loop count
outer_loop_start:
#init
li t1,0 # t1 for outer_loop count
outer_loop:
beq t1,a1,outer_loop_end
inner_loop_start:
#init
li t2,0 # t2 for inner_loop count
mv t4,a3 # t4 = &a3
inner_loop:
beq t2,a5,inner_loop_end
addi sp,sp,-52
sw ra,0(sp)
sw a0,4(sp)
sw a1,8(sp)
sw a2,12(sp)
sw a3,16(sp)
sw a4,20(sp)
sw a5,24(sp)
sw t0,28(sp)
sw t1,32(sp)
sw t2,36(sp)
sw t3,40(sp)
sw t4,44(sp)
sw a6,48(sp)
mv a1,t4
li a3,1
mv a4,a5
jal ra,dot # dot(a0,a3,a2,1,a5)
lw t0,28(sp)
lw a6,48(sp)
slli t0,t0,2
add t3,a6,t0 # t3 = &a6[t0]
sw a0,0(t3) # a6[t0] = sum
srli t0,t0,2
lw ra,0(sp)
lw a0,4(sp)
lw a1,8(sp)
lw a2,12(sp)
lw a3,16(sp)
lw a4,20(sp)
lw a5,24(sp)
lw t1,32(sp)
lw t2,36(sp)
lw t3,40(sp)
lw t4,44(sp)
addi sp,sp,52
addi t0,t0,1
addi t2,t2,1
addi t4,t4,4
j inner_loop
inner_loop_end:
slli a2,a2,2
add a0,a0,a2
srli a2,a2,2
addi t1,t1,1
j outer_loop
outer_loop_end:
ret
error:
li a0,38
j exit
这一节和之前相反,是给出了我们汇编代码,需要我们阅读之前的 test framework 来编写 python 调用 asm 的测试用例。
Part B 主要设计一些对文件读写的操作,
从前面几个小节我们知道,矩阵可以用数组的形式存储,只是我们需要提前给出行列值。下图为一个矩阵在文件中以16进制打开的形式,其中第一个字节为行数,第二个字节为列数:
🌀 read-matrix-1 [main] ⚡ xxd -e input.bin
00000000: 00000003 00000003 00000001 00000002 ................
00000010: 00000003 00000004 00000005 00000006 ................
00000020: 00000007 00000008 00000009 ............
Appendix 里有我们可能需要调用函数的全部 api ,对着写即可:
.globl read_matrix
.text
# ==============================================================================
# FUNCTION: Allocates memory and reads in a binary file as a matrix of integers
#
# FILE FORMAT:
# The first 8 bytes are two 4 byte ints representing the # of rows and columns
# in the matrix. Every 4 bytes afterwards is an element of the matrix in
# row-major order.
# Arguments:
# a0 (char*) is the pointer to string representing the filename
# a1 (int*) is a pointer to an integer, we will set it to the number of rows
# a2 (int*) is a pointer to an integer, we will set it to the number of columns
# Returns:
# a0 (int*) is the pointer to the matrix in memory
# Exceptions:
# - If malloc returns an error,
# this function terminates the program with error code 26
# - If you receive an fopen error or eof,
# this function terminates the program with error code 27
# - If you receive an fclose error or eof,
# this function terminates the program with error code 28
# - If you receive an fread error or eof,
# this function terminates the program with error code 29
# ==============================================================================
read_matrix:
# Prologue
addi sp, sp, -20
sw ra, 0(sp)
sw s0, 4(sp)
sw s1, 8(sp) # store the rows
sw s2,12(sp) # store the columns
sw s3,16(sp)
# Epilogue
mv s1,a1
mv s2,a2
li a1,0 #only-read
jal ra,fopen
li t0,-1
beq a0, t0, fopen_error # if a0 == t0 then fopen_error
#read the rows
mv s0,a0 #store the file descriptor
mv a1,s1
li a2,4
mv s3,a2
jal ra,fread
bne s3, a0, fread_error # if a2 != t0 then fread_error
#read the columns
mv a0,s0 #restore the file descriptor
mv a1,s2
li a2,4
mv s3,a2
jal ra,fread
bne s3,a0, fread_error # if a2 != t0 then fread_error
lw t1,0(s1) # rows
lw t2,0(s2) # columns
mul s1,t1,t2
slli s1,s1,2 # the size of the memory to be allocated
mv a0,s1
jal ra,malloc
li t0,0
beq a0,t0,malloc_error
mv s2,a0
mv a1,s2
mv a0,s0
mv a2,s1
jal ra,fread
bne s1,a0,fread_error
mv a0,s0
jal ra,fclose
bnez a0,fclose_error
mv a0,s2
lw ra, 0(sp)
lw s0, 4(sp)
lw s1, 8(sp)
lw s2,12(sp)
lw s3,16(sp)
addi sp, sp, 20
ret
fopen_error:
li a0, 27
j exit
fread_error:
li a0, 29
j exit
malloc_error:
li a0, 26
j exit
fclose_error:
li a0, 28
j exit
这一节和上节差不多,只是从读变成了写
.globl write_matrix
.text
# ==============================================================================
# FUNCTION: Writes a matrix of integers into a binary file
# FILE FORMAT:
# The first 8 bytes of the file will be two 4 byte ints representing the
# numbers of rows and columns respectively. Every 4 bytes thereafter is an
# element of the matrix in row-major order.
# Arguments:
# a0 (char*) is the pointer to string representing the filename
# a1 (int*) is the pointer to the start of the matrix in memory
# a2 (int) is the number of rows in the matrix
# a3 (int) is the number of columns in the matrix
# Returns:
# None
# Exceptions:
# - If you receive an fopen error or eof,
# this function terminates the program with error code 27
# - If you receive an fclose error or eof,
# this function terminates the program with error code 28
# - If you receive an fwrite error or eof,
# this function terminates the program with error code 30
# ==============================================================================
write_matrix:
# Prologue
addi sp, sp, -24
sw ra, 0(sp)
sw s0, 4(sp)
sw s1, 8(sp)
sw s2,12(sp)
sw s3,16(sp)
sw s4,20(sp)
# Epilogue
mv s1,a1 #pointer to matrix
mv s2,a2 #rows
mv s3,a3 #columns
li a1,1 #write
jal ra,fopen
li t0,-1
beq a0, t0, fopen_error
mv s0,a0
li a0,8
jal ra,malloc
beqz a0,malloc_error
#write the rows and columns
mv s4,a0
sw s2,0(s4)
sw s3,4(s4)
mv a1,s4
mv a0,s0
li a2,2
mv s4,a2
li a3,4
jal ra,fwrite
bne a0,s4,fwrite_error
mv a0,s0
mv a1,s1
mul a2,s2,s3
mv s4,a2
li a3,4
jal ra,fwrite
bne a0,s4,fwrite_error
mv a0,s0
jal ra,fclose
bnez a0,fclose_error
lw ra, 0(sp)
lw s0, 4(sp)
lw s1, 8(sp)
lw s2,12(sp)
lw s3,16(sp)
lw s4,20(sp)
addi sp, sp, 24
ret
malloc_error:
li a0, 26
j exit
fopen_error:
li a0, 27
j exit
fclose_error:
li a0, 28
j exit
fwrite_error:
li a0, 30
j exit
我的学习顺序,快速过一遍 Notes,了解一下这一节的大纲和重点,之后根据自己的能力选择是看视频还是直接看 slides(并发控制之前的章节难度还 ok ),学习完一章后,尝试用自己的话把一章的重点梳理一遍,最后选择性阅读帆船书。
以下内容为我归纳整理的一些问答式:
1. 为什么不使用 OS 自带的磁盘管理模块 (mmap)?
2. 存储模型 N-Ary Storage Model (NSM) 和 Decomposition Storage Model (DSM) 的优缺性?
3. 为什么会有 buffer pool?
4. buffer pool 可以做哪些优化?
5. 为什么数据库BUFFER POOL需要LRU-K的策略?
6. 有哪一些哈希方案?
7. 是否当有节点不到 half-full,就 MERGE 节点?
8. B+ 树的优化
9. B+树的 Latch Crabbing 是什么?
10. Lock 和 Latch 的区别是什么?
11. 108个页,内存 buffer pools 中只能容下5个页,怎么利用 General (K-way) Merge Sort 做外部排序?
12. 有哪一些做 aggregation(聚集)的方案?
13. join算法有哪些?以下述例子分析复杂的:
14. 有哪一些数据库执行模型?
15. 有哪一些数据读取的方法?
16. 单个 Intra-Query 如何做并行?
17. 谈一谈数据库执行的逻辑优化和物理优化?
18. 简单介绍一下ACID?
19. 如何判断事务的执行顺序(schedule)是否与其串行执行(serial)等价?
20. 什么是 View Serializability?
21. 什么是两阶段锁?如何解决集联中止和死锁的问题?
22. 什么是 LOCK GRANULARITIES ,为什么要这么设计?
23. 如何基于时间戳进行并发控制?
24. 并行事务可能出现怎样的问题?事务有哪几种隔离级别,有什么特点及其实现?
25. MVCC 设计需要考虑哪几个方面?
26. 能否不使用 LOG 实现 undo 和 redo?
27. 什么是 WAL?
为什么不使用 OS 自带的磁盘管理模块 (mmap)?
if i die in this class , you want to have a memorial something just Say Andy hated mmap
Andy专门写了一篇论文批判不应该在数据库中使用 mmap :Are You Sure You Want to Use MMAP in Your Database Management System? (CIDR 2022)
简单来说作为底层的操作系统,他只能看到毫无规律的读取和调用,而 DBMS 希望自己拥有足够的控制权。
存储模型 N-Ary Storage Model (NSM) 和 Decomposition Storage Model (DSM) 的优缺性?
为什么会有 buffer pool?
优点:内存速度大于磁盘
缺点:命中率很低,刷新很快 -> BUFFER POOL BYPASS
把一些查询不放入buffer pool中
buffer pool 可以做哪些优化?
为什么数据库BUFFER POOL需要LRU-K的策略?
LRU ( Least Recently Used ):最近最久未使用,防止了SEQUENTIAL FLOODING,提高了命中率。
有哪一些哈希方案?
静态哈希方案:
Linear Probe Hashing
线性探测法是开放寻址法(Open Addressing)中的一种,插入元素时,若存在哈希冲突,则往后顺移,直到找到一个空的位置。
线性探测法会导致同类哈希的聚集 (Primary Clustering)。
Robin Hood Hashing
罗宾汉是英国民间传说中的英雄人物,劫富济贫,整治暴戾。罗宾汉哈希是线性探测法的拓展,传统的线性探测只是无条件的顺移,而罗宾汉哈希会记录每个键离本来应该插入的位置的距离,当有新的键冲突,往后顺移的同时会比较冲突位置 key 的距离和自身距离的大小,如果自身的距离已经超过冲突的位置 key 的距离,则选择替换该项,顺移被替换的 key 。
罗宾汉哈希可以显著降低探测长度的方差,把 rich key 的位置交给 poor key,从而使大部分元素的探测长度趋于平均值。但是每次 insert 时又增加了新的比对开销,这是不可避免的。
Cuckoo Hashing
Cuckoo Hashing 采用多个 hash table,每个 hash table 都采用不同的 hash function seed 。
对于任何一个 key ,都可以选择其中一个没有冲突 hash table 插入,若全部 hash table 都没有空的位置,则随机选择一个 hash table 中对应的位置插入,并取出原来这位置上的 key 重新插入。
在容量不够的情况下,连锁的冲突可能会导致死循环,解决办法只能扩容然后 rehash。
动态哈希方案
Chained Hashing
每个 slot 维护一个 linked list,对于冲突的哈希则直接追加到链表后面。
如果数据很大链表过长,时间复杂度又退回到 O (n),我们内部会维护一个负载因子(最长链表长度 / slot 个数),当负载因子达到某个阈值便扩容 slot 并 rehash。
Extendible Hashing
简单来说 Extendible Hashing = Hash + Trie( 字典树 )
Extendible Hashing 会在 project 2 中实现,详细请见:
Linear Hashing
http://queper.in/drupal/blogs/dbsys/linear_hashing
是否当有节点不到 half-full,就 MERGE 节点?
MERGE 有一定的开销,可以先把该节点保存,然后过一段时间周期性的批量处理这些节点(重新建树)。
B+ 树的优化
Prefix Compression 前缀压缩
在同一个叶子节点上,如果 key 有共同的前缀,可以提取出来节省空间。
Suffix Truncation 后缀截断
对于内部节点,如果前面的数据就足够做 ROUTE ,后面的数据没有区分度,那么就可以把没有帮助的部分截断。
Bulk Insert
在构建一棵 B+ 树时,如果逐个插入节点可能会比较慢,Bulk Insert 就是先对 key 排序,然后自底向上先生成叶子节点,再根据叶子节点形成内部节点。Bulk Insert 让我们能更快去构建一棵 B+ Tree。
Pointer Swizzling
B+树的 Latch Crabbing 是什么?
因为 B+树会经常涉及到分离和归并,所以我们需要严格进行锁处理。
我们从上到下先拿到父 latch,再拿到孩子的 latch:
上面这样有一个问题,我们发现根节点始终是一个上锁的状态,这样对效率的影响比较大。我们可以采取一个 『 乐观 』的态度,我们假设叶子节点都是安全的,所以无论是增还是删都先上读锁,等到具体情况如果发现并不安全,则重头再按照写锁的逻辑来一次。
Lock 和 Latch 的区别是什么?
Lock 是一个高级别,逻辑意义上的锁,一般特指事务锁,用于保护数据库内容免受其他 transactions 的影响 ,而且需要考虑一个事务回滚的问题。
Latch 是一个低级别的锁,用于维护 DBMS 内部数据结构在多线程情况下安全读写,比如互斥锁 mutex。
108个页,内存 buffer pools 中只能容下5个页,怎么利用 General (K-way) Merge Sort 做外部排序?
有哪一些做 aggregation(聚集)的方案?
排序聚集
对于本身需要排序的例子(order by),先排序再去重。
哈希聚集
如果不需要排序(group by、distinct),再排序的话开销就会很大,这时候我们可以选择哈希聚集。
外部哈希聚集(external hash agg)主要分为两部:
join算法有哪些?以下述例子分析复杂的:
M pages in table R (Outer Table), m tuples total
N pages in table S (Inner Table), n tuples total
Nested Loop Join
Simple Nested Loop Join (stupid)
把外表的每一个元组和内表的元组进行比对。
Cost:$M +(m×N)$
Block Nested Loop Join
利用 buffer pools 存储一个一个块扫描,假设buffer pools 容量为B
Cost:$M+(\lceil \frac{M}{B-2}\rceil×N)$
Index Nested Loop Join
利用B+Tree索引
Cost:$M +(m×C)$ C是一个常数
Sort-Merge Join
先排序,再归并
排序复杂度可见上面的 (K-way) Merge Sort
Total Cost: Sort + Merge
Hash Join
把两个表数据做 Hash 然后存储到外部文件,再读取文件进行比对 join 连接。
Total Cost: $3 × (M + N)$
有哪一些数据库执行模型?
Iterator Model
迭代器模型,又称为火山模型,每个关系函数都会调用自己的next
方法直到有数据元组返回(整个调用过程有点像栈,先进后出,直到叶子节点返回数据,再一路回到顶,像火山的流动一样)。
一些关系函数会堵塞,直到所有数据返回(比如 joins, subqueries, order by),也被称作 pipeline breakers.
Materialization Model
相比于火山模型流式的一条条返回和读取数据,物化模型会把所有数据全部打包好一次性返回。
Vectorization Model
向量化模型是以上两种模型的折中模型,其内部也实现了next
方法,但是每个next
方法返回的是一批数据而不是单个元组,自定义数据大小也避免了物化模型中数据量返回过大的可能。
有哪一些数据读取的方法?
Sequential Scan
顺序扫描,或者说全表扫描。数据库维护一个扫描当前页的指针,顺序扫描有以下优化方法:
Prefetching
Buffer Pool Bypass
上面两种方法都是站在缓存池的角度,具体可以看 6. buffer pool 可以做哪些优化?
Parallelization
Zone Map
维护一个额外的分区表去缓存页信息,可以降低扫描时读取页面的总次数。
Late Materialization
延迟物化,即数据传递中,自底向上只传入一个关键信息(比如关键字段、偏移量等),需要用到具体数据时再获取全部的信息。此方法只在 DSM(列存储)中有效。
Index Scan
优化器选择最合适的索引去扫描(该索引的选择率最高)。
还有一种方式是采用 bitmap 多索引扫描,再根据具体语句选择交集或者并集。
单个 Intra-Query 如何做并行?
Intra-Operator Parallelism (Horizontal)
算子内并行(水平切分),指把单个数据集拆分为多个单位,然后在数据子集上执行相同的函数,最后再利用Exchange
操作进行合并。
Inter-Operator Parallelism (Vertical)
算子间并行(垂直切分),有一点像火山模型中的流式处理,
Bushy Parallelism
相当于水平切分+垂直切分,底层数据读取使用水平切分,数据往上传递使用垂直切分。
谈一谈数据库执行的逻辑优化和物理优化?
逻辑优化是站在代数级别的,通常是通过一些静态规则和启发函数(heuristics),在代数等价的前提下对表达式进行优化,比如谓词下放、投影下放等优化。
物理优化是站在开销的角度,这个开销可以指多方面,比如 CPU、Disk I/O、Memory、Network,然后在执行层进行等价优化,比如单个 join连接 有多种算法,单个数据读取有索引读取和全表读取,优化器就会根据具体的开销决定执行哪一种算法。最后再用动态规划确定整个执行的所有方式,如果需要 join 的表很多,动态规划的开销本身也很大,可以选择遗传算法的思想确定。
简单介绍一下ACID?
如何判断事务的执行顺序(schedule)是否与其串行执行(serial)等价?
如果 schedule 只有两个事务,可以交换事务间的非冲突操作(冲突指读写冲突、 写写冲突等),如果最终可以转换到 serial schedule(即一个事务的所有操作结束完再执行另外一个,而不是 interleave ),则说明这个 schedule 是可串行的(serializable)。
如果 schedule 包含多个事务,可以用依赖图判断,如果一个来自事务 A 的操作和来自事务 B 的操作冲突,并且 A 先于 B 执行,则我们加一条从 A -> B 的边,以此遍历,若最后依赖图有环,则说明此 schedule unserializable;若最后依赖图无环,则根据箭头顺序,可构造 serial schedule。
什么是 View Serializability?
上述我们判断是否可串行化,是通过冲突判断的,但是这样有弊端,因为有的事务具体的执行之间即使发生冲突,也不会影响最终结果,我们认为这两者还是可以调度的,这是基于观察的判断,比基于冲突更加缓和一些。这是一种理想化的判断,可以作为是否可串行化的标准,但是很难实现。
什么是两阶段锁?如何解决集联中止和死锁的问题?
两段锁协议 2PL(two-Parse-locking)指一个事务分为 Growing 和 Shrinking 两个阶段,第一个阶段事务所有操作申请锁,从一个锁释放开始进入第二个阶段,事务只能释放锁不能再申请。2PL 解决了一致性的问题(某个事物对多个变量操作,不会被其他事务所 interleave):
但是 2PL 还是有两个问题需要解决,一个是 cascading aborts ,如果事务 T2 读取了事务 T1 的某个中间状态,但是事务 T1 最后没有 commit 而是 abort,那么事务 T2 也应该回滚。解决的办法是采用 Strong Strict 2PL,即事务只有在最后时刻(commit、abort)才会一同释放锁,这是一种更加悲观的决定,也限制了并发。
还有一个问题就是死锁,上面这张图中的 T2 事务,如果先申请 B 的锁再申请 A,就会导致 T1 申请 B时被锁住,导致死锁。
站在死锁检测(Detection)的角度,我们创建一个锁依赖图,如果存在 T1 事务申请锁被 T2 事务堵塞的情况,则绘制一条从 T1 到 T2 的边,如果依赖图成环,则表明存在死锁,我们需要选取一名 victim 事务,对它进行 abort 或者 recommit,以打破死锁。victim 的选择需要考虑多方面,比如已经开始的时间、已经执行的操作、多少事务需要被回滚等等。
死锁检测是已经发生死锁后再进行处理,死锁预防(Prevention)是在死锁发生前 kill 一个事务,我们给事务定义一个优先级,在这里我们认为决定因素是起始时间,开始早的为 old,晚的为 young
Wait-Die (“Old Waits for Young”):如果是新事务占有,老事务申请,则选择等待;反之老事务占有,新事务申请,则新事物直接 abort 。(碰瓷)
Wound-Wait (“Young Waits for Old”):如果是新事物占有,老事务申请,则新事物直接 abort,老事务占有锁(抢过来),反之选择等待。
还需要注意一点,一个事务如果 abort,begin 时间应该不能改变,不然很有可能导致饥饿。
什么是 LOCK GRANULARITIES ,为什么要这么设计?
假设一个表有 100 行数据,我们需要更新里面所有的数据,我们可能需要 100 把锁。所以我们需要引入锁的粒度,也叫 Hierarchical locks(分层锁协议)。 锁可以是面向 Page 的,也可以是面向 Table,再往小还有 Tuple,如果我们往低级别上锁的同时,也给高级别一个标记,可以减少实际的锁用量,这里引入意向锁的概念:
我们以下面这个例子为例:
T1:扫描 R 并更新几个 Tuple ,我们需要给 Table R 上 SIX 锁,并给更新的Tuple 上 X 锁。
T2:从 R 中读取一个 Tuple ,给Table R 上 IS 锁,并给读取的 Tuple 上 S 锁。
T3:扫描 R,需要给 R 上 S 锁,和之前的 SIX 冲突,被堵塞。
还有一个问题,我们需要去设计一个这个颗粒度,比如我们读取多个 Tuple,并不是只有读取全部才上 S 锁,从 IS 到 S 有一个自适应的升级过程,需要我们去权衡。
如何基于时间戳进行并发控制?
比较基础的方式是 Timestamp Ordering (T/O),每一个事务 $T_i$ 有一个起始时间戳 $TS(T_i)$ ,若 $ TS(T_i)<TS(T_j)$ ,则说明 $T_i$ 的执行顺序应在 $T_j$ 之前。
对于每一个数据库 item 都全局使用 $R-TS(X)$ 和 $W-TS(X)$ 记录上一次被读、写的时间戳。
当事务需要读写 item 时,就会去检查上面的这个时间戳,如果发现是将
则选择 abort 该事务,并设置新的 timestamp。
可以看到 T/O 是一种乐观的事务模型,并没有使用锁机制。但是如果存在某个长事务,会不断被后面的一些短事务中止,有可能出现饥饿的情况。
如果我确定所有的事务都很短并且冲突较少,Optimistic Concurrency Control(OCC)也是一种很好的乐观处理方式,OCC 中每个事务都有一个私有的属性表,用于暂存读写的内容,并在 Validation Phase 阶段判断是否 abort,通过则把私有表刷新到数据库。( OCC 是最后一起判断是否通过,所以长事务更加容易饥饿)
并行事务可能出现怎样的问题?事务有哪几种隔离级别,有什么特点及其实现?
从上往下对一致性的影响依次减轻:
四种隔离级别,从上往下隔离级别递增,性能效率递减:
MVCC 设计需要考虑哪几个方面?
MVCC (Multi- Version Concurrency Control)的核心思想为读写互不堵塞,一共需要考虑以下四个方面:
Concurrency Control Protocol
选取一种并发控制协议,也就是我们前面所谈到的 2PL、T/O、OCC 等。
Version Storage
DBMS 为每个 tuple 维护了一个全局属性表, tuple 不同版本之间通过链表连接起来,根据存储逻辑的不同也分为三种:
Garbage Collection
那些用不上的旧版本属性,我们需要垃圾回收,GC可以站在 tuple 的角度,一些线程定时扫描整个 table,寻找可回收的旧版本;或者站在事务的角度,事务自身决定哪些 tuple 需要被回收。
Index Management 我们可以通过索引查询数据,所以索引也是需要链接到多版本的。
能否不使用 LOG 实现 undo 和 redo?
SHADOW PAGING 对 database 有 master 和 shadow 两份拷贝,更新只改变 shadow 拷贝的部分,当提交时只需要改变 DB Root 的指向,把 shadow Page 刷盘并 GC 之前的 master。如果 undo 则不需要做任何更改。缺陷是在内存中拷贝一份复制代价太大。
什么是 WAL?
WAL 即(Write-Ahead Logging),是现在主流的备份形式。在事务提交前所有操作都会被记录到 WAL,主要包括事务ID、Object ID、Before Value(用来 UNDO)、After Value(用来REDO)。
WAL的记录格式分为三种:
随着 WAL LOG的增大,DBMS 会定时给 WAL 写入 Checkpoints(类似游戏中的存档点),在 checkpoints 之前的日志如果已经 commit,我们可以确保已经写入磁盘,则可以把这部分内容清除。
]]>后续一部分关于分布式数据库的知识,打算有时间结合 6.824 一起学习。
这一部分主要是涉及到数据库并发控制,这是一个很大并且很有意思的领域,光是课程上的理论知识就足够消化很长一段时间。包括后面还有一些分布式数据库的知识,因为时间关系先暂时搁置,以后有机会再结合 6.824 一起学习。
]]>第三个实验需要我们实现一个执行器(并非解释器),我认为这个实验和 project2 来说各有各的难点。project2 难在即使非常清楚了可拓展哈希表的流程,在多线程、各种锁的折腾下还是很容易出错,每一个小 TASK 都需要debug很长时间。而这个 project 难在已经实现的类、及其成员变量非常繁杂,相互之间的调用很不清晰,完全不知道各个变量的具体含义。所以在做这个 project 之前,梳理已给代码非常重要。
整个 project 只有这一个 task,需要我们实现以下9个执行器:
在前言中我们提到各种成员变量,包括
Catalog、Tuple、Schema、TableHeap、TableHeapIterator、Value、Column...等待
其每个变量都在具体数据库中有一个对应,我是通过 debug 慢慢摸索出其含义,后来发现 这篇文章 总结非常好,在做第一个执行器(Sequential Scan)的时候强烈推荐结合文章理清楚各个类的交互关系。
说一下我踩的几个坑,应该是我对聚合算子本身不来熟悉,导致一些比较乌龙的错误。
一个是要注意 group by
是可以接多个列的,站在数据库层表示以 n 个完全相同的列进行分组,在代码中体现就是 AggregateKey
的成员变量 group_bys_
是一个 vector
。
二是我在看聚合的测试文件时,发现 ValueExpression
必须指定输出列是否为 is_group_by_term
。根据执行逻辑如果是则代表其为 group by
的字段,如果不是则代表其为聚合的字段。
比如这样的语句:select count(Id),City from Employee group by City
,前者则为false,后者则为 true。
后来我突发奇想,如果是这样的语句select count(Id),City,Address from Employee group by City
,其中 Address
既不是 group by
的字段,也不是聚合的字段,那该怎么支持呢?
count(ID) | City | Address |
---|---|---|
1 | Kirkland | 722 Moss Bay Blvd. |
4 | London | 14 Garrett Hill |
1 | Redmond | 4110 Old Redmond Rd. |
2 | Seattle | 507 - 20th Ave. E. Apt. 2A |
1 | Tacoma | 908 W. Capital Way |
后来想了想在聚合里这个字段也没有意义,如果一个组有多个数据,可能只会随机输出一个,真实情况下我们一般会考虑group_concat
:
select count(Id),City,group_concat(Address,' ;') from Employee group by City
count(ID) | City | group_concat(Address,’ ;’) |
---|---|---|
1 | Kirkland | 722 Moss Bay Blvd. |
4 | London | 14 Garrett Hill ;Coventry House Miner Rd. ;Edgeham Hollow Winchester Way ;7 Houndstooth Rd. |
1 | Redmond | 4110 Old Redmond Rd. |
2 | Seattle | 507 - 20th Ave. E. Apt. 2A ;4726 - 11th Ave. N.E. |
1 | Tacoma | 908 W. Capital Way |
那这样的话就又得支持新的算子了,虽然感觉上不难… 不过我暂时还没有实现 XD
最重要的一点,请不要使用官方的打包命令,gradescope 上莫名其妙把一堆自带的 GTEST 给检测,其 check-lint G了一大片,后来群里大哥说加上 src/include/storage/page/tmp_tuple_page.h
可以解决,原因位置:
zip project3-submission.zip \
src/include/buffer/lru_replacer.h \
src/buffer/lru_replacer.cpp \
src/include/buffer/buffer_pool_manager_instance.h \
src/buffer/buffer_pool_manager_instance.cpp \
src/include/storage/page/hash_table_directory_page.h \
src/storage/page/hash_table_directory_page.cpp \
src/include/storage/page/hash_table_bucket_page.h \
src/storage/page/hash_table_bucket_page.cpp \
src/include/container/hash/extendible_hash_table.h \
src/container/hash/extendible_hash_table.cpp \
src/include/execution/execution_engine.h \
src/include/execution/executors/seq_scan_executor.h \
src/include/execution/executors/insert_executor.h \
src/include/execution/executors/update_executor.h \
src/include/execution/executors/delete_executor.h \
src/include/execution/executors/nested_loop_join_executor.h \
src/include/execution/executors/hash_join_executor.h \
src/include/execution/executors/aggregation_executor.h \
src/include/execution/executors/limit_executor.h \
src/include/execution/executors/distinct_executor.h \
src/execution/seq_scan_executor.cpp \
src/execution/insert_executor.cpp \
src/execution/update_executor.cpp \
src/execution/delete_executor.cpp \
src/execution/nested_loop_join_executor.cpp \
src/execution/hash_join_executor.cpp \
src/execution/aggregation_executor.cpp \
src/execution/limit_executor.cpp \
src/execution/distinct_executor.cpp \
src/include/storage/page/tmp_tuple_page.h
]]>第二个实验需要我们实现一个磁盘支持的可拓展哈希表,哈希表只负责快速搜索,不必大规模遍历表内的每一条记录。(后者用 B+ Tree 索引更加合适,20 年的实验实现的就是这个,感兴趣也可以做一做)。
我们先跟着 geeksforgeeks 这个网址,快速过一遍可拓展哈希表的实现。
在上述示意图中,一共设计以下 4 种属性:
page_id
即可。5 = 00...00101
,意味着这个结果的后 global depth 位有效(我们默认使用 LSB Least Significant Bit:最低有效位),在上图中 global depth = 2 表示 5 将落在 01 所对应的 bucket 上。你也可以发现任意时刻 directory 的数目 = 2^global_depth
,但这并不意味着 bucket 的数目,因为还有一个 Local Depth。一个 bucket 可以维护的数据大小由具体情况而定,这里我们假定为 3 ,然后 hash 算法就用 int 的二进制表示,我们通过以下的例子理解:
假设初始的 directories 和 buckets 状态如下,
这时候插入数据 16(10000),global_depth 为 1,所以它会落在 bucket_index = 0 的 bucket 上。
后续同理插入 4(100) 和 6(110) :
我们再插入22 (10110),这时候 bucket 容量已满,我们需要对 0 号 bucket 进行分裂,但是此时 0号 bucket 的local_depth = global_depth,所以我们需要先对 global_depth 也就是 directories 进行扩容:
扩容第一步也就是 global_depth++
,这时候上述 directories 包含 00、01、10、11
,然后需要进行一步 rehash:每一个扩容前的 bucket 都需要找到它的 brother_bucket(即不算新的一位后续完全一致),然后重新排列内部的数据
之后再选择插入 22(10110)
针对以上的例子,我们有三个需要注意的地方:
任何时刻,所有的 local_depth ≤ global_depth
每个 bucket 有 2^(global_depth-local_depth)
数目个索引指向它:
这个其实比较好理解,因为目录扩容时,没有发生 split 的 bucket 将保持不变,索引也会翻倍,split 时索引会减半,这会导致不同local_depth 的 bucket 索引不同 。
指向同一个 bucket 的 local_depth 相同:
local_depth 并不是 bucket 自身的成员属性,而是在 directories 内的一个 local_depths_[bucket_index]
数组,所以我们在 split 和 merge 操作时需要注意。
在这个教程中没有介绍 remove 和 merge,我也就随便画个草图:
同样是上述例子,假设我们 把 6、22 都删除后,变为:
此时 01 所对应的 bucket 为空,我们需要进行 merge 操作。
merge 其实很简单,就是当 remove 之后,发现该 bucket 为空,则删除这个 bucket 页,并在 directories 中把所有指向该 bucket 的 索引 指向它的 brother_bucket ,并修改 local_depth 。
这里我们发现所有的 local_depth < global_depth ,换句话来说就是每一个 bucket 都至少有两个索引指向它,并且这多个索引对是 互为 brother 的关系,我们就可以直接 global_depth--
,全局收缩 directories 。
相当于回到了扩容前的状态。
有了以上基础,我们完成 project 就会简单很多。
这一部分,需要我们完成 directories 和 bucket 的内部实现。
我们先看看 directories 的内部成员变量:
directories 需要实现的方法没什么好说的,根据注释一步一步写就行了,如果你对 split 不太了解可能会对 GetSplitImageIndex
这个方法感到奇怪,这个其实就是上面我们所说的去一个 bucket 的 brother_bucket,也可以先空着,后面具体实现再补上。
再来看看 bucket 的成员变量:
char occupied_[(BUCKET_ARRAY_SIZE - 1) / 8 + 1];
char readable_[(BUCKET_ARRAY_SIZE - 1) / 8 + 1];
MappingType array_[1];
occupied_ 和 readable_ 都是 char 数组,一个 char类型占一个字节,也就是8位,从00000000->11111111
,所以对于任何一个 bucket_index,我们只需要用 bucket_index / 8
判断在哪一个数组内,再用 bucket_index % 8
判断在8位中的哪一位即可。
其中 readable_ 表示某一位是否有元素占用,occupied_ 表示某一位是否有过元素占用,简单来说就是经过 remove,前者将变为 0 而后者仍为 1,知乎上有讨论 occupied_ 的实际作用,其实在 bucket 有一个 PrintBucket()
用于打印当前 bucket 的状态,里面就用到了 occupied_ ,所以我能给出的唯一解释可能也只是用作调试分析吧。
最后的 array_[1]
是一个变长数组,用于存储真正的 kv。
在 directories 中有一个 VerifyIntegrity()
方法,用于检测 所有 bucket 的状态是否正确,其注释为:
/**
* (1) All LD <= GD.
* (2) Each bucket has precisely 2^(GD - LD) pointers pointing to it.
* (3) The LD is the same at each index with the same bucket_page_id
*/
这三点我在前言中提到过,这个非常重要,我的大多半 bug 都是产生于此 XD。
TASK 2 是具体实现我们的可拓展哈希表,TASK 3是在 TASK 2 的基础上加锁,实现线程安全。我个人建议这两个 TASK 一起完成,因为重新 review 代码很容易漏掉一些考虑不全的点。
首先确定一下,无论是 insert、remove 还是 getValue,步骤应该都是:
怎么拿到页呢?其实就是通过我们 Project1 所实现的 buffer_pool_manager_
,extend_hash_table 自身维护了一个 BufferPoolManager 私有成员,可通过 FetchPage
或 NewPage
拿到 Page。
拿到 Page 我们还需要进行一次类型转换,这里我们用 reinterpret_cast
强制类型转换,把 Page 转换为我们所需的 directory 或者 bucket 。注意每次我们使用完 directory 或者 bucket之后,记得 UnpinPage
。
剩下的内容其实就是对我们前言部分对完善,所以接下来着重说一下并发控制。
对可拓展哈希表而言,其私有成员ReaderWriterLatch table_latch_
是一把读写锁,用于控制 directory 的线程安全;对于每一个 bucket 而言,其本身也是一个 Page (从获取来看我们也是先拿到 Page 再通过强制类型转换得到 bucket ),在 page.h
中我们可以看到每个 Page 也维护了一把读写锁 ReaderWriterLatch rwlatch_
,所以我们所有并发控制都是通过这两把锁来考虑的。
Insert
: directory 读锁、bucket 写锁。SplitInsert
: directory 写锁、两个 bucket 都上写锁。(SplitInsert
会设计 bucket 和 brother_bucket 两个桶)Remove
: directory 读锁、bucket 写锁。Merge
:directory 读锁、bucket 读锁
FetchDirectoryPage
:这里我在 extend_hash_table 中加了一个锁成员,在 directory_page_id_ == INVALID_PAGE_ID
之前锁住,防止并发时 INVALID_PAGE_ID 的判断错误。在 merge 的官方注释上,有三点:
/**
* Optionally merges an empty bucket into it's pair. This is called by Remove,
* if Remove makes a bucket empty.
*
* There are three conditions under which we skip the merge:
* 1. The bucket is no longer empty.
* 2. The bucket has local depth 0.
* 3. The bucket's local depth doesn't match its split image's local depth.
*/
这个第一点很有意思,我们是在 bucket 为空的时候进入 merge ,然后在 merge 的同时还需要判断一次 bucket 是否为空,因为这里的锁并不是一直关上,两个方法调度时是有一个空档让其他线程执行的。
我在 debug 的时候发现多线程重复插入 500 个key,经常会出现多次 split 的情况(int 类型一个 bucket默认大小为496,按道理只会 split 一次),就是因为在进入 SplitInsert
我没有重新判断 bucket->IsFull()
,当然这个由美每个人具体的写法而定,我也只是说一下我踩的坑hh。
这个 project 最主要还是提高了我多线程并发的调试能力,合理运用 Log 定位到出问题的点。
排名感觉还不错,另外前 20 名时间都在 1s 内,估计是把 test 给 bypass 了,面向测试用例编程 💤。
]]>总完成时长 11小时。
实现 LRU页面替换策略。
如果之前对 LRU 没有怎么接触,推荐可以先去 LeetCode 上做一下这道题:146. LRU 缓存
数据结构选取双向链表 + 哈希表,双向链表有助于我们在移动节点时更方便的找到某个节点的前节点。
同时设置 dummy_tail
,因为在置换时我们总是需要移除最后一个节点。
理清 Pin
和 Unpin
的含义,注意我们需要实现的只是一个 POLICY
,具体的实现是在下一个 TASK,如果直接当 buffer pool 处理很多含义都是相反的。
Pin
:代表有线程正在占用这个 frame,所以需要从 cache_
和 DLinkedNode
中删除,防止被替换。
Unpin
:占用结束,重新把 frame 加入 cache_
和 DLinkedNode
,重复 Unpin 不会影响节点的位置。
另外,如果你和我一样是自己实现的双向链表而不是用 STL 中的 list,一定要记住在 remove(node)后将 node 给 delete(我直接搬运了 leetcode 代码没考虑太多…),我这一节是满分过了,但是 project2 的内存检测后来死活过不了,才意识到问题…
实现缓冲器管理器实例。
首先明确一下 BufferPoolManagerInstance
中几个成员变量的含义:
std::list<frame_id_t> free_list_;
缓冲池剩余空间的 frames 用free_list_
表示,初始大小为 pool_size_
的大小,free_list_
为空,则说明此时 buffer pool已满,需要用上一个 TASK 完成的 LRU 获取新的 frame 。
std::unordered_map<page_id_t, frame_id_t> page_table_
frame_id
是 缓冲池中的帧序号。
page_id
是页的标识符。
每当把 page 放入缓冲池,相当于在 page_table_
对两者做一个映射。
Page *pages_;
缓冲池 page 的数组,以 frame_id 为索引。
根据 page_table_
和 pages_
,如果给定一个 page_id
,我们可以通过 page_table_.find(page_id)
,去获取这个page_id
所对应在内存中的 frame_id
,再通过 Page* page = pages_[framge_id]
即获取到了这个 page
.
注意,在 Page 类中已经把 BufferPoolManagerInstance 设置为友元,所以我们可以直接访问 Page 对象中的私有成员进行修改。
需要实现的方法注释已经非常详细
auto FetchPgImp(page_id_t page_id) -> Page * override;
auto NewPgImp(page_id_t *page_id) -> Page * override;
auto UnpinPgImp(page_id_t page_id, bool is_dirty) -> bool override;
auto FlushPgImp(page_id_t page_id) -> bool override;
auto DeletePgImp(page_id_t page_id) -> bool override;
void FlushAllPgsImp() override;
不过其实很多代码都是可以重用的,比如 NewPgImp
和 FetchPgImp
都会涉及到从free_list
和lru
中获取一个新的frame
,我们可以将其包装为auto GetFreeFrame() -> frame_id_t;
,还有比如脏页我们需要重新写回磁盘、涉及到对 page
的 Reset ,如果追求代码简约的话都可以封装一下。
记录一个我犯的挺蠢的错误…
auto iter = page_table_.find(page_id);
if (iter != page_table.end()){
xxxx...
page_table_.erase(page_id);
free_list_.push_back(iter->second);
xxxx...
}
原因在于我把 page_table_.erase(page_id);
写在了前面,而 iterator
只是 STL 中指针的封装,erase 之后这片地址空间发生了改变,再去调用iter->second
就发生了错误。
还有可能是我自作聪明,过多封装了一些方法,导致锁处理有一些麻烦 .. gradescope 上跑出了几个timeout(死锁),如果和我一样写单元测试不太熟练,可以尝试把测试文件输出出来:
std::ifstream file("/autograder/bustub/test/buffer/grading_buffer_pool_manager_instance_test.cpp");
string line;
while (file.good()){
std::getline(file,line);
cout<<line<<endl;
}
把上述代码放在一个会涉及到的方法里即可,不过还是推荐自己写单元测试 debug 。
实现并行缓冲池管理器。
因为缓冲池实例的操作必须上锁,随着缓冲池数据规模的增大锁的开销也增大了。这一个 TASK 就是让我们实现一个缓冲池管理器,其内部维护了一个 vector 容器。
std::vector<BufferPoolManagerInstance *> buffer_pool_manager_;
这样我们可以根据 page_id % num_instances_
的值,均匀分配给不同的缓冲池实例。