跳至主要内容

ADR-005: 檔案儲存與資料庫交易的一致性

ADR 編號: 005 狀態: 已接受 (Accepted) 決策日期: 2026-06-22 決策者: Development Team 取代: 無 被取代: 無


摘要

非交易檔案後端(Local / S3 / Azure / SFTP)的實體檔案操作與資料庫交易不在同一邊界,會在交易失敗時殘留不一致。決策採三層防護:① TransactionAwareFileStorage 裝飾器把所有失敗模式偏向「孤兒」、永不「懸空」(persist 失敗補償刪除、delete 延到 commit 後執行);② 與檔案同介質的 FileJournal 寫前日誌,作為崩潰殘留的對帳來源與稽核軌跡;③ FileOrphanSweepTask 對帳清除,預設策略 A(只刪可證明的孤兒、模糊項報告),可插拔 FileReferenceResolver 升級為策略 B(權威全自動)。Database 後端交易對齊,豁免全部三層。


背景 (Context)

問題陳述

檔案處理採暫存區(staging)→ 永久區(persist)兩階段(見檔案處理設計指南檔案儲存設計)。業務 Service 通常為 @Transactional,在交易內呼叫 FileStorage.persist() / delete()。但對非交易後端,這些是立即發生的實體副作用Files.move、S3 CopyObject…),不隨資料庫交易 rollback 復原,於是:

  1. persist 成功、交易 rollback → 永久區留下無人參照的檔案(孤兒)。原 staging cleanup 只掃暫存區,永久區孤兒無人清。
  2. delete 後交易 rollback → 實體檔案已刪、但 DB 參照被還原 → DB 指向已不存在的檔案(懸空參照),使用者看到壞掉的連結。

限制條件

  • 已決策: FileStorage 為框架層純 Java 介面、不依賴 Spring(見檔案儲存設計)。
  • 框架哲學: 框架提供工具集(builder 風格),由應用層 FileStorageConfig 組裝為 bean(見 10-java.md「提供工具集而非預設實作」)。
  • 後端差異: Database 後端的 persist/delete 是交易內 JPA 操作(UPDATE status / DELETE),隨交易 rollback 復原——不存在此問題
  • 無分散式交易: 不引入 XA / 兩階段提交。

假設前提

  • 業務寫入路徑在交易內呼叫 FileStorage(既有 ProductService 等皆 @Transactional)。
  • 「崩潰窗」(DB 已 commit、進程在收尾前崩潰)極少發生,但必須有可對帳的善後路徑。

考量的方案 (Options Considered)

方案 A: 維持現狀(僅 staging cleanup)

僅靠暫存區過期清理。缺點:完全不處理永久區孤兒與懸空參照;懸空對使用者可見。

方案 B: 交易感知裝飾器 + 同介質日誌 + 對帳 sweep(採用)

裝飾器把副作用對齊交易邊界,把所有失敗收斂成「孤兒」;日誌與檔案同介質、脫離業務交易而存活,供 sweep 對帳。優點:無 XA、無新增 DB 相依、後端自包含、Database 自然豁免。缺點:崩潰窗的模糊項無法單憑日誌權威判定(見權衡)。

方案 C: Outbox(DB「待處理檔案」表 + 與業務同交易寫入)

以 DB outbox 表達成最終一致。優點:可達權威全自動。缺點:強制所有檔案後端相依 DB、每次操作多一次交易寫入;與「後端自包含、Database 之外不綁 DB」相違。保留為未來選項,策略 B 的 FileReferenceResolver 已提供大部分價值。


決策 (Decision)

方案 B,三層防護,全部置於 io.leandev.appfuse.file.tx(框架層工具),由應用層組裝;只包非交易後端,Database 維持裸用

第一層:TransactionAwareFileStorage(裝飾器)

操作對策Hook
persist / store立即執行(需 fileId 寫 DB),交易 rollback 時補償刪除afterCompletion(STATUS_ROLLED_BACK)
delete延到 commit 後才執行afterCommit

核心原則:刻意把所有失敗模式偏向「孤兒」、永不「懸空」——孤兒只佔空間、使用者看不到,且可被 sweep 清除;懸空是使用者可見的破壞。無 active 交易時退化為「立即執行、不補償」(等同未包裝)。

第二層:FileJournal(同介質寫前日誌)

append-only 事件日誌,存於與檔案相同的介質(Local→FS、S3→S3、Azure→Azure、SFTP→SFTP),脫離業務交易而存活,作為崩潰殘留的對帳來源與稽核軌跡。狀態機:CREATED → CONFIRMED | ROLLED_BACKDELETE_PENDING → DELETED。Database 注入 NoOpFileJournal

第三層:FileOrphanSweepTask(對帳)

依每個 fileId 的最後狀態對帳:ROLLED_BACK 但檔案仍在 → 可證明孤兒,直接刪;CREATED/DELETE_PENDING 過 grace 仍未收尾 → 模糊(崩潰窗)。

  • 策略 A(預設):模糊項只報告、不自動刪(零誤刪)。
  • 策略 B(可插拔):提供 FileReferenceResolver bean 時,對模糊項查「是否仍被已 commit 的業務 row 參照」,未參照才刪 → 權威全自動。

權衡分析 (Trade-offs)

我們獲得什麼 (Gains)

  • 消滅懸空參照;99% 的孤兒由裝飾器即時補償。
  • 崩潰殘留有可對帳、可稽核的善後路徑。
  • 後端自包含、無 XA、無新增 DB 相依;Database 自然豁免。
  • 對未採用者完全 inert(不套用裝飾器即原行為)——Database 後端的豁免是結構性的(根本不呼叫裝飾器),不靠旗標。

我們放棄什麼 (Losses)

  • 非原子保證:崩潰窗(commit 與 afterCommit 之間)仍可能殘留,須靠 sweep 兜底。
  • 雲端 journal 採「一筆事件一物件」,sweep 需 list + 逐筆讀回(頻率低,可接受)。

風險與緩解措施 (Risks & Mitigations)

風險緩解
崩潰窗模糊項誤刪活檔策略 A 預設不刪模糊項(只報告);策略 B 以權威參照查詢才刪
journal 寫入失敗best-effort WAL,失敗只記 log,退回 sweep 的 grace-period
誤包 Database 後端 → delete 被延後脫離交易明訂只包非交易後端;FileStorageConfig 對 Database 裸用、不套裝飾器
journal 無限增長待辦:sweep 對終態且健康的舊事件做修剪

影響 (Consequences)

正面影響

  • 業務碼零變更ProductService 等仍注入 FileStorage 介面、呼叫不變;行為差異僅在「失敗時的善後」。

負面影響

  • delete 語意改變:同一交易內 delete 之後檔案直到 commit 才真的消失;勿在同交易內依賴「delete 後檔案立即不存在」。

中性影響

  • 新增設定:app.storage.orphan-sweep.*(排程開關,比照 app.storage.staging.cleanup.enabled)。
  • app.storage.transaction-aware —— 2026-07-17 移除,見下方「修訂:移除 transaction-aware 旗標」。

修訂:移除 transaction-aware 旗標(2026-07-17)

初版提供 app.storage.transaction-aware=false 作為採用過渡的逃生口(「對未採用者完全 inert」)。2026-07-17 移除,理由如下。

證據

事實查核
零使用全 fleet(框架 + 全部下游的 classpath 與外部 conf)無任何一處設定過它
零測試false 分支自初版起從未被任何測試執行——它是未驗證的碼
未採用者已不存在fleet 的 server 只有 app-server(框架自身,Database 後端、結構性豁免)與兩個下游;唯一跑非交易後端者(SFTP)已於 2026-07-17 採用,另一個尚未 provision file/feature sync 涵蓋不到它,旗標對它本就無作用)

為何它是負資產,而非中性的選項

  • 對唯一的語意差異,它是錯的解法。 旗標存在的真正用途是替「同交易內 delete 後檔案延到 commit 才消失」留退路(見「負面影響」)。但關閉它換來的是檔案外洩——正解是修業務碼 (把「delete 後立即不存在」的假設拿掉),不是放棄補償。
  • ops 逃生口的價值是幻覺。 journal 與檔案同介質(如 SFTP 的 {basePath}/journal/)—— 該後端若不可用,檔案寫入本來就已失敗,關閉旗標救不了任何事。
  • 靜默且無警告。 設下去不會有任何提示,只是補償悄悄消失。

保留的部分

  • app.storage.orphan-sweep.enabled 保留——它是排程開關(與現行 app.storage.staging.cleanup.enabled 同族的既有慣例),性質是 ops 控制,不是行為語意旗標。
  • Database 後端的豁免不受影響——它從來就不是靠旗標,而是「不呼叫 wrapTransactional」。

這條修訂只動旗標,不動三層防護的任何設計。 非交易後端一律包裝,是初版就有的意圖; 移除的只是那個沒人用、沒測過、且對真問題給錯答案的旁路。


實作指南 (Implementation Guidelines)

必須遵守的規則

  • 只用 TransactionAwareFileStorage非交易後端(Local/S3/Azure/SFTP);Database 裸用。
  • FileReferenceResolver 實作只查已 commit 資料(@Transactional(readOnly = true))。

建議的最佳實踐

  • 生產環境(多租戶 / 高刪改)建議實作 FileReferenceResolver 升級策略 B。
  • 監控 sweep 日誌的 ambiguousReporteddeletedOrphans

檢查清單

  • 非交易後端的 FileStorage bean 已用裝飾器包裝、注入對應 FileJournal無條件,無關閉旗標)
  • Database 後端未被包裝
  • FileOrphanSweepScheduler 已啟用(或明確關閉)
  • 視需要提供 FileReferenceResolver

相關文檔 (References)

內部文檔

相關 ADR


變更歷史 (Change Log)

日期變更決策者
2026-06-22初版:三層防護(裝飾器 + 同介質日誌 + 對帳 sweep);策略 A 預設、策略 B 可插拔Development Team
2026-07-17移除 app.storage.transaction-aware 旗標(零使用、零測試、對真問題給錯答案);非交易後端一律包裝。三層防護設計不變AppFuse Team
2026-07-27更新 staging cleanup canonical 設定名稱為 app.storage.staging.cleanup.enabledAppFuse Team