Summary

前面讲队列的时候,规则是"先进先出"——排队买票,来得早的先走。但现实里大量场景不讲先来后到,只讲谁最急:医院急诊室按病情严重程度排,操作系统按优先级调度进程,定时任务按到期时间触发。

这类需求抽象出来是同一个操作:在一堆动态变化的数据里,随时取出"最小(或最大)的那一个"。堆就是为这个操作而生的结构,而优先队列是它对外的那层接口。

这篇会先把堆本身讲透,然后拿 Kubernetes 的 client-go 开刀——它的 workqueue 是"用堆做工程"的教科书级案例,把延时重试、去重、优先级更新这些真实问题全踩了一遍。


为什么需要堆

先看看已有的结构能不能顶上。需求是两个操作反复交替:插入一个元素、取出当前最小的元素。

方案一:用无序数组。 插入是尾部追加,O(1),很快。但取最小值要遍历全部元素,O(n)。

方案二:用有序数组(或有序链表)。 取最小值是取头部,O(1),很快。但插入要找位置 + 挪动后面所有元素,O(n)。

无序数组:  插入 O(1)      取最小 O(n)
有序数组:  插入 O(n)      取最小 O(1)

两个方案各有一头是 O(n),而我们的场景是两个操作交替高频发生,所以总代价还是被 O(n) 那一头拖死。

问题出在哪?出在我们要的信息其实很少,但排序给的信息太多了。有序数组维护了"所有元素之间的完整顺序关系",而我们每次只需要知道"谁是最小的",剩下 n-1 个元素谁在前谁在后完全不关心。为了那一个信息,付出了全排序的代价。

堆的思路就是:只维护"部分有序"——保证根是最小的,别的不管。

有序数组:  1 3 4 7 8 9 12      ← 完全有序,信息过剩
堆:              1
                / \
               3   4           ← 只保证"父 < 子",兄弟之间不排序
              / \  / \
             7  8 9  12

维护的约束变少了,代价自然就下来了:两个操作都是 O(log n)。


堆是什么

堆是一棵满足堆序性质的完全二叉树。 两个关键词都不能少。

完全二叉树:除最后一层外都填满,最后一层的节点靠左连续排列。

堆序性质:每个节点和它的孩子之间有固定的大小关系。

  • 小顶堆(min-heap):父节点 ≤ 两个孩子。根是全局最小值。
  • 大顶堆(max-heap):父节点 ≥ 两个孩子。根是全局最大值。
小顶堆:                     大顶堆:
        1                          9
       / \                        / \
      3   4                      7   8
     / \  / \                   / \  / \
    7  8 9  12                 3  1 4  2

  根 = 最小值                  根 = 最大值

有两点特别容易搞错,单独强调一下:

第一,堆只约束父子,不约束兄弟。 上面小顶堆里 3 和 4 是兄弟,3 < 4;但如果换成 4 和 3 也完全合法。所以堆不是有序的,你不能指望遍历堆的数组能得到有序序列,这一点和二叉搜索树是不一样的。

第二,堆只保证根是极值,不保证第二层就是第二小。 小顶堆里第二小的元素一定是根的两个孩子之一(因为其他节点都在某个孩子的子树里,必然比那个孩子大),但它可能在左也可能在右,要比一下才知道。

用数组存储,不需要指针

这是堆最漂亮的地方。因为它是完全二叉树——节点靠左连续、中间没有空洞——所以可以按层序直接铺进数组,父子关系用下标算出来,一个指针都不用存:

        1                下标:  0  1  2  3  4  5  6
       / \               数组: [1, 3, 4, 7, 8, 9, 12]
      3   4
     / \  / \            对下标 i (从 0 开始):
    7  8 9  12             左孩子 = 2i + 1
                           右孩子 = 2i + 2
                           父亲   = (i - 1) / 2

验证一下:下标 1 的元素是 3,它的左孩子在 2*1+1 = 3,即 7;右孩子在 2*1+2 = 4,即 8。对上了。

这带来三个实际好处:

  • 内存紧凑,没有指针开销(链式二叉树每个节点要多存两个 8 字节指针)
  • 缓存友好,父子在数组里位置相近,大概率落在同一条缓存行上
  • 实现简单,不用处理指针的各种边界情况

两个核心操作

堆的所有操作都建立在两个动作上:上浮和下沉。名字很直观——元素在树里往上冒或者往下沉,直到回到合法的位置。

上浮(siftUp):用于插入

新元素只能加在数组末尾(也就是完全二叉树最后一层的下一个空位),否则会破坏完全二叉树的形态。但它放在那里可能比父亲小,违反堆序,所以要往上冒。

插入 2 到这个小顶堆:

        1                    1                      1
       / \                  / \                    / \
      3   4      →         3   4        →         2   4
     / \  / \             / \  / \               / \  / \
    7  8 9  12           7  8 9  12             7  3 9  12
                        /                      /
                       2                      8
                    ← 放末尾                ← 2 < 3,交换
                       2 < 8,交换             2 > 1,停止

每次和父亲比一次,最多走到根,所以是 O(log n)。

// 小顶堆的上浮
func siftUp(h []int, i int) {
    for i > 0 {
        parent := (i - 1) / 2
        if h[parent] <= h[i] {
            break                  // 已经满足堆序,停
        }
        h[parent], h[i] = h[i], h[parent]
        i = parent
    }
}

下沉(siftDown):用于删除极值

取走根之后,根的位置空了。不能直接把某个孩子提上来——那样会在树中间留个洞,破坏完全二叉树。正确做法是:把数组最后一个元素搬到根上,长度减一,然后让它往下沉。

从小顶堆取出最小值 1:

        1                  12                    3                    3
       / \                / \                   / \                  / \
      3   4      →       3   4        →       12   4       →        7   4
     / \  / \           / \  /                / \  /                / \  /
    7  8 9  12         7  8 9                7  8 9               12  8 9

   取走根 1          末尾的 12 搬到根      12 和较小的孩子       12 和较小的孩子
                     (长度减一)          3 交换               7 交换,没孩子了,停

注意一个细节:下沉时必须和两个孩子中较小的那个交换(小顶堆)。如果随便挑一个,换完之后新的父节点可能还是比另一个孩子大,堆序照样破了。

// 小顶堆的下沉,n 是堆的有效长度
func siftDown(h []int, i, n int) {
    for {
        left, right := 2*i+1, 2*i+2
        smallest := i

        if left < n && h[left] < h[smallest] {
            smallest = left
        }
        if right < n && h[right] < h[smallest] {
            smallest = right      // 关键:挑两个孩子里更小的
        }
        if smallest == i {
            break                 // 自己就是最小,停
        }
        h[i], h[smallest] = h[smallest], h[i]
        i = smallest
    }
}

建堆:为什么是 O(n) 而不是 O(n log n)

建堆最好的方式是使用下沉建堆,复杂度为:O(n log n)。

可以这么理解,当你构建一个公司的体系时,底层员工肯定是最多的,如果要动底层员工,每个人都要执行一步,那就是O(n),但是如果使用下沉,那就从董事长(非叶子节点)开始,他们最多移动O(log n)。所以使用下沉复杂度最低,方便的话自己写一下建堆的代码,可以深入理解。


优先队列 = 堆的接口

堆是实现,优先队列是接口。优先队列定义的是"能插入、能取出优先级最高的",至于内部用什么实现它不管——可以用堆,也可以用有序链表、跳表、配对堆。但实践中绝大多数优先队列就是用二叉堆实现的,所以这两个词经常混用。

Go 的 container/heap

Go 标准库不直接给你一个"优先队列"类型,而是给了一套算法:你提供底层容器和比较规则,它帮你维护堆序。这是典型的 Go 风格——用接口把算法和数据解耦。

你需要实现 heap.Interface 的五个方法:

type Interface interface {
    sort.Interface          // Len() int, Less(i, j int) bool, Swap(i, j int)
    Push(x any)             // 把元素加到末尾
    Pop() any               // 从末尾摘掉元素并返回
}

这里有个几乎人人踩过的坑:接口里的 Push/Pop 和你实际调用的 heap.Push/heap.Pop 不是同一个东西,具体的可以看下container/heap.go源码的额位置。

实现的接口Push(x any),只负责"追加到末尾",不维护堆序,所以不是真的上浮,heap.Push(h, x) 才是完成的流程。

你实现的 pq.Pop(),只负责"摘掉末尾元素",不回自动下沉,heap.Pop(h) 才是完整的流程。

所以业务代码永远调用 heap.Push / heap.Pop(包级函数),绝不直接调用 pq.Push / pq.Pop(方法)。 直接调方法的话堆序不会被维护,退化成普通切片操作,而且不报错——是那种能安静运行很久的 bug。

container/heap 还给了两个很有用的函数,后面 client-go 会用到:

  • heap.Fix(h, i):下标 i 的元素优先级变了,就地修复堆序(向上或向下都行)。
  • heap.Remove(h, i):删除任意位置的元素,O(log n)。

client-go 实战:workqueue 里的堆

client-go 是 Kubernetes 的官方 Go 客户端,所有 controller 都基于它写。它的 workqueue 包是"用堆解决真实工程问题"的最佳教材,因为 controller 的核心循环天然需要队列:事件进来 → 放队列 → worker 取出处理 → 失败了延迟重试。

以下的代码都来自 client-go master 分支。

延时队列:一个按时间排序的小顶堆

DelayingInterface 提供 AddAfter(item, duration)——“这个东西过 5 秒再给我”。这是失败重试和退避的基础设施。

实现它需要回答一个问题:成千上万个待延时的 item,怎么知道下一个该到期的是哪个?这正是堆的主场——按到期时间建小顶堆,根就是最近要到期的那个。

先看数据结构(util/workqueue/delaying_queue.go):

// waitFor 保存"要加入的数据"和"该在什么时候加入"
type waitFor[T any] struct {
	data    T
	readyAt time.Time
	// index in the priority queue (heap)
	index int          // ← 记住自己在堆里的位置,后面 heap.Fix 要用
}

// 底层就是一个切片,没有任何多余包装
type waitForPriorityQueue[T any] []*waitFor[T]

func (pq waitForPriorityQueue[T]) Len() int {
	return len(pq)
}

// 比较规则:readyAt 早的排前面 → 小顶堆,根是"最近要到期的"
func (pq waitForPriorityQueue[T]) Less(i, j int) bool {
	return pq[i].readyAt.Before(pq[j].readyAt)
}

// 注意这里:交换元素的同时,必须同步更新它们记录的 index
func (pq waitForPriorityQueue[T]) Swap(i, j int) {
	pq[i], pq[j] = pq[j], pq[i]
	pq[i].index = i
	pq[j].index = j
}

// Push adds an item to the queue. Push should not be called directly; instead, // 注释中直接把上述的坑写在这里
// use `heap.Push`.
func (pq *waitForPriorityQueue[T]) Push(x interface{}) {
	n := len(*pq)
	item := x.(*waitFor[T])
	item.index = n
	*pq = append(*pq, item)      // 只是追加,不管堆序
}

// Pop removes an item from the queue. Pop should not be called directly;
// instead, use `heap.Pop`.
func (pq *waitForPriorityQueue[T]) Pop() interface{} {
	n := len(*pq)
	item := (*pq)[n-1]
	item.index = -1              // 标记"已不在堆中"
	*pq = (*pq)[0:(n - 1)]
	return item
}

// Peek returns the item at the beginning of the queue, without removing the
// item or otherwise mutating the queue. It is safe to call directly.
func (pq waitForPriorityQueue[T]) Peek() interface{} {
	return pq[0]                 // 根 = 最近到期的那个
}

几个值得学的点:

① 自定义了一个 Peek,并且注明"可以直接调"。 heap.Interface 里没有 Peek,但"只看一眼根、不取出"是刚需。因为根永远在下标 0,所以 pq[0] 就够了,不需要任何堆操作——所以它是安全的。

② 每个元素记着自己的 index。 这是为了支持"优先级更新",下面细说。

为什么要存 index:heap.Fix 与优先级更新

AddAfter 有个语义问题:同一个 item 被要求延时两次怎么办?比如一个 Pod 先被要求"10 秒后重试",紧接着又被要求"2 秒后重试"。

Kubernetes 的选择是取更早的那个——用户既然要求更快处理,就别让它多等。代码在 insert 函数里:

// insert adds the entry to the priority queue, or updates the readyAt if it already exists in the queue
func insert[T comparable](q *waitForPriorityQueue[T], knownEntries map[T]*waitFor[T], entry *waitFor[T]) {
	// if the entry already exists, update the time only if it would cause the item to be queued sooner
	existing, exists := knownEntries[entry.data]
	if exists {
		if existing.readyAt.After(entry.readyAt) {
			existing.readyAt = entry.readyAt    // 改成更早的时间
			heap.Fix(q, existing.index)         // 就地修堆序,O(log n)
		}
		return                                  // 不重复入堆
	}

	heap.Push(q, entry)
	knownEntries[entry.data] = entry
}

这段短短十几行,信息量很大:

堆 + map 是一对标配。 堆本身只能高效拿极值,不能高效查找"某个特定元素在哪"——那需要 O(n) 遍历。所以旁边挂一个 map[数据]*元素 做索引,查找变成 O(1)。这个组合(堆负责排序、map 负责定位)在工程里到处都是。

heap.Fix 比"删了再插"划算。 想改一个元素的优先级,朴素做法是 heap.Remove + heap.Push,两次 O(log n) 加上重新分配。heap.Fix(q, i) 只从位置 i 开始修一个方向,一次 O(log n)。

而 Fix 需要知道 i,这就是 index 字段存在的全部理由。 也正因为如此,Swap 里那两行 pq[i].index = i 绝对不能漏——一旦漏了,index 就和真实位置脱钩,heap.Fix 会去修一个错误的位置,堆序静默损坏。这是自己实现 heap.Interface 时最容易出的错。

主循环:堆的根用来算定时器

拿到"最近要到期的元素"之后怎么等?这里的处理很值得学(waitingLoop 函数):

for {
    now := q.clock.Now()

    // 1) 先把所有已经到期的都取出来,塞进真正的工作队列
    for waitingForQueue.Len() > 0 {
        entry := waitingForQueue.Peek().(*waitFor[T])
        if entry.readyAt.After(now) {
            break                                   // 根都没到期,后面的更不可能
        }
        entry = heap.Pop(waitingForQueue).(*waitFor[T])
        q.Add(entry.data)
        delete(waitingEntryByData, entry.data)
    }

    // 2) 按"根的到期时间"设一个定时器,而不是轮询
    nextReadyAt := never
    if waitingForQueue.Len() > 0 {
        entry := waitingForQueue.Peek().(*waitFor[T])
        nextReadyAtTimer = q.clock.NewTimer(entry.readyAt.Sub(now))
        nextReadyAt = nextReadyAtTimer.C()
    }

    select {
    case <-q.stopCh:
        return
    case <-q.heartbeat.C():        // 兜底心跳,防止永久阻塞
    case <-nextReadyAt:            // 最近的那个到期了,回到循环顶部
    case waitEntry := <-q.waitingForAddCh:
        // 有新的延时请求进来
    }
}

三个设计值得单独说:

① break 那一行是堆序性质的直接应用。 根没到期,就说明整个堆里没有任何元素到期——因为根是最小的。所以可以立刻停止检查,不需要扫剩下的元素。如果底层不是堆而是普通切片,这里就得全表扫描。

② 用定时器而不是轮询。 拿根的到期时间算出"还要睡多久",精确睡到那一刻。如果改成"每 100ms 醒一次看看有没有到期的",既浪费 CPU 又有最多 100ms 的延迟误差。堆让"下一次该在什么时候醒"变成一个 O(1) 的查询(Peek 根),定时器方案才成立。

③ Peek 和 Pop 分工明确。 只是想"看看什么时候醒"用 Peek(不改堆);确定要处理了才 Pop(改堆)。如果没有 Peek,只能先 Pop 出来看,发现没到期还得 Push 回去,白做两次 O(log n)。

这套"堆存到期时间 + 根算定时器“的组合是定时任务调度的通用范式。Go 运行时自己的 timer、Redis 的过期键、Java 的 ScheduledThreadPoolExecutor、各种延时消息队列,内核都是这个思路(有些用时间轮优化,但基本盘是它)。

另一个例子:tools/cache 里的通用堆

client-go 里还有一个更通用的封装,tools/cache/heap.go。它把"堆 + map + 锁 + 条件变量"打包成了一个线程安全的生产者消费者队列:

type heapData struct {
	// items is a map from key of the objects to the objects and their index.
	items map[string]*heapItem     // key → (对象, 在堆里的下标)
	// queue keeps the keys of objects stored in "items".
	queue []string                 // 堆本身,存的是 key 不是对象

	keyFunc  KeyFunc               // 怎么从对象算出 key
	lessFunc LessFunc              // 怎么比较两个对象   ← 优先级规则注入
}

func (h *heapData) Less(i, j int) bool {
	if i > len(h.queue) || j > len(h.queue) {
		return false
	}
	itemi, ok := h.items[h.queue[i]]
	if !ok {
		return false
	}
	itemj, ok := h.items[h.queue[j]]
	if !ok {
		return false
	}
	return h.lessFunc(itemi.obj, itemj.obj)    // 委托给用户传入的函数
}

和上面的延时队列比,这里有两处不同的取舍:

① 堆里存的是 key(string),不是对象本身。 对象放在 map 里,堆只排序 key。好处是 Swap 交换的是 string 而不是大结构体;代价是每次 Less 都要查两次 map。

② 比较规则由外部注入(LessFunc)。 延时队列的 Less 写死了"按 readyAt 比”,因为它只干一件事。而这个 Heap 是通用组件,优先级规则必须让调用方决定。这就是"策略模式"在 Go 里最自然的形态——传一个函数进来。

对外的 Pop 用条件变量实现了阻塞语义:

func (h *Heap) Pop() (interface{}, error) {
	h.lock.Lock()
	defer h.lock.Unlock()
	for len(h.data.queue) == 0 {
		// 队列空 → 阻塞等待,直到有人 Add 或者 Close
		if h.closed {
			return nil, fmt.Errorf("heap is closed")
		}
		h.cond.Wait()
	}
	obj := heap.Pop(h.data)
	// ...
}

注意 for 而不是 if——被唤醒后要重新检查条件,因为可能有其他消费者先抢到了元素。这是条件变量的标准用法,写成 if 就是虚假唤醒 bug。

对照组:不用堆的那个队列

client-go 最常用的 workqueue.Typed(基础 FIFO)反而没有用堆。看它的字段就明白为什么:

type Typed[t comparable] struct {
	queue []t                  // 就是个切片,FIFO

	dirty      sets.Set[t]     // 需要处理的
	processing sets.Set[t]     // 正在处理的
}

controller 的语义是"这个资源变了,去调谐一次"。同一个资源在短时间内变了 10 次,只需要调谐一次就够了(因为调谐总是读取最新状态)。所以这个队列的核心需求不是优先级,而是去重——用两个 set 实现"处理中的东西如果又变脏了,处理完再重新入队一次,且只入一次"。

放在一起看的价值在于:同一个包里,需求不同就选了不同结构。 需要按时间排序 → 堆;只需要去重和先来后到 → 切片 + set。不要因为学了堆就到处用堆,这是 client-go 给的最实在的一课。


常见坑

① 直接调用 pq.Push() 而不是 heap.Push(pq, x)。 堆序不会被维护,而且不报错。前面说过,这是最高频的错误。

② 自定义 Swap 时忘了同步 index。 如果你的元素记录了自己在堆里的下标(为了用 heap.Fix),Swap 里必须更新它。漏掉之后 Fix 会修错位置,表现为"偶发的顺序错乱",极难排查。

③ 以为遍历堆的数组能得到有序序列。 堆只保证父子关系。要有序输出只能反复 Pop,那就是堆排序,O(n log n)。

④ 大顶堆小顶堆搞反。 求"最大的 K 个数"要用小顶堆(堆里维护当前最大的 K 个,根是这 K 个里最小的,新元素比根大就替换掉根)。这个反直觉,是面试高频错点。记法:堆顶放的是"最容易被淘汰的那个"。

⑤ 在 Less 里做重活。 Less 在一次插入中会被调用 log n 次,建堆时被调用 O(n) 次。如果里面有字符串拼接、map 查找、反射,整体性能会明显下降。上面 heapData.Less 每次查两次 map 已经是有代价的设计,只是它换来了别的好处。

⑥ 忘了堆不支持高效查找。 想知道"元素 X 在不在堆里",堆本身只能 O(n) 遍历。需要就配一个 map,像 client-go 那样。


复杂度与适用场景

操作复杂度说明
取极值(Peek)O(1)就是根,下标 0
插入(Push)O(log n)末尾追加 + 上浮
删除极值(Pop)O(log n)根与末尾交换 + 下沉
修改优先级(Fix)O(log n)需要知道下标
删除任意元素(Remove)O(log n)需要知道下标
建堆O(n)倒序下沉,不是 O(n log n)
查找任意元素O(n)堆的短板,要配 map

和其他结构的取舍:

取极值插入查找任意有序遍历
堆O(1)O(log n)O(n)不支持
有序数组O(1)O(n)O(log n)支持
平衡树 / 跳表O(log n)O(log n)O(log n)支持
哈希表O(n)O(1)O(1)不支持

选堆的判据很清晰:只关心极值、不需要有序遍历、不需要按值查找。 三条里有一条不满足,就该考虑平衡树或跳表了。