图片删了,为什么旧链接还能打开、后台还能生成回来?

图片删了,为什么旧 链接 还能打开、后台还能生成回来?

用户点了删除,页面里的图片消失了。复制到聊天窗口的旧链接却还能打开;过一会儿,生成任务的回调又送来一张"已经删除"的图片。

这是本文设定的排障情境,并非线上事故记录。两个现象也可能分别发生:旧链接命中了 缓存 ,迟到任务重新写入了产物。把它们都归因于"删除不彻底",容易修错位置。

图像生成的删除应先阻止新访问和新发布,再逐项确认存储、副本及任务尾部已经收敛。数据库里少一行,无法证明这些条件都成立。 下文用一个实际运行的 Python 构造时序解释迟到写回,再结合2026年10月8日核对的 AWS 官方文档拆开各个验证对象。没有调用真实云 存储 、CDN、模型供应商或生产数据库。

删除跑在前面,旧任务仍可能把图片发布回来

一次任务开始时记录产物代次 generation=7。工作进程读到"尚未删除",开始等供应商出图。删除请求随后把代次推进到8,并留下删除标记。最后,旧任务拿着出图结果回来。

本文的可 运行脚本 给出下面这组结果:

同一个事件顺序 发布时采用启动前的检查结果 发布时比较当前代次与删除标记
工作进程先读到代次7、可发布 保留旧判断 保留任务代次
删除写入代次8、撤销访问 图片当前不可见 图片当前不可见
旧任务上传到私有暂存区 等待发布 等待发布
旧任务提交产物 重新变为可见 拒绝发布、补偿删除暂存对象

这里的"重新可见"是脚本里的布尔状态,不是实际网络请求。它证明这个交错顺序下,开始时检查过一次权限,仍不足以决定完成时能否发布 。脚本以顺序执行模拟事件,未证明所有并发情况。1

修复的关键是把"检查当前状态"和"登记可交付产物"放到同一次原子提交中。可采用数据库事务中的同一父记录锁,或带当前代次及未删除条件的更新,并检查影响行数。删除也必须参与同一个串行化规则。只在上传前再查一次,查询与登记之间仍存在空隙。代次不能只是日志字段;条件更新没有命中,应拒绝并进入对账,不能继续返回可访问地址。

对象存储通常不能加入数据库事务。因此应先预留可追踪的 Attempt(单次生成尝试)及私有对象键,再上传;只有条件提交成功、审核通过的产物才进入访问路径。

条件提交失败后删除私有暂存对象。若工作进程在上传之后崩溃,这一步也可能没执行,需要对账任务按已预留对象键扫描未完成尝试,核对供应商状态并清理。没有预留对象键,业务列表可能不知道这次私有上传存在;预留能定位尝试,却不保证供应商状态永远可查。未知结果保持待对账,调用超时不能自动当生成失败,也不能直接宣布物理删除完成。SQS 标准队列官方说明消息可能重复投递,因此重复回调、重复删除都要可重入;其他队列应核对各自语义。2

两个提交顺序都要得到确定结果

先考虑发布先完成:工作进程按当前代次登记了产物,删除随后推进代次并枚举这个已登记对象。再考虑删除先完成:旧任务回来时条件不符,不得成为可交付产物。这两个顺序需要共享同一串行化规则。只验证"删除先于上传"一种交错,还没有覆盖登记已经成功的另一条路径。

重复回调与重试可能发生在删除之后。幂等处理不只是防止新增两条数据库记录,也包括拒绝旧代次、避免反复签发访问、对暂存清理可重入。若重复删除发现对象已不存在,可以按系统合同解释;若无法确定是否删了,应保留失败原因。这里未运行真实队列,SQS材料只提供标准队列可能重复投递的边界。

把上面的交错缩成一个离仓可跑的单文件。代码原样复用已有研究例,只依赖json标准库;运行时只创建字典、 集合 与字符串,没有文件上传、删除、云请求或线程。保存为deletion-race.py可用python3直接执行。仓库根目录本次运行的是下面的包内附件:

bash 复制代码
/Users/wuzikang/.pyenv/versions/3.11.6/bin/python3 08-publishing-pipeline/generated-packages/PKG-20261008-0001-image-artifact-deletion-race/examples/deletion-race.py

完整代码:

python 复制代码
"""Constructed sequential schedule only; no cloud calls or concurrency proof."""
import json

def run(guarded):
    row = {'generation': 7, 'deleted': False, 'visible': False}
    worker_generation = row['generation']
    worker_saw_live = not row['deleted']
    trace = ['worker reads generation=7, live=true']
    row.update(generation=8, deleted=True, visible=False)
    trace.append('delete atomically tombstones generation=8 and revokes access')
    private_objects = {'attempt-7/output.png'}
    trace.append('late worker uploads private attempt object')
    # ponytail: sequential schedule models one interleaving; real DB/storage probes are separate.
    if guarded:
        accepted = (not row['deleted'] and row['generation'] == worker_generation)
    else:
        accepted = worker_saw_live
    if accepted:
        row['visible'] = True
        trace.append('publish accepted')
    else:
        private_objects.clear()
        trace.append('publish rejected; private output compensated')
    return {'trace': trace, 'visible': row['visible'], 'remaining_private_objects': sorted(private_objects)}

result = {'naive_stale_check': run(False), 'conditional_commit': run(True)}
assert result['naive_stale_check']['visible'] is True
assert result['conditional_commit']['visible'] is False
assert not result['conditional_commit']['remaining_private_objects']
print(json.dumps(result, ensure_ascii=False, indent=2))

2026年10月14日,本机Python3.11.6实际运行,退出码0,stderr为空。三条assert分别检查旧判断会置visible为真、条件提交保持假、拒绝后暂存集合为空;stdout与原研究包保存的JSON逐字一致,完整输出如下:

text 复制代码
{
  "naive_stale_check": {
    "trace": [
      "worker reads generation=7, live=true",
      "delete atomically tombstones generation=8 and revokes access",
      "late worker uploads private attempt object",
      "publish accepted"
    ],
    "visible": true,
    "remaining_private_objects": [
      "attempt-7/output.png"
    ]
  },
  "conditional_commit": {
    "trace": [
      "worker reads generation=7, live=true",
      "delete atomically tombstones generation=8 and revokes access",
      "late worker uploads private attempt object",
      "publish rejected; private output compensated"
    ],
    "visible": false,
    "remaining_private_objects": []
  }
}

两组输入除了guarded开关没有差别:都先缓存代次7及"未删除",再把当前记录推进8并标记删除,再放入同一个私有对象键。naive分支只看启动时worker_saw_live,故即使当前deleted仍为真也把visible置真。这个矛盾状态恰是旧发布条件失效的反例,并不意味着某个真实鉴权系统一定会允许访问。

conditional分支重新读当前deleted与generation,旧代次不符合便拒绝,并清空 模型 中的暂存集合。remaining_private_objects非空表示模型还记录着那个暂存键,不证明对象实际上存在于S3;空集合表示本例假定补偿成功,不是DeleteObject响应或物理清除证据。trace里的"atomically""revokes access"也只是作者为该步骤指定的合同,字典更新没有执行数据库事务或撤销HTTP权限。

这次交错同时改变了代次和删除标记,没有分别证明每个条件单独足够,更未枚举全部调度。它也没有运行发布先完成的另一个顺序;那个顺序仍是上文真实实现需要验证的要求。读者可以先用此反例核查自己发布代码是否依赖旧判断,再在受控联调里查同一父记录锁或条件更新的影响行数。新增代码不验证任何锁配置、真实事务隔离或多个worker的竞态。

补偿删除在例中总能成功,而现实里进程可以在上传后崩溃,删除请求也可能失败或超时。把set.clear当作真实清理证明,会漏掉对账最需要处理的尾部。真实记录要继续保留Attempt、私有对象键、条件提交结果与清理结果;不知道供应商是否生成、对象是否删除时,保持未知而不是补零或恢复访问。旧代次不可交付和物理副本清理,是各自要取证的两件事。

这个程序也不读取签名URL、缓存或 历史版本 ,所以不能解释一次CDN200的具体来源。对于既有链接在有效窗口内仍可读的情形,继续按下一节定位取回路径,不能把可解释的签名窗口误判为存储不一致。构造例提供可复算失败反转,真实数据库、存储、缓存和备份联调仍需独立证据。

旧链接还可打开,先问这次请求取到了哪里

工作进程不再发布,并不意味着旧链接已经失效。应用、对象存储、CDN、浏览器检查的状态不同。

CloudFront 官方流程中,应用先决定用户是否有权限并签发 URL;之后 CloudFront 校验签名与策略,必要时回源。该流程不会自动查询业务数据库里的 Artifact 删除标记。停止签发新的链接,只能阻止新授权,已经签发且仍满足策略的 URL 仍需单独处理。产品回执应分别说明新请求拒绝、已有链接窗口与已 下载 副本边界;只有短期签名能力时,不能宣传为瞬时撤回全部访问,精确承诺才有对应验证对象。3

短有效期可以缩小已有链接的新请求窗口,但无法撤回已下载的副本。CloudFront 在 HTTP 请求开始时检查到期时间;到期前开始的下载可在到期后继续完成。不要把"签名有效期一分钟"写成"一分钟内所有传输终止"。3

需要按产物立即拒绝后续请求时,应选能在每次请求处检查撤销状态的鉴权代理或边缘授权,并封闭直接访问源站的旁路。敏感内容的缓存策略也要与这个承诺匹配。若仍采用直连签名 URL,产品应明确既有链接的剩余有效窗口与缓存清理边界。

CloudFront 文档要求在自然过期前移除边缘缓存时发起 invalidation(缓存失效);用新文件名可以绕开旧缓存,却不能使别人手里的旧地址消失。提交清理任务与清理完成还应分别记录。4

排障时保留同一产物的应用请求、源站直读、CDN 请求、旧签名 URL 的状态码与时间。源站 404 而CDN仍返回内容,应查缓存及访问策略;页面隐藏但源站直读200,应查源站权限或对象;缓存清掉后原路径又返回内容,继续查迟到上传。浏览器已保存的图片和用户下载的文件不在服务端撤销范围内。

先确认同一地址与同一对象,再解释状态码

排障记录应保存原始旧地址所属产物与路径,不把临时换的新地址混成一次验证。新地址404、旧地址仍200,可能只是检查了两个不同入口;缩略图地址与原图地址也不能互相替代。地址中若含签名或敏感参数,报告应保留必要关联而非公开可用凭据。

HTTP状态码需要结合来源。权限拒绝、对象不存在、缓存命中和错误页都可能使页面表现为不可见;它们证明的对象不同。源站直读返回错误,也不自动确认历史版本和CDN已处理。相反,CDN200需要核对返回是否仍是原产物,而不是同路径新的内容;可以在受控验证中用内容摘要辅助关联。

缓存清理之前,应先阻止回源再次取得可发布内容。若清缓存后源站仍允许旧对象,下一次请求可能重新填充缓存,用户会看到"刚清完又回来了"。这是逻辑构造,不是CloudFront事故实测。访问策略、源站对象和缓存任务需要在同一产物记录中配对,而不是只重复发失效请求。

按请求处撤销的设计,也要核直接源站是否成为旁路。应用入口有删除检查,但另一个仍可公开访问的地址不经过它,承诺就没覆盖所有入口。本文不指导绕过鉴权,仅提醒设计者记录实际提供给用户的路径及其控制点。浏览器和下载者已经获得的内容则不能由服务端请求拒绝追溯抹除。

原图删掉,派生图和历史版本仍有自己的身份

一个 Artifact(可管理的图像产物)可能对应模型原始输出、审核后交付图、缩略图、裁剪图与格式转换图。删除任务需要按父子关系枚举这些对象,而不是只拿页面上那个 URL 去删除。

至少记录 artifact_id、父产物ID、attempt_id、生成代次、对象键、对象版本ID及派生参数版本。CDN的可访问路径另行关联,避免从当前展示地址猜出所有缓存地址。输入图若被多个任务引用,应按引用关系与访问范围处理,不能删除一个任务就误删其他任务仍有权使用的数据。

版本化存储还有一层区别。S3 官方说明:未启用版本管理时,删除会移除对象;启用版本管理时,不带 versionId 的删除通常创建删除标记,永久移除具体版本需要指定版本ID。只检查当前键404,不足以证明历史版本已经清理。版本管理暂停时还有 null 版本的专门规则,也不能直接套用启用状态的流程。5

S3 对成功的对象删除提供强读后写一致性,这个保证约束源站对象读写,并不替业务数据库、CDN或外部供应商提供统一事务或副本清单。实际系统还需处理访问权限、保留锁及跨存储副本。对象删除响应与物理保留条件分别记录:保留策略限制操作,不能把请求已发出记为已完成;不在本系统控制范围的对象,标为另行处理。不能因某个存储是强一致,就跳过其余路径。6

父子关系比文件名猜测更可靠

缩略图、裁剪与格式转换可能以不同参数形成多个对象。只依靠原图URL替换后缀枚举,容易遗漏曾经使用的参数版本,也可能误伤同名其他产物。父产物、Attempt、派生参数及对象版本的明确关系,既用于删除枚举,也用于解释某条旧地址对应哪个生成结果。

共享输入尤其需要区分删除对象的请求范围。用户删除一个输出,不必然要求删掉仍被其他授权任务使用的共同输入;反过来,删除输入也不能凭想象保证所有独立输出自动消失。按实际引用关系和授权范围处置,不能用一个全局目录删除代替对象级判断。本文没有删除真实文件,只讨论模型。

版本状态要从目标桶真实配置确认。启用、暂停和未启用在官方DeleteObject说明中不同;迁移过配置后,也不应只看当前界面一个开关来猜历史对象。删除任务需要对应可枚举版本记录,复查必须具备所需权限,否则访问被拒不能证明对象已不存在。

最后以对象键、版本和内容标识关联复查结果,避免只保存截图。一个截图可以说明某个页面当时不显示,无法说明所列对象和历史版本各自被处理。证据应来自对应层,且只对枚举与验证到的范围结论,不能以当前键404签全量删除证明。

把删除完成拆成可以验收的结果

删除请求先在同一事务中写删除标记、推进代次、停止新签发,并留下待清理工作记录,事务提交后才告诉调用方"删除已受理"。随后可以并行清理可枚举对象、派生图与CDN,核对仍在运行的任务和供应商副本。清理结果按项保存,可重试项附上失败原因及下次处理时间。

备份策略可能使物理清除延后。AWS Backup 文档说明,在合规模式锁定且宽限期结束后,受保护的恢复点会按保留期保留,不能承诺立即逐条擦除。应明确备份到期日期、用途限制与无法提前删除的原因。7

以下是一份可直接用于联调的验收记录:

要验收的结果 应留下的证据 不能替代它的证据
新访问与新发布被阻断 同产物访问拒绝、旧代次条件提交失败;记录已有链接窗口 前端列表不显示
对象和派生副本清理 对象键及版本ID清单、删除结果、权限下的复查 只删除原图键
CDN清理 路径清单、失效任务完成状态、请求复查 清理请求已提交
任务尾部收敛 迟到回调、重复投递、上传后崩溃的对账结果 发出取消命令
备份及供应商处理 保留期限、删除能力、请求或受限状态、恢复过滤验证 在线库删除成功

删除标记要覆盖实际备份恢复窗口、供应商结果迟到窗口和重试窗口。恢复数据库时也要在重新开放访问前重放删除标记或过滤已删除产物,否则旧备份会把记录带回来。过早丢弃标记,还会让后来对账无法判断旧结果是否应拒绝。标记本身尽量只留必要ID、时间和处置结果,不保留图片或敏感提示词;永久保留完整内容会制造额外保留,其他字段是否必要应另判。期限由实际保留策略确定,本文不建议统一固定天数。

对低敏感、没有CDN和派生图的内部工具,可以把流程缩减为私有存储、一个事务内删除标记与发布检查、对象清理及失败重试。新增备份、公开访问或外部供应商后,再增加对应验收项。流程范围和复杂度应跟真实副本路径一致;验收不是按钮成功率,而是新访问被限制、旧任务不得发布、对象逐项收敛,对暂不能清理的范围如实说明。

下一次出现"删了还能看",先拿这份记录定位它仍可访问的那条路径。只有访问控制、任务写回、对象副本与保留期限分别有证据,用户才知道这次删除已经做到哪里。

失败项要继续收敛,不能重新开放旧产物

删除受理后,各项清理可能以不同速度完成。对象删除失败、CDN待完成、供应商结果未知和备份保留,都应有明确状态与处理方。某项重试失败不应让业务撤销删除标记,恢复旧访问只是掩盖了清理缺口。后续生成若允许,应作为明确的新任务与新代次,不能让旧回调隐式复活。

取消命令只是任务控制请求。供应商可能已经完成,回调也可能在路上;因此尾部验证需要迟到结果和可追踪尝试,不能只检查取消API返回。上传后崩溃、条件提交失败、重复回调与物理删除失败可以各设一个受控联调情形,观察是否继续保持不可交付及是否进入对账。

备份恢复还需关注恢复后的首个开放时刻。若先恢复旧数据库并开放,再补删除标记,期间可能重新签发访问。要求先过滤或重放标记,是为了让恢复过程不改变删除承诺;具体恢复协议要依实际系统验证。本文没有操作备份或真实云资源,不能提供恢复通过证明。

回执可分别写"新访问已阻断""所列对象已清理""缓存清理待确认""锁定恢复点按期限保留"。这些状态让用户知道还能访问什么、需要等什么。没有能力立刻处理的副本要说明边界,但不能凭空列未证实副本;以实际对象关系为准。

本文保留2026年10月8日的来源快照;2026年10月14日再次只读核对六份AWS官方页面,原始HTML字节与旧快照一致。文档约束仍按下列对应服务解释,没有运行云端删除或恢复验证。


  1. 作者构造时序与结果:examples/deletion-race.py及正文完整stdout;原研究包deletion-race.py、deletion-race-result.json保持字节。本地顺序构造,无云端 E2E。 ↩︎

  2. Amazon SQS 至少一次投递。 ↩︎

  3. CloudFront 签名 URL 与请求时到期检查。 ↩︎ ↩︎

  4. CloudFront 缓存失效。 ↩︎

  5. S3 DeleteObject API 的版本行为。 ↩︎

  6. S3 数据一致性模型。 ↩︎

  7. AWS Backup Vault Lock。 ↩︎

相关推荐
yuanyuan33440 分钟前
2026枣庄带检测报告伸缩门品牌测评|本地服务
大数据·数据库·人工智能·伸缩门
无所畏俱的老尹1 小时前
Agent 落地为什么需要影子对照:同输入只读双跑、写动作变提案、差异报警,比直接切全量更安全
人工智能·机器学习·云计算·aigc·devops
神鹰手哥_起飞版2 小时前
深度研判任务里,开思考、换更强的模型、套方法论,哪个更有用?
人工智能
ObserverX212 小时前
我是如何用 13KB 纯 Python,发现本地 RAG 的“隐式递归记忆”的
人工智能
znyin2 小时前
dsh源码解析 - Cordis 如何启动:插件树与热更新
agent
Jason_gao9992 小时前
Github Copilot 新手极速上手指南
人工智能·github·copilot
用户360055579002 小时前
Kestrel_VLLM - 35 · 把 m 循环搬进内核:一次「先量发射、再合并发射」的完整回合
人工智能
Linye2 小时前
为什么让 AI 修个小 Bug 却把底层全重写了?聊聊 Coding Agent 的最小修改边界
人工智能·架构
柒和远方2 小时前
从「存一条记忆」到「管一段记忆」——Mem0 与自建 psql+向量长期记忆的取舍
agent