ZK--watch

1、简介

ZooKeeper的Watch机制是一种高效的事件通知机制,允许客户端监听ZooKeeper数据节点的变化,实现实时感知分布式系统的状态变更。

 

工作流程

  1. 注册Watch:客户端调用读操作(如getData)时可选注册Watch。

  2. 事件触发:当监听节点发生对应操作(如数据修改),服务端生成事件。

  3. 通知客户端:服务端发送事件到客户端,客户端在事件线程中处理。

  4. 重新注册:若需持续监听,客户端需在处理事件后重新注册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
  ...

 

posted @ 2025-03-06 16:09  心恩惠动  阅读(103)  评论(0)    收藏  举报