
2026/04/15 21:26
本当にデータベースが必要なのですか?
RSS: https://news.ycombinator.com/rss
要約▶
日本語翻訳:
従来のデータベースはすべてのアプリケーションに必須ではないという核心的なメッセージであり、初期段階のシステムでは規模とニーズに応じて単純な平文ファイルが有効に機能します。ファイルベースのストレージと複雑なデータベースの間での選択は、最終的にデータセットサイズ、特定のクエリング要件、および利用可能なシステムリソースによって決定されます。Go、Bun、Rust を使用したテストから明らかになったのは、メモリマップが最も高速なパフォーマンス(Rust では約 169k req/s)を提供する一方で、データを RAM に全て載せる必要があるためスケーラビリティに制限がかかること、および大規模ファイルに対するラインアースキャンはインデックスがない場合により深刻なパフォーマンス低下を引き起こすこと(Go で 100 万レコードの場合、インデックスなしのラインアースキャンでは 23 req/s に低下)です。対照的に、SQLite は内部でインデックスを管理することで一貫した効率性(約 25k req/s)を維持し、幅固定インデックス付きソート済みディスクファイルでのバイナリ検索は高いスループット(約 40k req/s)を実現し、純粋な ID ロックアップでは SQLite よりも约 1.7 倍のスループット向上をもたらしました。平文ファイルのアプローチでは、ラインアースキャンが失敗するまでに約 9,000 万の日次アクティブユーザーを支持できるため、複雑なジョインや原子性トランザクションはまだ必要とされない大規模なデータセットに対して実用的です。メモリ制約に到達した場合、またはデータ整合性の要件が進化した場合、平文ファイルの移植性によりデータベースへの移行は簡単に行え、ベンダーロックインを回避しつつ、高度なクエリング能力(非 ID ロックアップ、ジョイン、同時書き込み、ACID トランザクション)を必要とするシナリオに重層なデータベースを留保できます。
本文
データベースとは単にファイルの集合体に過ぎません。SQLite はディスク上の単一ファイルであり、PostgreSQL はその前にプロセスが座っている一連のファイルからなるディレクトリです。あなたが過去に使ってきたあらゆるデータベースは、コードで
open() を呼び出した時と同じように、ファイルシステムに対して読み書きを行います。つまり、「ファイルを使うべきか」という問いではなく、いつも使っているのはファイルです。問題は「データベースのファイルを使うのか、それともご自身のファイルを使うのか」です。特に初期段階の多くのアプリケーションにおいて、答えは「ご自身で管理する」ことになるかもしれません。
もちろん、我々はデータベースを愛しています。「DB Pro」は Mac、Windows、Linux に対応したデータベースクライアントの開発を行っており、事実上の回答でもあります。しかし、「本当に一つ必要なのか」という問いへの正直な答は「スケーリング(規模)」に依存します。そして、多くのアプリケーションはそのほど大規模ではないのが実情です。我々はこれを検証しました。Go、Bun、Rust を使用して同一の HTTP サーバーを構築し、2 つのストレージ戦略を採用した上で、wrk というツールで過酷な負荷テストを行いました。その結果の数値は以下の通りです。
環境設定 3 つのフラットファイル(
users.jsonl、products.jsonl、orders.jsonl)を使用します。形式は newline-delimited JSON(JSONL)であり、各行に 1 件のレコードが記録され、書き込み時に追記されます。各ファイルは単一のエンティティタイプのみを保持します。
2 つの HTTP エンドポイント:ユーザー作成のための POST /users と ID で取得するための GET /users/:id です。ベンチマークは GET パス上で実施しました。読み取り動作において、戦略の違いが顕著に現れます。
-
アプローチ 1: ファイルを毎回読み取る 最もシンプルな方法です:ユーザー「abc-123」の依頼があり次第、ファイルを開き、全ての行をスキャンし、各行を JSON パースして ID を照合します。一致するものを発見すると即座に返却します。
- Go: ...
- TypeScript (Bun): ...
- Rust: ... これはいわゆる O(n) 処理です。各依頼でファイル全体を先頭から読み込むため、平均して目標を見つけるまでに半分ほどのスキャンが必要です。ファイルが大きくなると、各依頼の処理速度も低下します。
-
アプローチ 2: メモリに読み込む サーバー起動時にファイルを一度全量読み込み、すべてのレコードを ID でキーとするハッシュマップに格納します。書き込みはマップと両方のファイルへ行われ、読み取りは単一回のマップ検索で完了します。 ファイルは永続的なバックエンドストアとして機能し、マップはインデックスです。プロセスが再起動した場合は、ファイルを再読み込みします。
- Go: ...
- TypeScript (Bun): ...
- Rust: ...
読み取り経路は規模に関係なく O(1) となります。Go の
や Rust のsync.RWMutex
を活用することで、複数のリーダが並列に進むため、同時期の依頼同士がブロックし合うことはありません。RwLock
-
アプローチ 3: ディスク上のバイナリサーチ メモリに全データをロードする必要があるのに飽き足らず、かつ全ファイルスキャンも避けたい場合、どうすればよいでしょうか。中間の策として、データファイルを ID でソートし、それに伴う固定幅のインデックスを構築します。そして
を用いてそのインデックスにバイナリサーチを行います。各検索で O(log n) の Seek(100 万レコードなら約 20 回)を行い、データファイルから正確な 1 レコードを読み出します。 インデックス形式は単純です:各レコード 1 行あたり固定の 58 バイトReadAt
です。固定幅であるため、単一の<36 キャラの UUID>:<データファイル内のオフセット (20 デシマル)>\n
で任意のエントリにジャンプできます。 データファイルはインデックス構築前に ID でソートされていなければなりません。種付け時または既存の JSONL ファイル上のワンタイム移行ステップとしてソートを行います。新規レコードの追記はソート順序を壊すため、実システムでは定期的にインデックスを再構築するか、非ソートの書込前バッファ(Write-ahead buffer)を持ち、それらをマージする仕組みが必要となります。このマージパターンこそが LSM ツリーが行うことです。ReadAt(buf, entryIndex * 58)
ベンチマーク 種付けデータとして 1 万件・10 万件・100 万件の 3 つのデータセットを用意し、各サーバーに対して wrk を用いて 10 秒間の負荷テストを行いました(スレッド数:4 連、同時接続:50、ランダムな GET リクエストで実 ID のサンプリングリストから選択)。 全てのサーバーは同一のマシン(Apple M1 Mac mini、macOS 15)上で動作しました。Go 1.26、Bun 1.3、Rust 1.94(リリースビルド)を使用しています。 さらに Go において、ディスク上のソート済みファイルへのバイナリサーチ、および
modernc.org/sqlite を使った SQLite(純粋な Go 実装、CGO なし)のテストも行いました。このバイナリサーチでは固定幅インデックスファイル(エントリあたり 58 バイト:<uuid>:<offset>)を用いて O(log n) の ReadAt 呼び出しを行い、その後該当レコードへと直接 Seek します。データは RAM に読み込ませません。
結果
1 秒あたりの処理依頼数(多いほど良い)
| 1 万件レコード | 10 万件レコード | 100 万件レコード | |
|---|---|---|---|
| Go: リニアスキャン | 783 | 852 | 3 |
| Go: バイナリサーチ (ディスク) | 45,742 | 41,661 | 38,866 |
| SQLite (Go) | 26,000 | 25,507 | 25,085 |
| Go: メモリマップ | 97,040 | 98,277 | 97,829 |
| Bun: リニアスキャン | 469 | 611 | 9 |
| Bun: メモリマップ | 106,064 | 107,058 | 105,367 |
| Rust: リニアスキャン | 2,883 | 25,152 | - |
| Rust: メモリマップ | 163,687 | 155,364 | 169,106 |
1 依頼あたりの平均レイテンシ(少ないほど良い)
| 1 万件レコード | 10 万件レコード | 100 万件レコード | |
|---|---|---|---|
| Go: リニアスキャン | 84ms | 552ms | 1,010ms |
| Go: バイナリサーチ (ディスク) | 1.2ms | 1.4ms | 1.4ms |
| SQLite (Go) | 2.0ms | 2.0ms | 2.1ms |
| Go: メモリマップ | 497µs | 571µs | 584µs |
| Bun: リニアスキャン | 101ms | 754ms | 1,060ms |
| Bun: メモリマップ | 449µs | 443µs | 463µs |
| Rust: リニアスキャン | 17ms | 195ms | 753ms |
| Rust: メモリマップ | 231µs | 482µs | 221µs |
いくつか注目すべき点
- リニアスキャンは線形に劣化する。 100 万件のレコードでは、Go は 1 秒間に 23 件の依頼しか処理できず、Bun では 1 件あたり平均 1 秒以上を要します。この時点でパフォーマンスチューニングではなく、「なぜページが読み込めないのか」をユーザーに説明する段階です。
- ディスク上のバイナリサーチは高速でフラット。 1 万件では 4.5 万 req/s、100 万件でも 3.8 万 req/s です。データセットが 100 倍拡大しても性能低下はわずか 15% です。ここで OS のページキャッシュが大きな役割を果たしています:ウォームアップ期間後、1 万件用のインデックスファイル(566KB)は完全にキャッシュに収まります。100 万件ではインデックスが約 55MB になりますが、バイナリサーチの上層階級は常に同じオフセット付近をアクセスするため、どのキーを検索してもそれらのページは「ホット」として維持されます。各検索でインデックスに対し約 20 回の
とデータファイルへの 1 回の Seek が発生します。ReadAt - バイナリサーチが SQLite を上回る。 これは予想外です。手動で作成したソート済みファイル+ハンドローされたインデックスは、あらゆるスケールで SQLite の B-ツリーよりも約 1.7 倍高速です。SQLite も単純なプライマリキー読み込みであっても、バイナリサーチよりも多くの処理を行うためです。機能が必要になる際にはそのオーバーヘッドも価値がありますが、純粋な ID 検索のみであれば、使用していない機材分のコストを支払うことになります。
- SQLite は安定して高速。 データセット規模に関係なく、2.5 万〜2.6 万 req/s、平均レイテンシ 2ms を維持します。B-ツリーインデックスのため、レコード数が 1 万件から 100 万件に増えようとも検索時間の増大は僅かです。
- メモリマップが天井(上限)です。 あらゆるスケールで 9.7 万 req/s とサブミリ秒のレイテンシを実現。データセットを RAM に収められれば、ディスク上のいずれものもこれに迫ることはできません。
- Bun (JavaScript) はマップアプローチにおいて Go を上回る。 Bun の HTTP サーバーは平均 10.6 万 req/s against Go の 9.7 万です。Bun は JavaScriptCore を JS エンジンとし、uWebSockets で Zig にてネイティブに HTTP レア実装しているため、libuv を完全にバイパスしています。ここでは言語よりもランタイムが重要になります。
- Rust はリニアスキャンにおいて著しく勝つ。 1 万件では Rust が 2,883 req/s vs Go の 783 と Bun の 469 です。単純なファイルスキャンでは 3〜6 倍高速で、これは I/O オーバーヘッドの低さと、serde を用いた高速な JSON デシリアライズによるものと思われます。マップアプローチでも Rust が先行していますが、差は著しく縮まっています。
ユースケース別推奨:
| ユースケース | 勝者 | 絶対的な最高スループット | RAM に全データをロードせずの最高値 | 後で SQL クエリが必要の場合 | 最速のリリース速度 |
|---|---|---|---|---|---|
| R | Rust: メモリマップ (169k req/s) | Go: ディスク上のバイナリサーチ (~40k req/s) | SQLite (Go) (25k req/s、必要な時に完全な SQL) | Go: リニアスキャン (インデックス不要、セットアップなし、コード行数 ~20) |
「1 秒あたりに 2.5 万のリクエスト」とは実際何を意味するのか? データベースが必要になる時期について話す前に、これらの数値の文脈を整理しましょう。「1 秒間に 2.5 万リクエスト」という数字は多く響きますが、実際にはその種負荷を生み出すようなプロダクト是什么样的なものかを考える方が重要です。
トラフィックは一様ではありません。ユーザーは昼間起きていて、夜間は寝ています。ウェブアプリケーションの容量計画ガイドでは、B2B および B2C プロダクトにおいてピークと平均の比を 1.5〜2.0 と仮定するのが一般的です(ByteByteGo, Geek Culture)。ここでは 2:1 とします。つまり、平均して全日の 1.25 万 req/s を得ているプロダクトでも、最繁忙時は 2.5 万 req/s にピークに達する可能性があります。
その逆算を行います。アクティブユーザー 1 人あたり時間に約 10 のデータベース検索(プロフィールの読み込みやデータ取得など)を発生させるものとして仮定しましょう(概数であり、アプリによっては異なるかもしれません)。また、最繁忙時においても同時にオンラインな日次アクティブユーザー (DAU) は 10% とします。
ピーク req/s = DAU × 0.10 × (10 検索/時間 ÷ 3600 秒/時間) = DAU × 0.000278
各アプローチを飽和させるための DAU を求めるため、これを裏返します。
| アプローチ | ピーク容量 | 飽和させるための DAU |
|---|---|---|
| Go: リニアスキャン (1 万件レコード) | 783 req/s | 280 万人 |
| Go: バイナリサーチ (ディスク) | 40,000 req/s | 1.44 億人 |
| SQLite (Go) | 25,000 req/s | 9,000 万人 |
| Go: メモリマップ | 97,000 req/s | 3.49 億人 |
| Bun: メモリマップ | 106,000 req/s | 3.81 億人 |
| Rust: メモリマップ | 169,000 req/s | 6.08 億人 |
リニアスキャンは、1 万件レコードのファイルに対して約 300 万人の DAU という現実的なプロダクト規模で壊れます。これは意味のある数値です。他のもうひとつ? これらアプローチを押し付けるには何千万という DAU を必要とします。Instagram は 4 億人の DAU でも PostgreSQL を主要データストアとして運用しています(Instagram Engineering)。多くのプロダクトはそのような規模には達しません。
より現実的な例を与えます:月間有料顧客が 1 万人で、各顧客が毎日アプリを一度使用する SaaS では、上記の仮定に基づけばピークは約 3 req/s です。DAU が 10 万人の消費者向けアプリでは、同様の仮定に基づくピークは約 30 req/s です。どちらも我々がテストしたアプローチのいずれにも及んでいません。
「本当にデータベースが必要なのか」という問いへの正直な答は:おそらくまだ必要ではないということです。そして実際に必要になった時には、フラットファイル上で動作する SQLite が単一のサーバーで 9,000 万人の DAU を処理できることを覚えておいてください。
実際にはいつデータベースが必要なのでしょうか? ID による検索の場合:メモリマップが約 9.7 万 req/s、ディスク上のバイナリサーチが約 4 万 req/s、SQLite が約 2.5 万 req/s です。全てのアプローチは単一のサーバーから得られる大多数のアプリケーションを超える能力を持っています。
フラットファイルからの成長が見られるケース:
- データセットが RAM に収まらない。 メモリマップアプローチは起動時に全データをロードする必要があります。数百万件の小規模なレコードであれば問題ありませんが、数千万件になるとインデックスだけでもギガバイト単位の RAM を必要とします。データをページングする仕組みが必要です。データベースはこの点を手助けてくれます。
- 1 つのフィールド以上の検索が必要。 現在、高速な操作は「ID での検索」だけです。「ユーザー X の全オーダーを検索する」や「価格が 50 ドル未満の製品を検索する」といった必要があれば、ファイルのスキャンか追加マップの維持が必要になります。そうしたものが 3〜4 つある時点で、クエリエンジンを構築したことになります。
- 結合 (Joins) の必要性。 オーダー、ユーザー、製品を単一のレスポンスで組み合わせるには、3 つのファイルから読み込み、アプリケーションコード内で結果を組み立てなければなりません。SQL はこれらを効率的に行います。
- 複数プロセスが同時に書き込む必要がある。 これらのサーバーでの RwLock は 1 プロセス内の同時アクセスを保護します。しかし、サーバーインスタンスを 2 つ起動してそれぞれ独立したメモリマップを持つと、データは乖離します。外部の真理ソースが必要です。それがデータベースです。
- エンティティ間でのアトミックな書き込みが必要。 オーダーを作成しながら在庫を減らす処理は、双方が成功するか失敗するかどちらかです。別々のファイルでは取引ログ(Transaction log)を実装する必要があります。データベースは ACID 保証でこれを解決します。
これらの制約は多くのアプリケーションには適用されません。社内ツール、サイドプロジェクト、早期段階のプロダクトでは、単一サーバーの RAM に収まらないデータセットを持つことは決してなく、負荷下でもテーブルを超えた結合を行う必要もありませんし、より多くのインスタンスを動かすこともないでしょう。そのようなアプリケーションにとってはこのアプローチは有効です。ファイルが存在するため後から移行したい場合はいつでも可能です。JSONL はあらゆるデータベースへ簡単にインポートできます。ロックインされるわけではありません。
上記には 3 つの言語全てのサーバーコードが埋め込まれています。種付けスクリプト、ベンチマークランナー、および wrk の Lua スクリプトは内行表示されていません。独自に実行するには全プロジェクトをダウンロードしてください:ベンチマークコードのダウンロード (.zip)。
アーカイブには
go-server/、bun-server/、rust-server/、seed.ts、および run_bench.sh が含まれています。ベンチマークスクリプトは 3 つのスケールでデータを種付けし、サンプリングされた実 ID を持つ Lua スクリプトを生成し、各サーバーを起動して wrk を実行し、やがて停止します。
クイックスタート: