アップロードされたファイルの保存先は、「本体はオブジェクトストレージかファイルシステムに置き、データベースにはメタデータ(保存キー・元ファイル名・サイズ・ハッシュ・MIMEタイプ)だけを持つ」形が基本です。 本体とメタデータを分けると、データベースのバックアップが軽くなり、配信もストレージ側に任せられます。そのかわり、2か所のずれ(孤立ファイル)を見つける仕組みと、保存キーの決め方を設計に含める必要があります。

この記事は保存場所とキーの設計に絞り、4つの保存先の判断表、保存キーの命名規則と階層分割、files テーブルのDDL、孤立ファイルを検出するSQL、削除と保存期間の設計項目一覧を載せます。受け取ったファイルの検証方法や、署名付きURLの発行手順は扱いません。

確認環境(2026-10-05):Ubuntu 24.04、PostgreSQL 16.15、Python 3.11.15。公式ドキュメントは PostgreSQL 16 の TOAST の章、Amazon S3 ユーザーガイドのプレフィックスとバージョニングのページ、OWASP File Upload Cheat Sheet を同日に開いて確認しました。

この記事の結論

  • 本体はストレージ、DBにはメタデータだけ。 DBに95MB分のファイルを入れて試すと、テキスト形式のバックアップは約200MBになりました。メタデータだけの表は6行で約4KBです。
  • 保存キーはアプリが作る。 元ファイル名は使わず、UUIDと日付を組み合わせ、元の名前はDBの列に持ちます。
  • 1ディレクトリあたりの件数は、UUIDの先頭2文字+次の2文字で分けると抑えられる。 100万件で平均15.3件、最大34件でした。
  • DBとストレージのずれは必ず起きる前提で、両方向の突き合わせSQLを用意する。
  • 削除は「利用者から見えなくする」と「本体を消す」を分け、その間の日数を仕様に書く。

保存先の判断表(早見表)

比べる点 DBのBLOB(bytea など) アプリサーバーのローカルディスク 共有ストレージ(NFS など) オブジェクトストレージ
容量 DBの容量を消費する。PostgreSQL は1つの値が1GBまで そのサーバーのディスクまで 共有ボリュームの容量まで 事実上、容量を気にせず増やせる
バックアップ DBのバックアップに含まれる。件数が増えるとバックアップと復元の時間が延びる サーバーごとに別途必要 ボリューム単位で取る ストレージ側の機能(複製、世代管理)を使う
整合性 メタデータと本体が同じトランザクションで確定する DBと別管理。ずれの検出が必要 DBと別管理。ずれの検出が必要 DBと別管理。ずれの検出が必要
配信 アプリがDBから読み出して返す アプリまたはWebサーバーが返す アプリまたはWebサーバーが返す ストレージやCDNから直接返せる
サーバーを増やすとき そのまま増やせる 別のサーバーからファイルが見えない そのまま増やせる そのまま増やせる
向いている場面 小さなファイルが少数で、本体とデータを必ず同時に確定させたい サーバー1台の小さなシステム、試作 既存の共有ボリュームがあり、複数サーバーから同じパスで読みたい 件数や容量が増えていくシステム全般

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「ファイルアップロードの保存先設計|DBかストレージかの判断表と命名規則」

条件から選ぶときは、次の表を上から順に見ます。

条件 選ぶもの
ファイルが数十KB以下で件数も少なく、データと必ず同時に保存・取り消しをしたい DBのBLOB
サーバーは1台で、当面増やす予定がない ローカルディスク(保存キーの形はオブジェクトストレージと同じにしておく)
複数サーバーで動かし、共有ボリュームがすでにある 共有ストレージ
容量や件数の見通しが立たない、利用者へ直接配信したい オブジェクトストレージ
迷ったとき オブジェクトストレージ+DBにメタデータ

どの保存先でも、保存キーの形と files テーブルを同じにしておくと、後から保存先を移すときにアプリの変更が小さく済みます。

DBに本体を入れると何が起きるか

PostgreSQL 16 で、約4.8MBのランダムなデータを20行(合計95MB)bytea 列に入れて、大きさを測りました。

CREATE TABLE blob_files (id int PRIMARY KEY, body bytea NOT NULL);
-- 20行、合計95MBを INSERT した後
SELECT count(*),
       pg_size_pretty(sum(octet_length(body)))               AS body_total,
       pg_size_pretty(pg_total_relation_size('blob_files')) AS table_total,
       pg_size_pretty(pg_relation_size('blob_files'))       AS heap_only
FROM blob_files;
 count | body_total | table_total | heap_only  
-------+------------+-------------+------------
    20 | 95 MB      | 99 MB       | 8192 bytes
(1 row)

テーブル本体(heap_only)は8KBで、残りは TOAST という別の領域に入っています。PostgreSQL の公式ドキュメントによると、行が約2KBを超えると値を圧縮するか別の領域へ出す仕組みが働き、値は約2,000バイトの断片に分けて保存されます。1つの値の上限は1GBです。

この表を pg_dump で取り出した大きさは次のとおりでした。

取り出し方 大きさ
pg_dump(テキスト形式) 200,001,441 バイト(約200MB)
pg_dump -Fc(カスタム形式、圧縮あり) 114,034,701 バイト(約114MB)
参考:メタデータだけの files テーブル(6行) 3,861 バイト

テキスト形式では、bytea が16進の文字で書き出されるため元の約2倍になります。 DBに本体を入れる設計は整合性の面で分かりやすい一方、バックアップの大きさと時間がファイルの総量に比例して増えていきます。本体を外に出すと、DBのバックアップはメタデータの分だけで済みます。

保存キーの命名規則

保存キーは、ストレージの中でファイルの場所を表す文字列です。利用者が付けた名前は使わず、アプリが生成します。 OWASP File Upload Cheat Sheet も、ファイル名をアプリが生成したものに変えること、UUIDのようなランダムな文字列を使うこと、保存先を別のホストかWebの公開領域の外にすることを挙げています。

部品 例 役割
種別 attachments 用途ごとに権限や保存期間を分ける単位
日付 2026/10/05 期間での一覧・一括削除・移行をしやすくする
分散用の2文字 65 1ディレクトリあたりの件数を抑える(UUIDの先頭2文字)
UUID 6567e417855f46daa4ef31fbb565cc52 重複しない名前。推測もしにくい
拡張子 .pdf 許可リストにあるものだけ。小文字にそろえる

元ファイル名を保存キーに使わない理由は4つあります。

元ファイル名を使うと起きること 保存キーをアプリが作ると
同じ名前のファイルが上書きし合う UUIDなので重ならない
日本語・空白・記号がURLやOSごとに違う扱いになる 英小文字・数字・スラッシュ・ピリオドだけになる
名前にパスの区切りが入っていると、意図しない場所を指す 入力の文字列がキーに入らない
名前の長さがまちまちで、上限に当たることがある 長さが一定になる

キーを作るPythonコードと実行結果

標準ライブラリだけで動きます。後半で、100万件を保存したときの1ディレクトリあたりの件数も数えています。

"""保存キーの生成と、1ディレクトリあたりの件数の確認(標準ライブラリのみ)
実行: python3 storage_key.py
"""
import collections
import hashlib
import uuid
from datetime import date
from pathlib import PurePosixPath

ALLOWED_EXT = {".pdf", ".png", ".jpg", ".csv", ".xlsx"}


def make_key(original_name: str, day: date, kind: str = "attachments") -> str:
    """保存キーを作る。元ファイル名からは、許可リストにある拡張子だけを受け取る。"""
    ext = PurePosixPath(original_name).suffix.lower()
    if ext not in ALLOWED_EXT:
        ext = ".bin"
    u = uuid.uuid4().hex                       # 32文字の16進
    return f"{kind}/{day:%Y/%m/%d}/{u[:2]}/{u}{ext}"


day = date(2026, 10, 5)
for name in ["見積書 最終版(2).PDF", "../../etc/passwd", "photo.JPG", "memo"]:
    print(f"{name!r:28} -> {make_key(name, day)}")

# メタデータに持つハッシュとサイズ
body = b"hello, storage\n"
print("size_bytes:", len(body))
print("sha256    :", hashlib.sha256(body).hexdigest())

# 100万件を保存したときの、1ディレクトリあたりの件数
N = 1_000_000
hexes = [uuid.uuid4().hex for _ in range(N)]
for label, width in [("分割なし", 0), ("先頭2文字(256個)", 2),
                     ("2文字+2文字(65,536個)", 4), ("3文字+3文字(約1,678万個)", 6)]:
    c = collections.Counter(h[:width] for h in hexes)
    print(f"{label:22} ディレクトリ数={len(c):>9,} 平均={N/len(c):>12,.1f} 最大={max(c.values()):>9,}")

実行結果(UUIDは乱数なので、実行のたびに変わります):

'見積書 最終版(2).PDF'             -> attachments/2026/10/05/65/6567e417855f46daa4ef31fbb565cc52.pdf
'../../etc/passwd'           -> attachments/2026/10/05/54/545f6fb7392a413391a0bae5a4f10881.bin
'photo.JPG'                  -> attachments/2026/10/05/60/60cb818c9ed148c4bd6cbcaeb3d5a003.jpg
'memo'                       -> attachments/2026/10/05/75/75fb067438f44e159a412e97d0bcce82.bin
size_bytes: 15
sha256    : c0dca2173bf10709489d1bacbcb9f8686667bb5c3e796189006e94d46824938e
分割なし                   ディレクトリ数=        1 平均= 1,000,000.0 最大=1,000,000
先頭2文字(256個)            ディレクトリ数=      256 平均=     3,906.2 最大=    4,090
2文字+2文字(65,536個)       ディレクトリ数=   65,536 平均=        15.3 最大=       34
3文字+3文字(約1,678万個)      ディレクトリ数=  970,706 平均=         1.0 最大=        4

日本語や空白、パスの区切りが入った名前でも、キーに入るのは許可リストにある拡張子だけです。拡張子がないものや許可リストにないものは .bin になります。

階層分割の選び方

ファイルシステムに置く場合、1つのディレクトリにファイルが集まりすぎると、一覧の取得やバックアップの走査に時間がかかるようになります。上の結果を表にまとめます。

分け方 ディレクトリ数 100万件のときの平均 最大 向いている件数の目安
分割なし 1 1,000,000 1,000,000 数千件まで
先頭2文字 256 3,906.2 4,090 数十万件まで
2文字+2文字 65,536 15.3 34 数千万件まで
3文字+3文字 970,706(使われた分) 1.0 4 100万件では分けすぎ

3文字+3文字では、100万件のファイルに対して約97万個のディレクトリができ、ほぼ1ファイル1ディレクトリになりました。 細かく分けるほどよいわけではなく、見込みの件数から「1ディレクトリあたり数百〜数千件」に収まる深さを選びます。「向いている件数の目安」は、1ディレクトリあたり数千件までに収めるとした場合の例です。

日付プレフィックスと分散用の文字は役割が違うので、組み合わせて使います。

部品 得意なこと 単独だと困ること
日付(2026/10/05) 期間ごとの一覧、保存期間を過ぎた分の一括削除、月単位の移行 1日の件数が多いと、その日のディレクトリに集中する
分散用の文字(UUIDの先頭) 件数を均等に散らす 期間で絞り込めない

オブジェクトストレージではプレフィックスとして働く

オブジェクトストレージにはディレクトリがありません。Amazon S3 のユーザーガイドは、プレフィックスを「オブジェクトのキー名の先頭にある文字列」と説明し、ディレクトリのようにデータを整理できるがディレクトリそのものではない、としています。区切り文字(スラッシュなど)を指定して一覧を取ると、同じプレフィックスを持つキーをまとめて1階層ずつたどれます。キー名の長さは1,024バイトまでです。

このため、オブジェクトストレージでは「1ディレクトリあたりの件数」よりも、どの単位で一覧・権限・保存期間のルールを掛けたいかでプレフィックスを決めます。種別と日付を先頭に置く形は、ファイルシステムでもオブジェクトストレージでもそのまま使えます。

メタデータを持つ files テーブルのDDL

PostgreSQL 16 で実行したDDLです。

CREATE TABLE files (
  id            uuid        PRIMARY KEY DEFAULT gen_random_uuid(),
  storage       text        NOT NULL DEFAULT 'main',     -- 保存先の名前(バケットやボリュームの識別子)
  storage_key   text        NOT NULL,                    -- 保存先の中での場所。アプリが生成する
  original_name text        NOT NULL,                    -- 利用者が付けた名前。表示とダウンロード時にだけ使う
  content_type  text        NOT NULL,                    -- サーバー側で判定した MIME タイプ
  size_bytes    bigint      NOT NULL CHECK (size_bytes >= 0),
  sha256        char(64)    NOT NULL,                    -- 本体のハッシュ(16進64文字)
  status        text        NOT NULL DEFAULT 'pending'
                CHECK (status IN ('pending', 'active', 'deleted')),
  uploaded_by   bigint      NOT NULL,
  created_at    timestamptz NOT NULL DEFAULT now(),
  deleted_at    timestamptz,                             -- 利用者が削除した時刻
  purge_after   timestamptz,                             -- この時刻を過ぎたら本体を消してよい
  CONSTRAINT files_storage_key_key UNIQUE (storage, storage_key)
);
CREATE INDEX files_sha256_idx ON files (sha256);
CREATE INDEX files_purge_idx  ON files (purge_after) WHERE status = 'deleted';

-- 業務データからの参照(例:備品予約の申請に付ける添付)
CREATE TABLE request_attachments (
  request_id bigint NOT NULL,
  file_id    uuid   NOT NULL REFERENCES files(id),
  PRIMARY KEY (request_id, file_id)
);
列 持つ理由
storage 保存先を移すとき、移行前と移行後の行を区別できる
storage_key 本体の場所。storage と合わせて一意にする
original_name 画面表示とダウンロード時のファイル名に使う。保存場所には使わない
content_type 配信するときのヘッダーに使う。利用者の申告ではなくサーバー側で判定した値を入れる
size_bytes 容量の集計と、ストレージ側との突き合わせに使う
sha256 本体が変わっていないかの確認と、同じ内容の重複の検出に使う
status 保存の途中(pending)、利用中(active)、削除済み(deleted)を分ける
purge_after 本体を消してよい時刻。削除から本体の消去までの猶予を表す

業務のテーブルは files の id を参照します。保存キーを業務テーブルに直接書かないことで、保存先やキーの形を変えても業務データを書き換えずに済みます。

保存の順番で、ずれ方が決まる

DBとストレージは同じトランザクションにできないので、どちらを先に書くかで、途中で止まったときに残るものが変わります。

順番 途中で止まったときに残るもの 見つけやすさ
1. DBに pending で登録 → 2. 本体を保存 → 3. active に更新 pending のままの行(本体はあるかもしれないし、ないかもしれない) DBを見れば分かる
1. 本体を保存 → 2. DBに登録 DBに行がない本体 ストレージ全体の一覧と突き合わせないと分からない

先にDBへ pending で登録する順番にしておくと、止まった処理の後片付けを「一定時間を過ぎた pending の行」という条件で探せます。

孤立ファイルを検出するSQL

ストレージ側の一覧(キーとサイズ)を作業用テーブルに取り込み、files と突き合わせます。

-- 保存先の一覧を取り込む作業用テーブル(棚卸しのたびに入れ替える)
CREATE TABLE storage_inventory (
  storage     text   NOT NULL,
  storage_key text   NOT NULL,
  size_bytes  bigint NOT NULL,
  listed_at   timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (storage, storage_key)
);

確認用に、files に6行、request_attachments に3行、storage_inventory に6行を入れました(うち1件はDBにないキー、1件はストレージにないキー、1件はサイズ違いにしてあります)。以下の出力の時刻は UTC で表示されています。

A. ストレージにあるが、DBに行がない

SELECT i.storage_key, i.size_bytes
FROM storage_inventory i
LEFT JOIN files f ON f.storage = i.storage AND f.storage_key = i.storage_key
WHERE f.id IS NULL;
            storage_key             | size_bytes 
------------------------------------+------------
 attachments/2026/10/04/99/99ee.pdf |      45000
(1 row)

B. DBに行があるが、ストレージにない

SELECT f.id, f.storage_key, f.status
FROM files f
LEFT JOIN storage_inventory i ON i.storage = f.storage AND i.storage_key = f.storage_key
WHERE i.storage_key IS NULL AND f.status <> 'deleted';
                  id                  |            storage_key             | status 
--------------------------------------+------------------------------------+--------
 00000000-0000-4000-8000-000000000003 | attachments/2026/10/02/0d/0d41.pdf | active
(1 row)

C. 両方にあるが、サイズが違う

SELECT f.storage_key, f.size_bytes AS db_size, i.size_bytes AS storage_size
FROM files f JOIN storage_inventory i ON i.storage = f.storage AND i.storage_key = f.storage_key
WHERE f.size_bytes <> i.size_bytes;
            storage_key             | db_size | storage_size 
------------------------------------+---------+--------------
 attachments/2026/10/01/b2/b27c.png |   80000 |        79000
(1 row)

D. どの業務データからも参照されていない

登録から24時間を過ぎても、どの申請にも付いていないファイルです。基準の時刻は、確認用に 2026-10-05 0時(日本時間)で固定しています。

SELECT f.id, f.storage_key, f.status, f.created_at
FROM files f
WHERE f.status IN ('pending', 'active')
  AND f.created_at < timestamptz '2026-10-05 00:00+09' - interval '24 hours'
  AND NOT EXISTS (SELECT 1 FROM request_attachments a WHERE a.file_id = f.id);
                  id                  |            storage_key             | status  |       created_at       
--------------------------------------+------------------------------------+---------+------------------------
 00000000-0000-4000-8000-000000000003 | attachments/2026/10/02/0d/0d41.pdf | active  | 2026-10-02 01:00:00+00
 00000000-0000-4000-8000-000000000004 | attachments/2026/10/02/7e/7e55.csv | pending | 2026-10-02 02:00:00+00
(2 rows)

参照するテーブルが増えたら、NOT EXISTS の条件をテーブルの数だけ足します。

見つかったときの扱い

種類 起きる原因の例 扱い
A. ストレージだけにある 本体の保存後にDB登録が止まった、DBをバックアップから戻した 一定期間(例:7日)置いてから本体を消す。すぐには消さない
B. DBだけにある 本体の保存が途中で止まった、手作業で本体を消した 利用者に見える状態なら再アップロードを依頼し、行を deleted にする
C. サイズが違う 保存が途中で切れた、別の内容で上書きされた ハッシュを計算し直して確かめ、Bと同じ扱いにする
D. 参照がない 添付の画面でアップロードだけして保存しなかった 行を deleted にし、purge_after を設定する

Aをすぐに消さないのは、アップロードの途中(本体は保存済みでDB登録の直前)のファイルを消さないためです。

削除と保存期間の設計

削除は2段階に分けます。利用者の操作では status を deleted にするだけにし、本体は purge_after を過ぎてから定期処理で消します。

-- 本体を消してよい行(基準の時刻は確認用に固定)
SELECT id, storage_key, purge_after FROM files
WHERE status = 'deleted' AND purge_after <= timestamptz '2026-10-05 00:00+09';
                  id                  |            storage_key             |      purge_after       
--------------------------------------+------------------------------------+------------------------
 00000000-0000-4000-8000-000000000005 | attachments/2026/08/20/c1/c1d8.pdf | 2026-10-01 00:00:00+00
(1 row)

定期処理は、本体を消してから files の行を消す(または消去済みの印を付ける)順にします。逆の順で途中で止まると、DBに行のない本体が残ります。

業務データから参照されている files の行は、外部キーがあるので消せません。

DELETE FROM files WHERE id = '00000000-0000-4000-8000-000000000001';
ERROR:  update or delete on table "files" violates foreign key constraint "request_attachments_file_id_fkey" on table "request_attachments"
DETAIL:  Key (id)=(00000000-0000-4000-8000-000000000001) is still referenced from table "request_attachments".

削除と保存期間の設計項目一覧

No 項目 決めること 記入例(備品予約システムの添付)
1 削除できる人 本人、管理者、だれも削除できない、のどれか 申請者本人と管理者
2 削除の操作で起きること status の変更だけか、本体もすぐ消すか status を deleted にするだけ
3 本体を消すまでの猶予 削除から何日後に本体を消すか 30日
4 猶予中の復元 できるか、だれができるか 管理者が復元できる
5 保存期間 削除されなくても、いつまで持つか 申請の完了から5年
6 保存期間を過ぎたときの扱い 自動で消すか、一覧を出して人が判断するか 月次の定期処理で deleted にする
7 参照元が消えたとき 申請が削除されたら添付も消すか 申請の削除と同時に deleted にする
8 保存途中のファイル pending のまま何時間で片付けるか 24時間
9 上書きの扱い 同じ添付を差し替えるとき、前の版を残すか 新しい行を作り、前の行は deleted にする
10 世代管理 ストレージ側の世代管理を使うか、何世代・何日残すか 使う。以前の版は30日で消す
11 同じ内容の重複 sha256 が同じファイルを1つにまとめるか まとめない(集計だけ行う)
12 棚卸し 突き合わせSQLをどの頻度で流すか 週1回
13 消去の記録 だれが・いつ・どのキーを消したかを残すか 定期処理のログに残す

表計算ソフトに貼れるタブ区切りです。

No  項目  決めること   記入例
1   削除できる人  本人、管理者、だれも削除できない、のどれか   申請者本人と管理者
2   削除の操作で起きること statusの変更だけか、本体もすぐ消すか   statusをdeletedにするだけ
3   本体を消すまでの猶予  削除から何日後に本体を消すか  30日
4   猶予中の復元  できるか、だれができるか    管理者が復元できる
5   保存期間    削除されなくても、いつまで持つか    申請の完了から5年
6   保存期間を過ぎたときの扱い   自動で消すか、一覧を出して人が判断するか    月次の定期処理でdeletedにする
7   参照元が消えたとき   申請が削除されたら添付も消すか 申請の削除と同時にdeletedにする
8   保存途中のファイル   pendingのまま何時間で片付けるか 24時間
9   上書きの扱い  差し替えるとき、前の版を残すか 新しい行を作り、前の行はdeletedにする
10  世代管理    ストレージ側の世代管理を使うか、何世代・何日残すか   使う。以前の版は30日で消す
11  同じ内容の重複 sha256が同じファイルを1つにまとめるか  まとめない(集計だけ行う)
12  棚卸し 突き合わせSQLをどの頻度で流すか   週1回
13  消去の記録   だれが・いつ・どのキーを消したかを残すか    定期処理のログに残す

世代管理を使うときに決めること

保存キーをUUIDで作る設計では、同じキーへの上書きは通常起きません。それでも、誤操作による消去から戻せるようにストレージ側の世代管理を使うことがあります。Amazon S3 のユーザーガイドによると、バージョニングを有効にしたバケットでは次のように動きます。

操作 起きること
上書き 新しいバージョンが作られ、以前のバージョンに戻せる
削除 オブジェクトは完全には消えず、削除マーカーが現在のバージョンになる
有効にした後 バージョニングなしの状態には戻せない(一時停止はできる)

世代管理を有効にすると、「消したつもりの本体」が以前のバージョンとして残ります。 同ガイドは、期限で消すルールを使っていたバケットでバージョニングを有効にする場合、現在ではないバージョンを消すルールを追加する必要があるとしています。設計項目の「本体を消すまでの猶予」と、ストレージ側の「以前のバージョンを残す日数」は、足し合わせた日数で考えます。

同じ内容のファイルを数える

sha256 を持っておくと、同じ内容が何件あるかを集計できます。

SELECT sha256, count(*) AS n, sum(size_bytes) AS total_bytes FROM files
WHERE status = 'active' GROUP BY sha256 HAVING count(*) > 1;
                              sha256                              | n | total_bytes 
------------------------------------------------------------------+---+-------------
 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa | 2 |      240000
(1 row)

重複を1つの本体にまとめると容量は減りますが、片方の削除でもう片方の本体まで消さないよう、参照の数を数える仕組みが要ります。まずは集計だけにして、容量に占める割合が大きいと分かってからまとめる、という順番が無理のない進め方です。

確かめて分かったこと

  • 95MBの bytea を入れたテーブルは、本体の領域が8KBで、ほぼ全部が TOAST の領域に入っていた。 行の一覧を読む速さには響きにくい一方、バックアップには全量が含まれます。
  • pg_dump のテキスト形式は、bytea を入れた表で元の約2倍(200MB)になった。 カスタム形式でも114MBでした。
  • UUIDの先頭4文字で分けると、100万件で最大でも34件に収まった。 6文字まで分けると約97万個のディレクトリができ、分けすぎになります。
  • 「../../etc/passwd」という名前から取れる拡張子は空で、キーは .bin になった。 元の名前の文字列は、キーのどこにも入りません。
  • ずれの検出は3種類のSQLが必要だった。 ストレージだけにある、DBだけにある、両方にあるがサイズが違う、の3つは別々の問い合わせでないと出てきません。
  • 保存キーに一意制約を付けておくと、同じキーの二重登録はエラーで止まる。 PostgreSQL のメッセージは duplicate key value violates unique constraint "files_storage_key_key" でした。

よくある質問

Q. アップロードしたファイルはDBに保存してもよいですか? A. 小さなファイルが少数なら選べます。本体とデータを同じトランザクションで確定できるのが利点です。件数や容量が増える見込みがあるなら、DBのバックアップが総量に比例して大きくなるため、本体はストレージに置き、DBにはメタデータだけを持つ形が向いています。

Q. 保存するときのファイル名は、元のファイル名のままでよいですか? A. 保存キーはアプリが生成し、元のファイル名はDBの列に持つのがおすすめです。同じ名前の上書き、日本語や記号の扱いの違い、パスの区切りの混入をまとめて避けられます。ダウンロード時には、DBの original_name をファイル名として返します。

Q. ディレクトリはどのくらいの深さで分ければよいですか? A. 見込みの件数から、1ディレクトリあたり数百〜数千件に収まる深さにします。UUIDの先頭2文字で256分割、4文字で65,536分割です。100万件なら2文字+2文字で平均15件ほどになります。

Q. 孤立ファイルとは何ですか? A. DBのメタデータとストレージの本体の片方だけが残った状態のファイルです。保存や削除が途中で止まったときに生まれます。ストレージの一覧をテーブルに取り込み、LEFT JOIN で両方向から突き合わせると見つかります。

Q. ファイルを削除できないときは何を確認すればよいですか? A. 業務データからの参照が残っていないかを確認します。外部キーがあると、参照されている files の行は消せません。先に参照元を消すか、行は残したまま status を deleted にして本体だけを後で消す設計にします。

まとめ

  • 本体はオブジェクトストレージかファイルシステム、DBにはメタデータ(保存キー・元ファイル名・サイズ・ハッシュ・MIMEタイプ)だけを持つ。
  • DBに本体を入れると、バックアップがファイルの総量に比例して大きくなる(95MBで、テキスト形式のダンプは約200MB)。
  • 保存キーは「種別/日付/分散用の2文字/UUID+拡張子」でアプリが作り、元ファイル名は使わない。
  • 階層分割は見込みの件数で決める。 100万件なら2文字+2文字で最大34件。
  • 保存は「DBに pending → 本体 → active」の順にすると、止まった処理をDBから探せる。
  • ずれの検出は、ストレージだけ・DBだけ・サイズ違い・参照なしの4つのSQLで行う。
  • 削除は2段階にし、猶予の日数、保存期間、世代管理の日数を設計項目一覧に書く。

参考・出典

  • PostgreSQL 16 Documentation: TOAST — https://www.postgresql.org/docs/16/storage-toast.html
  • Amazon S3 User Guide: Organizing objects using prefixes — https://docs.aws.amazon.com/AmazonS3/latest/userguide/using-prefixes.html
  • Amazon S3 User Guide: Retaining multiple versions of objects with S3 Versioning — https://docs.aws.amazon.com/AmazonS3/latest/userguide/Versioning.html
  • OWASP Cheat Sheet Series: File Upload Cheat Sheet — https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html

(いずれも 2026-10-05 に確認)

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「ファイルアップロードの保存先設計|DBかストレージかの判断表と命名規則」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?