
Civitai 存储对象清理架构审计基于 Outbox DB 触发器模型的 S3/R2/B2 删除方案设计【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai本文是 Civitai 仓库中关于S3/R2/B2 存储对象删除的架构审计与设计文档docs/storage-object-cleanup.md的完整技术解读。文档回答了一个核心问题每个实体Image、File、ModelFile、VaultItem 等拥有哪些存储对象这些对象是否被数据库跟踪以及在所属记录被删除时如何或是否被回收。读者读完本文将掌握仓库现有的JobQueue出站队列、Outbox表、各域清理 Job 的现状与缺陷以及一套专用 outbox 表 DB 触发器 单一 drain 任务的替代删除模型并了解其表结构、触发器写法、任务处理流程与实施成本。背景与现状为什么存储对象删除需要专项治理在 Civitai 的主应用中对象存储S3/R2/B2承担着图片、模型文件、训练数据、Vault 附件等海量二进制对象的持久化。文档明确给出了三条现状判断上传走或应当走客户端直传基于用户已认证、带约束锁定的 presign 预签名 URL无需服务器到服务器的中转。删除发生在服务端、主应用进程内作为删除数据库记录这一 DB 变更的副作用同步执行目前不经过apps/storage服务。apps/storage服务保留的唯一正当理由是隔离真正危险的凭据如 CSAM 相关删除、B2 删除能力而不是充当通用的 presign/delete 代理——文档的结论是不删除该服务而是将其职责瘦身。在此基础上文档给出了首选删除形态outbox / cleanup-job 模式而不是 commit 之后的同步删除。原因非常直接同步删除一旦在行记录已被删除之后失败对象就会静默成为孤儿orphan没有任何机制能再找到并回收它。已有的通用 outboxJobQueue队列文档强调了一个关键事实不需要重新建设 outbox 基础设施因为仓库里已经有了。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() 实现本质是INSERT … ON CONFLICT DO NOTHING按 500 条一批分块批量写入。ON CONFLICT DO NOTHING配合复合主键(entityType, entityId, type)从构造上保证了幂等——重复入队不会产生重复行。消费cron 任务按where type X读取通过reduceJobQueueToIds按entityType分组执行实际工作后删除对应队列行。例如 removeImageScanJobQueue() 展示了如何按ImageScan类型定向清理已终结扫描的队列行。现有的删除类型其中一种已经在删 S3TypeJobDeletes S3?BlockedImageDeleteremoveBlockedImages每小时cron0 * * * *是——从队列取出Image经过 7 天保留期门槛BLOCKED_IMAGE_RETENTION_DAYS调用deleteImages()→deleteImageFromS3带引用计数保护。这是需要泛化的先例。CleanUphandleJobQueueCleanup每 1 分钟cron*/1 * * * *否——只处理 DB 关系imageConnection、collectionItem并重算 nsfw 等级。当前没有任何生产者入队它休眠处理器。CleanIfEmptyhandleJobQueueCleanIfEmpty每小时不适用——删除没有图片的空 Post且会保留已发布版本的空锚点 Post避免级联取消发布。deleteImages() 已支持批量删除图片并连带删除 S3 对象其内部对每个 id/url 调用deleteImageFromS3。removeBlockedImages的实现细节image-ingestion.ts本身就是一份删除作业的教科书它用dbWrite直读防止复制延迟导致证据被误删、从CsamReport计算需要暂停删除的用户heldUsers并在批次外排除避免被挂起的行永远占满批次导致全站删除停摆、以队列行的createdAt即封禁时间而非Image.createdAt作为 7 天保留期计时起点、并保留AiNotVerified类型的行由单独的重新验证路径处理。DB 现实现存Outbox表2026-07-16 核查审计发现一个重要事实仓库里存在一张活跃的Outbox表。它是手工应用到数据库的——不在 Prisma migrations 中因此直接在prisma/下 grep 会漏掉它。它是一张由触发器填充的领域事件 CDC outbox几乎可以确定是事件总线event-bus计划的底层设施。当前状态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_*触发器。观测到的事件有TO_SCAN、PUBLISHED、UPDATED、DELETED、UNPUBLISHED。url 捕获模式已经存在outbox_image_to_scan触发器执行INSERT … details jsonb_build_object(url, NEW.url)。该模式已在生产环境验证无需重新发明。DELETE 事件对 S3 清理不够用outbox_post_deleted/outbox_model_deleted只插入(event, entityType, entityId)没有details/url且粒度是领域级Post/Model而非真正持有 S3 对象的 Image/File/ModelFile 行。休眠且从未被消费约 49.8 万行最旧 2025-10-13最新 2026-06-17最近 7 天零写入。没有 relay/consumer 存在——没有任何东西读取它。触发器在pg_trigger中显示为已附加且启用却不产生新行状态不明确。决策取决于事件总线路线图而非代码文档的核心结论是我们设计的触发器 → 表 → url 捕获机制在这里已经存在。因此新建表 vs 复用 Outbox可以简化为两个选项复用 Outbox若事件总线计划被重启在Image/File/ModelFile上新增 DELETE 触发器将OLD.url捕获进details复制to_scan模式扩展entityType枚举并把清理任务做成第一个真正的消费者。但必须定义多消费者行保留策略——该表没有processedAt/offset 字段硬删除行的消费者会破坏未来的 Kafka relay反之亦然。专用存储删除队列若 Outbox 暂停/废弃同样使用触发器机制但建一张完全自有的表不与休眠的无主设施耦合将来在事件层再收敛。阻塞该选择的开放问题是Outbox/ 事件总线是被重启、重新设计还是废弃为什么不用JobQueue来做这件事JobQueue只携带(entityType, entityId)没有 url 载荷——因此作业只能通过重读实体行来回收对象这会强制全站采用软删除。而且它由应用代码enqueueJobs填充任何忘记入队的新删除路径都会静默重新引入孤儿 bug。最终否决它转而采用 DB 触发器方案见下节触发器从OLD捕获 url并且在每一条删除路径上无条件触发。选定设计专用 outbox 表 DB 触发器目标把所有内联的 S3 删除从应用代码中移除由单个作业排空一张由 DB 触发器填充的 outbox。表结构Prisma 模型用于类型化作业读取触发器通过原生迁移添加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上的索引支撑按到期时间批量读取。触发器设计AFTER DELETE … FOR EACH STATEMENT 过渡表transition table使用REFERENCING OLD TABLE AS old_rows执行一次基于集合的INSERT … SELECT url, source FROM old_rows。语句级 过渡表是对高吞吐表如 Image的性能正确选择批量删除 N 行只写入 outbox一次而不是 N 次行级触发。文档特别提醒需要在测试中验证级联删除在此触发器形式下的覆盖情况——如果某条级联路径不触发语句级触发器回退方案是行级触发器。覆盖表Image、File、ModelFile、VaultItem所有持有 S3 对象的表每张表各自标记自己的source。迁移方式手写 SQL、人工应用仓库规则——不执行migrate deploy。先例见 w1_publish_requests 迁移。阶段一仅处理 DELETEurl替换model-file 替换见 model-file.service.ts:172、替换文件的清理属于AFTER UPDATE OF url WHERE OLD.url NEW.url的阶段二触发器。阶段一严格限定在行删除。排空作业单个作业取代所有内联 S3 删除文档给出了 5 步处理流程读取批次按id排序读取processAfter now()的批次。保护检查必需删除前重新查询活动表——是否仍有行引用这个url若有丢弃 outbox 行但不删除对象对象仍在使用中。这就是被迁移过来的deleteImageFromS3/urlsSafeToDelete引用计数保护。解析后端并删除通过现有resolveMediaLocation(url)解析后端R2 vs B2执行 S3 删除幂等因此重复处理是安全的对图片清除 resize 缓存。成败处理成功则删除 outbox 行失败则attempts1、写入lastError、保留待重试。按id顺序处理使毒丸行poison row无法阻塞队列超过最大尝试阈值后转入暂停/告警。保留DATABASE_IS_PROD门禁outbox 在所有环境都会被填充但作业只在生产环境删除对象。需要移除的内联 S3 删除调用点清扫清单以下所有代码路径停止直接触碰 S3改为依赖触发器 作业图片deleteImageFromS3及其调用者——image.service.ts 中的deleteImageById与deleteImagespost.service.ts 中deletePost末尾的 S3 循环image-ingestion.ts 中的封禁图片路径。模型文件deleteModelFileObject(s)——model-file.service.ts:172、:256model-version.service.ts:865、:2801model.service.ts:1753purge-replaced-files.ts并入阶段二的替换触发器。训练deleteObject——training.service.ts×4 处、delete-old-training-data.ts。VaultdeleteManyObjects——vault.service.ts:292删除S3_VAULT_BUCKET下的对象。附件File目前任何地方都不删除File上的触发器正好修复这个现存的孤儿问题。成本与注意事项这些表上的每次 DELETE 都会写一条 outbox 行更大的事务、热表如 Image 上更多 WAL。保持触发器最小化、表结构窄语句级 insert-select 使批量删除保持廉价。触发器是不可见逻辑——必须在这里和迁移中记录它们使其可发现。级联会扇出删除一个用户会级联到成千上万张图片 → 单事务内产生成千上万条 outbox 行。作业必须分批语句级触发器保证写入本身只是一个语句。待决问题Open decisions宽限期——立即处理processAfter now()还是加一点延迟以吸收删除后立刻用同一 url 重建的竞态建议小延迟例如几分钟。语句级 vs 行级触发器——取决于级联覆盖验证结果。表/枚举命名以及source是文本标签还是 Postgres 枚举。旧的每域模式标记列 专用 cron该模式早于JobQueue统一至今仍在运行。思路相同但每个域一个作业而非共享队列Job标记 → 扫描保护幂等purge-replaced-files.tsModelFile.replacedAt now−30d 且dataPurged非 truecron15 11 * * *引用计数deleteModelFileObject→urlsSafeToDelete设置dataPurged truedelete-old-training-data.ts训练completedAt 30d、非公开、dataPurged非 true—设置dataPurgeduser-deleted-cleanup.tsUser.deletedAt≥ 上次运行cron55 * * * *—getJobDate/setLastRun水位线模式要点Postgres 的onDelete: Cascade/SetNull免费处理相关行的清理作业只负责 DB 无法级联的跨系统副作用S3、搜索索引。deleteOldTrainingData 展示了标记列型幂等先按dataPurged is not true扫描处理完把dataPurged: true写回。实体审计状态图例✅ 已回收 · ⚠️ 已回收但脆弱有静默孤儿风险· ❌ 孤儿无 S3 删除。Article — deleteArticleById对象删除时处理 S3DB 跟踪机制状态封面图是Image行 Article.coverIddeleteImageById→ deleteImageFromS3⚠️正文内嵌图仅当真正孤儿无任何实体残留ImageConnectionImage行 ImageConnectiondeleteImageById⚠️附件File行否File行entityTypeArticle直到被删除tx.file.deleteMany—仅删行❌审计发现附件永远不会从 S3 删除。删除路径article.service.ts:1288和移除附件的编辑路径article.service.ts:1101都只调用tx.file.deleteMany(...)就结束。File.url处的对象留在 bucket 中且没有任何 DB 行引用它 →不可回收的孤儿。File没有引用计数保护、宽限期或清理作业。文档建议在编写保护逻辑前先确认附件存放在哪个 bucketFile.url通过 /api/download/attachments/[fileId].ts 提供下载因为引用计数检查必须限定在那个后端。图片删除是同步 best-effort 且吞掉错误。deleteImageFromS3是引用计数保护的otherImagesWithSameUrl见 image.service.ts:481且仅生产环境生效if (!env.DATABASE_IS_PROD) return;第 462 行但外层被catch { /* do nothing */ }包裹——而且它在Image行已被删除之后运行。任何 S3 失败或进程在deleteImageById中途崩溃都会静默产生孤儿对象且没有对账reconciliation过程去发现它。这正是 outbox 模型要闭合的持久性陷阱。deleteImageFromS3内部还有一层值得注意的保护逻辑它拒绝删除外部 URLurl.startsWith(http)时跳过只对civitai.com首方 URL 记 warning 日志并对resolveMediaLocation的失败做了兜底继续删 B2的设计——未注册的 location 会记 warning 日志但删除照常进行保证查找失败永远不会阻断删除。下一个实体——Model、ModelVersion、Post、Bounty……— TODO文档明确标注这是未完待续的部分Article 审计完成后Model、ModelVersion、Post、Bounty 等实体的同类审计尚待补充。这也意味着上述专用 outbox 表 DB 触发器方案在实施时需要按同样格式对剩余实体逐一完成对象归属与回收机制的盘点。总结从内联同步删除到触发器驱动 outbox整篇文档的演进主线清晰现状是删除行为散落在主应用各服务的请求路径中deleteImageFromS3、deleteModelFileObject(s)、deleteObject、deleteManyObjects依赖 best-effort 同步删除目标是让持有 S3 对象的表上的 DB 触发器在删除发生时无条件把url写入专用 outbox再由一个带引用计数保护、幂等、可重试、生产环境门禁的 drain 作业统一回收。这一模型对既有基础设施的复用做到了极致JobQueue提供了现成的队列模式但缺 url 载荷、Outbox表提供了现成的触发器→表→url 捕获先例但处于休眠且粒度不符、BlockedImageDelete作业提供了队列 保留期 引用计数 生产门禁的完整先例。真正需要新写的只是一张窄表、四个语句级触发器和一个 drain 作业以及把上文列出的所有内联调用点逐一摘除。仓库中所有相关实现job-queue.service.ts、image.service.ts、image-ingestion.ts、purge-replaced-files.ts、delete-old-training-data.ts、user-deleted-cleanup.ts都可以作为落地时的直接参考。【免费下载链接】civitaiA repository of models, textual inversions, and more项目地址: https://gitcode.com/GitHub_Trending/ci/civitai创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考