合并控制器的重复事件,保留处理期间的新变化
Kubernetes 控制器按对象键排队,用 dirty 和 processing 两个集合合并重复通知;处理期间发生的更新会在本轮结束后触发下一轮。
工程问题#
一个 Deployment 的新增、修改及其 ReplicaSet 变化都可能触发同步。同一对象不应被多个 worker 同时处理,但如果简单地丢弃所有重复键,又可能遗漏正在处理期间发生的新变化。
实际实现#
队列保存对象键而非完整事件历史。Add 先把键标为 dirty:已经 dirty 的键不会再次入队,正在 processing 的键只留下 dirty 标记。Get 把键转入 processing 并清除 dirty,Done 再移除 processing;若此时 dirty 已被新的 Add 置回,就把键放回队尾。三种操作在同一条件变量锁下更新状态。Deployment worker 用 defer Done 配对取出操作,syncDeployment 则从 informer 缓存读取当前对象,按当前状态进行协调。
处理流程#
- 对象事件转换为 namespace/name 键,调用 Add
- 若尚未排队也未被处理,加入队列;否则只保留需要再次处理的状态
- worker 取出键,在锁内标记 processing 并清除 dirty
- 协调期间同键再次到达时,只重新置 dirty,不交给另一 worker
- 当前 worker 调用 Done;存在新变化则再排队一次
源码片段#
固定版本 · 原文摘录400func (dc *DeploymentController) enqueue(deployment *apps.Deployment) {401 key, err := controller.KeyFunc(deployment)402 if err != nil {403 utilruntime.HandleError(fmt.Errorf("couldn't get key for object %#v: %v", deployment, err))404 return405 }406407 dc.queue.Add(key)408}Deployment 事件只把对象键交给队列;不是把每次事件的完整快照排队。
487func (dc *DeploymentController) processNextWorkItem(ctx context.Context) bool {488 key, quit := dc.queue.Get()489 if quit {490 return false491 }492 defer dc.queue.Done(key)493494 err := dc.syncHandler(ctx, key)495 dc.handleErr(ctx, err, key)496497 return true498}defer Done 保证正常返回和错误返回时都完成状态交接;handleErr 另行决定是否限速重试。
235func (q *Typed[T]) Add(item T) {236 q.cond.L.Lock()237 defer q.cond.L.Unlock()238 if q.shuttingDown {239 return240 }241 if q.dirty.has(item) {242 // the same item is added again before it is processed, call the Touch243 // function if the queue cares about it (for e.g, reset its priority)244 if !q.processing.has(item) {245 q.queue.Touch(item)246 }247 return248 }已有 dirty 标记时不重复入队。若键还未被 worker 取走,可让底层队列 Touch 调整自身属性;默认 FIFO 的 Touch 为空操作。
250 q.metrics.add(item)251252 q.dirty.insert(item)253 if q.processing.has(item) {254 return255 }256257 q.queue.Push(item)258 q.cond.Signal()259}先写 dirty,再判断 processing。正在被处理的键保留新通知,但此时不入队,防止另一 worker 同时取得它。
273func (q *Typed[T]) Get() (item T, shutdown bool) {274 q.cond.L.Lock()275 defer q.cond.L.Unlock()276 for q.queue.Len() == 0 && !q.shuttingDown {277 q.cond.Wait()278 }279 if q.queue.Len() == 0 {280 // We must be shutting down.281 return *new(T), true282 }283284 item = q.queue.Pop()285286 q.metrics.get(item)287288 q.processing.insert(item)289 q.dirty.delete(item)290291 return item, false292}从队列取出键后,在同一把锁内登记 processing 并清除 dirty,为判断处理期间的新通知建立边界。
297func (q *Typed[T]) Done(item T) {298 q.cond.L.Lock()299 defer q.cond.L.Unlock()300301 q.metrics.done(item)302303 q.processing.delete(item)304 if q.dirty.has(item) {305 q.queue.Push(item)306 q.cond.Signal()307 } else if q.processing.len() == 0 {308 q.cond.Signal()309 }310}Done 清除 processing 后重新查看 dirty;新变化存在时才再排队,多个新通知仍只形成一次待处理任务。
六段原文依次展示真实控制器调用、Add 的两种状态分支、Get 与 Done;对象读取和失败重试的完整路径另附来源。
收益与取舍#
工程分析带来的收益
- 同键的大量重复通知可合并为一次待处理任务,队列大小主要由不同对象键决定
- 保证遵守 Get/Done 协议的 worker 不会从同一队列并发处理同一键
- 不需要保存每次变更的完整事件对象,适合按当前状态反复协调的控制器
付出的代价
- 合并会丢失事件次数和中间状态,因此不适合必须逐条消费的审计日志或消息账本
- 默认 FIFO 存储下,集合操作为期望 O(1),Push 为摊销 O(1);所有状态转换共用锁,高并发仍可能争用
- 空间随待处理和处理中不同键数增长。队列是进程内状态,不提供持久化或跨控制器实例的 exactly-once 保证;失败重试及限速由外层队列和控制器策略处理
可比较的选择
每次事件都入 FIFO保留事件顺序与次数,但热对象会积累重复任务,还需要额外机制阻止同键并发。
只保留一个去重集合若完成任务时直接删除标记,容易把处理期间的新通知一并抹掉;需要区分待处理与处理中的状态。
版本与适用范围#
固定 Kubernetes v1.32.0,展示 Deployment 控制器使用的 client-go 通用工作队列核心,不是 kube-scheduler 的 Pod 优先级队列。默认底层是 FIFO,但接口允许替换存储;这里的复杂度按默认实现讨论。工作队列串行化同键,不等于 Kubernetes 对象读取具有线性一致性,实际对象来自 informer 缓存。
参考来源#
源码、官方文档与原始论文pkg/controller/deployment/deployment_controller.go · enqueue · 对象转为任务键
pkg/controller/deployment/deployment_controller.go · processNextWorkItem · 取出、协调与归还
staging/src/k8s.io/client-go/util/workqueue/queue.go · Add · 合并已有 dirty 标记
staging/src/k8s.io/client-go/util/workqueue/queue.go · Add · 处理中的键只记为 dirty
staging/src/k8s.io/client-go/util/workqueue/queue.go · Get · 转入 processing
staging/src/k8s.io/client-go/util/workqueue/queue.go · Done · 检查处理期间的新变化
pkg/controller/deployment/deployment_controller.go · syncDeployment · 读取 informer 当前对象
pkg/controller/deployment/deployment_controller.go · handleErr · 有限次数的限速重试
staging/src/k8s.io/client-go/util/workqueue/queue.go · DefaultQueue · 默认 FIFO
相关实现与比较#
4 个相关案例QEMU TCG 用 QHT 保存 TranslationBlock 索引,帮助复用与当前客体执行状态一致的翻译结果。
libvirt 为 Domain 对象维护 UUID 与名称两个哈希索引,两个入口指向同一个对象。
把上下文身份编码进块哈希,让重复请求共享已经计算的 KV cache。
Redis 有序集合既要按成员查分数,也要按分数和名次遍历;较大集合通过两种索引服务这些访问方式。