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進数に変換 →15
15
を公式の表と照らし合わせると、Serial Type
がN≥13 and odd
であることが分かるので、Content Size
の計算式に当てはめると次の値が得られる- (15-13)/2 = 1byte
- ページヘッダの次の値
35
が1byte分なので、この部分であることが分かります(下図の赤いハイライト部分)
name
列がどこからどこまでなのかを調べる23
は16進数なので、10進数に変換 →35
35
を公式の表と照らし合わせて計算式に当てはめると- (35-13)/2 = 11byte
59 61 6D 61 64 61 20 47 6F 72 6F
の11byte分(下図の黄色ハイライト部分)
pref
列がどこからどこまでなのかを調べる19
は16進数なので、10進数に変換 →25
25
を公式の表と照らし合わせて計算式に当てはめると- (25-13)/2 = 6byte
41 6F 6D 6F 72 69
の6byte分(下図の緑色ハイライト部分)
図にまとめると、次のようになります。
これらの結果をまとめると、どの列にどのデータがどこまで格納されているかが明確になりました。
- num列→
5
- name列 →
Yamada Goro
- pref列 →
Aomori
以上、初期データの解析を終了します。
終わりに
今回は、用意した初期データが綺麗に格納されており、テーブル数やデータ数も少ないので非常に解析しやすかったのですが、実際に使い込まれているようなデータベースはテーブルが複数あり、格納されているデータ量も多く、またデータの更新、削除などを繰り返されているので複雑です。
次回またブログを書くような機会があれば、テーブルを増やし、削除したデータも含むようなデータベースを作成し解析する/しないかもしれない…