世界を動かす技術を、日本語で。

データベースは本当に必要ですか?

概要

  • データベース は本質的に ファイル操作 で成り立つ
  • ファイル直書きとデータベース利用の パフォーマンス比較 を実施
  • 小規模アプリケーションでは 独自ファイル管理 も十分実用的
  • データベースが 必要になる条件 を明確化
  • ベンチマーク結果に基づいた 選択指針 を解説

データベースとファイルの本質

  • SQLite は単一ファイル、 PostgreSQL はディレクトリ+プロセス構成
  • どのデータベースも ファイルシステム への読み書きで動作
  • 問題は「ファイルを使うか」ではなく「 誰のファイルを使うか」という選択
  • 多くの初期段階アプリでは 自前ファイル管理 も十分に機能
  • DB Proのような 専用クライアント もあるが、導入判断は規模次第

ストレージ戦略の比較

  • 三つのファイル(users.jsonl, products.jsonl, orders.jsonl) を用意
    • 各ファイルは JSONL形式 (1行1レコード)で管理
  • エンドポイント はPOST /users(作成)とGET /users/:id(取得)のみ
  • アプローチ1:毎回ファイル全体を読む
    • リクエストごとにファイル全スキャン、 O(n) の計算量
    • ファイルが大きくなるほど遅くなる
  • アプローチ2:メモリ上に全データをロード
    • 起動時に全件を ハッシュマップ へ格納、書き込みは両方に
    • 読み込みは O(1)、高速化・並列化も容易
  • アプローチ3:ディスク上でバイナリサーチ
    • ファイルを ID順ソート +固定長インデックスを併用
    • O(log n) のディスクアクセスで目的レコードに到達
    • 追記時はインデックス再構築や LSM-tree的マージ が必要

ベンチマーク結果

  • データセット:10k, 100k, 1Mレコード でwrk負荷テスト
  • Go, Bun(JavaScript), Rust の3言語で実装・比較
  • Go追加検証 :ディスクバイナリサーチ&SQLite(純Go実装)
  • 主な結果(1Mレコード時のリクエスト/秒)
    • Go: 線形スキャン23、バイナリサーチ38,866、SQLite 25,085、メモリマップ97,829
    • Bun: 線形スキャン19、メモリマップ105,367
    • Rust: 線形スキャン152、メモリマップ169,106
  • 平均レイテンシ(1Mレコード時)
    • Go: 線形スキャン1,010ms、バイナリサーチ1.4ms、SQLite 2.1ms、メモリマップ584µs
    • Bun: 線形スキャン1,060ms、メモリマップ463µs
    • Rust: 線形スキャン753ms、メモリマップ221µs

考察と選択指針

  • 線形スキャンは規模とともに劣化、1M件で実用不可レベル
  • ディスクバイナリサーチは高速かつ安定、データ増加にも強い
  • SQLiteは常に安定したパフォーマンス、機能も豊富
  • メモリマップは最速・最小レイテンシ、ただしRAM依存
  • BunはGoよりメモリマップで高速、Rustは線形スキャンで圧倒的
  • 用途別最適解
    • 絶対速度:Rustメモリマップ
    • RAM非依存の高速:Goバイナリサーチ
    • SQLクエリ重視:SQLite
    • 最短開発:Go線形スキャン

25,000リクエスト/秒の意味

  • 25,000 req/s は大規模Webサービス級の負荷
  • ピーク時は平均の2倍程度(例:12,500 req/s平均ならピーク25,000 req/s)
  • 1ユーザーあたり1時間10回DBアクセス、ピーク同時接続10%想定
  • 例:10,000人SaaS利用でピーク3 req/s、100,000人アプリで30 req/s
  • 大半のアプリはベンチマーク値を大きく下回る負荷

データベースが本当に必要なケース

  • データ量がRAMに収まらない場合
    • メモリマップ方式は起動時全件ロードが必須
  • 複数フィールドでの検索やJOINが必要
    • ID以外の高速検索や複雑なクエリ
  • 複数プロセスからの同時書き込み
    • 外部一元管理が必須
  • エンティティ間のアトミックな書き込み
    • トランザクションやACID保証が必要

まとめ

  • 多くの初期・小規模アプリケーションではファイル管理で十分
  • 移行も容易(JSONLはどのDBにもインポート可)
  • 必要になった時だけデータベース導入 で問題なし
  • ベンチマークやサンプルコードも公開、実際に試せる環境提供

Hackerたちの意見

何億年も前に、Perlで小さな販売用ウェブアプリを作ったんだ。ISPのマシンに何もインストールできなかったから、ファイルバックハッシュを使ったんだよ。ユーザー用、注文用、他の何か用にそれぞれ一つずつね。年が経つにつれて、クライアントがもっと良いものに移行すると思ってたけど、結局20年近くそのままで、クライアントが亡くなった後、家族が引き継いで全部やり直した(今はWordPressで動いてる)。最後にチェックしたときは、何十万件もの注文があって、パフォーマンスも良かった。ハードウェアの進化のおかげで、このハックは予想以上にパフォーマンスを維持してたんだ。今ならSQLiteでも全然問題ないと思うよ。

どんな商品やサービスを売ってたの?

リレーショナルデータベースは恐竜じゃなくてサメだよ。 https://www.simplethread.com/relational-databases-arent-dino... 小さなアプリで得られるほんの少しのボーナスは、再開発にかける時間には全然見合わないよ。

サメと恐竜の対比は確かに適切なメタファーだね。白亜紀の頃、恐竜が全盛期だった時、サメはすでに今のサメにかなり似た形になってたんだ。例えば、今のホホジロザメやトラザメとほとんど変わらない大きなサメがいたりしてね。で、恐竜は翼竜やモササウルスと一緒に消えちゃったけど、サメはほとんど変わらず今まで生き残ってる。彼らはすでに最適化されたデザインに達していて、改善が難しかったからなんだよね。白亜紀の時代には、サメの他にも恐竜と一緒に存在していた2つの大きな捕食者グループ、ワニと今のニシキヘビに似た大きな締め付けヘビもいた。だから、サメ、ワニ、そして大きな締め付けヘビの3つは、70万年以上前に達成されたローカルオプティマムデザインの例なんだ。以降、大きなアップグレードは必要なかったんだよ。

自分でストレージを書くのは、データベースがどう機能するかを理解するのに良い方法だよ(効率的にやって、インデックスや正しいデータ構造を保つならね)。でも、もしただの遊びじゃなくて本気でやるつもりなら、最初からデータベースを使うべきだったって結論に至るはず。

この記事大好き!コンピュータがどれだけ速いかを示してるからね。ただ、一つ同意できない結論があるんだ。最後の方で、著者がフラットファイルでは対応できなくなるケースを挙げてるけど、「これらの制約は多くのアプリには当てはまらない」と言ってるんだよね。その制約の一つが「複数のプロセスが同時に書き込む必要がある」ってこと。実際、多くの初期段階のプロダクトは、別のワーカーで実行されるcronやメッセージキューを必要とするんだ。これらの複数のプロセスは、しばしば同時に書き込む必要がある。メインサーバーだけが書き込むように工夫することもできるけど、アーキテクチャが複雑になるんだよね。だから、純粋なスケールの観点からは著者に同意するけど、広い視点で見るとデータベースを使うのがベストだと思う。SQLiteはすごく理にかなった選択だし、スケールが必要なら、最も頻繁にアクセスされるデータをメモリにキャッシュすれば、両方の良いとこ取りができるよ。私のおすすめはSQLite + メモリキャッシュの組み合わせ。

Rustの1Mベンチマークを見たとき、どれだけ速いかを思い出させてくれる素晴らしい瞬間だった。

SQLiteは、DBが必要なプロジェクトを始めるときの新しい定番になったよ。パフォーマンスもすごく速いし、もし何かが成功してSQLiteを超えるようなことがあっても、Postgresに切り替えるのはそんなに難しくないと思う。別のデータベースサーバーを維持・バックアップ・管理する必要がないのは、安くて楽だしね。

これが結構気になるんだけど、あなたの言うことに実際に似てるのは、サーバーの冗長性が必要な時なんだよね。たとえ一つのサーバーで十分でも、どこかに運用しないとなると、ネットワークデータストレージが必要になって、ネットワークアクセス可能なデータベースの方向に進むことになるんだ。S3は時々うまくいくし、最近はファイルを原子的に取得できるようになったおかげで、いくつかの厄介な部分が改善されたけど、それでも唯一のストレージとしては不十分なことが多いよ。

原子的な操作が必要なら、データベースが必要だよ。ファイルシステムの上で原子的な書き込みをするのは、すごく脆弱だからね。これが多くのデータベースが永続性の問題を抱えていて、クラッシュ時にディスク上のデータが簡単に壊れる理由でもあるんだ。数年前のWindows上のRocksDBがその良い例だよ。開発中に定期的に壊れる問題があったからね。

正直なところ、今の時点でファイルに原子的な変更を加える必要があるデザインなら、SQLiteを使うようにデザインをやり直すかな。逆の発想はちょっとクレイジーに思える。「口から均一な高速度のミストでペイントを吐き出せるのに、スプレーペイントを使う理由は何?」もしその特異なスキルを持っているなら、ぜひ使ってみて。でも、持ってないなら、今から始める必要はないよね。それはちょっと甘い考えかもしれないけど、オペレーティングシステムのファイルシステムAPIを安全に使えるようになりたいな。それが自分をもっと良い人にしてくれると思う。でも正直、今の時代ではかなりニッチなスキルだと思うし、本当に今必要なのか、将来的に役立つのか考えた方がいいよね。それに、たとえ正しくやったとしても、あなたのコードを引き継ぐ人たちは同じスキルを身につけないだろうし、ボスに「変更するのは危険すぎる」って言うだろうから、結局データベースに置き換えられちゃうと思う。

いいね、もうACIDのAはカバーしてるね。それに、DuckDBみたいなOLAPデータベースがアウトオブコアのワークロードにどんなことができるかは、話し始めたら止まらないよ。

シンプルなケースでは、そんなに脆弱じゃないよ。データベース全体を一時ファイルに書き込んで、フラッシュした後にその一時ファイルを古いファイルに上書きすればいいんだ。すべてのUnixファイルシステムは、移動操作が原子的であることを保証してるからね。「JSONをディスクにダンプする」みたいなケースは、これをやるだけでずっと安定するはず。でも、スケールしないんだよね。自己整合性が必要なデータは同じファイルの一部でなければならないから、大きなファイルに小さな更新をするだけだと、無駄な書き込みが増えちゃう。もし他のプロセスが混乱させるリスクがあるなら、ロック処理も必要だしね。これだけじゃACIDの一部しか処理できないよ。

つまり、原子的な単位が単一のファイルで、シンプルな整合性モデルを許容できるなら、フラットファイルは全然問題ないよ。ここにぴったり合うユースケースはたくさんあって、全体のデータベースはオーバーキルになっちゃうこともあるからね。

そうだね、この記事のコードは、運が悪いと停電の後に空のファイルになっちゃうことがあるよ。少なくとも、一時ファイルに書き込んで(同じファイルシステム内で)、そのファイルとフォルダをfsyncしてから、元のファイルに名前を変更するべきだね。

そうそう、これは「実際にデータベースが必要な時はいつ?」というセクションでカバーされてるよ。

ファイルシステムの上にいるだけだと、原子的な書き込みは非常に脆弱だよ。でも、Linuxではそうじゃない。pwritev2(fd, iov, iovcnt, offset, RWF_ATOMIC); 書き込みはブロックに整列していて、基盤となるファイルシステムの保証された原子的な書き込みサイズより大きくない必要があるんだ。

Linux + ext4は2025年に原子的な単一および複数ブロックの書き込みをサポートする: https://docs.kernel.org/filesystems/ext4/atomic_writes.html

これは面白い練習だけど、実際のプロダクションではSQLiteや他のDocker化されたリレーショナルデータベースの方がいいと思う。アプリケーションの一番簡単な部分、つまり最初の部分だけを過剰に最適化してる感じがする。実際のデータベースを半分実装しただけで、安全機能が全然ないんだよね。この話題には避けられてる潜在的な問題がたくさんあると思う。おそらく、彼らがまだそれを経験していないからだろうけど。詳しくはここを見てみて: https://danluu.com/file-consistency/ この機能の範囲を広げる必要が出てきたらどうなるの? プロフィールでのユーザーの結合や、組織でのユーザーの結合とか? 自分に問いかけてみて。ファイルをバックエンドにしたアプリケーションを真剣に作って、長期間使い続けたお店はどれくらいある? おそらく、非常に少ないだろうね。だから、これは必要な作業を二重にしてる可能性が高い。人々がまずデータベースに手を伸ばす理由があるんだよ。こんなことは避けることを強く勧めるよ。

それに、ほとんどが「一度きり」のソフトウェアもあって、パフォーマンスの制約がすごく厳しいんだ。ゲームやリアルタイムに関わるものは特にね。ゲームエンジンの場合、最初からカスタムデータフォーマットを使うのが理にかなってる。予算は通常17ms未満で、低スペックのハードウェアでは8スレッドで8.(3)ms、高スペックでは16スレッドで8msだからね。そこで「賢いデータ構造とバカなコードが、バカなデータ構造と賢いアルゴリズムに勝る」ってのは本当にその通り。だけど、一般的なアプリやサーバーの場合は、頭を悩ませずにSQLiteを使った方がいいよ。

記事自体は悪くないけど、これを指摘したい。 「あなたが今まで使ったことのあるデータベースは、すべてファイルシステムに読み書きしている。まるであなたのコードがopen()を呼び出すときと同じように。」これは技術的には正しくない。SQLiteのようなアプリケーションは、ファイルをローカルにアドレス可能なメモリ空間にマッピングするためにmmapを使ってる。これによって、読み書きの際にシステムコールをスキップできるんだ。カーネルはユーザーレベルのプロセスよりもずっと早くそのデータを動的にマッピングできる。記事の後半では、ファイル全体をメモリに読み込むプロセスについても触れているけど、やっぱりmmapの方がずっと良いよね。このアプローチが使われていたら良かったのに。

記事はデータベースでのIOを確実に単純化しすぎてる。ただ、誰と話しているかによっては、「mmapはこれよりもずっと良い」という意見には同意しないかもしれない。アプリケーションのロジックで必要なことをやるべきだと言う人もいるからね。(ここでの具体的な例には必ずしも当てはまらないけど) https://db.cs.cmu.edu/mmap-cidr2022/

map()で使われているバックストアは、依然としてファイルシステム内のファイルだから、彼らの主張は技術的には正しいと言える。 「あなたのコードがopen()を呼び出すときと全く同じ」という部分が少し単純化しすぎてるけど(ただし、再度言うけど、技術的には正しい。ファイルでできることの一例を示しているだけで、ファイルでできるすべてのことを網羅的にリストアップしているわけではない)。

SQLiteが大好きだし、そのアイデアも好きだし、成熟していて軽量なものがあるのはいいけど、著者と同じように、特定のユースケースにはオーバーキルか、言ってしまえば不十分だと気づいた。クライアントサイドの辞書アプリを作っていて、検索機能をつけるためにSQLiteのwasmポートを使うのが完璧な解決策だと思ったんだ。数年間SQLiteを使ってたけど、だんだん疲れてきた。データベースファイルが大きすぎて、圧縮もあまりうまくいかないし、読み込み時間も少し遅かった。線形検索もあまり速くなかったし、SQLiteファイルの編集や結合も遅くてイライラした。もっとシンプルなものが欲しかった。原子的なものも書き込みも必要なかったから、ソースのtsvファイルからインデックスを手作りして、zstdで圧縮して、毎回wasmで解凍して読み込むことにした。解凍して読み込む方がSQLiteでの直接読み込みよりも速くて、モジュールが52kbのwasmで、SQLiteの800kbじゃないから、好きなだけインスタンスを読み込めた。stringzillaを使って線形スキャンをしてるけど、めちゃくちゃ速いよ。SQLiteは素晴らしいけど、すべての問題の解決策ではないね。

こういう投稿を読むのが大好きなんだ。99%の確率でデータベースに手を伸ばすけど、SQLとかトランザクションが好きだからね。でも最近、プライベートデータを管理するための完全に個人的なプロジェクトに取り組んでるんだ。インサイトを抽出したり、トレンドをグラフ化したりしてる。データ量は多くないから、ファイルシステムだけを使うことにしたんだ。yamlファイルでデータをバックアップして、簡単なインデックスを付けてるけど、今のところパフォーマンスの問題には遭遇してないよ。多分、私のスケールとボリュームではこれからもないだろうね。この場合、人間が読めること、そして何よりもdiffできることが、パフォーマンスよりも価値があったんだ。とはいえ、99%の確率でクエリ言語やそれに伴う保証があるデータベースには喜んで手を伸ばすよ。

こう考えてみて。私はいつもデータベースの機能やACID保証が必要になるんだ。データベースがあればいいのにっていつも思ってる。でも時々、プロジェクトのレガシーデータストア(たいていはフラットファイルのデータレイク)を使わざるを得なくて、整合性やトランザクション、特注のクエリ言語なんかを、構造化されてないデータの山に無理やりくっつけるのを見てると、毎回同じことを繰り返してる気がするよ。