前言
ob數據庫大賽由螞蟻的oceanbase團隊組織,今年是第一屆,宣傳很廣,比賽十月份開始,但早在上半年就看見大量的宣傳了,比賽也是相當的卷。我們進了復賽之后感覺要卷進決賽需要付出的時間精力都太大了,趕上實驗室項目年終總結,於是就止步第41名了。參賽隊伍接近1200個,我們在前3%。ob比賽讓我們可以快速地深入學習數據庫的內核,對各個數據庫內核模塊的功能和它們的關聯都有所了解。初賽基於miniob數據庫進行。miniob數據庫【代碼在這里https://github.com/oceanbase/miniob】是一個小型的數據庫,用的是B+樹作為索引結構,實現了部分事務,有許多功能不全。初賽要求我們編寫代碼實現給定的功能,根據難易程度有10分的和20分的。這篇文章總結初賽中我做的部分:drop table, update, null, groupby, aggregate-func以及simple-sub-query。【我的實現在這里https://github.com/MiaoMiaoGarden/miniob-1】
drop table
測試示例:
create table t(id int, age int);
create table t(id int, name char);
drop table t;
create table t(id int, name char);
drop table就是支持刪除表了,這個功能還是很好實現的。與create table相反,參考create table的具體實現,drop table要清理掉所有創建表和表相關聯的資源:數據文件(.text文件)、相關索引文件(.index文件)、元數據文件(.table)。將一層層的調用鏈組織好,然后在存儲層的Table類里寫最后的實現函數就好了。這里直接貼主要代碼了,大概就是找到文件路徑然后直接刪除對應的文件,找索引文件需要從元數據中獲取表的索引以及文件。注意一些錯誤校驗就可以了。一個需要注意的地方就是,由於有buffer pool的存在,因此即使刪除了磁盤上的文件,表相關的數據可能在內存的buffer中仍然保留着,因此在刪除文件之前還要調用sync函數刷新臟頁,清空buffer pool。最后將存儲層與這個表有關的項從數據結構中刪除。sync函數會刷新buffer pool和索引,代碼如下:
update
測試示例:
update t set age =100 where id=2;
update set age=20 where id>100;
update支持更新數據,不要求實現事務,也很簡單。分有索引和沒有索引兩條支路,通過filter找到需要更新的record之后,將需要更新的field替換成命令中給出的value就可以,這里可以模仿SelectExenode實現一個UpdateExenode,但我直接寫了一個Table類內的功能函數來實現。
record的結構是這樣的:
Sysfield | value1 | value2 | value3 | … | valuen
一個record就是表中的一行,它可能有多個列,列的元數據信息保存在table對象中的table_meta_里,record中的數據以char的形式存儲。我們根據offset和len可以使指針指向任一列,查詢列的元數據信息時,也是在table_meta_中根據偏移量進行找到特定列的信息。需要注意的就是索引的處理。索引的更新相當於刪除舊值然后插入新值,調用現有函數即可。我寫了一個類來處理update:
null
測試示例:
create table t1 (id int not null, age int not null, address nullable);
create table t1 (id int, age int, address char nullable);
insert into t1 values(1,1, null);
這題要求支持null類型,包括但不限於建表、查詢和插入。默認情況不允許為null,使用nullable關鍵字表示字段允許為NULL。null不區分大小寫。注意null字段的對比規則是null與任何數據對比,都是FALSE。這個功能的實現不難,但是要求很繁瑣,涉及了數據庫的各個部分,因此改動也是比較大的,從lex和yacc,到存儲層,包括filter、元數據校驗等各個部分都需要適應null。實現的核心難點就是如何標識record中的一個字段的值是不是null。數字、字符串都是字段可能的合法數據類型,因此不能用"null"、-1這種來標識。實現yacc的時候順便瞥了一眼,發現miniob的parser中,字符串的識別是這樣的:
{QUOTE}[\40\42\47A-Za-z0-9_/\.\-]*{QUOTE} yylval->string=strdup(yytext); RETURN_TOKEN(SSS);
一些特殊符號是不支持輸入數據庫的。利用這一點,我用一個'!'的字符標識這個字段為null,如果這一字段是null,那么存儲在record中的會是'!'。至於字段是否是nullable,直接在元數據中添加一個bool型變量標識即可。
null功能的實現需要從語法分析器和詞法分析器開始實現,這里貼上生成器的編譯命令:
groupby和aggregate-func
測試示例:
select t.id, t.name, avg(t.score),avg(t2.age) from t,t2 where t.id=t2.id group by t.id,t.name;
測試示例:
select max(age) from t1;
select count(*) from t1;
select count(1) from t1;
select count(id) from t1;
groupby和aggregate-func都屬於Select語句,在執行層都會在do_select函數中完成執行。先分析一下do_select函數的功能。miniob中給ExecuteStage::do_select函數的功能十分繁雜,從解析出來的sql->sstr.selection到輸出執行結果的全過程都由do_select函數掌控。其流程是:
- 首先對這個select命令涉及的關系表及其對應的condition取出,生成最底層的select執行節點。
- 然后對每個執行節點調用execute,將得出的結果集合tuple_set推入tuple_sets保存。
- 如果本次查詢了多張表,要做join操作,否則直接調用print函數通過stringstream輸出執行結果。
do_select函數在實現了多表查詢之后還需要管理join操作里tuple_set的合並,condition的合法性校驗以及輸出schema的生成。
aggregate-func要求實現的是count、avg、min、max這四種聚合。
- count的困難在於null值是不算入count的,例如,select count(id) from t1; 這一語句,如果select出的record中,id的字段全為null,那么返回的count結果應該是0。只有count(*)或者count(1)等常數才需要將null算入。因此,count不能簡單地對第3步的輸出結果求length。min、max的困難是當數據為空的時候需要返回null;
- avg除了null之外還需要注意數據類型的轉變,通常avg一列整數得到的是浮點數,題目要求保留兩位小數。
- 原先的do_select函數中只支持簡單的select操作,關系表select操作暫存結果的tuple_set的schema元數據和最終結果的tuple_set的schema元數據是一致的,但聚合操作不再一致,例如選擇列的時候schema中的field是id,但輸出的時候應該是count(id)。與
基於這些思考,我們進行了三步改造:
輸入schema和輸出schema的獨立。
這里的schema和數據庫中的不太一樣。這里是代碼中的一個變量,是一些屬性的集合,類似於表頭的概念,每一個tuple_set數據集合都有一個schema來記錄元數據,包括列的字段,groupby依據的屬性等與這個臨時的record集合有關的信息。對於從關系表中select出的tuple_set,我們稱為tuple_set1,schema的設置是:不論是在condition中出現的,還是在待select中出現的,不論是不是聚合,所有出現的屬性均一一放置入schema。例如,select count(id) from t1 where name='Alice';這一語句,schema中的屬性是id和name(如果有多表,屬性會添加上表字段,變為t1.id和t1.name),這樣在t1這個表中選擇出的就是這兩個屬性。將這個tuple_set1進行聚合、排序等后處理得到的結果我們稱為tuple_set2,它對應的schema將會直接被輸出函數打印,因此需要設置為select中的內容,即count(id)。
tuple_set1到tuple_set2的轉換。
如果select的對象中沒有聚合,那就直接將tuple_set2賦值為tuple_set1然后返回即可。如果有聚合操作,則需要進一步處理。聚合如果沒有groupby屬性,則所有的record都屬於同一個grouby。下面討論有groupby的聚合操作的實現。
我們設計了一個GroupHandler類來管理group。groupby很自然的處理是使用unordered_map來記錄每個groupby屬性的值和其對應的groupid,但groupby可能有多個屬性,這不適合做map的鍵。我們的解決方案是使用hash函數。假如有多個groupby屬性,例如:select count(id) from t1 group by name, age;這一語句,name和age都一致的record才會被歸為一個group,根據name和age兩個屬性,用std::hash生成hash值來標識,具體做法是hashed_value = hash_fn(name) + hash_fn(age),根據hashed_value來決定其groupid,groupid逐漸遞增,這一對應關系用一個map存儲。
設計AggregateExeNode類來管理所有的聚合函數,設計AggregateValue的抽象類來管理int、char等數據類型的聚合操作。
我們又設計了一個AggregateExeNode抽象類來執行所有的聚合操作。調用抽象類中的add_value函數將數據流輸入聚合執行節點處理。AggregateExecNode中維護了一個record_map的字典變量,它維護了groupid和字段索引這個二元數據到一個AggregateValue的映射。其鍵由groupid和字段索引的組合決定,因為每個聚合節點是在一個二維的表矩陣上選取某幾行(某groupid)某一列(某字段索引)的數據進行聚合的。這兩個維度因為數量已知,就不用hash函數,直接用線性組合來合成一個維度做map的鍵了。數據流先根據自己的鍵去record_map中查找,如果沒有就新建一個AggregateValue。agg_value和get_value實際上都會調用到對應的AggregateValue類中的對應函數。AggregateValue內部就是在進行數值的統計了。例如avg的聚合操作,add_value的時候記錄sum,直到get_value的時候再做一個avg運算。這些AggregateValue子類的實現也比較繁雜,既要考慮到不同的聚合函數(不同的聚合函數需要不同的計算),又要考慮到聚合對象的數據類型(各種數據類型的聚合必須用不同的數據類型承接),並且要足以應對null、空tuple_set等特殊情況(這里用一個vector
simple-sub-query
測試示例:
select * from t1 where name in(select name from t2);
select * from t1 where t1.age >(select max(t2.age) from t2);
select * from t1 where t1.age > (select avg(t2.age) from t2) and t1.age > 20.0;
NOTE: 表達式中可能存在不同類型值比較
簡單子查詢的思路比較簡單,但是實現起來比較復雜。我們是將括號內的子查詢語句在解析的時候先識別為一個字符串,存儲在condition的一邊(子查詢出現在condition的一側或者兩側作為篩選條件)。執行層的時候需要根據condition構建condition_filter,此時檢查是否有子查詢,如果有子查詢,就將子查詢的字符串假裝成用戶的輸入,從解析層開始走完執行層,得到的子查詢結果替代掉condition中原來的字符串。然后就變成正常的查詢了。
解析層實現
語法解析樹寫起來倒也簡單,將所有可能出現子查詢的情況或入condition的可能分支里就可以。例如這樣:
需要注意的就是詞法分析器的實現,如何識別出是一個子查詢。我的寫法是根據左右括號和select的出現,這里需要一些自動機的知識。
ANYTHING [^()]*{LRBRACE}*[^()]* [(][\ ]*[Ss][Ee][Ll][Ee][Cc][Tt][\ ]*{ANYTHING}*[)] yylval->string=strdup(yytext); RETURN_TOKEN(SUB_SELECTION);
執行層實現
condition_filter里in和not in的實現。使用了一層封裝,將in和not in的一對多或者多對多關系轉換成一對一的關系,再調用原來的condition_filter,是簡單而且對原代碼改動不大的實現。
總結
miniob設計得雖然有一些缺陷:沒有考慮並發讀寫、代碼整體架構不平衡等缺陷,對於拿數據,一般數據庫系統會采用火山模型或者向量模型,然后調用對應的exeuctor的next方法拿到對應的數據即可,但miniob是我們自己創建完exeuctor之后,調用execute拿到所有數據,之后ConditionFilter的創建,初始化,以及過濾操作全部得自己處理,抽象得不太好。但它提供了一個很好的學習平台讓我們快速上手數據庫內核的設計和實現,對數據庫進行了深入的了解,很多設計也十分典型:LRU緩存,B+樹的索引等。功能的實現需要從lex和yacc,從存儲層到執行層,各個方面都有涉及,更要考慮各種數據類型和異常情況,收獲還是很大的。