「奇怪,我明明刪了 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 檢查時…

VACUUM 前:資料表仍然是 207MB

207MB?資料都刪了,空間怎麼沒變?


為什麼會這樣?理解 PostgreSQL 的 MVCC

這不是 bug,而是 PostgreSQL 的設計特性。

DELETE 不是真的刪除

PostgreSQL 使用 MVCC(Multi-Version Concurrency Control) 來處理並發交易。當你執行 DELETE 時,PostgreSQL 不會真的把資料從磁碟上移除,而是:

  1. 將該行標記為「已刪除」(稱為 dead tuple
  2. 保留原始資料,直到沒有任何交易需要參照它
  3. 新的查詢看不到這些行,但它們仍佔用磁碟空間

為什麼要這樣設計?

  • 效能考量:標記刪除比實際移除資料快非常多
  • 並發安全:其他正在執行的 transaction 可能還需要看到舊版本
  • ACID 保證:確保 transaction isolation

不處理會怎樣?

  • 磁碟空間不會釋放,資料庫持續膨脹
  • 查詢效能逐漸下降(需要跳過大量 dead tuples)
  • Index 也會膨脹,進一步影響效能

用圖來理解整個流程

Mermaid Diagram

這張圖說明了為什麼 DELETE 後空間不會立即釋放——資料只是變成 dead tuple,需要 VACUUM 才能處理。


SQLite 呢?比較兩者的差異

你可能會想:「SQLite 也是這樣嗎?」

答案是:類似但不完全相同。

特性PostgreSQLSQLite
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

特性VACUUMVACUUM FULL
鎖表❌ 不鎖✅ 會鎖(獨佔鎖)
空間處理標記為可重用實際釋放回 OS
執行速度
生產環境✅ 安全⚠️ 離峰時段

普通 VACUUM 不會立即縮小檔案大小,但會將空間標記為「可重用」,下次 INSERT 時會優先使用這些空間。

VACUUM FULL 會重寫整個資料表,真正釋放磁碟空間,但代價是獨佔鎖定整張表——在鎖定期間,任何讀寫操作都會被阻塞。

實際執行

VACUUM FULL user_notifications;

結果:

VACUUM 後:資料表縮小到 8.1MB

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。


延伸閱讀