前言
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,從儲存層到執行層,各個方面都有涉及,更要考慮各種資料型別和異常情況,收穫還是很大的。