1. 程式人生 > >python實現簡易資料庫之一——儲存和索引建立

python實現簡易資料庫之一——儲存和索引建立

  今天完成了學生生涯最後一個課堂作業,資料庫project,要求實現一個簡單的資料庫,能滿足幾個特定的查詢,這裡主要介紹一下我們的實現過程,程式碼放在過ithub,可參看這裡。都說python的執行速度很慢,但因為時間比較急,工作量大,我們還是選擇了高效實現的python。

一、基本要求

1、設計儲存方式

測試的資料量大小為1.5GB,最大的表有6,001,215條記錄。最大限度減少I/O次數,減少磁碟佔有空間。

2、實現和優化group by,order by

對大表進行group by 聚集、排序,提高查詢效率

3、實現和優化TOP k 

高效率實現TOP k

4、對插入不作要求

二、總體設計

由於要求主要是對查詢做優化,對插入刪除不作考慮,完全可以採用列式資料庫,發揮列式資料庫的優勢。

以下是主要框架(忽略了建表和插入資料的過程),圖中藍色箭頭表示生成流,橘黃色箭頭表示執行流。

整個過程其實是至上而下建立索引,從下往上執行查詢,具體細節在詳細設計中講述。

三、詳細設計

為了實現高效查詢,我將表的各個列從表中抽取出來,單獨存放,在查詢時,只讀取所需要的列,不需要讀取原始記錄,可以大大的減少I/O次數,但對於select * 這樣的查詢語句需要讀取其他屬性,因此我採用行式和列式結合的方式。

1、建立表,生成元資料

在插入記錄之前需要建表,確定表的格式和完整性約束,如執行一下建表操作:

create table NATION (N_NATIONKEY int primary key,N_NAME varchar(25),
          N_REGIONKEY int,N_COMMENT varchar(152),
          foreign key (N_REGIONKEY) references REGION(R_REGIONKEY));

將生成一個meta.table的元資料檔案,該元資料檔案第一行儲存的是資料庫的所有表名,以下的每一行為一個表的詳細描述,格式如下:

[表名1]|...|[表名n]
[表名1]|[表1主鍵]|[表1第一個屬性,約束]|[表1第二個屬性,約束]|...|[[表1第N個屬性,約束]]
.
.
.

測試資料共有8個表,REGION,NATION,LINEITEM,ORDERS,CUSTOMER,PARTSUPP,PART,SUPPLIER。示例如下(省略了一些表的描述):

#meta.table:
REGION|NATION|LINEITEM|ORDERS|CUSTOMER|PARTSUPP|PART|SUPPLIER| NATION|N_NATIONKEY |N_NATIONKEY INT|N_NAME VARCHAR(25)| N_REGIONKEY INT|N_COMMENT VARCHAR(152) REGION|R_REGIONKEY |R_REGIONKEY INT|R_NAME VARCHAR(25)|R_COMMENT VARCHAR(152)
.
.
.

建立元資料的作用,是在查詢處理時可以知道某個表的某個屬性在原記錄檔案中的列號,以及該屬性屬於什麼型別,要知道對屬性排序和比較時必須知道屬性型別。因此原資料表meta.table的屬性必須按原記錄的順序儲存。該資料表常駐記憶體,以python的有序字典(map)在記憶體中存放:

{'NATION': OrderedDict([('N_NATIONKEY', 'INT'), ('N_NAME', 'VARCHAR(25)'), ('N_REGIONKEY', 'INT'), ('N_COMMENT', 'VARCHAR(152)'), ('primary', ['N_NATIONKEY'])]),
'REGION': OrderedDict([('R_REGIONKEY', 'INT'), ('R_NAME', 'VARCHAR(25)'), ('R_COMMENT', 'VARCHAR(152)'), ('primary', ['R_REGIONKEY'])]),...
}

2、插入記錄

元資料建立好後,可以進行插入資料,由於時間有限,插入資料時我沒有進行完整性檢查,假設插入的記錄都是合法的,整個插入過程完成後,資料記錄如下(REGION表):

#REGION
0
|AFRICA|lar deposits. blithely final packages cajole. regular waters are final requests. regular accounts are according to | 1|AMERICA|hs use ironic, even requests. s| 2|ASIA|ges. thinly even pinto beans ca| 3|EUROPE|ly final courts cajole furiously final excuse| 4|MIDDLE EAST|uickly special accounts cajole carefully blithely close requests. carefully final asymptotes haggle furiousl|

屬性之間用‘|’分割,在抽取屬性列之前,記錄檔案不能壓縮,我們將在生成列索引時壓縮這個原始記錄。

3、抽取屬性列(同時建立記錄行號與行地址的對應表)

  將表中的每個屬性單獨抽取出來,格式為:

[屬性值0]|[行號0]
[屬性值1]|[行號1]
[屬性值2]|[行號2]
.
.
.

抽取的列示例如下:

#REGION_R_NAME 
AFRICA|0
AMERICA|1
ASIA|2
EUROPE|3
MIDDLE EAST|4

上面的示例是REGION表中的R_NAME屬性的列,後來發現行號可以不用儲存,讀取到陣列中後,陣列下標就是行號,這樣可以節省一些空間,不過,這個屬性列是按原始記錄的順序存放,不能實現按塊讀取,當記錄數很多時,不能放入記憶體,因此這個屬性列檔案在下一步之後可以刪掉。

抽取列的同時還要完成兩個工作,第一個是壓縮原始記錄表,壓縮後原始記錄不再需要,讀記錄只需要讀壓縮記錄即可;第二個就是建立行號到壓縮後的行首地址的對應表,這樣以後的操作都是按行號進行。在掃描原記錄檔案的每行時,寫壓縮檔案儲存壓縮記錄表,並記錄壓縮後的每一行的首地址(獲得壓縮後的地址在這裡)。行號與行首地址的對應表格式如下:

[第0行首地址]
[第1行首地址]
[第2行首地址]

行號與行首地址的對應表示例如下:

#REGION
0
127
171
212
269

  用行號代替行地址有,可以節省空間,單個表文件只要大於3*2^32B=12GB(乘以3是因為壓縮比率約為3:1),位元組地址就超過就超過long int能表示的範圍,而行號可以表示更大的表;另一個好處,如果後續需要建立點陣圖索引,用行號比行地址好,因為行號是連續的整數,而行地址是離散在整數空間中,如上面的示例行地址從0直接跳到了127,中間的一串整數都沒有用到,那建立的點陣圖索引將是相當稀疏的。

行首地址在查詢中不會用到,只有在最後讀取原始記錄時才需要轉換為行號,因此可以將它進行壓縮,我們用gzib壓縮,gzib為我們提供一個透明的檔案壓縮,所謂透明,就是像讀寫普通檔案一樣,gzib自動在緩衝區進行壓縮和解壓。

python寫壓縮檔案主要程式碼如下:

import gzip
condenseFile= gzip.open(os.path.join(path,fileName+".gz"),'wb',compresslevel = 4)#以二進位制寫,壓縮等級為4,值越大壓縮率越高,但時間越長
block = '...'
condenseFile.write(block)
condenseFile.flush()
condenseFile.close()

讀壓縮檔案:

path = os.path.join(DATABASE,"line2loc")    
with gzip.open(os.path.join(path,fileName),'rb') as transFile:
     locations = transFile.read().split("\n")#也可以只讀以行 transFile.readline()
     transFile.close()

4、屬性列壓縮並建立分塊索引

上一步我們得到的屬性列只是簡單從表中抽取出來,這樣的屬性列有很多冗餘,比如一個表有10000行,某個屬性只有10個取值,那在屬性列中就需要儲存儲存10000行,我們可以按屬性值進行分組,記錄出現該屬性值得行號,格式如下: 

[屬性值0]|[出現該值的行0]|[出現該值的行1]|...|[出現該值的行n]
[屬性值1]|[出現該值的行0]|[出現該值的行1]|...|[出現該值的行n]
.
.
.

  接下來按屬性值排序,這樣得到的屬性列就有序了,排序的過程中需要用到外部排序。排序後的結果,示例如下:

#PARTSUPP_PS_SUPPLYCOST
1.0|81868|307973|409984|490169|620444|632371
1.01|25328|36386|172687|243808|287934|558840|774633|775920
1.02|108457|137974|175055|206681|246824|297497|374608
1.03|38563|117772|175895|289935|381497|486960|630290|644984|723651|726647
1.04|284511|314284|327411|392035|639283|721325|754065|783577
.
.
.
6.5|193020|436686|746401
6.51|46883|59908|129012|189045|398695|437094|455012|458310|490801|598787
6.52|54123|129198|145810|223578|336148|377020|377755|379426|430717|442844|500296|549401
6.53|32341|54384|149844|208256|437181|528380
6.54|7164|41427|377948|417213|432345|625698|652283|757838

上面的示例擷取自PARTSUPP表的PS_SUPPLYCOST。排序之後,我們就可以對它進行分塊讀取,為了節省空間和減少I/O次數,我們對這個屬性表進行分塊壓縮,並在塊上建立索引,我們把屬性表稱為一級索引,這個在塊上的索引稱為二級索引。實現時,我們以32KB為一塊,不過實際操作時我們的塊大於等於32KB,我們依次將各行新增到一個字串string中,每新增一行我們都會檢查string的是否大於等於32*1024B,如果小於32KB,就繼續新增一行;直到大於等於32KB,將string寫壓縮檔案,同時記錄壓縮後的大小,儲存該塊的首地址和塊大小,然後清空string,開始記錄下一個塊。實現的主要程式碼如下:

 1 scwf = open(os.path.join(scddir,fileName),"w")   
 2 wf = gzip.open(os.path.join(sortdir,fileName+".gz"),'wb',compresslevel = 4)#壓縮屬性表
 3 block = ""
 4 newblock = True#新塊標誌
 5 for k in li:#li存放的是有序的屬性值和行號表
 6     if newblock == True:
 7         blockattr = str(k[0])#塊首屬性值
 8         newblock = False
 9     line = str(k[0])+"|"
10     for loc in k[1]:
11         line += loc+"|"
12     block += line+"\n"
13     if len(block) > BLOCKSIZE:
14         startloc = endloc
15         wf.write(block)
16         endloc = wf.tell()
17         size = endloc - startloc
18         scwf.write(blockattr+SPLITTAG+str(startloc)+SPLITTAG+str(size)+'\n')#儲存塊頭的屬性值,塊首地址和塊大小
19         block = ""#塊清空
20         newblock = True#新塊,下次迴圈記住新塊首部屬性值

壓縮後生成二級索引,二級索引示例如下:

1.0|0|32812
6.52|32812|32810
12.03|65622|32800
17.46|98422|32835
22.92|131257|32787
28.39|164044|32771
33.77|196815|32794
39.29|229609|32810
44.7|262419|32843

上面的示例中,第一行表示該屬性值1.0-6.52為一塊,塊內的屬性值在[1.0,6.52)之間,塊的起始地址為0,塊大小為32812B,二級索引也是有序的,因此建立二級索引後,我們可以在二級和一級索引上都進行折半查詢,查詢速度很快。

整個測試資料壓縮後的一級索引列表:

對原始記錄表進行壓縮後,不能指定抽取屬性列建立索引,只能同時對一個表的所有屬性建立索引,這在實際應用中有很大缺陷,因為有一些屬性根本就不會再查詢條件中使用,建立的索引浪費了磁碟空間,也延長了建立索引的時間。雖然設計了壓縮原始記錄表,但最後我們實現沒有壓縮原始記錄表,行到地址的對應表存的是原始記錄的行首部地址。原始記錄檔案不會刪除,這樣可以指定表和屬性建立索引。

到此,自頂向下的儲存和索引一級建立好了,下一篇將介紹SQL語句解析和查詢處理。