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读取,通过reduceJobQueueToIds按entityType分组,执行实际工作后删除对应队列行。例如 removeImageScanJobQueue() 展示了如何按ImageScan类型定向清理已终结扫描的队列行。
现有的删除类型(其中一种已经在删 S3)
| Type | Job | Deletes S3? |
|---|---|---|
BlockedImageDelete | removeBlockedImages(每小时,cron0 * * * *) | 是——从队列取出Image,经过 7 天保留期门槛(BLOCKED_IMAGE_RETENTION_DAYS),调用deleteImages()→deleteImageFromS3(带引用计数保护)。这是需要泛化的先例。 |
CleanUp | handleJobQueueCleanup(每 1 分钟,cron*/1 * * * *) | 否——只处理 DB 关系(imageConnection、collectionItem)并重算 nsfw 等级。当前没有任何生产者入队它(休眠处理器)。 |
CleanIfEmpty | handleJobQueueCleanIfEmpty(每小时) | 不适用——删除没有图片的空 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)计划的底层设施。当前状态:
- Schema:
Outbox(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 迁移。 - 阶段一仅处理 DELETE:url替换(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 行;失败则
attempts+1、写入lastError、保留待重试。按id顺序处理,使毒丸行(poison row)无法阻塞队列;超过最大尝试阈值后转入暂停/告警。 - 保留
DATABASE_IS_PROD门禁:outbox 在所有环境都会被填充,但作业只在生产环境删除对象。
需要移除的内联 S3 删除调用点(清扫清单)
以下所有代码路径停止直接触碰 S3,改为依赖"触发器 + 作业":
- 图片:
deleteImageFromS3及其调用者——image.service.ts 中的deleteImageById与deleteImages;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。 - Vault:
deleteManyObjects——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.ts | ModelFile.replacedAt< now−30d 且dataPurged非 true(cron15 11 * * *) | 引用计数(deleteModelFileObject→urlsSafeToDelete) | 设置dataPurged = true |
| delete-old-training-data.ts | 训练completedAt> 30d、非公开、dataPurged非 true | — | 设置dataPurged |
| user-deleted-cleanup.ts | User.deletedAt≥ 上次运行(cron55 * * * *) | — | getJobDate/setLastRun水位线 |
模式要点:Postgres 的onDelete: Cascade/SetNull免费处理相关行的清理;作业只负责 DB 无法级联的跨系统副作用(S3、搜索索引)。deleteOldTrainingData 展示了"标记列"型幂等:先按dataPurged is not true扫描,处理完把dataPurged: true写回。
实体审计
状态图例:✅ 已回收 · ⚠️ 已回收但脆弱(有静默孤儿风险)· ❌ 孤儿(无 S3 删除)。
Article — deleteArticleById
| 对象 | 删除时处理 S3 | DB 跟踪 | 机制 | 状态 |
|---|---|---|---|---|
| 封面图 | 是 | Image行 +Article.coverId | deleteImageById→ deleteImageFromS3 | ⚠️ |
| 正文内嵌图 | 仅当真正孤儿(无任何实体残留ImageConnection) | Image行 +ImageConnection | deleteImageById | ⚠️ |
附件(File行) | 否 | File行(entityType='Article')直到被删除 | tx.file.deleteMany—仅删行 | ❌ |
审计发现:
附件永远不会从 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 提供下载),因为引用计数检查必须限定在那个后端。图片删除是同步 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"
整篇文档的演进主线清晰:现状是删除行为散落在主应用各服务的请求路径中(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),仅供参考