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 復原,於是:
- persist 成功、交易 rollback → 永久區留下無人參照的檔案(孤兒)。原 staging cleanup 只掃暫存區,永久區孤兒無人清。
- 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_BACK、DELETE_PENDING → DELETED。Database 注入 NoOpFileJournal。
第三層:FileOrphanSweepTask(對帳)
依每個 fileId 的最後狀態對帳:ROLLED_BACK 但檔案仍在 → 可證明孤兒,直接刪;CREATED/DELETE_PENDING 過 grace 仍未收尾 → 模糊(崩潰窗)。
- 策略 A(預設):模糊項只報告、不自動刪(零誤刪)。
- 策略 B(可插拔):提供
FileReferenceResolverbean 時,對模糊項查「是否仍被已 commit 的業務 row 參照」,未參照才刪 → 權威全自動。
權衡分析 (Trade-offs)
我們獲得什麼 (Gains)
- 消滅懸空參照;99% 的孤兒由裝飾器即時補償。
- 崩潰殘留有可對帳、可稽核的善後路徑。
- 後端自包含、無 XA、無新增 DB 相依;Database 自然豁免。
- 對未採用者完全 inert(
app.storage.transaction-aware=false或不套用裝飾器即原行為)。
我們放棄什麼 (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.transaction-aware、app.storage.orphan-sweep.*。
實作指南 (Implementation Guidelines)
必須遵守的規則
- 只用
TransactionAwareFileStorage包非交易後端(Local/S3/Azure/SFTP);Database 裸用。 FileReferenceResolver實作只查已 commit 資料(@Transactional(readOnly = true))。
建議的最佳實踐
- 生產環境(多租戶 / 高刪改)建議實作
FileReferenceResolver升級策略 B。 - 監控 sweep 日誌的
ambiguousReported與deletedOrphans。
檢查清單
- 非交易後端的
FileStoragebean 已用裝飾器包裝、注入對應FileJournal - Database 後端未被包裝
-
FileOrphanSweepScheduler已啟用(或明確關閉) - 視需要提供
FileReferenceResolver
相關文檔 (References)
內部文檔
相關 ADR
- ADR-001: 多租戶資料隔離(partition 多租戶用法)
- ADR-003: Entity 關聯設計策略(FileDescriptor 嵌入 Entity)
變更歷史 (Change Log)
| 日期 | 變更 | 決策者 |
|---|---|---|
| 2026-06-22 | 初版:三層防護(裝飾器 + 同介質日誌 + 對帳 sweep);策略 A 預設、策略 B 可插拔 | Development Team |