オブジェクトストレージとデータベースのブロブ比較:メタデータは Postgres に、バイトはオブジェクトストレージに置き、ダウンロードを高速かつコスト予測可能に保つ方法。

ユーザーのアップロードは単純に聞こえます:ファイルを受け取り、保存して、あとで表示する。利用者が少なくファイルが小さいうちはそれで問題ありません。ところがユーザーやファイルサイズが増えると、アップロードボタンとは無関係のところで問題が表面化します。
ダウンロードが遅くなるのは、アプリサーバやデータベースが重い処理を担っているからです。バックアップは巨大で遅くなり、復旧に時間がかかると必要なときに困ります。ストレージや帯域(エグレス)料金が効率の悪い配信や重複、未削除のファイルによって急増することもあります。
多くの場合望ましいのは地味で信頼できることです:負荷下でも高速な転送、明確なアクセスルール、バックアップや復元、クリーンアップが簡単で、利用の増加に応じて費用が予測できること。
そこに到達するために、よく混同されがちな2つを分けて考えます。
メタデータ はファイルに関する小さな情報です:誰が所有しているか、ファイル名、サイズ、タイプ、いつアップロードされたか、どこにあるか。これは検索やフィルタ、ユーザーやプロジェクト、権限との結合が必要なのでデータベース(例えば Postgres)に置くべきです。
ファイルのバイト列 は実際のファイルの中身(写真、PDF、動画)です。データベースのブロブにバイトを入れることは動作しますが、データベースを重くし、バックアップを大きくし、パフォーマンスの予測を難しくします。バイトをオブジェクトストレージに置けば、データベースは得意な部分に集中でき、ファイルはそのために作られた仕組みで高速かつ安価に配信されます。
「アップロードをデータベースに保存する」と言うとき、多くはデータベースのブロブを指します:行内に生のバイトを入れる BYTEA カラムか、Postgres の "large objects" 機能のどちらかです。どちらも動作しますが、ファイルバイトの配信をデータベースに任せることになります。
オブジェクトストレージは別の考え方です:ファイルはバケット内のオブジェクトとして保存され、キー(例:uploads/2026/01/file.pdf のような)で参照します。大きなファイル、安価な保存、ストリーミング配信に向いており、多数の同時読み取りにも強く、データベース接続を占有しません。
Postgres はクエリ、制約、トランザクションに強みがあります。ファイルの所有者や種類、アップロード日時、ダウンロード可否などのメタデータは Postgres に置くのが向いています。メタデータは小さく、インデックスしやすく、一貫性を保ちやすいです。
実用的な経験則:
簡単なチェック:バックアップ、レプリカ、マイグレーションがファイルバイトのせいで面倒になるなら、バイトは Postgres に入れないでください。
多くのチームが最終的に行き着くのは明快な分離です:バイトはオブジェクトストレージに、ファイルのレコード(所有者、種類、保存場所)は Postgres に。API は調整と認可を行いますが、大きなアップロードやダウンロードをプロキシしません。
これにより3つの責務が明確になります:
file_id、所有者、サイズ、Content-Type、オブジェクトポインタなど。その安定した file_id は添付を参照するコメント、PDF を指す請求書、監査ログ、サポートツールなどあらゆるところで主キーになります。ユーザーがファイル名を変えても、バケットを移動しても、file_id は変わりません。
可能なら、格納したオブジェクトは不変として扱いましょう。ユーザーがドキュメントを置き換えるときは、上書きする代わりに新しいオブジェクト(たいていは新しい行かバージョン行)を作ります。これによりキャッシュが単純になり、"古いリンクが新しいファイルを返す" といった驚きが減り、ロールバックも簡単になります。
プライバシーは早めに決めておきます:既定で非公開、例外的に公開。良いルールは、データベースが誰がアクセスできるかの真実の単一ソースであり、オブジェクトストレージは API が付与する短時間の権限を強制するだけにすることです。
分離が明確になれば、Postgres はファイルについて小さな事実を保存し、オブジェクトストレージがバイトを保存します。結果としてデータベースは小さく保たれ、バックアップは速く、クエリは簡単になります。
実用的な uploads テーブルは、"誰が所有しているか"、"どこに保存されているか"、"ダウンロードして安全か" といった現実的な質問に答えるための少数フィールドがあれば十分です。
CREATE TABLE uploads (
id uuid PRIMARY KEY,
owner_id uuid NOT NULL,
bucket text NOT NULL,
object_key text NOT NULL,
size_bytes bigint NOT NULL,
content_type text,
original_filename text,
checksum text,
state text NOT NULL CHECK (state IN ('pending','uploaded','failed','deleted')),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX uploads_owner_created_idx ON uploads (owner_id, created_at DESC);
CREATE INDEX uploads_checksum_idx ON uploads (checksum);
将来の手間を減らすいくつかの判断:
bucket + object_key を使う。アップロード後は不変に保つ。\n- state を追跡する。ユーザーがアップロードを開始したら pending 行を挿入し、オブジェクトの存在とサイズ(可能ならチェックサム)を確認してから uploaded に切り替える。\n- original_filename は表示用に保存するだけ。型やセキュリティ判断の根拠にはしない。差し替えをサポートするなら(例:ユーザーが請求書を再アップロードする場合)、upload_versions のような別テーブルを追加して upload_id、version、object_key、created_at を持たせるとよいです。これにより履歴を残し、間違いをロールバックしやすくなり、古い参照を壊さずに済みます。
API は調整を行い、バイトは扱わせないことでアップロードを高速に保ちます。データベースは応答性を保ち、オブジェクトストレージが帯域の負荷を受けます。
先にアップロードレコードを作り、upload_id、保存先(object_key)と短時間のアップロード権限を返すところから始めます。
一般的なフロー:
pending の行を作り、期待サイズと予定の Content-Type を記録する。\n2. API が事前署名 URL を返す:大きなファイルなら事前署名されたアップロード URL を生成する。小さなファイル(アバター等)ではバックエンド経由でプロキシしてもよい(クライアントコードが単純になる)。\n3. クライアントは直接ストレージにアップロード:ブラウザやモバイルアプリが Bytes を API を通さずにストレージへ送る。\n4. ファイナライズ:クライアントが upload_id とストレージの応答フィールド(ETag 等)を API に送る。サーバはサイズ、チェックサム(使うなら)、Content-Type を検証し、行を uploaded にする。\n5. 安全に失敗扱い:検証が失敗したら failed にし、必要ならオブジェクトを削除する。リトライや重複は普通に起きます。ファイナライズ呼び出しは冪等にしておきましょう:同じ upload_id が2回ファイナライズされても成功を返し、無駄な変更が起きないようにします。
重複を減らすためにチェックサムを保存し、"同じ所有者 + 同じチェックサム + 同じサイズ" を同一ファイルとみなすことができます。
良いダウンロードフローはアプリ側でひとつの安定した URL を持つことから始まります。例:/files/{file_id}。API は file_id で Postgres を参照し、権限をチェックしてから配信方法を決めます。
file_id であなたの安定 URL を要求する。\n2) API はユーザーがアクセスでき、ファイルが uploaded であることを確認する。\n3) API はオブジェクトストレージへのリダイレクトを返すか、非公開ファイルには短時間の事前署名された GET URL を返す。\n4) クライアントはオブジェクトストレージから直接ダウンロードし、API とアプリサーバをホットパスから外す。公開またはセミ公開ファイルにはリダイレクトが簡単で高速です。非公開ファイルには事前署名付き GET URL がストレージを非公開に保ちながらブラウザからの直接ダウンロードを可能にします。
ビデオや大きなダウンロードでは、オブジェクトストレージ(やプロキシ層)がレンジリクエスト(Range ヘッダ)をサポートしていることを確認してください。これによりシークや再開可能なダウンロードが可能になります。API を経由してバイトを流すとレンジサポートが壊れたり、コストが高くなったりします。
キャッシュが速度の鍵です。アプリの安定した /files/{file_id} エンドポイントは通常キャッシュ不可(認可ゲート)にしておき、オブジェクトストレージの応答はコンテンツに応じてキャッシュ可能にします。ファイルが不変であれば長いキャッシュ寿命を設定でき、上書きするなら短くするかバージョン付きキーを使いましょう。
CDN は多くのグローバルユーザーや大きなファイルがある場合に有効です。ユーザーが少数で地域が限られるなら、最初はオブジェクトストレージだけでも十分で安価です。
驚きの請求は通常、保存している生のバイト数ではなくダウンロードやチャーン(重複、不要データ)から生じます。
コストに影響する4つの要因を価格化しましょう:保存量、読み書き頻度(リクエスト数)、プロバイダから外に出るデータ量(エグレス)、CDN を使うかどうか。小さなファイルが 10,000 回ダウンロードされると、誰も触らない大きなファイルよりも高くつくことがあります。
支出を安定させるためのコントロール:
ライフサイクルルールは手っ取り早い勝ち筋です。例えば、オリジナル写真は30日間は "ホット" に保持し、その後安価なストレージクラスに移す。請求書は7年間保持、失敗したアップロードのパーツは7日で削除するといった具合です。基本的な保持方針でもストレージの膨張を止められます。
重複排除は簡単にできます:ファイルメタデータテーブルにコンテンツハッシュ(例:SHA-256)を保存し、所有者ごとに一意性を保つことで、同じ PDF を2回アップロードされたときに既存オブジェクトを再利用してメタデータ行だけ作る、といったことが可能です。
最後に、使用量は既にユーザー会計をしている場所(Postgres)で追跡しましょう。bytes_uploaded、bytes_downloaded、object_count、last_activity_at をユーザーやワークスペースごとに保存すると、UI に制限を表示したり請求前にアラートを出したりしやすくなります。
アップロードのセキュリティは二つに尽きます:誰がファイルにアクセスできるか、問題が起きたときに後で証明できるか。
明確なアクセスモデルを最初に決め、それを Postgres のメタデータにエンコードします。バラバラのサービスに散らばった一時的なルールは後で管理が難しくなります。
多くのアプリをカバーするシンプルなモデル:
非公開ファイルでは生のオブジェクトキーを露出しないようにします。短時間かつスコープ限定の事前署名アップロード/ダウンロード URL を発行し、頻繁にローテーションするのが良いです。
転送中と保存時の暗号化を確認してください。転送中は HTTPS(クライアント→ストレージを含む)で行い、保存時はストレージプロバイダのサーバサイド暗号化を有効にして、バックアップやレプリカも暗号化されていることを確認します。
安全性とデータ品質のためのチェックポイントを追加します:アップロード URL 発行前に Content-Type とサイズを検証し、アップロード後に(ファイル名ではなく実際に保存されたバイトで)再検証します。リスクが高ければ非同期でマルウェアスキャンを実行し、合格するまでファイルを隔離する運用も検討してください。
調査や基本的なコンプライアンス要件に備えて監査用フィールド(uploaded_by、ip、user_agent、last_accessed_at など)を保存しておくと役に立ちます。
データの居住地要件がある場合は、ストレージのリージョンを慎重に選び、計算基盤の配置と一致させてください。
多くのアップロード問題は生の速度そのものの問題ではありません。初期には便利に見えて、実際のトラフィックやデータ、サポートが増えたときに厄介になる設計上の選択が原因です。
invoice.pdf のような衝突や特殊文字の問題が起きます。表示用にはファイル名を保持し、ストレージ用キーはユニークに生成しましょう。\n- ファイナライズ時の検証を省く:クライアント側で検証していても、サーバ側でサイズ、Content-Type、所有権を確認して uploaded にする必要があります。\n- 誤ってオブジェクトを公開にしてローテーションをしない:"一時的" な公開バケットポリシーや長期の URL は恒久化しがちです。短時間のダウンロードリンクを優先し、アクセスをすばやく取り消せる仕組みを持ちましょう。\n- 片方だけ削除する(メタデータかバイトのどちらか):Postgres の行だけ削除してオブジェクトを残すとコストが漏れますし、オブジェクトだけ削除してメタデータを残すと壊れたダウンロードやサポート負荷が発生します。具体例:ユーザーがプロフィール写真を3回置き換えると、古いオブジェクトをずっと保持してしまい料金がかさむことがあります。安全なパターンは Postgres でソフトデリートを行い、バックグラウンドジョブでオブジェクトを削除して結果を記録することです。
最初の大きなファイルが来たとき、ページを更新してアップロードが中断されたとき、アカウントを削除したときにバイトだけ残るといった問題はローンチ前に検出したいものです。
Postgres テーブルにファイルのサイズ、チェックサム(整合性確認用)、明確な状態遷移(例:pending, uploaded, failed, deleted)を記録していることを確認してください。
最終チェックリスト:
uploaded 行を作らないこと。\n- アップロードが再開可能、少なくとも再開しやすいこと(タイムアウトやモバイルネットワークを考慮)。\n- ダウンロードがレンジリクエストに対応していること(大きなファイルをすばやく開始し、中断後も再開できる)。\n- 削除のエンドツーエンド定義:メタデータのトゥームストーン、オブジェクトの削除、ジョブ失敗時の遅延クリーンアップ処理。\n- 基本的なモニタリング:アップロード/ダウンロードのエラー率、ストレージ増加、急なエグレススパイク。具体的なテスト:2GB のファイルをアップロードし、30% のところでページを更新して再開する。その後低速回線でダウンロードし、中間にシークする。どちらかのフローが不安定なら、ローンチ前に直してください。
SaaS アプリではアップロードの種類が大きく分かれます:プロフィール写真(頻繁、小さい、キャッシュしても安全)と PDF 請求書(機密、非公開にしたい)。メタデータを Postgres、バイトをオブジェクトストレージに分けるメリットが最も活きる場面です。
1つの files テーブルにメタデータを置き、挙動に関わるいくつかのフィールドを持たせるとこうなります:
| field | profile photo example | invoice PDF example |
|---|---|---|
kind | avatar | invoice_pdf |
visibility | private (署名付き URL で配信) | private |
cache_control | public, max-age=31536000, immutable | no-store |
object_key | users/42/avatars/2026-01-17T120102Z.webp | orgs/7/invoices/INV-1049.pdf |
status | uploaded | uploaded |
size_bytes | 184233 | 982341 |
ユーザーが写真を置き換えるときは上書きではなく新しいファイルとして扱いましょう。新しい行と object_key を作り、ユーザープロフィールが新しい file_id を指すように更新します。古い行には replaced_by=<new_id>(または deleted_at)を設定し、バックグラウンドジョブで古いオブジェクトを後で削除します。これにより履歴が残り、ロールバックが簡単になり、競合状態が避けられます。
サポートやデバッグもメタデータがストーリーを語るので簡単になります。ユーザーが "アップロードに失敗した" と言ったら、サポートは status、可読な last_error、storage_request_id や etag(ストレージログの追跡用)、タイムスタンプ(停止したか?)、owner_id と kind(アクセス方針は正しいか?)を確認できます。
小さく始めてハッピーパスを地味に安定させましょう:ファイルがアップロードされ、メタデータが保存され、ダウンロードは高速、ファイルが失われない、という状態です。
良い最初のマイルストーンは、最小限の Postgres テーブルと 1 つのアップロードフロー、1 つのダウンロードフローをホワイトボードで説明できることです。それがエンドツーエンドで動いたら、バージョン、クォータ、ライフサイクルルールを追加します。
ファイル種別ごとに一つの明確なストレージポリシーを決めて文書化してください。例えば、プロフィール写真はキャッシュ可、請求書は非公開で短時間のダウンロード URL のみでアクセス、など。1 つのバケットプレフィックスにポリシーを混在させると偶発的な公開の原因になります。
早い段階で計測を入れておきます。初日から欲しい数値は、アップロードファイナライズ失敗率、孤立率(DB 行がないオブジェクトやその逆)、ファイル種別ごとのエグレス量、P95 ダウンロードレイテンシ、平均オブジェクトサイズなどです。
このパターンを素早くプロトタイプしたければ、Koder.ai (koder.ai) はチャットから完全なアプリを生成する仕組みを提供しており、ここで紹介したスタック(React、Go、Postgres)に合致します。スキーマ、エンドポイント、バックグラウンドクリーンアップジョブのスキャフォールドを再実装することなく反復できます。
その先は、一文で説明できることだけ追加してください:"古いバージョンは30日保持する"、"各ワークスペースは10GB" のように。実際の利用が必要にさせるまではシンプルに保ちましょう。
Postgres はクエリやセキュリティに必要なメタデータ(所有者、権限、状態、チェックサム、格納先ポインタ)を保存するために使い、実際のバイトはオブジェクトストレージに置きましょう。こうすることでダウンロードや大容量転送がデータベース接続を消費したり、バックアップが膨れ上がったりするのを防げます。
データベースにファイルバイトを入れると、ファイルサーバの役割まで DB が担うことになり、テーブルサイズの増加、バックアップやリストアの遅延、レプリケーション負荷の増大、同時ダウンロード時の性能の不確実性などの問題が出ます。
はい。アプリ側で安定した file_id を保持し、メタデータは Postgres、バイトは bucket と object_key で指すオブジェクトストレージに置きます。API はアクセスを認可し、ファイル転送のための短時間の権限を渡すだけにして、バイトをプロキシしないようにします。
まず pending 行を作り、ユニークな object_key を生成してからクライアントに短時間の権限を渡して直接ストレージにアップロードさせます。アップロード後、クライアントはファイナライズ用のエンドポイントを呼び出し、サーバでサイズやチェックサムを検証してから行を uploaded に切り替えます。
実際のアップロードは失敗や再試行が起きるので、pending/uploaded/failed/deleted のような状態を持つことで、UI やクリーンアップ処理、サポートが正しく動作します。
original_filename は表示用として扱い、ストレージのキーにはユニークな生成済みキー(UUID ベースのパスなど)を使いましょう。こうすると衝突や特殊文字の問題、セキュリティの落とし穴を避けられます。
アプリ側で /files/{file_id} のような安定した URL を権限ゲートとして用意します。Postgres でアクセスを確認したら、リダイレクトを返すか、非公開ファイルには短時間の署名付き GET URL を発行してクライアントが直接オブジェクトストレージからダウンロードできるようにします。これで API をホットパスから外せます。
ダウンロード(エグレス)と繰り返しアクセスが主な要因です。ファイルサイズの上限やユーザーごとのクォータを設定し、ライフサイクルルールや保持ポリシーを使い、チェックサムで重複排除を行い、使用量カウンタを Postgres に保存して請求やアラートの根拠にしましょう。
最初から Postgres を真の情報源として権限や可視性を管理し、ストレージはデフォルトで非公開にします。アップロード前後のサイズと型の検証、HTTPS での送信、保存時の暗号化、監査用フィールド(uploaded_by、ip、user_agent、last_accessed_at など)を追加することが初日からやるべき基本です。
最小限のメタデータテーブル、直接ストレージへのアップロードフロー、1つのダウンロードゲートエンドポイントで始め、孤立オブジェクトのクリーンアップやソフトデリートのジョブを追加していくのが良いです。React/Go/Postgres スタックで素早くプロトタイプしたければ、Koder.ai (koder.ai) はエンドポイント、スキーマ、バックグラウンドタスクをチャットから生成して反復を早める手段になります。