资讯动态

client-go Informer源码解析:ListAndWatch与缓存同步机制

发布时间:2026/10/27 5:46:42 来源:尧图企业网站定制
1. 从一次缓存不同步说起Informer 到底替谁扛了压力第一次在线上盯 Informer是因为自研控制器报出的数据和集群里的实际状态对不上——日志里明明打印了 Add 事件转头去缓存里查对象却查不到过几秒又自己好了。翻 client-go 的源码从SharedIndexInformer.Run一路追到Reflector.ListAndWatch最后发现问题出在resourceVersion接续和 resync 的交互上外加 Handler 里做了一次远程调用把分发链路拖住了。这篇就围绕 client-go 的ListAndWatch 机制和Informer 源码展开把我读源码时记下的分层结构、事件流转路径、参数取舍以及几个真实踩过的坑写清楚。这篇文章适合两类人。一类是已经能用 Informer 写出能跑的控制器、但对内部“它凭什么不会漏事件”“为什么我的 Handler 会阻塞”“为什么 Watch 老是重连”这些问题只有模糊印象的同学另一类是刚上手 client-go正准备把轮询改成 List/Watch想知道这条路到底能省多少东西、代价又在哪里的人。我不会只贴结论会把“为什么这么设计”和“如果不这么设计会怎样”一起讲。在正式拆源码之前先把最关键的一点摆出来Informer 的本质是一台本地状态镜像机。它做的所有事归纳起来就三件——把服务端的全量快照拉下来一次带进内存用一条长连接把增量事件接住然后把这些事件按顺序分发给注册过的处理函数。ListAndWatch 是第一件的后半和第二件DeltaFIFO 和 Indexer 是第三件的下半场sharedProcessor 是第三件的上半场。搞清楚这三段各自的边界源码就不难啃了。1.1 为什么不能直接轮询一笔很直观的账假设一个集群有 5000 个 Pod你要统计它们的 Ready 状态。直接 List 轮询每 2 秒一次每次返回 5000 个对象的完整 JSON单个按 2KB 算就是 10MB每秒 5MB 的下行流量加上 apiserver 每次都要从存储里读取并序列化一遍。十个这样的控制器挂上去apiserver 的 CPU 曲线会先给你脸色看。更要命的是延迟——你永远只能在两次轮询之间的某个时刻看到状态遇到瞬时抖动根本没机会观察。List/Watch 换了一种思路先拉一次全量拿到一个resourceVersion作为“我看到这里了”的书签然后用 Watch 长连接让服务端把这本书签之后发生的变化推给你。数据的传输从“每次全量重发”变成“只发增量”延迟从轮询间隔降到事件产生即推送。代价是必须维护一个本地缓存来还原全量视图还得处理连接断开、版本过期、事件乱序等一系列分布式系统里常见的麻烦事——Informer 的价值就是把这一堆麻烦封装成了一个Run()方法。注意List/Watch 不是“替代轮询”这么简单。它把你从“定时拉数据”换成了“维护一个可能随时失步的副本”运维复杂度的重心从服务端转移到了客户端。如果你的控制器只是一天跑一次批量任务老老实实 List 反而是最优解别为了用而用。1.2 缓存不是附属品它才是主角很多人把 Indexer 当成顺带提供的一个查询便利实际上反过来了缓存才是 Informer 的主体事件回调是副产品。你在OnAdd里做的事情绝大多数时候只是“打个标记告诉某个 goroutine 去干活”真正的状态查询全都要靠informer.GetStore().GetByKey()或者带索引的ByIndex()。理解了这一点就能理解为什么 AddEventHandler 注册得太晚会导致“收不到初始事件”也能理解为什么 resync 时会收到一堆对象根本没变的 Update 回调——因为那是在给缓存和你的业务视图对齐用的不是给你处理真实变更的。2. Informer 的分层结构五个组件各管一摊client-go 里和 Informer 相关的文件不少第一次看很容易陷进shared_informer.go的几百行里出不来。我的建议是先画一张职责图把每个组件的输入输出定死再看代码。2.1 五个核心组件的职责边界组件所在文件输入输出关键职责Reflectortools/cache/reflector.goAPI 的 List/Watch 接口Delta 写入 DeltaFIFO拉全量、维持长连接、处理断线重连与版本过期DeltaFIFOtools/cache/delta_fifo.goDelta按 key 合并的 Deltas去重、合并、保序避免同一对象的事件乱序Indexertools/cache/index.go/store.go对象本地内存索引提供 Get/List/ByIndex 查询controllertools/cache/controller.goDeltaFIFO 弹出的对象调用 Process 回调单 goroutine 循环保证处理顺序sharedProcessortools/cache/shared_informer.go通知对象分发给各 listener一对多广播隔离不同 Handler这五个组件串起来是一条单向管道Reflector → DeltaFIFO → controller → HandleDeltas → Indexer 更新 processor 分发→ listener → 你的 Handler。管道上任何一个环节堵住上游都会跟着停这一点在第 4 节会重点讲。除了这五个还有两个容易被误认为是 Informer 一部分的东西workqueue和SharedInformerFactory。前者是配合使用的消费者侧队列后者是管理多个 Informer 生命周期与共享缓存的工厂。它们很重要但不在 ListAndWatch 的主链路上先放一边。2.2 一条对象创建路径从 NewSharedIndexInformer 看依赖注入顺着NewSharedIndexInformerWithOptions往下看能清楚看到各个组件是怎么被组装起来的。核心代码在newInformer里逻辑大致是先创建DeltaFIFO把KeyFunc传进去再创建Indexer带自定义索引然后把 DeltaFIFO 交给NewController同时传入Process回调——这个回调就是sharedIndexInformer.HandleDeltas最后创建sharedProcessor。这里有个细节值得留意DeltaFIFO在构造时需要两个东西一个是KeyFunc决定对象在队列里用什么字符串标识默认是MetaNamespaceKeyFunc产出namespace/name形式的 key另一个是knownObjects也就是 Indexer。knownObjects的存在不是锦上添花它让 DeltaFIFO 在Replace时能对比新旧集合判断哪些对象是“新增的”、哪些是“真正被删掉的”。少了它全量同步就只能产生一堆无差别的 Added删掉的旧对象会永远残留在缓存里。提示如果你的对象没有 namespace集群级资源MetaNamespaceKeyFunc会退化成直接用 name不需要自己写 KeyFunc。只有当你需要按复合键做全局唯一标识时才考虑自定义。2.3 为什么强调“Shared”一次 List 服务多个消费者SharedIndexInformer的 Shared 二字指的是同一个资源的多份缓存视图被合并了。如果每个控制器各建一个普通 Informer那 10 个控制器就要 List 10 次、维持 10 条 Watch 连接、在各自内存里存 10 份同样的对象。换成 SharedInformerFactory一次 List、一条 Watch、一份缓存10 个 Handler 挂上去共享。这个设计带来的一个隐性约束是共享意味着互相影响。如果 A 控制器的 Handler 处理慢事件分发链路上游被堵住B 控制器也会跟着收不到新事件因为分发是串行遍历 listener 的。这个坑后面还会提到现在先记住“共享的不仅是数据还有背压”。3. ListAndWatch 的完整链路Reflector 是怎么跑起来的Reflector.Run的核心是一个不退出的大循环里面调用ListAndWatch。理解了这两步各自的语义和衔接方式就能回答“会不会漏事件”这个反复被问的问题。3.1 List分页、resourceVersion 与初始快照的一致性List 阶段最容易被忽略的是分页。默认pager.PageSize是 500也就是说对象超过 500 个时会自动带上continue参数多次请求。分页不是纯粹为了省内存更重要的是避免单次响应过大导致超时或者被服务端截断。我自己遇到过一次“缓存里只有前 500 个对象”的问题排查半天最后发现是某个中间层把continue参数过滤掉了导致第一页返回后客户端以为结束。拿到 List 结果后Reflector 会做两件关键的事。第一是从ListMeta里取resourceVersion记录到lastSyncResourceVersion这是后续 Watch 的起点。第二是把整个列表包装成一个Replaced类型的 Delta 塞进 DeltaFIFO。Replaced和普通的Added不同它携带的是完整集合DeltaFIFO 会拿它和knownObjects做 diff算出哪些是新的、哪些消失了再产出对应的Added和Deleted。这就是为什么首次 List 之后你的OnAdd会收到全量对象而删除过的对象不会“诈尸”。这里有个常被误解的参数List 时的resourceVersion该填什么。Reflector 的处理是如果这是首次同步lastSyncResourceVersion为空填0如果是重新同步填上一次的版本号。填0的语义是“给我任意一个足够新、且一致性可接受的版本”服务端通常会走 watch cache不会真的穿透到存储做 quorum read。这个选择的意图很明确首次同步追求的是快不需要严格到某一个时间点因为后续的 Watch 会把中间的变化补上。3.2 Watch从断点接续以及 410 之后的重来List 结束后立刻进入 Watch起始版本就是刚才记下的lastSyncResourceVersion。请求里还会带上AllowWatchBookmarks: true和一个随机的TimeoutSeconds——默认在 5 到 10 分钟之间随机取值。为什么要随机如果所有客户端都用固定超时大量连接会在同一时刻集体断开重连形成周期性的尖峰。随机化把重连时间打散这是分布式系统里非常常见的一个小技巧。Watch 过程中每条事件都会被转换成相应的 DeltaAdded/Updated/Deleted写进 DeltaFIFO同时更新lastSyncResourceVersion。如果事件里带的是Bookmark类型只更新版本号不产生 Delta——bookmark 的价值就是让你在某个对象长期没有变化时仍能推进版本号减少重连时回退的距离。真正的麻烦出现在 Watch 返回错误的时候。Reflector的watchHandler会对错误分类处理如果错误满足IsResourceExpired也就是通常说的 410 Gone版本太旧、历史已经被压缩掉了那就没法从断点继续只能丢掉lastSyncResourceVersion重新走一遍 List从头再来。如果是其他错误比如连接超时、网络抖动、服务端临时不可用就交给退避管理器处理按指数退避重试 Watch不重新 List。错误类型判定方式处理动作影响版本过期410apierrors.IsResourceExpired清空版本号重新 ListAndWatch一次全量重拉缓存会被 Replaced 校正服务端超时IsTimeout退避后重试 Watch缓存不丢可能短暂延迟连接关闭/EOF普通 err退避后重试 Watch同上权限不足IsForbidden计为错误持续重试需要检查 RBAC退避参数在NewExponentialBackoffManager里设置初始 800 毫秒上限 30 秒总时长 2 分钟倍率 2.0。也就是说短时间内连续失败会从 0.8 秒一路涨到 30 秒再稳住避免把 apiserver 打穿。这个值我一般不动除非你的集群规模特别大、且明确知道 410 是常态。注意410 的频率和 etcd 的压缩间隔、watch 缓存的保留时长强相关。如果你的控制器经常触发全量重拉先去看服务端的压缩配置而不是在客户端加大重试次数——重试只会让问题更明显。3.3 DeltaFIFO为什么需要“按 key 合并”而不是简单队列事件进到 DeltaFIFO 之后并不是简单地排队。FIFO 内部维护了items map[string]Deltas和queue []string两个结构。同一个对象如果连续来了三条 Delta它们会被追加到对应 key 的Deltas切片里queue里只会出现一次这个 key。等到Pop的时候一次性把这三条一起取走交给Process按顺序处理。这个设计的意图是保证同一对象的因果顺序同时避免重复排队。想象一个对象在极短时间内被创建、修改、删除如果三条事件各自独立排队中间插进来别的对象处理顺序就会乱最终缓存状态可能错得离谱。按 key 合并成一组之后只要这组按序处理完最终状态就是对的。而且中间的Updated在最终态上其实是冗余的合并后只处理一次省了不少无谓的回调。Pop本身是阻塞的内部用sync.Cond等待。当queue为空且处于关闭状态时返回错误controller 循环据此退出。这里还有一个容易被忽略的细节DeltaFIFO在Resync时会为knownObjects里所有对象生成Sync类型的 Delta。Sync和Updated的区别在于它不携带“变更前”的信息也不代表对象真的变了纯粹是一次提醒。这就是 resync 期间你会收到大量“假 Update”的根源。4. 事件如何到达你的回调从 HandleDeltas 到 processorListenercontroller.processLoop是个死循环从队列里Pop出一条记录然后调用Process(obj)。这个Process就是sharedIndexInformer.HandleDeltas。整个链路从这一刻起分成两条支路一条更新缓存一条分发事件。4.1 HandleDeltas缓存先更新再通知HandleDeltas里对每个 Delta 逐个处理顺序是固定的先s.indexer.Add/Update/Delete把缓存改掉再调s.processor.distribute(...)把通知推出去。这个顺序非常关键——它保证了你的 Handler 在被调用时去GetStore()查到的一定是已经更新过的对象。如果反过来先通知后更新Handler 里读到的就是旧值很多隐蔽的 bug 都由此而来。对Deleted类型的处理还有个特殊分支只有当indexer里确实存在这个对象时才真正删除并通知。这样一来重复的删除事件不会重复触发 Handler。同样Replaced会遍历新旧集合做对比只对真正新增和消失的对象发通知避免全量刷新时把每个对象都当新对象处理一遍。4.2 sharedProcessor两级队列与背压的产生distribute做的事情很朴素遍历所有注册过的processorListener逐个调用add(notification)。而add的实现是把通知写进一个 channel。pop()从这个 channel 取出来再塞进nextCh而nextCh是一个默认容量 1024 的环形缓冲区。最后每个 listener 的run()从nextCh里读调用你注册的OnAdd/OnUpdate/OnDelete。这条链路上有三个地方可能堵nextCh满了pop()阻塞add写 channel 阻塞distribute阻塞HandleDeltas阻塞controller 循环停摆DeltaFIFO 涨起来最终 Reflector 写不进队列。你的 Handler 里有耗时操作远程调用、写数据库、大量日志直接拖慢run()的消费速度。listener 数量多串行遍历本身就有开销。第一种和第二种其实是同一个问题的两端。Handler 里的任何阻塞操作最终都会反压到整个 Informer而且因为缓存是共享的其他控制器也会被连累。这就是为什么几乎所有讲 Informer 最佳实践的文章都在说Handler 里只做最轻的事把任务丢进 workqueue 让 worker 慢慢处理。提示processorListener的缓冲不是给你当保险用的。1024 看起来不小但一个高频变更的集群里几千个对象同时触发变更瞬间就打满了。缓冲区只是削峰不是兜底。4.3 AddEventHandler 的注册时机与“初始事件”的真相AddEventHandler必须在对 Informer 调用Run之前注册。如果在Run之后注册虽然新 listener 会开始接收新产生的事件但初始 List 产生的那批Add已经分发完了你收不到。表现就是“缓存里有数据Handler 却没被调用”。正确的姿势是创建 Informer、注册 Handler、启动Run、然后WaitForCacheSync。HasSynced返回 true 的条件是首批 Delta 已经全部被Pop并处理完注意是“处理完”而不是“接收到”所以它同时也意味着 Indexer 里已经有完整视图了。很多控制器在Run之后立刻查询缓存拿到空结果就是因为漏了这一步等待。5. 动手写一个最小实现把内部状态打印出来光看源码容易浮在表面我习惯写个最小可运行的例子把关键状态打出来观察。下面这段代码用 clientset 建一个 Pod 的 SharedIndexInformer注册 Handler同时定期打印缓存大小和最后同步版本。5.1 骨架代码package main import ( context log time metav1 k8s.io/apimachinery/pkg/apis/meta/v1 k8s.io/client-go/informers k8s.io/client-go/kubernetes k8s.io/client-go/tools/cache k8s.io/client-go/tools/clientcmd ) func main() { cfg, err : clientcmd.BuildConfigFromFlags(, clientcmd.RecommendedHomeFile) if err ! nil { log.Fatal(err) } // 把客户端限速放宽一点避免首批 List 被客户端自身限流卡住 cfg.QPS 50 cfg.Burst 100 cs, err : kubernetes.NewForConfig(cfg) if err ! nil { log.Fatal(err) } // resync 设成 0 表示关闭周期性重放先观察纯净的 List/Watch 行为 factory : informers.NewSharedInformerFactory(cs, 0) podInformer : factory.Core().V1().Pods().Informer() podInformer.AddEventHandler(cache.ResourceEventHandlerFuncs{ AddFunc: func(obj interface{}) { key, _ : cache.MetaNamespaceKeyFunc(obj) log.Printf(ADD %s, key) }, UpdateFunc: func(oldObj, newObj interface{}) { oldMeta : oldObj.(metav1.Object) newMeta : newObj.(metav1.Object) // 资源版本没变说明是 resync 触发的 Sync不是真实变更 if oldMeta.GetResourceVersion() newMeta.GetResourceVersion() { log.Printf(SYNC %s, newMeta.GetName()) return } log.Printf(UPDATE %s rv%s-%s, newMeta.GetName(), oldMeta.GetResourceVersion(), newMeta.GetResourceVersion()) }, DeleteFunc: func(obj interface{}) { key, _ : cache.MetaNamespaceKeyFunc(obj) log.Printf(DELETE %s, key) }, }) stopCh : make(chan struct{}) defer close(stopCh) factory.Start(stopCh) // 等待所有注册的 informer 完成首次同步 if !cache.WaitForCacheSync(stopCh, podInformer.HasSynced) { log.Fatal(cache sync failed) } log.Printf(synced, local cache size %d, len(podInformer.GetStore().List())) go func() { t : time.NewTicker(10 * time.Second) defer t.Stop() for { select { case -t.C: // 这个接口能直接看到 Reflector 当前的同步版本 if r, ok : podInformer.(interface{ LastSyncResourceVersion() string }); ok { log.Printf(cache%d lastSyncRV%s, len(podInformer.GetStore().List()), r.LastSyncResourceVersion()) } case -stopCh: return } } }() select {} }这段代码里有两个刻意加的点。一个是cfg.QPS和Burstclient-go 默认的客户端限速是 QPS 5、Burst 10在对象数量多、事件密集的场景下你甚至会被自己的客户端限流卡住日志里表现为“事件来得很慢、但服务端一切正常”。调大之前先确认是不是真的需要别一上来就给个大数值。另一个是 UpdateFunc 里对resourceVersion的对比。这个判断是区分“真实变更”和“resync 重放”最简单直接的办法比看其他字段靠谱。开启 resync 后你会看到大量SYNC日志那不是 bug是提醒。5.2 用日志等级观察 Reflector 内部行为把klog的-v开到 6reflector.go会打印出 List 的开始结束、Watch 的建立、每次重连的原因。我通常会抓这几条关键日志Listing and watching出现说明触发了完整的 ListAndWatchwatch of ... ended with后面的错误类型直接告诉你是不是 410caches populated说明首批 Delta 处理完了。这比在代码里到处打日志高效得多尤其是在排查“为什么反复 List”这类问题时。5.3 打开 resync 观察 Sync Delta 的传播路径把上面代码里 factory 的 resync 改成 30 秒重新跑一遍你会看到每 30 秒每个对象都触发一次 UpdateFunc但resourceVersion没变。这就是DeltaFIFO.Resync()生成的Sync类型的 Delta一路走到distribute时带的标记是“同步”最终映射成OnUpdate。值得留意的是resync 期间processor.distribute会把通知只发给syncingListeners而不是全部 listener——这是为了防止那些对重放敏感的处理逻辑被打乱。如果你确实不想要任何重放就直接设 0如果需要务必在 Handler 里靠版本号或者自己的幂等判断把重放的逻辑区别对待。6. 常见问题与排查速查表实际生产中遇到的 Informer 问题绝大多数能归到下面这几类。6.1 症状对照表现象高概率原因排查手段处理方式Handler 收不到初始数据注册时机晚于 Run检查 AddEventHandler 与 Start 的先后顺序先注册再启动并等待 HasSynced事件突然整体停止某个 Handler 阻塞导致 nextCh 满看 goroutine 栈找卡在回调里的栈帧Handler 内只入队不做重活反复出现完整 ListWatch 返回 410开-v6看错误类型检查服务端压缩间隔减少断连频率HasSynced 一直 falseList 失败、权限不足、分页异常看 reflectgor 的 List 日志与错误修 RBAC、检查中间层是否透传 continue内存持续增长缓存对象过大、Handler 里持有引用用 pprof 看堆上的对象类型用 TransformFunc 裁剪字段避免长期持有同一个对象频繁重复回调开了 resync对比连续回调的 resourceVersion在 Handler 内做幂等或版本判断只收到某个命名空间的事件构造时指定了 namespace检查 NewSharedInformerFactory 的参数确认范围是否符合预期6.2 几个我踩过的坑坑一在 Handler 里做同步写操作。早期写过一个控制器OnUpdate里直接调 API 更新状态结果遇到一次批量滚动更新几十个对象同时触发回调里排队等接口返回nextCh迅速打满然后整个 Informer 停止接收新事件——包括那些本该覆盖掉旧状态的删除事件。表现就是“状态卡在中间态永远不动”。改成写入 workqueue、由固定数量 worker 消费之后问题立刻消失。坑二以为 resync 能修复丢失的缓存。resync 只是把 Indexer 里的对象重新发一遍它不会去服务端重新拉数据。真正修复缓存失步的是 410 之后的全量 List。指望 resync 兜底是个常见的误解。坑三自定义 KeyFunc 时忘了保持一致性。DeltaFIFO 和 Indexer 必须用同一个 KeyFunc否则同一个对象在队列里和在缓存里是两个 keyknownObjects的 diff 就全乱了表现是删除事件不触发、更新被当成新增。用默认的MetaNamespaceKeyFunc基本不会踩到但一旦自定义就要两边都传。坑四忽略客户端的 QPS 限制。首批 List 分页请求、加上后续的 Watch 重连都会被客户端限速器管着。对象规模上来之后默认 5 QPS 会让你觉得“同步特别慢”但服务端指标一切正常。这种情况在-v6的日志里能看到明显的间隔。坑五多个 Informer 共享 factory 却各自加了大量 Handler。共享缓存的收益是实打实的但分发是串行的一个慢 Handler 拖累所有人。如果确实有轻重不一的需求考虑拆成两个 factory各自承担不同的资源负载。7. 我对这套机制的一点使用心得读源码这件事最大的收益不是记住每个函数名而是建立一种“知道哪里会堵、堵了会怎样”的直觉。我现在写任何 Informer 相关的代码第一反应是先问自己三个问题Handler 里有没有可能阻塞超过几十毫秒的操作这个 Informer 和谁共享了缓存resync 开了没有、Handler 能不能承受重放这三个问题答清楚基本能躲掉八成的坑。另外一个很实用的习惯是把LastSyncResourceVersion()打进监控。它比“缓存里对象数量”更能反映同步是否健康——对象数量不变可能是真的没变化但版本号长期不推进基本就说明 Watch 断了或者在反复退避。配合 Reflector 的重连日志定位问题的速度会快很多。如果你打算继续往下挖我建议的路径是先看DeltaFIFO的Replace和Resync两个方法把新旧集合的 diff 逻辑吃透再回头看HandleDeltas会发现整条链路一下就通了。至于sharedProcessor那一层把pop和run两个循环读一遍就够剩下的都是细节。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑