審計:基于 Outbox + DB 觸發(fā)器模型的 S3/R2/B2 刪除方案設(shè)計)
Civitai 存儲對象清理架構(gòu)審計基于 Outbox DB 觸發(fā)器模型的 S3/R2/B2 刪除方案設(shè)計【免費下載鏈接】civitaiA repository of models, textual inversions, and more項目地址: https://gitcode.com/GitHub_Trending/ci/civitai本文是 Civitai 倉庫中關(guān)于S3/R2/B2 存儲對象刪除的架構(gòu)審計與設(shè)計文檔docs/storage-object-cleanup.md的完整技術(shù)解讀。文檔回答了一個核心問題每個實體Image、File、ModelFile、VaultItem 等擁有哪些存儲對象這些對象是否被數(shù)據(jù)庫跟蹤以及在所屬記錄被刪除時如何或是否被回收。讀者讀完本文將掌握倉庫現(xiàn)有的JobQueue出站隊列、Outbox表、各域清理 Job 的現(xiàn)狀與缺陷以及一套專用 outbox 表 DB 觸發(fā)器 單一 drain 任務(wù)的替代刪除模型并了解其表結(jié)構(gòu)、觸發(fā)器寫法、任務(wù)處理流程與實施成本。背景與現(xiàn)狀為什么存儲對象刪除需要專項治理在 Civitai 的主應(yīng)用中對象存儲S3/R2/B2承擔(dān)著圖片、模型文件、訓(xùn)練數(shù)據(jù)、Vault 附件等海量二進制對象的持久化。文檔明確給出了三條現(xiàn)狀判斷上傳走或應(yīng)當(dāng)走客戶端直傳基于用戶已認證、帶約束鎖定的 presign 預(yù)簽名 URL無需服務(wù)器到服務(wù)器的中轉(zhuǎn)。刪除發(fā)生在服務(wù)端、主應(yīng)用進程內(nèi)作為刪除數(shù)據(jù)庫記錄這一 DB 變更的副作用同步執(zhí)行目前不經(jīng)過apps/storage服務(wù)。apps/storage服務(wù)保留的唯一正當(dāng)理由是隔離真正危險的憑據(jù)如 CSAM 相關(guān)刪除、B2 刪除能力而不是充當(dāng)通用的 presign/delete 代理——文檔的結(jié)論是不刪除該服務(wù)而是將其職責(zé)瘦身。在此基礎(chǔ)上文檔給出了首選刪除形態(tài)outbox / cleanup-job 模式而不是 commit 之后的同步刪除。原因非常直接同步刪除一旦在行記錄已被刪除之后失敗對象就會靜默成為孤兒orphan沒有任何機制能再找到并回收它。已有的通用 outboxJobQueue隊列文檔強調(diào)了一個關(guān)鍵事實不需要重新建設(shè) outbox 基礎(chǔ)設(shè)施因為倉庫里已經(jīng)有了。JobQueue是一個按實體主鍵entity-keyed的通用隊列其 Prisma 模型定義在 packages/civitai-db-schema/prisma/schema.full.prismamodel JobQueue { type JobQueueType // CleanUp | BlockedImageDelete | CleanIfEmpty | UpdateNsfwLevel | UpdateSearchIndex | UpdateMetrics | ModerationRequest | ImageScan entityType EntityType // Image | Post | Article | Model | ModelVersion | Bounty | ... entityId Int createdAt DateTime default(now()) id([entityType, entityId, type]) // idempotent by construction }值得注意的是JobQueueType枚舉同上文件第 5620-5625 行附近實際還包含ReplacedImageDelete等更多成員。該隊列的機制分兩步入隊由 enqueueJobs() 實現(xiàn)本質(zhì)是INSERT … ON CONFLICT DO NOTHING按 500 條一批分塊批量寫入。ON CONFLICT DO NOTHING配合復(fù)合主鍵(entityType, entityId, type)從構(gòu)造上保證了冪等——重復(fù)入隊不會產(chǎn)生重復(fù)行。消費cron 任務(wù)按where type X讀取通過reduceJobQueueToIds按entityType分組執(zhí)行實際工作后刪除對應(yīng)隊列行。例如 removeImageScanJobQueue() 展示了如何按ImageScan類型定向清理已終結(jié)掃描的隊列行?,F(xiàn)有的刪除類型其中一種已經(jīng)在刪 S3TypeJobDeletes S3?BlockedImageDeleteremoveBlockedImages每小時cron0 * * * *是——從隊列取出Image經(jīng)過 7 天保留期門檻B(tài)LOCKED_IMAGE_RETENTION_DAYS調(diào)用deleteImages()→deleteImageFromS3帶引用計數(shù)保護。這是需要泛化的先例。CleanUphandleJobQueueCleanup每 1 分鐘cron*/1 * * * *否——只處理 DB 關(guān)系imageConnection、collectionItem并重算 nsfw 等級。當(dāng)前沒有任何生產(chǎn)者入隊它休眠處理器。CleanIfEmptyhandleJobQueueCleanIfEmpty每小時不適用——刪除沒有圖片的空 Post且會保留已發(fā)布版本的空錨點 Post避免級聯(lián)取消發(fā)布。deleteImages() 已支持批量刪除圖片并連帶刪除 S3 對象其內(nèi)部對每個 id/url 調(diào)用deleteImageFromS3。removeBlockedImages的實現(xiàn)細節(jié)image-ingestion.ts本身就是一份刪除作業(yè)的教科書它用dbWrite直讀防止復(fù)制延遲導(dǎo)致證據(jù)被誤刪、從CsamReport計算需要暫停刪除的用戶heldUsers并在批次外排除避免被掛起的行永遠占滿批次導(dǎo)致全站刪除停擺、以隊列行的createdAt即封禁時間而非Image.createdAt作為 7 天保留期計時起點、并保留AiNotVerified類型的行由單獨的重新驗證路徑處理。DB 現(xiàn)實現(xiàn)存Outbox表2026-07-16 核查審計發(fā)現(xiàn)一個重要事實倉庫里存在一張活躍的Outbox表。它是手工應(yīng)用到數(shù)據(jù)庫的——不在 Prisma migrations 中因此直接在prisma/下 grep 會漏掉它。它是一張由觸發(fā)器填充的領(lǐng)域事件 CDC outbox幾乎可以確定是事件總線event-bus計劃的底層設(shè)施。當(dāng)前狀態(tài)SchemaOutbox(id bigint, event text, entityType enum, entityId bigint, createdAt timestamptz, details jsonb)。entityType枚舉為Article | Image | Model | Post | ModelVersion沒有File/ModelFile。僅id上有主鍵。填充方式Image/Model/ModelVersion/Post 上掛著 8 個outbox_*觸發(fā)器。觀測到的事件有TO_SCAN、PUBLISHED、UPDATED、DELETED、UNPUBLISHED。url 捕獲模式已經(jīng)存在outbox_image_to_scan觸發(fā)器執(zhí)行INSERT … details jsonb_build_object(url, NEW.url)。該模式已在生產(chǎn)環(huán)境驗證無需重新發(fā)明。DELETE 事件對 S3 清理不夠用outbox_post_deleted/outbox_model_deleted只插入(event, entityType, entityId)沒有details/url且粒度是領(lǐng)域級Post/Model而非真正持有 S3 對象的 Image/File/ModelFile 行。休眠且從未被消費約 49.8 萬行最舊 2025-10-13最新 2026-06-17最近 7 天零寫入。沒有 relay/consumer 存在——沒有任何東西讀取它。觸發(fā)器在pg_trigger中顯示為已附加且啟用卻不產(chǎn)生新行狀態(tài)不明確。決策取決于事件總線路線圖而非代碼文檔的核心結(jié)論是我們設(shè)計的觸發(fā)器 → 表 → url 捕獲機制在這里已經(jīng)存在。因此新建表 vs 復(fù)用 Outbox可以簡化為兩個選項復(fù)用 Outbox若事件總線計劃被重啟在Image/File/ModelFile上新增 DELETE 觸發(fā)器將OLD.url捕獲進details復(fù)制to_scan模式擴展entityType枚舉并把清理任務(wù)做成第一個真正的消費者。但必須定義多消費者行保留策略——該表沒有processedAt/offset 字段硬刪除行的消費者會破壞未來的 Kafka relay反之亦然。專用存儲刪除隊列若 Outbox 暫停/廢棄同樣使用觸發(fā)器機制但建一張完全自有的表不與休眠的無主設(shè)施耦合將來在事件層再收斂。阻塞該選擇的開放問題是Outbox/ 事件總線是被重啟、重新設(shè)計還是廢棄為什么不用JobQueue來做這件事JobQueue只攜帶(entityType, entityId)沒有 url 載荷——因此作業(yè)只能通過重讀實體行來回收對象這會強制全站采用軟刪除。而且它由應(yīng)用代碼enqueueJobs填充任何忘記入隊的新刪除路徑都會靜默重新引入孤兒 bug。最終否決它轉(zhuǎn)而采用 DB 觸發(fā)器方案見下節(jié)觸發(fā)器從OLD捕獲 url并且在每一條刪除路徑上無條件觸發(fā)。選定設(shè)計專用 outbox 表 DB 觸發(fā)器目標(biāo)把所有內(nèi)聯(lián)的 S3 刪除從應(yīng)用代碼中移除由單個作業(yè)排空一張由 DB 觸發(fā)器填充的 outbox。表結(jié)構(gòu)Prisma 模型用于類型化作業(yè)讀取觸發(fā)器通過原生遷移添加model StorageObjectDeleteQueue { // name TBD id BigInt id default(autoincrement()) url String // S3 key/url captured from OLD row source String // Image | File | ModelFile | VaultItem — picks bucket guard entityId Int? // for tracing only createdAt DateTime default(now()) processAfter DateTime default(now()) // grace window (see open decisions) attempts Int default(0) lastError String? index([processAfter]) }要點source字段決定使用哪個 bucket 與哪套保護邏輯entityId僅用于追蹤attempts/lastError支撐失敗重試processAfter支持寬限期窗口processAfter上的索引支撐按到期時間批量讀取。觸發(fā)器設(shè)計AFTER DELETE … FOR EACH STATEMENT 過渡表transition table使用REFERENCING OLD TABLE AS old_rows執(zhí)行一次基于集合的INSERT … SELECT url, source FROM old_rows。語句級 過渡表是對高吞吐表如 Image的性能正確選擇批量刪除 N 行只寫入 outbox一次而不是 N 次行級觸發(fā)。文檔特別提醒需要在測試中驗證級聯(lián)刪除在此觸發(fā)器形式下的覆蓋情況——如果某條級聯(lián)路徑不觸發(fā)語句級觸發(fā)器回退方案是行級觸發(fā)器。覆蓋表Image、File、ModelFile、VaultItem所有持有 S3 對象的表每張表各自標(biāo)記自己的source。遷移方式手寫 SQL、人工應(yīng)用倉庫規(guī)則——不執(zhí)行migrate deploy。先例見 w1_publish_requests 遷移。階段一僅處理 DELETEurl替換model-file 替換見 model-file.service.ts:172、替換文件的清理屬于AFTER UPDATE OF url WHERE OLD.url NEW.url的階段二觸發(fā)器。階段一嚴格限定在行刪除。排空作業(yè)單個作業(yè)取代所有內(nèi)聯(lián) S3 刪除文檔給出了 5 步處理流程讀取批次按id排序讀取processAfter now()的批次。保護檢查必需刪除前重新查詢活動表——是否仍有行引用這個url若有丟棄 outbox 行但不刪除對象對象仍在使用中。這就是被遷移過來的deleteImageFromS3/urlsSafeToDelete引用計數(shù)保護。解析后端并刪除通過現(xiàn)有resolveMediaLocation(url)解析后端R2 vs B2執(zhí)行 S3 刪除冪等因此重復(fù)處理是安全的對圖片清除 resize 緩存。成敗處理成功則刪除 outbox 行失敗則attempts1、寫入lastError、保留待重試。按id順序處理使毒丸行poison row無法阻塞隊列超過最大嘗試閾值后轉(zhuǎn)入暫停/告警。保留DATABASE_IS_PROD門禁outbox 在所有環(huán)境都會被填充但作業(yè)只在生產(chǎn)環(huán)境刪除對象。需要移除的內(nèi)聯(lián) S3 刪除調(diào)用點清掃清單以下所有代碼路徑停止直接觸碰 S3改為依賴觸發(fā)器 作業(yè)圖片deleteImageFromS3及其調(diào)用者——image.service.ts 中的deleteImageById與deleteImagespost.service.ts 中deletePost末尾的 S3 循環(huán)image-ingestion.ts 中的封禁圖片路徑。模型文件deleteModelFileObject(s)——model-file.service.ts:172、:256model-version.service.ts:865、:2801model.service.ts:1753purge-replaced-files.ts并入階段二的替換觸發(fā)器。訓(xùn)練deleteObject——training.service.ts×4 處、delete-old-training-data.ts。VaultdeleteManyObjects——vault.service.ts:292刪除S3_VAULT_BUCKET下的對象。附件File目前任何地方都不刪除File上的觸發(fā)器正好修復(fù)這個現(xiàn)存的孤兒問題。成本與注意事項這些表上的每次 DELETE 都會寫一條 outbox 行更大的事務(wù)、熱表如 Image 上更多 WAL。保持觸發(fā)器最小化、表結(jié)構(gòu)窄語句級 insert-select 使批量刪除保持廉價。觸發(fā)器是不可見邏輯——必須在這里和遷移中記錄它們使其可發(fā)現(xiàn)。級聯(lián)會扇出刪除一個用戶會級聯(lián)到成千上萬張圖片 → 單事務(wù)內(nèi)產(chǎn)生成千上萬條 outbox 行。作業(yè)必須分批語句級觸發(fā)器保證寫入本身只是一個語句。待決問題Open decisions寬限期——立即處理processAfter now()還是加一點延遲以吸收刪除后立刻用同一 url 重建的競態(tài)建議小延遲例如幾分鐘。語句級 vs 行級觸發(fā)器——取決于級聯(lián)覆蓋驗證結(jié)果。表/枚舉命名以及source是文本標(biāo)簽還是 Postgres 枚舉。舊的每域模式標(biāo)記列 專用 cron該模式早于JobQueue統(tǒng)一至今仍在運行。思路相同但每個域一個作業(yè)而非共享隊列Job標(biāo)記 → 掃描保護冪等purge-replaced-files.tsModelFile.replacedAt now?30d 且dataPurged非 truecron15 11 * * *引用計數(shù)deleteModelFileObject→urlsSafeToDelete設(shè)置dataPurged truedelete-old-training-data.ts訓(xùn)練completedAt 30d、非公開、dataPurged非 true—設(shè)置dataPurgeduser-deleted-cleanup.tsUser.deletedAt≥ 上次運行cron55 * * * *—getJobDate/setLastRun水位線模式要點Postgres 的onDelete: Cascade/SetNull免費處理相關(guān)行的清理作業(yè)只負責(zé) DB 無法級聯(lián)的跨系統(tǒng)副作用S3、搜索索引。deleteOldTrainingData 展示了標(biāo)記列型冪等先按dataPurged is not true掃描處理完把dataPurged: true寫回。實體審計狀態(tài)圖例? 已回收 · ?? 已回收但脆弱有靜默孤兒風(fēng)險· ? 孤兒無 S3 刪除。Article — deleteArticleById對象刪除時處理 S3DB 跟蹤機制狀態(tài)封面圖是Image行 Article.coverIddeleteImageById→ deleteImageFromS3??正文內(nèi)嵌圖僅當(dāng)真正孤兒無任何實體殘留ImageConnectionImage行 ImageConnectiondeleteImageById??附件File行否File行entityTypeArticle直到被刪除tx.file.deleteMany—僅刪行?審計發(fā)現(xiàn)附件永遠不會從 S3 刪除。刪除路徑article.service.ts:1288和移除附件的編輯路徑article.service.ts:1101都只調(diào)用tx.file.deleteMany(...)就結(jié)束。File.url處的對象留在 bucket 中且沒有任何 DB 行引用它 →不可回收的孤兒。File沒有引用計數(shù)保護、寬限期或清理作業(yè)。文檔建議在編寫保護邏輯前先確認附件存放在哪個 bucketFile.url通過 /api/download/attachments/[fileId].ts 提供下載因為引用計數(shù)檢查必須限定在那個后端。圖片刪除是同步 best-effort 且吞掉錯誤。deleteImageFromS3是引用計數(shù)保護的otherImagesWithSameUrl見 image.service.ts:481且僅生產(chǎn)環(huán)境生效if (!env.DATABASE_IS_PROD) return;第 462 行但外層被catch { /* do nothing */ }包裹——而且它在Image行已被刪除之后運行。任何 S3 失敗或進程在deleteImageById中途崩潰都會靜默產(chǎn)生孤兒對象且沒有對賬reconciliation過程去發(fā)現(xiàn)它。這正是 outbox 模型要閉合的持久性陷阱。deleteImageFromS3內(nèi)部還有一層值得注意的保護邏輯它拒絕刪除外部 URLurl.startsWith(http)時跳過只對civitai.com首方 URL 記 warning 日志并對resolveMediaLocation的失敗做了兜底繼續(xù)刪 B2的設(shè)計——未注冊的 location 會記 warning 日志但刪除照常進行保證查找失敗永遠不會阻斷刪除。下一個實體——Model、ModelVersion、Post、Bounty……— TODO文檔明確標(biāo)注這是未完待續(xù)的部分Article 審計完成后Model、ModelVersion、Post、Bounty 等實體的同類審計尚待補充。這也意味著上述專用 outbox 表 DB 觸發(fā)器方案在實施時需要按同樣格式對剩余實體逐一完成對象歸屬與回收機制的盤點??偨Y(jié)從內(nèi)聯(lián)同步刪除到觸發(fā)器驅(qū)動 outbox整篇文檔的演進主線清晰現(xiàn)狀是刪除行為散落在主應(yīng)用各服務(wù)的請求路徑中deleteImageFromS3、deleteModelFileObject(s)、deleteObject、deleteManyObjects依賴 best-effort 同步刪除目標(biāo)是讓持有 S3 對象的表上的 DB 觸發(fā)器在刪除發(fā)生時無條件把url寫入專用 outbox再由一個帶引用計數(shù)保護、冪等、可重試、生產(chǎn)環(huán)境門禁的 drain 作業(yè)統(tǒng)一回收。這一模型對既有基礎(chǔ)設(shè)施的復(fù)用做到了極致JobQueue提供了現(xiàn)成的隊列模式但缺 url 載荷、Outbox表提供了現(xiàn)成的觸發(fā)器→表→url 捕獲先例但處于休眠且粒度不符、BlockedImageDelete作業(yè)提供了隊列 保留期 引用計數(shù) 生產(chǎn)門禁的完整先例。真正需要新寫的只是一張窄表、四個語句級觸發(fā)器和一個 drain 作業(yè)以及把上文列出的所有內(nèi)聯(lián)調(diào)用點逐一摘除。倉庫中所有相關(guān)實現(xiàn)job-queue.service.ts、image.service.ts、image-ingestion.ts、purge-replaced-files.ts、delete-old-training-data.ts、user-deleted-cleanup.ts都可以作為落地時的直接參考?!久赓M下載鏈接】civitaiA repository of models, textual inversions, and more項目地址: https://gitcode.com/GitHub_Trending/ci/civitai創(chuàng)作聲明:本文部分內(nèi)容由AI輔助生成(AIGC),僅供參考