跳至主要內容
Skip to content

檔案上傳系統設計 (2) —— 資料庫設計與目錄結構

在上篇中,我們處理了「如何將一個檔案安全地存進硬碟」。但當你的網站開始成長,累積了 1 萬、10 萬甚至 100 萬張圖片時,簡單的 ./uploads 資料夾就會變成效能噩夢。

這一篇,我們要討論如何從資料庫層級與作業系統路徑規劃,建立一個「可擴展」的檔案系統架構。


一、 迷思破解:為何不把檔案直接存進資料庫 (BLOB)?

新手常會想:「既然資料庫可以存二進制 (BLOB),那把圖片通通塞進資料庫不是最方便管理嗎?還不用擔心檔案遺失的問題。」

事實是:絕對不建議這麼做。

  1. 資料庫備份災難:如果資料庫裡塞了 500GB 的圖片,每次備份或進行主從同步 (Replication) 都會慢到讓你懷疑人生。
  2. 記憶體佔用:資料庫通常會優化索引查找。大體積的欄位會導致緩衝池 (Buffer Pool) 效率下降,嚴重拖慢一般查詢的效能。
  3. 擴展性差:檔案通常應該交由專用的「物件儲存」或「檔案系統」處理,利用 Nginx 直接吐靜態資源,而不是讓耗費資源的應用程式碼去資料庫抓出來再傳給前端。

IMPORTANT

正確做法: 資料庫只存「檔案的中繼資料 (Metadata)」和「物理路徑」,真正的二進制數據存在磁碟上。


二、 資料庫 Schema 設計

一個好的 files 資料表應該包含哪些欄位?這裡有一份實戰建議:

sql
CREATE TABLE files (
    id UUID PRIMARY KEY,
    original_name VARCHAR(255) NOT NULL, -- 使用者看到的檔名 (如: my_cat.jpg)
    stored_name VARCHAR(255) NOT NULL,   -- 硬碟上的真實檔名 (如: a1b2...c3d4.jpg)
    file_path VARCHAR(512) NOT NULL,     -- 相對路徑 (如: /2025/12/26/)
    mime_type VARCHAR(100) NOT NULL,     -- 檔案類型 (如: image/jpeg)
    file_size BIGINT NOT NULL,           -- 檔案大小 (Bytes)
    uploader_id INT NOT NULL,            -- 誰上傳的
    created_at TIMESTAMP DEFAULT NOW(),
    deleted_at TIMESTAMP NULL            -- 軟刪除標記
);

為什麼要有兩個檔名?

  • original_name:這是使用者上傳時的稱呼。當使用者點擊「下載」時,你應該把 header 的 Content-Disposition 設為這個名字,讓他們感覺檔案沒變。
  • stored_name:這是我們在第一篇提到的 UUID,用來確保物理儲存不會衝突。

三、 檔案目錄結構策略

如果你把 10 萬個檔案全都塞在同一個 /uploads 資料夾下,作業系統在搜尋檔案時會變得極慢(因為目錄索引過大)。

以下是兩種主流的優化方案:

1. 時間分區法 (Time-based Partitioning)

這是最常見的做法,適合大多數應用。

  • 路徑格式: /uploads/YYYY/MM/DD/filename.ext
  • 範例: /uploads/2025/12/26/a1b2.jpg
  • 優點: 結構直覺,方便依時間進行冷熱數據備份(例如備份去年的資料)。

2. Hash 分區法 (Hash-based Partitioning)

如果你的上傳頻率極高,或不希望檔案路徑與時間掛鉤,可以使用 Hash 分區。

  • 路徑格式: /uploads/{hash_prefix1}/{hash_prefix2}/filename.ext
  • 範例: 假設 UUID 是 e98e...,路徑就是 /uploads/e9/8e/e98ebf21...jpg
  • 優點: 檔案分配均勻,不會因為某一天上傳特別多檔案而導致單個資料夾過載。

四、 關鍵邏輯:資料庫與檔案的一致性

處理檔案時,最怕「資料庫有紀錄,但硬碟沒檔案」或是反過來。

推薦的操作流程:

  1. 先寫入硬碟:將檔案流寫入指定目錄,並取得最終路徑。
  2. 再寫入資料庫:如果資料庫寫入失敗(例如欄位長度超限),記得要 刪除剛剛寫好的檔案 (Cleanup),否則會產生孤兒檔案。
javascript
try {
  const fileInfo = await saveToDisk(req.file);
  await db.files.create(fileInfo);
} catch (error) {
  await deleteFromDisk(fileInfo.path); // 失敗時的回滾機制
  throw error;
}

總結

本篇我們學到了:

  1. 不要把檔案直接存進資料庫,資料庫層只負責「指路」。
  2. Schema 設計要區分「原始名」與「儲存名」,並紀錄完整的 MIME 資訊。
  3. 利用時間或 Hash 分層目錄,避免單一資料夾檔案過多的效能瓶頸。

下一篇,我們將進入最硬核的部分:安全性防禦與驗證。我們將學習如何防止駭客上傳惡意腳本 (Web Shell) 並保護你的伺服器。


️ 進階挑戰

如果一個使用者刪除了他的照片,你應該立即從硬碟刪除該檔案嗎?還是使用「軟刪除」先標記起來?各別的優缺點是什麼?


延伸閱讀與資源

← 上一章:底層原理與本地儲存 | 返回專案首頁 | 下一章:安全性防禦與驗證