「奇怪,我明明刪了 52 萬筆資料,為什麼資料表還是 207MB?」
這是我今天在清理資料庫時遇到的真實情況。如果你也曾經困惑過這個問題,這篇文章會告訴你背後的原因。
事情是這樣的
專案的 user_notifications 資料表累積了幾十萬筆推播通知記錄,佔用了 207MB 空間。為了控制資料庫大小,我寫了一個 cron job 來清理超過 7 天的舊資料:
// 刪除 7 天前的通知
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
await strapi.db.query('api::user-notification.user-notification').deleteMany({
where: { createdAt: { $lt: sevenDaysAgo.toISOString() } },
});
執行結果很漂亮:
[Cleanup] Successfully deleted 521604 old user notifications
刪除了 521,604 筆!只剩下約 2 萬筆近期資料。
但當我打開 DBeaver 檢查時…

207MB?資料都刪了,空間怎麼沒變?
為什麼會這樣?理解 PostgreSQL 的 MVCC
這不是 bug,而是 PostgreSQL 的設計特性。
DELETE 不是真的刪除
PostgreSQL 使用 MVCC(Multi-Version Concurrency Control) 來處理並發交易。當你執行 DELETE 時,PostgreSQL 不會真的把資料從磁碟上移除,而是:
- 將該行標記為「已刪除」(稱為 dead tuple)
- 保留原始資料,直到沒有任何交易需要參照它
- 新的查詢看不到這些行,但它們仍佔用磁碟空間
為什麼要這樣設計?
- 效能考量:標記刪除比實際移除資料快非常多
- 並發安全:其他正在執行的 transaction 可能還需要看到舊版本
- ACID 保證:確保 transaction isolation
不處理會怎樣?
- 磁碟空間不會釋放,資料庫持續膨脹
- 查詢效能逐漸下降(需要跳過大量 dead tuples)
- Index 也會膨脹,進一步影響效能
用圖來理解整個流程
這張圖說明了為什麼 DELETE 後空間不會立即釋放——資料只是變成 dead tuple,需要 VACUUM 才能處理。
SQLite 呢?比較兩者的差異
你可能會想:「SQLite 也是這樣嗎?」
答案是:類似但不完全相同。
| 特性 | PostgreSQL | SQLite |
|---|---|---|
| DELETE 行為 | 標記為 dead tuple (MVCC) | 標記頁面為 free |
| 空間處理 | 需要 VACUUM 清理 | 需要 VACUUM 釋放 |
| 並發控制 | MVCC(多版本並存) | 檔案級鎖定 |
| VACUUM 方式 | 原地清理或完整重寫 | 完整重寫檔案 |
| 自動清理 | autovacuum 背景執行 | 需手動或設定 auto_vacuum |
SQLite 的情況:
-- SQLite 刪除後,空間也不會立即釋放
DELETE FROM notifications WHERE created_at < '2025-12-24';
-- 查看資料庫檔案大小 - 沒變!
-- 需要執行 VACUUM
VACUUM;
-- 現在檔案才會縮小
SQLite 的 VACUUM 會重寫整個資料庫檔案,這在小型資料庫很快,但大型資料庫會很慢且需要額外空間。
關鍵差異: PostgreSQL 的 MVCC 允許高並發讀寫,代價是需要 VACUUM 清理;SQLite 使用檔案鎖,較簡單但並發能力有限。
解決方案:VACUUM
PostgreSQL 提供 VACUUM 指令來清理 dead tuples。但這裡有個重要區別:
VACUUM vs VACUUM FULL
| 特性 | VACUUM | VACUUM FULL |
|---|---|---|
| 鎖表 | ❌ 不鎖 | ✅ 會鎖(獨佔鎖) |
| 空間處理 | 標記為可重用 | 實際釋放回 OS |
| 執行速度 | 快 | 慢 |
| 生產環境 | ✅ 安全 | ⚠️ 離峰時段 |
普通 VACUUM 不會立即縮小檔案大小,但會將空間標記為「可重用」,下次 INSERT 時會優先使用這些空間。
VACUUM FULL 會重寫整個資料表,真正釋放磁碟空間,但代價是獨佔鎖定整張表——在鎖定期間,任何讀寫操作都會被阻塞。
實際執行
VACUUM FULL user_notifications;
結果:

207MB → 8.1MB,空間終於釋放了。
實務建議:整合到自動化流程
知道問題後,最好的做法是將 VACUUM 整合到清理流程中:
// cron job: 每天凌晨 3 點執行
cleanupOldUserNotifications: {
task: async ({ strapi }) => {
// 1. 批次刪除舊資料
let totalDeleted = 0;
while (true) {
const old = await strapi.db.query('...').findMany({ limit: 10000 });
if (old.length === 0) break;
await strapi.db.query('...').deleteMany({ where: { id: { $in: ids } } });
totalDeleted += old.length;
}
// 2. 刪除後執行 VACUUM FULL(凌晨低流量時段,鎖表可接受)
if (totalDeleted > 0) {
await strapi.db.connection.raw('VACUUM FULL user_notifications');
}
},
options: {
rule: '0 0 3 * * *', // 每天凌晨 3 點
tz: 'Asia/Taipei',
},
}
這裡選擇 VACUUM FULL 而非普通 VACUUM,因為:
- 凌晨 3 點流量極低,短暫鎖表影響有限
- 真正釋放空間回 OS,而非只標記可重用
- 同時重建 index,避免 index bloat 問題
- 對於
user_notifications這種高流量刪除的表,定期徹底清理更有效
如果你的服務 24 小時都有流量,可以改用普通 VACUUM(不鎖表但不釋放空間)。
未來會踩的坑
1. autovacuum 不是萬能的
PostgreSQL 有 autovacuum 機制會自動清理 dead tuples,但它的觸發條件是基於「異動比例」。大量刪除後,autovacuum 可能不會立即啟動,或者清理速度跟不上你的刪除速度。
2. VACUUM FULL 的隱藏成本
除了鎖表之外,VACUUM FULL 還需要額外的磁碟空間來建立新版本的資料表。如果你的磁碟已經快滿了,執行 VACUUM FULL 可能會失敗。
3. Index 也需要清理
普通 VACUUM 不會處理 index bloat。 VACUUM 只清理 table(heap)的 dead tuples,index 的空頁會被標記為可重用,但檔案大小不會縮小。
| 操作 | Table 處理 | Index 處理 |
|---|---|---|
| VACUUM | ✅ 清理 dead tuples | ❌ 不重建,只回收空頁 |
| VACUUM FULL | ✅ 重寫並釋放空間 | ✅ 同時重建所有 index |
| REINDEX | ❌ 不處理 | ✅ 專門重建 index |
如果你發現查詢變慢但 table 已經 VACUUM 過,可能是 index bloat 的問題:
-- 重建特定 index(會鎖定該 index)
REINDEX INDEX idx_user_notifications_created_at;
-- PostgreSQL 12+ 非阻塞版本(推薦生產環境)
REINDEX INDEX CONCURRENTLY idx_user_notifications_created_at;
-- 重建整張表的所有 index
REINDEX TABLE user_notifications;
注意:REINDEX CONCURRENTLY 雖然不阻塞讀寫,但會消耗更多資源且執行時間更長。
這是基礎知識嗎?
對 DBA 來說,是的。
但對大多數 Fullstack 開發者來說,這是「踩過坑才會知道」的經驗。畢竟,誰會預期 DELETE 之後空間不會自動釋放呢?
今天學到的教訓:PostgreSQL 的 DELETE 只是標記刪除,真正的清理需要 VACUUM。
