ZK--watch
1、简介
ZooKeeper的Watch机制是一种高效的事件通知机制,允许客户端监听ZooKeeper数据节点的变化,实现实时感知分布式系统的状态变更。
工作流程
-
注册Watch:客户端调用读操作(如
getData)时可选注册Watch。 -
事件触发:当监听节点发生对应操作(如数据修改),服务端生成事件。
-
通知客户端:服务端发送事件到客户端,客户端在事件线程中处理。
-
重新注册:若需持续监听,客户端需在处理事件后重新注册Watch。
核心特性
-
一次性触发:Watch在事件触发后自动失效,需重新注册。避免重复通知,但需注意处理事件后重新监听。
-
事件类型:支持多种事件(如节点创建、删除、数据变更、子节点变化),不同API注册不同事件。
-
异步通知:通过回调或事件队列通知客户端,不阻塞主流程。
-
顺序性保证:事件按ZooKeeper事务顺序触发,确保客户端处理顺序与服务器变更一致。
事件类型
| 事件类型 | 触发条件 | 注册方法 |
| NodeCreated | 节点被创建 | exists() |
| NodeDeleted | 节点被删除 | exists(),getData() |
| NodeDataChanged | 节点数据变更 | getData() |
| NodeChildrenChanged | 子节点列表变化(子节点增/删) | getChildren() |
注意事项
-
一次性触发:事件触发后需重新注册,避免遗漏后续变更。
-
会话失效处理:客户端会话过期时,所有Watch被清除,需在重连后重新注册。
-
避免羊群效应:大量客户端监听同一节点可能导致服务端负载激增。可通过以下方式优化:
-
分散监听不同子节点。
-
使用领导者选举模式,仅主节点监听关键变更。
-
-
状态一致性:事件仅通知变更,不携带具体数据,客户端需主动拉取最新状态。
-
网络延迟容忍:收到事件时节点可能已再次变更,需设计幂等操作或版本校验(如使用
Stat结构中的版本号)。
使用场景
分布式配置中心:
-
所有客户端监听配置节点
/config。 -
当配置更新时,触发
NodeDataChanged事件,客户端收到通知后拉取新配置。 -
优化:客户端监听父节点,或通过版本号控制,减少重复拉取。
集群节点管理:
-
监听服务注册节点
/services的子节点变化,实时感知服务实例上下线。
2、watch数量过多
watch数量过多一般是由客户端的行为模式导致的,所以需客户端规范使用watch。
2.1 watch数量过多的风险
服务端内存压力
-
内存占用
每个 Watch 在服务端都需要存储其元数据(如监听的节点路径、客户端会话信息)。当 Watch 数量过多时(例如百万级),会显著增加服务端内存消耗。 -
堆内存溢出
极端情况下,内存占用可能触发 JVM 的OutOfMemoryError,导致 ZooKeeper 服务崩溃。
性能下降
-
高 CPU 消耗
频繁的 Watch 触发会导致服务端需要处理大量事件通知,占用 CPU 资源。 -
网络带宽压力
Watch 事件通知需要通过网络发送给客户端,若通知频率过高,可能造成网络拥塞。 -
高延迟
服务端处理大量 Watch 事件时,其他请求(如读/写操作)可能被阻塞,导致集群整体延迟上升。
客户端资源耗尽
-
客户端处理瓶颈
客户端收到大量 Watch 事件后,若处理逻辑复杂(如触发业务操作),可能导致客户端线程池满载、任务堆积甚至崩溃。 -
事件丢失风险
若客户端处理速度跟不上事件触发速度,可能导致 Watch 事件丢失或延迟。
会话稳定性问题
-
会话超时风险
客户端处理大量 Watch 事件时,可能因线程阻塞而无法及时发送心跳(Ping),导致会话超时并被服务端关闭。
2.2 案例
-
场景 1:频繁更新节点数据
某服务频繁调用setData()更新节点,导致该节点上的所有 Watch 被触发,产生大量事件通知。 -
场景 2:递归监听子节点
客户端对父节点及其所有子节点注册 Watch(如ls -w /parent),当子节点数量庞大时,Watch 数量呈指数级增长。 -
场景 3:未正确管理 Watch
客户端在 Watch 回调中重复注册 Watch(如每次触发后重新注册),导致 Watch 数量无限增长。
2.3 如何避免 Watch 数量过多
设计优化
-
避免全局监听
不要对根节点(如/)或父节点递归监听,应仅监听必要的子节点。 -
合并高频操作
减少对同一节点的频繁更新(如使用批量写入或本地缓存)。 -
使用一次性 Watch
在 ZooKeeper 3.6.0+ 中,支持WatcherMode.ONCE,确保 Watch 触发后自动移除。
代码规范
-
及时移除 Watch
在不需要监听时,主动调用removeWatches()移除 Watch。 -
避免循环触发
确保 Watch 回调逻辑中不会重新注册 Watch 导致循环触发。
监控与告警
-
监控 Watch 数量
通过 ZooKeeper 四字命令(如stat)或 JMX 指标(如WatchCount)实时监控 Watch 总量。 -
设置阈值告警
当 Watch 数量超过预设阈值时(如 10 万),触发告警通知运维人员。
3、watch相关命令(3.4.10版本)
查看当前节点上 客户端显式注册的 Watch 总数(不含内部 Watch)
[root@test ~]# echo wchs | nc localhost 2181 # 2181 为 ZK 客户端端口 133 connections watching 13 paths # 133个连接监听着13个路径 Total watches:326 # 总 Watch 数量
查看当前节点上 所有 Watch 的总数(含客户端注册的 Watch 和 ZooKeeper 内部 Watch)
[root@test ~]# echo mntr | nc localhost 2181 | grep watch zk_watch_count 888
按会话列出 Watch 路径(不含内部 Watch)
[root@test ~]# echo wchc | nc localhost 2181 0x19535a915a30229 /dubbo/config/dubbo/com.oppo.os.gx.mix.router.service.IRouterServiceAppStoreNature:1.0.0:appNatureBrowserCardPre.configurators 0x19535a915a3023b /dubbo/config/dubbo/browser-ad-service.tag-router 0x19535a915a30237 /dubbo/config/dubbo/com.oppo.browser.common.api.feeds.IBrowserAdsAsync:3.0.0:ads.configurators ...
按路径列出 Watch 会话(不含内部 Watch)
[root@test ~]# echo wchp | nc localhost 2181 /dubbo/config/dubbo/com.oppo.os.gx.mix.router.service.IRouterServiceAppStoreNature:1.0.0:appNatureBrowserCardPre.condition-router 0x1954bafff1d028e 0x19535a915a3023f 0x19535a915a3023b 0x19535a915a30239 0x19535a915a30237 0x19535a915a3023b 0x19535a915a30237 0x19535a915a30243 0x19535a915a30229 0x19535a915a3023b 0x19535a915a3023b 0x19535a915a3023f 0x19535a915a3023b 0x19535a915a30239 0x19535a915a30239 0x19535a915a30237 /dubbo/config/dubbo/com.oppo.os.gx.mix.router.service.IRouterServiceAppStoreNature:1.0.0:appNatureBrowserCardPre.configurators 0x19535a915a30229 0x1954bafff1d028e 0x19535a915a3023f 0x19535a915a3023b ...
4、找出触发大量watch的客户端ip
查看watch的session_id和路径关系
echo wchc | nc localhost 2181 > /home/wchc.log
查看session_id 和客户端IP的对应关系
echo cons | nc localhost 2181 > /home/cons.log
解析log文件,输出各个ip和路径的watch数,及各个ip的watch数
package main import ( "bufio" "fmt" "os" "regexp" "strings" ) // 定义数据结构 type ( // SessionID 到 IP 的映射 SessionIPMap map[string]string // SessionID 到路径列表的映射 SessionPathsMap map[string][]string // IP 到路径计数的映射 (路径 -> 数量) IPPathCount map[string]map[string]int // IP 到总 Watch 数的映射 IPTotalCount map[string]int ) func main() { // 步骤 1: 解析 cons.log 文件,获取到SessionID 到 IP 的映射,如:{"0x123abc": "192.168.1.2","0x456abc": "10.0.0.5"} sessionIP := parseCons("logs/cons.log") // 步骤 2: 解析 wchc.log 文件,获取SessionID 到路径列表的映射,如:{"0x123abc": ["/path/to/resource1", "/path/to/resource2"],"0x456def": ["/path/to/resource3"]} sessionPaths := parseWchc("logs/wchc.log") // 步骤 3: 关联数据并统计结果 ipPathCount, ipTotalCount := analyze(sessionIP, sessionPaths) // 步骤 4: 打印统计结果 printResults(ipPathCount, ipTotalCount) } // 解析 cons.log 文件 func parseCons(filename string) SessionIPMap { sessionIP := make(SessionIPMap) // 打开文件 file, err := os.Open(filename) if err != nil { panic(fmt.Sprintf("无法打开文件 %s: %v", filename, err)) }// 在函数结束时关闭文件 defer file.Close() // 定义正则表达式 ipRegex := regexp.MustCompile(`/(\d+\.\d+\.\d+\.\d+):\d+`) sidRegex := regexp.MustCompile(`sid=([0-9a-fx]+)`) // 创建一个新的扫描器,可以逐行读取文件内容,处理输入数据 scanner := bufio.NewScanner(file) for scanner.Scan() { // 逐行读取 line := scanner.Text() // 获取当前行的内容 // 提取 IP 地址 // 查找字符串中与正则表达式匹配的子字符串,返回一个字符串切片,其中第一个元素是完整匹配的结果,后续元素是捕获组的匹配结果。 ipMatch := ipRegex.FindStringSubmatch(line) // 检查 ipMatch 切片的长度是否小于 2,如果小于 2,表示没有找到任何匹配的捕获组,程序将跳过当前循环的剩余部分,继续处理下一个 line,因为第一个元素是字符串本身 if len(ipMatch) < 2 { continue } // 将 ipMatch[1] 赋值给变量 ip ip := ipMatch[1] // 提取会话 ID sidMatch := sidRegex.FindStringSubmatch(line) if len(sidMatch) < 2 { continue } sid := sidMatch[1] // 存储映射关系 sessionIP[sid] = ip } return sessionIP } // 解析 wchc.log 文件 func parseWchc(filename string) SessionPathsMap { sessionPaths := make(SessionPathsMap) // 打开文件 file, err := os.Open(filename) if err != nil { panic(fmt.Sprintf("无法打开文件 %s: %v", filename, err)) }// 在函数结束时关闭文件 defer file.Close() // 匹配会话 ID 的正则 (0x 开头 + 16进制数字) sidRegex := regexp.MustCompile(`^0x[0-9a-f]+$`) // 创建sessionID变量 var currentSID string // 创建一个新的扫描器,可以逐行读取文件内容,处理输入数据 scanner := bufio.NewScanner(file) for scanner.Scan() { // 逐行读取 line := strings.TrimSpace(scanner.Text()) // 获取当前行的内容,并去除行首和行尾的空白字符 //fmt.Println(line) // 检查是否是会话 ID 行 if sidRegex.MatchString(line) { // 若匹配成功,将 currentSID 设置为该行内容,并跳过后续代码,开始下一次循环 currentSID = line continue } else { // 不是ID行,即为路径行 path := line // 将路径添加到 sessionPaths 映射中,sessionPaths 的键是 currentSID,值是路径的切片 sessionPaths[currentSID] = append(sessionPaths[currentSID], path) } } return sessionPaths } // 关联数据并统计 func analyze(sessionIP SessionIPMap, sessionPaths SessionPathsMap) (IPPathCount, IPTotalCount) { ipPathCount := make(IPPathCount) // ip+路径 watch计数 ipTotalCount := make(IPTotalCount) // ip watch总计算 // 遍历sessionPaths所有会话的路径 for sid, paths := range sessionPaths { // 使用会话 ID 查找对应的 IP 地址。如果找不到对应的 IP,则跳过当前循环 ip, exists := sessionIP[sid] if !exists { continue // 忽略无对应 IP 的会话 } // 检查 ipPathCount 中是否已经有该 IP 的记录。如果没有,则初始化一个新的 map 来存储路径计数 if _, exists = ipPathCount[ip]; !exists { ipPathCount[ip] = make(map[string]int) } // 遍历当前会话 ID 对应的路径列表 for _, path := range paths { ipPathCount[ip][path]++ // 对于每个路径,在 ipPathCount 中增加该路径的访问计数 ipTotalCount[ip]++ // 在 ipTotalCount 中增加该 IP 的总访问计数 } } return ipPathCount, ipTotalCount } // 打印结果 func printResults(ipPathCount IPPathCount, ipTotalCount IPTotalCount) { // 打印总览 fmt.Println("=== 总 Watch 数统计 ===") for ip, total := range ipTotalCount { // %-15s:输出一个字符串,左对齐,15 表示字段宽度为 15 个字符,如果字符串长度小于 15,会在右侧填充空格。 fmt.Printf("IP: %-15s Total: %d\n", ip, total) } // 打印详细路径分布 fmt.Println("\n=== 详细路径分布 ===") for ip, paths := range ipPathCount { fmt.Printf("\nIP: %s\n", ip) for path, count := range paths { fmt.Printf(" %-60s => %d\n", path, count) } } }
结果展示
=== 总 Watch 数统计 === IP: ***.***.100.67 Total: 189 IP: ***.***.216.191 Total: 218 IP: ***.81.170.32 Total: 45 === 详细路径分布 === IP: ***.***.100.67 /dubbo/config/... => 28 /dubbo/config/... => 16 ... IP: ***.***.216.191 /dubbo/config/... => 19 /dubbo/config/... => 19 /dubbo/config/... => 21 ...

浙公网安备 33010602011771号