SQLiteのファイルフォーマットを読み解いてみた
以前SQLiteについて、公式のドキュメントDatabase File Formatを読みつつ、実際にSQLiteのファイルフォーマットをBinary Editor BZ(以下BZエディタ)を使用して検証したことがあるのでまとめてみました。
今回はデータベースファイルのファイルフォーマットに焦点を当てたいので、SQLiteの概要やデータベース作成、テーブル作成の方法などについては、割愛させていただきます。
前提条件
- データベースは、page_size=512byteの最小サイズで作成
- デフォルトではpage_size=4096byteです。これだと1ページ分のサイズが大きく、データを追いづらい、また図にする際に全体を載せたかったので、page_size=512の最小サイズで作成しています。
- SQLite version 3.37.2
- WSLのUbuntu 22.04のOS標準のSQLiteです(現在の最新ではないです)
- 作成したデータベースの詳細(初期データ)
- データベースファイル名
hoge.db - テーブル名
test001- 列名
num,name,prefを持つ
- 列名
- 格納したデータの内容
sqlite> select * from test001; 1|Tanaka Taro|Tokyo 2|Sato Jiro|Saitama 3|Suzuki Saburo|Osaka 4|Takahashi Shiro|Fukuoka 5|Yamada Goro|Aomori
- データベースファイル名
- hoge.dbファイルの入手
初期データの解析
早速ですが、作成したデータベースファイル(hoge.db)を、BZエディタを使って閲覧していきます。
BZエディタを起動 > ファイル > 開く
ファイルブラウザが開くので、事前に作成したデータベースファイルhoge.dbを選択する
下の図は、説明の都合上4色で色分けしました。
-
データベースヘッダ(上記図の黄色ハイライト部分)
- 先頭から100 byte分
- 1ページ目だけに存在します
- データベースのバージョンやページサイズ等の情報が含まれる
- 先頭から100 byte分
-
ページヘッダ(上記図の緑色ハイライト部分)
- 各ページに存在する(8byteまたは12byteのb-treeヘッダ)
- 1ページ目は、データベースヘッダの後に存在する
- 2ページ目以降は、ページの先頭に存在する
- ページに格納されているデータの数等の情報が含まれる
- 各ページに存在する(8byteまたは12byteのb-treeヘッダ)
-
セルポインタ配列(上記図の青色ハイライト部分)
- b-treeページヘッダのすぐ後に続く
- 2byte分で1つのセル(データ)の位置を表す
- 各セルの位置を把握している
- b-treeページヘッダのすぐ後に続く
-
セルコンテンツ領域(上記図の紫色ハイライト部分)
- データベースに格納したデータ
- ページ内のデータの入り方が決まっている
- 先頭から一番遠い場所に、1番目のデータが置かれます
データベースヘッダ
データベースヘッダの各項目の意味については、公式のドキュメントのDatabese Header Formatの表と照らし合わせることで、内容が明らかになります。
本来であれば、各項目を1つずつ確認したいのですが、説明が長くなるので、ここではページサイズだけ確認する事とします。ページサイズは、前提条件に記載した通り、512byteで作成しています。
ですので、これから確認する値が、512であれば一致すると判断できます。
この表によると、Offset 16, Size 2 がページサイズを示すとあります。
実際のデータベースファイルの該当箇所は、下図の赤枠で囲った2byte分です。
その値をみると、02 00であることが分かります。
これは16進数なので、これを10進数に変換すると、512になり、意図した通りにデータベースファイルhoge.dbのページサイズが512byteで作成されていることが確認できました。
ページヘッダ
ページヘッダは、各ページに存在します。
1ページ目は、データベースヘッダの後に存在し、2ページ目以降はページの先頭に存在します。
今回のhoge.dbでは、実際に格納したデータの内容は2ページ目に存在しているので、ここからは、2ページ目(offset 200 ~ 400)のデータを参照しながら説明します。
ページヘッダも、公式ドキュメントの表B-tree Page Header Formatと照らし合わせる事で内容が明らかになります。
公式の表によると、Offset 0 size 1 が、b-tree page typeを表しているとあります。
その値をhoge.dbでみると、ODであることが分かります。
0Dは16進数なので、10進数に変換すると 13 になります。B-tree Page Header Formatの表に当てはめると、このページはbツリーページのリーフテーブルという種類であることが分かります。
リーフページの場合、8byteなので、今回は緑色のハイライト部分(8byte)がページヘッダであることが分かります。
A value of 13(0x0d) means the page is a leaf table b-tree page
ページヘッダ(緑色のハイライト部分)の値の解析結果です。
0D→ リーフテーブルのbツリーページである00 00→ fleeblockなし00 05→ セルの数は5 (データベースに格納したデータが5個であることを示している)01 84→ セルコンテンツ領域の開始位置 ページの先頭から388byteの位置00→ interior b-tree pageの時に値が表示される。今回はリーフテーブルなので省略されていることが分かる
セルポインタ配列
セルポインタ配列は、各セルの情報を把握しています。2byte分で1つのセルの位置を表します。
先程までで、データは5つ格納されていることが分かっているので、hoge.dbのセルポインタのそれぞれの位置は、このような結果になります。
01 E9→ 489(ページの先頭から489byteの位置であることを示す)01 D2→ 46601 B9→ 44101 9C→ 41201 84→ 388
この結果から、ページヘッダのコンテンツ領域の開始位置と、セルポインタの5つ目のデータが同じ位置を指している事が分かります。 これらを図にまとめると、このようになります。
ここまでで、ページヘッダには、実データの格納開始の位置や、実データの数が含まれていること、セルポインタは、各実データの位置を把握していることが分かりました。
セルコンテンツ領域
ここからは、セルコンテンツ領域を解析します。
セルコンテンツ領域には、各セル(データ)が格納されています。
今回の場合は5つのセルが格納されています。
公式ドキュメントのセルの構成は、このように説明されています。
今回の場合リーフセルなので、リーフセルの説明を抜粋しました。
Table B-Tree Leaf Cell (header 0x0d):
- A varint which is the total number of bytes of payload, including any overflow
- A varint which is the integer key, a.k.a. "rowid"
- The initial portion of the payload that does not spill to overflow pages.
- A 4-byte big-endian integer page number for the first page of the overflow page list - omitted if all payload fits on the b-tree page.
これを図にすると、セルの構成はこのようになります。
ペイロードの長さとRowIDは、可変長整数使用して格納されます。つまり正確なコンテンツの長さを知るにはvariantを算出する必要があります。
variantの算出方法は、こちらのサイトがとても参考になりました。
ペイロードの長さ
ペイロードの長さを表しているのは、今回の場合16です。
最上位ビットが立つかどうかを調べる必要があるので、16進数を2進数に変換します。
- 16進数の値
16を2進数に変換 →00010110- 最上位ビットは
0であり、最上位ビットは立たない
- 最上位ビットは
最上位ビットが立たない場合、これがvariantの最後である事を示します。つまり、00010110は22byteなので、ペイロードの長さが、22byte分であることが分かりました。
補足ですが、もしここで最上位ビットが立つ場合、2つ目の値が必要になります。2つ目も最上位ビットが立つ場合、3つ目の値が必要になります。
今回は、最上位ビットが立たなかったため、ペイロードは22byteである事が分かりましたので、次はRowIDのvariantを確認します。
RowID
- 16進数の値
05を2進数に変換 →00000101 - 最上位ビットは立たない
最上位ビットは立たないことが確認できたため、RowIDは5であることが分かりました。
ここまでで明確になった事をまとめ、図にすると緑のハイライトの箇所がセル1つ分(実際のデータ1つ分)であることが分かりました。
- ペイロードの長さは 22byte
- RowIdは 5
- 実際のデータの開始位置は、RowIDの隣の値
04の箇所から始まる(つまりペイロードヘッダの開始位置の値)
では、ここからは、その22byte分の内訳を確認します。
ペイロードヘッダ
先程までで、ペイロードヘッダの開始位置の値は04である事が分かっています。この値はペイロードヘッダの長さを表しているため、その値を含め4byte分04 0F 23 19がペイロードヘッダとなります。
04→ ペイロードヘッダの長さOF→ これから23→ これから19→ これから
ペイロード
ペイロードヘッダの残り3つの値 OF 23 19 については、公式ドキュメントの表Serial Type Codes Of The Record Formatとの照らし合わせが必要になります。
この残り3つという数字は、テーブルを作成した際の、列数と一致します。(列名は前提条件にも記載しましたが num,name,prefです)
OF 23 19 の値は、それぞれ、num列、name列、pref列なので、各列を解析し、格納されている値の長さを明確にします。表に照らし合わせて計算すると次のようになります。
num列がどこからどこまでなのかを調べるOFは16進数なので、10進数に変換 →1515を公式の表と照らし合わせると、Serial TypeがN≥13 and oddであることが分かるので、Content Sizeの計算式に当てはめると次の値が得られる- (15-13)/2 = 1byte
- ページヘッダの次の値
35が1byte分なので、この部分であることが分かります(下図の赤いハイライト部分)
name列がどこからどこまでなのかを調べる23は16進数なので、10進数に変換 →3535を公式の表と照らし合わせて計算式に当てはめると- (35-13)/2 = 11byte
59 61 6D 61 64 61 20 47 6F 72 6Fの11byte分(下図の黄色ハイライト部分)
pref列がどこからどこまでなのかを調べる19は16進数なので、10進数に変換 →2525を公式の表と照らし合わせて計算式に当てはめると- (25-13)/2 = 6byte
41 6F 6D 6F 72 69の6byte分(下図の緑色ハイライト部分)
図にまとめると、次のようになります。
これらの結果をまとめると、どの列にどのデータがどこまで格納されているかが明確になりました。
- num列→
5 - name列 →
Yamada Goro - pref列 →
Aomori
以上、初期データの解析を終了します。
終わりに
今回は、用意した初期データが綺麗に格納されており、テーブル数やデータ数も少ないので非常に解析しやすかったのですが、実際に使い込まれているようなデータベースはテーブルが複数あり、格納されているデータ量も多く、またデータの更新、削除などを繰り返されているので複雑です。
次回またブログを書くような機会があれば、テーブルを増やし、削除したデータも含むようなデータベースを作成し解析する/しないかもしれない…