共享下载:工作可以合并,所有权不能合并

自动关联目录:共享下载:工作可以合并,所有权不能合并

核心判断:同一制品只执行一次物理获取是正确优化,但订阅者不能只保存成回调数组;没有独立需求身份,就无法正确取消单个调用方,也无法在高优先级调用方退出后降权。

一、业务冲突:三个调用方共享下载,只有一个离开

同一资源可能同时被三个系统请求:

  • 场景流式系统做后台预取;
  • 镜头前即将出现的对象发起关键加载;
  • 一个短生命周期会话显式预载,随后会话被销毁。

正确行为应同时满足:

  1. 只发生一次传输、校验和安装;
  2. 关键加载加入后,共享任务获得更高优先级;
  3. 短生命周期会话取消时,只撤销自己的等待;
  4. 最高优先级需求消失后,任务按剩余需求降权;
  5. 物理任务只收尾一次,每个仍存活的调用方各收到一次终态。

这里最容易犯的错误,是把“取消我的请求”实现成“按资源取消任务”。一旦发生请求合并,这两个动作不再等价。

二、实际实现:物理任务已经共享,逻辑身份没有下沉

实践中的调度器已经不是“每次请求启动一次下载”。它维护一个共享物理任务:

flowchart LR
    A[多个业务请求] --> B{按资源查重}
    B -->|已有| C[追加 优先级 + 回调]
    B -->|没有| D[创建物理任务]
    C --> E[取历史最高优先级]
    D --> F[排队 / 传输 / 退避重试]
    E --> F
    F --> G[独立收尾与安装]
    G --> H[复制回调列表]
    H --> I[锁外广播终态]

调度状态由一个临界区保护,包含排序队列、排队集合、执行中映射、收尾映射和退避列表。物理任务保存版本、运行目标、解析代次、摘要、预期大小、断点和重试状态;重复请求只追加“优先级 + 回调”,并用历史最大值更新任务优先级。

上层业务桥接其实保存了更完整的身份:逻辑请求 ID、请求内内容项、存活状态和结果队列;回调也通过弱状态防止对象销毁后继续访问。问题发生在进入调度器时:这些调用方身份被压平成回调,下层只剩“这个资源有人等”。

三、值得保留:共享任务的执行路径是扎实的

第一,single-flight 本身正确。 多个订阅者只产生一个物理任务,避免重复流量、重复完整性校验和重复安装。

第二,重试不产生伪终态。 可重试失败进入退避,只有最终成功或最终失败才进入收尾。调用方不会把一次临时网络错误误认为业务结束。

第三,传输完成与安装完成分离。 任务先进入独立收尾阶段,安装和提交完成后才通知调用方,避免回调立即读取尚未可用的文件。

第四,回调在锁外执行。 实现先在锁内复制列表,再释放锁并广播,降低回调重入导致死锁的风险。

第五,不抢占已经执行的传输。 新的关键请求只提升排队或退避任务,不强杀正在进行的其他传输,避免浪费已传字节和连接抖动。

这些设计说明物理执行层的方向没有错。缺陷集中在“谁拥有这次需求”。

四、结构性问题:回调不是需求模型

订阅者只有优先级和回调,会直接造成四个语义漏洞。

1. 取消粒度错误

现有取消入口按资源定位:排队时删除整个任务,执行中请求终止整个传输,收尾中又无法取消。它适合“系统整体不再需要该资源”,不适合“某个调用方不再需要”。

即使收尾已经不能中断,也不代表已取消调用方仍应收到成功回调。物理提交与逻辑等待必须能分别结束。

2. 优先级只能升,不能降

任务记录历史最大优先级。关键调用方离开后,后台预取仍占据关键队列,造成优先级污染:

effectivePriority != maxPriorityEverSeen
effectivePriority == max(activeDemands.priority)

3. 去重键不足以证明结果可共享

只按逻辑资源查重,可能错误合并不同内容修订、不同运行目标或不同解析代次。single-flight 的键必须是不可变制品身份,而不是一个过宽的资源名。

4. 测试证明“共享成功”,没有证明“共享隔离”

现有测试覆盖提权、同级 FIFO、重试只收尾一次、多订阅者广播和销毁后忽略晚到结果;但没有覆盖单订阅者取消、最高优先级订阅者退出后的降权,以及一个订阅者超时而其他订阅者继续。

根因不是少一个取消分支,而是模型里只有“工作”,没有“需求”。

五、推荐方案:ArtifactTask 与 Demand 分层

物理任务按完整制品键去重;每个调用方获得独立需求句柄:

Subscribe(key, owner, generation, priority, deadline):
    task = tasks.getOrCreate(key)
    demand = Demand(newId(), owner, generation,
                    priority, deadline, Active)
    task.demands.add(demand)
    task.effectivePriority = max(task.activeDemandPriorities())
    return DemandHandle(key, demand.id)

Cancel(handle):
    task = tasks.find(handle.key)
    demand = task.demands.find(handle.demandId)
    if demand is not Active:
        return

    demand.state = Cancelled
    completeDemandExactlyOnce(demand, Cancelled)
    task.effectivePriority = max(task.activeDemandPriorities())

    if task.hasActiveDemands():
        rescheduleIfNeeded(task)
    else:
        decideWhetherToAbortPhysicalWork(task)

FinishPhysicalTask(task, result):
    installExactlyOnce(result)
    for demand in task.activeDemands():
        if ownerGenerationStillValid(demand):
            completeDemandExactlyOnce(demand, result)

这套模型明确了两种身份:

  • ArtifactKey 回答“哪些调用可以共享一次物理结果”;
  • DemandId 回答“谁可以取消、超时和接收终态”。

取消先撤销需求对优先级和生命周期的贡献,再决定物理任务是否值得继续。无人等待时,排队或退避任务可以直接删除;传输中任务则根据剩余成本、缓存复用概率和槽位压力选择中止或完成;收尾中的原子安装可以继续,但不再通知已取消需求。

六、取舍与验证结论

模型优点缺点结论
每个调用独立下载生命周期最简单重复传输、校验和安装只适合天然不共享的结果
物理任务 + 回调数组去重成本低,实现直接无独立取消、超时和降权只能作为过渡实现
物理任务 + Demand所有权语义完整,可提权也可降权状态、索引和并发测试更多多调用方共享的正确模型

目标测试不需要铺得很广,先守住五条合同:

  1. 相同 ArtifactKey 只产生一个物理任务;
  2. 取消一个 Demand,其他 Demand 继续并能成功;
  3. 取消最高优先级 Demand,任务立即按剩余需求降权;
  4. 重试和安装只执行一次,每个 Demand 恰好一个终态;
  5. 所有 Demand 消失后,无论物理任务是否继续,都不能再向旧 owner 投递成功。

我的反思是:当上层已经有请求 ID 和生命周期,下层却只接收资源 ID 与回调时,接口边界正在主动丢失业务语义。后续的按资源取消、历史最大优先级和弱指针只能分别堵住症状,无法恢复已经消失的所有权。

今后设计任何 single-flight,我会先画出一对多关系,再回答两个问题:共享的究竟是工作还是结果,调用方退出时撤销的是自己的需求还是大家的任务。去重可以合并成本,不能合并权利。

制品身份的前置判断见:资产身份:路径能找到文件,但不能证明文件正确。构建端的集合提交问题见:多目标资产构建:单目标重建不能覆盖其他制品。获取后的安装提交见:缓存安装:下载完成不等于资产可用。