一次控制器状态机在高并发下的竞态复盘
背景
线上某控制器负责管理 WorkItem 资源。删除流程里偶发出现:
- 日志显示已经把状态写成
Deleting - 紧接着同一轮 reconcile 又读到旧状态
Failed - 状态机落入 default 分支,提前
return nil - 在特定 predicate 组合下,不会被 status 变更再次触发,最终表现为“删除卡死”
现象与日志
下面是一次典型时间线(省略无关字段):
1 | 10:14:07.447 entering delete flow, setting phase to Deleting |
注意第三行和第一行间隔只有十几毫秒。
关键代码路径
删除状态机(简化后)大致是:
1 | func (r *Controller) reconcileDelete(ctx context.Context, item *v1.WorkItem) (ctrl.Result, error) { |
updateStatus 内部采用了 RetryOnConflict,每轮会先 Get 再 patch:
1 | func (r *Controller) updateStatus(ctx context.Context, item *v1.WorkItem, phase Phase) error { |
根因分析
问题不是“patch 失败”,而是同一轮 reconcile 内对象指针被二次覆盖。
时序如下:
updateStatus(..., PhaseDeleting)执行成功,内存对象item.Status.Phase = Deleting- 紧接着进入
syncDryRunCondition(...) - 该函数内部再次调用通用
updateStatus/update逻辑 - 逻辑开头的
r.Get(ctx, key, item)会把当前item指针覆盖为 informer cache 的快照 - 在高负载下,cache 还没及时消费到刚才
Deleting的 watch event,返回旧值Failed switch item.Status.Phase命中 default,提前return nil
核心点:
- controller 只能写 API server,不会直接写 informer cache
Status().Patch成功返回,不代表本地 cache 已经完成更新- 请求量暴涨时,watch 传播与 informer 队列延迟会显著放大这个窗口
为什么之前不明显
该竞态窗口一直存在,但在低负载时窗口很窄,通常“撞不上”。
当以下条件叠加时会明显暴露:
- reconcile 请求量突增(例如 30 倍)
- API server 与 informer 处理延迟升高
- 同一轮 reconcile 内多次复用同一个对象指针,并且每次更新前都
Get - 状态机 default 分支直接
return nil
修复方案
1) 兜底修复:default 分支固定 RequeueAfter
先避免“无事件永久沉默”:
1 | default: |
这能把“卡死”降级为“延迟恢复”。
2) 根因修复:用局部变量固定有效 phase
不要在 switch 里依赖后续可能被 Get 覆盖的对象字段:
1 | effectivePhase := item.Status.Phase |
额外排查点
这次排查还发现另一个容易被忽略的问题:
PhaseDeleted分支注释写“remove finalizer”- 但分支里如果没有真正执行移除 finalizer,重启后可能落到“已 Deleted 但对象无法 GC”的状态
建议把“已 Deleted 时确保 finalizer 被移除”做成幂等逻辑,而不是只依赖某一次成功路径。
经验总结
- 对于 controller-runtime:
Patch success != cache updated。 - 同一轮 reconcile 里,谨慎复用同一对象指针并多次
Get。 - 状态机 default 分支尽量提供可恢复路径(requeue 或 error)。
- 终态资源要做最终幂等收口(比如 finalizer 清理)。
这类问题在低压下很难复现,但一旦流量上来,会从“偶发”迅速变成“稳定踩坑”。
All articles on this blog are licensed under CC BY-NC-SA 4.0 unless otherwise stated.
Comments







