檔案上傳系統設計 (2) —— 資料庫設計與目錄結構
在上篇中,我們處理了「如何將一個檔案安全地存進硬碟」。但當你的網站開始成長,累積了 1 萬、10 萬甚至 100 萬張圖片時,簡單的 ./uploads 資料夾就會變成效能噩夢。
這一篇,我們要討論如何從資料庫層級與作業系統路徑規劃,建立一個「可擴展」的檔案系統架構。
一、 迷思破解:為何不把檔案直接存進資料庫 (BLOB)?
新手常會想:「既然資料庫可以存二進制 (BLOB),那把圖片通通塞進資料庫不是最方便管理嗎?還不用擔心檔案遺失的問題。」
事實是:絕對不建議這麼做。
- 資料庫備份災難:如果資料庫裡塞了 500GB 的圖片,每次備份或進行主從同步 (Replication) 都會慢到讓你懷疑人生。
- 記憶體佔用:資料庫通常會優化索引查找。大體積的欄位會導致緩衝池 (Buffer Pool) 效率下降,嚴重拖慢一般查詢的效能。
- 擴展性差:檔案通常應該交由專用的「物件儲存」或「檔案系統」處理,利用 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 - 優點: 檔案分配均勻,不會因為某一天上傳特別多檔案而導致單個資料夾過載。
四、 關鍵邏輯:資料庫與檔案的一致性
處理檔案時,最怕「資料庫有紀錄,但硬碟沒檔案」或是反過來。
推薦的操作流程:
- 先寫入硬碟:將檔案流寫入指定目錄,並取得最終路徑。
- 再寫入資料庫:如果資料庫寫入失敗(例如欄位長度超限),記得要 刪除剛剛寫好的檔案 (Cleanup),否則會產生孤兒檔案。
javascript
try {
const fileInfo = await saveToDisk(req.file);
await db.files.create(fileInfo);
} catch (error) {
await deleteFromDisk(fileInfo.path); // 失敗時的回滾機制
throw error;
}總結
本篇我們學到了:
- 不要把檔案直接存進資料庫,資料庫層只負責「指路」。
- Schema 設計要區分「原始名」與「儲存名」,並紀錄完整的 MIME 資訊。
- 利用時間或 Hash 分層目錄,避免單一資料夾檔案過多的效能瓶頸。
下一篇,我們將進入最硬核的部分:安全性防禦與驗證。我們將學習如何防止駭客上傳惡意腳本 (Web Shell) 並保護你的伺服器。
️ 進階挑戰
如果一個使用者刪除了他的照片,你應該立即從硬碟刪除該檔案嗎?還是使用「軟刪除」先標記起來?各別的優缺點是什麼?
延伸閱讀與資源
- AWS Architecture Blog: Storage Best Practices
- Database Indexing: UUID vs Serial