ファイルシステムベース・データベース・基礎考
プログラミング::technique
序
世の中にはデータベースと言った場合にRDBMSしか存在しないと考える人も多いようだが、実際はRDBMSに限らず様々なデータベースが存在し、それぞれに得手不得手が存在する。
プログラムの基本はデータであり、データを適切に取り扱うのは発展的プログラミングにおいて不可欠なことだ。
その中で最も基本的と言って良いデータベースが、ファイルシステムベースのデータベースである。
最も基本的と言ったが、実際にはファイルシステムをデータベースとして用いるのは割と発展的用法である。 勘違いしてはならないのは、ファイルシステムベースのデータベースはファイルベースのデータベースとは異なる、ということだ。ファイルベースのデータベースは、CSV, DBM, あるいはsqliteのように、単一のファイルがデータの集合となる。 対して、ファイルシステムベースのデータベースは、データの管理機能をファイルシステムに委ねる。
データベース種別の違いは、その特性を理解することから始まり、その設計もまたその特性に基づいて行われる。 ファイルシステムベースのデータベースを活用するには、ファイルシステム、そしてファイルというものについての理解が必要不可欠である。
本稿では、プログラミングスキルが初級である人から、RDBMSのみの世界からの脱却を目指す経験者までを対象として、ファイルシステムベースのデータベースの考え方とコツについて述べていく。
データベースについて
そもそもデータベースというのはデータの集合である。 データの集合であれば、それは何であってもデータベースとなる。
「データの集合」であるということは、任意数のデータを格納できる必要がある。
よってファイルはデータであり、ファイルシステムはデータベースであると言うことができるし、実際そうなのだが、さすがにそれはプリミティブすぎる。 もちろん、ファイルシステムを通じて任意のファイルを読み込むことは可能だが、データベースという用語を用いるときは、大抵はデータに型があり、同一型のデータの集合を意識している。
このようなデータを取り扱う方法は多数ある。
プログラミング言語に備わる配列や連想配列もまた、データベースたり得る。 プロセスが終了、あるいはシステムがシャットダウンされてもデータが失われないようにすることをデータの永続化と言うが、データベースにおいて永続化はオプショナルな要素であり、データの集合として取り扱えるなら変数もまたデータベースなのである。
実際に連想配列と永続化されたデータベースをシームレスに構成し、ユーザーにはどちらを利用しているか意識させないような実装も存在する。
データベースはその構成に様々な形がある。
例えばまずCSVファイルを考えてみよう。 ファイルに保存している以上、そのデータにはファイルシステムや、IOを司るOSが関わってはいるが、データベースの構成要素というわけではない。 CSVファイルはテキストファイルベースの規定に従ったフォーマットであるため、第三者の介入を必要とするわけではない。つまり、そのプログラム自身でCSVを読み書きすることが可能だ。
一方で、複雑なCSVの書法に従って読み書きするのはそれなりに骨の折れることである。 そのため、CSVを扱うことができるライブラリや、別のソフトウェアを用いることも多い。 混乱を招きやすい用語なので本稿では多用しないが、このようなものをデータベースドライバと呼ぶ。
XMLも同じようなものである。 プログラム自身で処理することもできるが、ライブラリや別ソフトウェアを使う場合もある。
DBMも単一、あるいは複数のファイルからなるものであるが、より複雑であり、それを利用するプログラム自身がデータフォーマットを取り扱うのは難しく、非効率である。 このため、DBMはDBMのライブラリを使うのが普通だ。
しかしこの場合、DBMのライブラリはデータベースに対するインターフェイスを提供するライブラリであり、プログラミング言語から直接扱うためのものではない。
例えばGDBMであればC言語で書かれており、C言語を用いる場合はGDBMのライブラリはそのままユーザーライブラリとなるが、C言語以外の場合はそうはいかない。 一般的に、C言語以外であればそのライブラリを当該プログラミング言語で扱うためのライブラリか、もしくはC言語で書かれたGDBMライブラリに対するラッパーライブラリが提供される。
sqliteもおおよそ似た構成だが、sqlite自身が各プログラミング言語用のバインディングを提供する。
MariaDBやPostgreSQL、あるいはMongoDBなどはサーバーソフトウェアであり、データと、データを扱うためのサーバーから構成されている。 ユーザープログラムはサーバーに対して各データベース規定のプロトコルによってアクセスを行う。
一般的にはユーザープログラムは直接プリミティブなAPIをコールするわけではなく、ライブラリを用いてアクセスする。
ファイルシステムベースのデータベースとは
ではデータベースとしてファイルシステムを用いる場合はどうだろうか。
この場合、ファイルシステムがデータベースの構成要素として入ってくる。 実際の構成要素は、ファイルシステム、そしてファイル(とシリアライズ)である。
ところが、ファイルシステムはシステムによって抽象化されて提供され、ユーザープログラムがファイルシステムの実装を意識する部分は非常に少ない。 加えて、ファイルアクセスはシステム標準のファイルIOによって行えるため、特別用意しなくてはならないものがほとんどない。
ファイルシステムをデータベースとして見た場合、階層化されたKVS1であると言える。 キーは階層化された文字列、データは文字列に制約される。
透過的にデータを構造化できるわけではなく、キーに対してしか階層化できないため、連想配列ほどの柔軟性はないが、比較的柔軟性が高いほうではある。
キーが階層化されていることにより、エンティティコンテンツではなくキーそのものをデータにすることができる。 これは、ファイルシステムとしては単一ディレクトリに所属するファイルの一覧であるが、ディレクトリ内のファイルの一覧を取得することは1コールでできるため、イテレータを回す必要がない。 階層化されていないKVSではキーを一覧しても、対象のエンティティに限定するためにはそこからフィルタする必要があるが、ファイルシステムをデータベースとして見た場合、文字列の連想配列に加えて文字列の配列も扱える、ということになる。
ファイルシステムベース・データベースの特性
システムの基礎部分にある機能なので、当たり前だがファイルシステムというのは熟成の進んでいるものである。 パフォーマンス的にも、ディスク上に永続化するデータベースであれば最終的にはファイルを扱うことになるため、ファイルをstaticに扱えるというのは非常に強い。
ただし、開いた後ということを考えると、ファイルシステムデータベースの弱みをきちんと理解しておく必要がある。 例えば、ファイルのオープンは意外と重い処理であり、エンティティに対してイテレートする場合はファイルベースのデータベース、つまり1ファイルをオープンすればいいものと比べるとかなり低速になる。
また、ディレクトリをオープンする、つまりファイルを一覧する処理も意外と重く、再帰的に全ファイルをリストしようとした場合も低速に感じられる。
これらのことから、基本的に検索には適さない2ため、キー一発で引くことを前提に考えなくてはならない。
エンティティから得られるものは文字列であるため、構造的なデータを保存・取得するにはシリアライズが必要になる。
扱いやすいのはテキストベースのYAMLやJSONであるが、これらはパーサによって拡張的解釈がなされたりするため、異なる言語のプログラムで受け渡すことを考えると最善ではない。
というより、解釈の一貫性と多くの型を満たす適切なシリアライズフォーマットというのが、実のところない。3 一番理想に近いのはArvoだろうか。
更新にも一癖ある。
やり方としてはr+
で開いてEXロックし、一旦truncateしてから書く方法と、r
で読んでから一度解放し、w
で開いてEXロックして書く方法がある。
後者の場合はタイミングによって空を読んでしまう場合があるので、読むプログラムはそれを考慮しなくてはいけない。
データを書き込むプロセスは他のプロセスのことを気にしなくて良いようにするのが理想。 その意味でOrbital Designとの相性が良く、私が多用する理由でもある。
データベースは単純なファイル群であるため、取り扱えるソフトウェアが非常に多く、それらを組み合わせることでユーザープログラムであれこれ準備しなくても機能を追加できるのは大きな魅力。
ファイルシステムの選択
Windowsでも利用可能ではあるが、NTFSであれExFATであれ、性能的・機能的・制約的に厳し目ではある。
基本的にはWindowsには適さないし、Windowsで使うのであれば(特にopen周りでパフォーマンスが低下するが)WSL2を用いたほうが良い。
Linuxでは基本的にExt4, Btrfs, XFSが候補。 極端な使い方をしなければこれにほとんど差はないが、私は最近はスナップショットの利便性を理由にBtrfsを使っている。
最初の一歩
受け取ったメッセージを、そのままデータディレクトリ配下に保存する。
declare DATA_DIR=/usr/share/foo
cat > $DATA_DIR/$(date +%s).$$.data
データをリストする。
declare DATA_DIR=/usr/share/foo
ls $DATA_DIR
エンティティの所在を確認する。
declare DATA_DIR=/usr/share/foo
[[ -e $DATA_DIR/"$1" ]]
エンティティを取得する。
declare DATA_DIR=/usr/share/foo
cat $DATA_DIR/"$1"
JSONでRubyとJavaScript(Node.js)
Node.jsでHTTPリクエストを受け付けて、JSONに保存:
const express = require("express")
const fs = require('fs')
const app = express()
.post("/in", express.json(), (req,res) => {
appconst data = req.body
const now = new Date()
.writeFileSync(`/usr/share/foo/data/${Number(now)}.json`, JSON.stringify({
fstimestamp: now,
value: data.value,
})).sendStatus(204)
res
})
.listen(8800, ()=> {
appconsole.log("OK, listen")
})
Rubyでロードする:
require 'json'
DATA_DIR = "/usr/share/foo/data"
= ARGV.shift or abort "No key given."
key
= JSON.load(File.read File.join(DATA_DIR, key))
data ["timestamp"] = Time.at(data["timestamp"] / 1000.0)
data
#...
Msgbridge
引数で与えられたメッセージをYAMLで保存する:
#!/bin/ruby
require 'yaml'
require 'fileutils'
= ARGV.shift
host = ARGV.shift
type = ARGV.join(" ")
msg
if !host || !type
abort "Invalid message"
end
= host == "all" ? Dir.children(File.join((ENV["XDG_DATA_HOME"] || [ENV["HOME"], ".local", "share"]), "msgbridge", "msgs")) : [host]
hosts = ENV["XDG_DATA_HOME"] ? [ENV["XDG_DATA_HOME"]] : [ENV["HOME"], ".local", "share"]
state_base
.each do |h|
hosts= [Time.now.to_i, ".", $$, ".yaml"].join("")
filename = File.join(*state_base, "msgbridge", "msgs", h, filename)
state_file
= File.dirname state_file
state_dir if !File.exist? state_dir
FileUtils.mkdir_p state_dir
end
File.open(state_file, "w") do |f|
YAML.dump({
"type" => type,
"message" => msg
}, f)
end
end
対象ホストのメッセージをすべて出力し、削除する:
#!/bin/zsh
setopt NULL_GLOB
host="$1"
shift
typeset data_dir="${XDG_DATA_HOME:-$HOME/.local/share}/msgbridge/msgs/${host}"
if [[ ! -e $data_dir ]]
then
print "Invalid host." >&2
exit 1
fi
for i in "${data_dir}"/*
do
cat $i && rm $i
done
構造化とインデックス
データディレクトリに直接データを格納するのではなく、サブディレクトリを作る。
File.open(File.join(DATA_DIR, "message", [key, ".json"].join("")), "w") do |f|
JSON.dump data, f
end
このサブディレクトリが、他のデータベースでいうところのテーブル名のようなものになる。 これにより、複数の構造のデータを分離して保存できる。
例えばdata.name
がグルーピング対象になるのであれば、次のようにする。
File.open(File.join(DATA_DIR, "_index", "name.json"), "r+") do |f|
.flock(File::LOCK_EX)
fbegin
= JSON.load(f)
index [data["name"]] ||= []
index[data["name"]].push key
index
.seek(0)
f.truncate(0)
fJSON.dump data, f
ensure
.flock(File::LOCK_UN)
fend
end
これで_index/name.json
をロードするだけで、同一のname
を持つエンティティ一覧が得られる。
値が文字列的に等しいエンティティを選択する場合は基本的にこの方法で良い。 複数の条件から積集合を得たい場合、言語にもよるがRubyなら単純に
= index1 & index2 & index3 match
という感じで可能。
JavaScriptの場合はSet化すればSet.prototype.intersection()
が使える。
const match = index1.intersection(index2.intersection(index3))
Pythonもsetなら&
演算子でいける。
= index1 & index2 & index3 match
もっと複雑な条件に関してはキーをイテレートすればできる。 例えば、キーが1000を下回るものを選択したいなら
= index.select {|i| i < 1000 }
selected_keys = selected_keys.map {|i| index[i] }.inject {|r, i| r | i } unified_index
という感じ。
ただ、この時点でファイルシステムベースのデータベースが最適かどうかは怪しくなっているので、そこから考える必要がある。
実用的なKVSインデックスの使い方としては、AWSのDynamoDBあたりと同じものあたりが現実的なライン。
よりシンプルなインデックス
エンティティがより単純な構成で(かつ変更されにくい値で)インデックスされるなら、リンクを使うと良い。
例えば誕生年でインデックスする場合は
JSON.dump(data, File.open(File.join(dir, "data", key), "w"))
File.link(File.join(dir, "data", key), File.join(dir, "index", data["bod"].year, key))
のようにできる。
ただ、この方法で候補を絞るのは複合検索するときだけ有効なもので、複合検索をファイルシステムベースのデータベースでやること自体が微妙である。
そしてパフォーマンス的にも、たとえデータサイズが大きくても検索対象データは1ファイルにシリアライズしたほうが良いことがほとんど。
こういうのを必要とするケースでは、もはやMongoDBあたりを使ったほうが良い。
特に有効なケース
アンケートやログなど、送信されたデータをそのまま保存するのにはかなり良い。
データを読む側にリアルタイム要求がそれほどないのであれば困らないので、バッチでの集計などを行うのなら検索性の問題も気にならない。
このような場合は階層化もそれほど重要な要素ではないため、Amazon S3などでも使えるというメリットもある。
もっと単純に、キーに固有のデータを保存したいというケースでも有効。 日記、アクティビティログ、統計情報など。
DBMとの比較だと、スナップショットやインクリメントバックアップとの相性が良いという点が大きく、またファイル関連のユーティリティの充実度が強みとなる場合もある。
キーを階層化できる強みを活かせて、なおかつファイルシステムベースの強みも活かせるケースというのは結構狭い。
主にはgrep
やfind
のような再帰検索ユーティリティで楽したいという場合が多い。
あとは、データを見るのが管理者だけであるなど、データを検索・見る側のソフトウェアの需要が低く、既存のコマンドラインユーティリティなどで済ませたい場合なども有効。