4. 任务调度与向量检索结合:向量检索任务的生命周期

大家好,我是老张。今天我们来聊聊向量检索任务调度里最核心的一块——任务的生命周期管理。说白了,就是一条查询请求从进门到出门,中间经历了什么,每一步该怎么管。

我在做第一个分布式向量检索系统时,踩过一个大坑。当时任务跑着跑着就卡死了,查了半天才发现是生命周期状态机没设计好,死锁了。嗯,从那以后我对这块就特别较真。

4.1 向量检索任务的生命周期

一个典型的向量检索任务,会经历这么几个阶段:

  1. 创建(Created):客户端提交查询请求,系统生成任务ID。
  2. 排队(Queued):任务进入调度队列,等待资源。
  3. 调度(Scheduled):调度器为任务分配计算节点。
  4. 执行(Running):任务在节点上实际执行向量检索。
  5. 完成(Completed):返回结果给客户端。
  6. 失败(Failed):执行过程中出现异常。
  7. 超时(TimedOut):超过预设时间仍未完成。

关键点:状态转换必须是单向的,不能出现循环。比如从Running回到Queued,这种设计会引发死锁。我见过有人这么干,结果集群直接瘫痪。

下面这张图,是我个人习惯用的任务状态机。你看一眼就明白了:

向量检索任务生命周期状态机 Created Queued Scheduled Running Completed Failed TimedOut 说明: 1. 只有 Running 状态可以进入 Completed、Failed、TimedOut。 2. 从 Running 到 Failed 或 TimedOut 后,任务不可恢复。 3. 建议在 Queued 状态设置最大排队时间,防止任务饿死。

我的经验:状态机里一定要加一个「取消(Cancelled)」状态。用户等不及了想取消任务,你得有地方放。我曾经漏了这个,结果用户投诉说任务取消不了,只能等它超时。

4.2 任务拆分与合并

向量检索有个特点:数据量大了,单机扛不住。所以得把一个大任务拆成多个小任务,分给不同节点去跑。跑完了再把结果合并回来。

你想想看,一个1000万条向量的数据集,你不可能让一台机器全量扫描。拆!必须拆!

4.2.1 拆分策略

我个人习惯用这几种拆分方式:

拆分方式 适用场景 优点 缺点
按分片拆分 数据已分片存储 天然隔离,无数据重叠 分片不均匀时负载倾斜
按向量ID范围拆分 连续ID的数据集 实现简单 ID分布不均时效果差
按哈希值拆分 任意数据集 分布均匀 需要额外计算哈希
按聚类中心拆分 IVF索引 减少候选集大小 聚类质量影响大

注意:拆分粒度不能太细。我见过有人把任务拆成10000个子任务,结果调度开销比执行时间还长。一般来说,子任务执行时间控制在100ms-1s之间比较合理。

4.2.2 合并策略

子任务跑完了,怎么合并?这里有个坑:

  • Top-K合并:每个子任务返回自己的Top-K结果,然后全局排序取前K个。这是最常用的方式。
  • 阈值合并:每个子任务返回距离小于阈值的结果,然后去重合并。适合召回率要求高的场景。
  • 加权合并:不同分片的数据重要性不同,给结果加权后再合并。我曾在推荐系统里用过这个。

代码实现其实不复杂,核心就是一个多路归并:

// 多路归并合并Top-K结果
func mergeTopK(results [][]Result, k int) []Result {
    h := &minHeap{}
    for i, list := range results {
        if len(list) > 0 {
            heap.Push(h, &item{result: list[0], listIdx: i, elemIdx: 0})
        }
    }
    
    merged := make([]Result, 0, k)
    for h.Len() > 0 && len(merged) < k {
        it := heap.Pop(h).(*item)
        merged = append(merged, it.result)
        
        nextIdx := it.elemIdx + 1
        if nextIdx < len(results[it.listIdx]) {
            heap.Push(h, &item{
                result:  results[it.listIdx][nextIdx],
                listIdx: it.listIdx,
                elemIdx: nextIdx,
            })
        }
    }
    return merged
}

避坑指南:合并时要注意去重。同一个向量可能出现在多个分片里,尤其是用IVF索引时。我曾经没做去重,结果返回的结果里有一半是重复的,被客户骂惨了。

4.3 任务优先级管理

不是所有任务都平等的。线上实时查询和离线批量分析,优先级能一样吗?

我建议把任务分成三个优先级:

  • 高优先级:在线实时查询,延迟要求<10ms。这类任务要抢占资源。
  • 中优先级:准实时查询,延迟要求<100ms。可以排队,但不能等太久。
  • 低优先级:离线批量任务,没有严格延迟要求。用空闲资源跑就行。

实现优先级调度,我推荐用多级队列:

type PriorityScheduler struct {
    highQueue   chan *Task  // 高优先级,容量1000
    mediumQueue chan *Task  // 中优先级,容量5000
    lowQueue    chan *Task  // 低优先级,容量10000
}

func (s *PriorityScheduler) Schedule() *Task {
    select {
    case task := <-s.highQueue:
        return task
    case task := <-s.mediumQueue:
        return task
    case task := <-s.lowQueue:
        return task
    default:
        return nil
    }
}

注意:要防止低优先级任务饿死。我习惯的做法是:如果低优先级任务排队超过30秒,就提升它的优先级。这叫「老化机制」。

4.4 任务超时与重试机制

分布式系统里,失败是常态。网络抖动、节点宕机、索引加载慢……什么情况都可能发生。

4.4.1 超时设置

超时时间怎么设?我总结了一个公式:

超时时间 = 预估执行时间 × 系数

系数一般取1.5到3之间。线上查询取1.5,离线任务取3。为什么?线上查询等不起,超时就赶紧返回降级结果;离线任务可以多等一会儿,避免频繁重试。

任务类型 预估执行时间 超时系数 实际超时
在线Top-10查询 5ms 1.5 7.5ms
批量聚类 30s 3 90s
索引构建 10min 2 20min

4.4.2 重试策略

重试不是简单的「失败了就再来一次」。我见过有人这么干,结果系统雪崩了。

正确的做法是:

  1. 指数退避:第一次重试等100ms,第二次等200ms,第三次等400ms……
  2. 最大重试次数:一般设3-5次。超过次数就标记为失败。
  3. 重试范围控制:只重试幂等的子任务。如果子任务已经写入了部分结果,重试会导致数据不一致。

核心原则:重试只适用于临时性故障。如果是永久性故障(比如节点宕机),重试多少次都没用,应该直接走降级策略。

下面是一个重试机制的伪代码,我在项目里就是这么用的:

func retryWithBackoff(task *Task, maxRetries int) error {
    for i := 0; i < maxRetries; i++ {
        err := executeTask(task)
        if err == nil {
            return nil
        }
        
        // 判断是否值得重试
        if !isRetryable(err) {
            return err
        }
        
        // 指数退避:100ms, 200ms, 400ms, 800ms, 1600ms
        wait := time.Duration(100 << uint(i)) * time.Millisecond
        time.Sleep(wait)
        
        log.Printf("任务 %s 第 %d 次重试,等待 %v", task.ID, i+1, wait)
    }
    return fmt.Errorf("任务 %s 重试 %d 次后仍然失败", task.ID, maxRetries)
}

func isRetryable(err error) bool {
    // 网络超时、节点繁忙 -> 可重试
    // 数据不存在、权限不足 -> 不可重试
    switch err.(type) {
    case *NetworkTimeoutError, *NodeBusyError:
        return true
    default:
        return false
    }
}

我的经验:重试时要加「去重」机制。同一个任务可能被多个调度器同时重试,导致重复执行。我习惯用Redis的分布式锁来保证只有一个副本在执行。

4.5 小结

好了,这一章的内容就这些。总结一下:

  • 任务生命周期要设计成单向状态机,别搞循环。
  • 拆分和合并要平衡粒度,太细了调度开销大,太粗了负载不均。
  • 优先级管理用多级队列,别忘了老化机制。
  • 超时和重试要配合使用,指数退避是标配。

这些经验都是我在实际项目中一点点踩坑踩出来的。你照着做,至少能避免80%的调度问题。剩下的20%,等你遇到了再来找我聊。