news 2026/9/18 21:14:18

Civitai 存储对象清理架构审计:基于 Outbox + DB 触发器模型的 S3/R2/B2 删除方案设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Civitai 存储对象清理架构审计:基于 Outbox + DB 触发器模型的 S3/R2/B2 删除方案设计

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),没有任何机制能再找到并回收它。

已有的通用 outbox:JobQueue队列

文档强调了一个关键事实:不需要重新建设 outbox 基础设施,因为仓库里已经有了JobQueue是一个按实体主键(entity-keyed)的通用队列,其 Prisma 模型定义在 packages/civitai-db-schema/prisma/schema.full.prisma:

model 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读取,通过reduceJobQueueToIdsentityType分组,执行实际工作后删除对应队列行。例如 removeImageScanJobQueue() 展示了如何按ImageScan类型定向清理已终结扫描的队列行。

现有的删除类型(其中一种已经在删 S3)

TypeJobDeletes S3?
BlockedImageDeleteremoveBlockedImages(每小时,cron0 * * * *——从队列取出Image,经过 7 天保留期门槛(BLOCKED_IMAGE_RETENTION_DAYS),调用deleteImages()deleteImageFromS3(带引用计数保护)。这是需要泛化的先例
CleanUphandleJobQueueCleanup(每 1 分钟,cron*/1 * * * *否——只处理 DB 关系(imageConnectioncollectionItem)并重算 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_SCANPUBLISHEDUPDATEDDELETEDUNPUBLISHED
  • 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 次行级触发。文档特别提醒:需要在测试中验证级联删除在此触发器形式下的覆盖情况——如果某条级联路径不触发语句级触发器,回退方案是行级触发器。
  • 覆盖表ImageFileModelFileVaultItem(所有持有 S3 对象的表),每张表各自标记自己的source
  • 迁移方式:手写 SQL、人工应用(仓库规则——不执行migrate deploy)。先例见 w1_publish_requests 迁移。
  • 阶段一仅处理 DELETE:url替换(model-file 替换见 model-file.service.ts:172、替换文件的清理)属于AFTER UPDATE OF url WHERE OLD.url <> NEW.url阶段二触发器。阶段一严格限定在行删除。

排空作业(单个作业,取代所有内联 S3 删除)

文档给出了 5 步处理流程:

  1. 读取批次:按id排序,读取processAfter <= now()的批次。
  2. 保护检查(必需):删除前重新查询活动表——是否仍有行引用这个url?若有,丢弃 outbox 行但不删除对象(对象仍在使用中)。这就是被迁移过来的deleteImageFromS3/urlsSafeToDelete引用计数保护。
  3. 解析后端并删除:通过现有resolveMediaLocation(url)解析后端(R2 vs B2);执行 S3 删除(幂等,因此重复处理是安全的);对图片清除 resize 缓存。
  4. 成败处理:成功则删除 outbox 行;失败则attempts+1、写入lastError、保留待重试。id顺序处理,使毒丸行(poison row)无法阻塞队列;超过最大尝试阈值后转入暂停/告警。
  5. 保留DATABASE_IS_PROD门禁:outbox 在所有环境都会被填充,但作业只在生产环境删除对象

需要移除的内联 S3 删除调用点(清扫清单)

以下所有代码路径停止直接触碰 S3,改为依赖"触发器 + 作业":

  • 图片deleteImageFromS3及其调用者——image.service.ts 中的deleteImageByIddeleteImages;post.service.ts 中deletePost末尾的 S3 循环;image-ingestion.ts 中的封禁图片路径。
  • 模型文件deleteModelFileObject(s)——model-file.service.ts:172、:256;model-version.service.ts:865、:2801;model.service.ts:1753;purge-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)

  1. 宽限期——立即处理(processAfter = now())还是加一点延迟以吸收"删除后立刻用同一 url 重建"的竞态?(建议小延迟,例如几分钟。)
  2. 语句级 vs 行级触发器——取决于级联覆盖验证结果。
  3. 表/枚举命名,以及source是文本标签还是 Postgres 枚举。

旧的每域模式:标记列 + 专用 cron

该模式早于JobQueue统一,至今仍在运行。思路相同,但每个域一个作业,而非共享队列:

Job标记 → 扫描保护幂等
purge-replaced-files.tsModelFile.replacedAt< now−30d 且dataPurged非 true(cron15 11 * * *引用计数(deleteModelFileObjecturlsSafeToDelete设置dataPurged = true
delete-old-training-data.ts训练completedAt> 30d、非公开、dataPurged非 true设置dataPurged
user-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行(entityType='Article')直到被删除tx.file.deleteMany仅删行

审计发现:

  1. 附件永远不会从 S3 删除。删除路径(article.service.ts:1288)和移除附件的编辑路径(article.service.ts:1101)都只调用tx.file.deleteMany(...)就结束。File.url处的对象留在 bucket 中,且没有任何 DB 行引用它 →不可回收的孤儿File没有引用计数保护、宽限期或清理作业。文档建议:在编写保护逻辑前,先确认附件存放在哪个 bucket(File.url通过 /api/download/attachments/[fileId].ts 提供下载),因为引用计数检查必须限定在那个后端。

  2. 图片删除是同步 best-effort 且吞掉错误。deleteImageFromS3是引用计数保护的(otherImagesWithSameUrl,见 image.service.ts:481)且仅生产环境生效(if (!env.DATABASE_IS_PROD) return;,第 462 行),但外层被catch { /* do nothing */ }包裹——而且它在Image已被删除之后运行。任何 S3 失败(或进程在deleteImageById中途崩溃)都会静默产生孤儿对象,且没有对账(reconciliation)过程去发现它。这正是 outbox 模型要闭合的持久性陷阱。

    deleteImageFromS3内部还有一层值得注意的保护逻辑:它拒绝删除外部 URL(url.startsWith('http')时跳过,只对civitai.com首方 URL 记 warning 日志),并对resolveMediaLocation的失败做了"兜底继续删 B2"的设计——未注册的 location 会记 warning 日志但删除照常进行,保证查找失败永远不会阻断删除。

(下一个实体——Model、ModelVersion、Post、Bounty……)— TODO

文档明确标注这是未完待续的部分:Article 审计完成后,Model、ModelVersion、Post、Bounty 等实体的同类审计尚待补充。这也意味着上述"专用 outbox 表 + DB 触发器"方案在实施时,需要按同样格式对剩余实体逐一完成对象归属与回收机制的盘点。

总结:从"内联同步删除"到"触发器驱动 outbox"

整篇文档的演进主线清晰:现状是删除行为散落在主应用各服务的请求路径中(deleteImageFromS3deleteModelFileObject(s)deleteObjectdeleteManyObjects),依赖 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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/18 21:13:16

【ComfyUI】Wan2.2 Rapid-AIO-Mega 二次元文生视频

今天给大家演示一套基于 Wan2.2 + Rapid AIO Mega 的二次元文生视频 ComfyUI 工作流。它从文本描述出发,结合角色设定、场景氛围和镜头语言,自动生成带有强烈动感与叙事性的短视频。 通过 SD3 采样体系与 Wan 系列专用的 WanVaceToVideo 视频生成节点,整个流程从模型加载、…

作者头像 李华
网站建设 2026/9/18 21:11:22

Redis INCR原子性原理与高并发计数器实现指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/18 21:09:36

人大金仓KingbaseES实战指南:从Oracle兼容到数据库迁移部署

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/18 21:08:25

Canvas字体大小怎么调?从ctx.font到高分屏适配的完整指南

你搜“Canvas字体大小怎么调”&#xff0c;大概率会看到一堆让你改DBeaver界面字号、给Word批注调字号的教程。它们都很热&#xff0c;但不是你要的答案。这里的“Canvas”&#xff0c;指的是HTML5页面上用来绘制图形的那个<canvas>元素&#xff0c;以及它背后的Canvas 2…

作者头像 李华
网站建设 2026/9/18 21:08:04

AI获客系统终端客户实操指南:从代理协作到线索转化的全流程解析

我这段时间被同一个问题问得最多&#xff1a;AI获客系统代理的终端客户&#xff0c;到底怎么用&#xff1f;很多人从代理那边拿到账号后&#xff0c;打开后台就是一脸懵&#xff0c;不知道从哪里开始。这其实很正常&#xff0c;因为AI获客系统不像传统CRM那样&#xff0c;打开就…

作者头像 李华
网站建设 2026/9/18 21:08:02

基于迁移学习的CNN甲状腺结节超声分类:VGG19/InceptionV3/DenseNet161对比

简介&#xff1a;面向医学图像处理、深度学习与机器学习研究者&#xff0c;这份学术论著系统探讨了基于卷积神经网络的甲状腺结节超声图像良恶性分类方法。研究采用迁移学习策略&#xff0c;对在自然图像上预训练的VGG19、Inception V3及DenseNet 161三种模型进行微调和评估&am…

作者头像 李华