clientv3 Watch 机制需配合启动时 Get 全量数据构建初始路由表,Watch 回调仅推事件至 channel,由独立 goroutine 持锁更新路由表,Director 函数必须基于本地缓存做 O(1) 路由决策,严禁同步查 etcd。

clientv3 的 Watch 机制不是轮询,但也不是“开箱即用”的实时路由更新方案。动态路由寻址的关键不在监听本身,而在如何把 etcd 中的服务变化,安全、低延迟、不抖动地映射到实际请求转发路径上。
Watch 前必须先 Get 全量,否则首次路由就是空的
刚启动的网关如果只 Watch 而不 Get,会错过所有历史注册实例——etcd 的 watch 流从当前 revision 开始,不会回溯。这意味着服务起来了,但网关完全不知道。
- 启动时先调用
client.Get(ctx, prefix, clientv3.WithPrefix())拉取全部存活实例(注意:只取Kvs,忽略Count) - 解析
kv.Key和kv.Value构建初始路由表,例如/services/user/10.0.1.2:8080→"10.0.1.2:8080" - 再用同一 ctx 启动
client.Watch(ctx, prefix, clientv3.WithPrefix(), clientv3.WithPrevKV()) - 不要在
Get和Watch之间 sleep 或做耗时操作,避免 revision 跳变
Watch 回调里只能推事件,不能改路由表
gRPC 或 Gin 的请求处理是并发的,直接在 Watch 回调里修改全局 map 或 slice,必然触发 fatal error: concurrent map writes。
开箱即用的技能链路由引擎。13 条预定义链覆盖搜索、开发、审查、MLOps、法律、创意等场景,三层路由架构(触发词→SAD反馈→DAG编排),recall@10=96.97%。配置驱动(chains.yaml),零代码扩展。pip install skill-weave-chains 一键安装。
- Watch 回调中只做三件事:解析
ev.Kv.Key得到服务名和地址、判断ev.Type是EventTypePut还是EventTypeDelete、把结构化事件(如{Service: "user", Addr: "10.0.1.2:8080", Op: "add"})推入一个无缓冲或带限长的chan - 另起一个 goroutine 持续消费该 channel,用
sync.RWMutex保护路由表读写:写时Lock(),读时RLock() - 避免在回调里调
http.Get、db.Query、time.Sleep—— 一旦阻塞,整个 watch 流就卡住,后续事件积压甚至丢弃
租约过期 ≠ 主动下线,要靠 WithPrevKV 区分
同一个 DeleteEvent 可能来自两种完全不同的场景:服务进程正常退出调用了 Revoke,或者网络断连导致 lease 过期被 etcd 自动清理。前者可立即剔除节点;后者需加熔断标记,避免刚恢复就被打爆。
- Watch 必须传
clientv3.WithPrevKV(),否则ev.PrevKv为 nil,无法判断删除前 key 是否存在 - 若
ev.PrevKv != nil,说明是 lease 过期自动删(被动下线),可标记该地址为“暂不可用”,保留几秒观察是否重连 - 若
ev.PrevKv == nil,说明 key 原本就不存在,大概率是主动注销或首次注册失败,直接忽略或记录告警 - 不区分这两者,会导致网关在抖动期间反复重建连接池,引发下游雪崩
Director 函数里禁止查 etcd,路由必须本地缓存
Director 是 gRPC 反向代理或 Gin 中间件里每次请求都执行的函数。在这里同步调用 client.Get 或 client.Watch,吞吐量立刻归零——etcd RTT 通常 5–50ms,QPS 直接掉两个数量级。
- 所有路由决策必须基于已加锁保护的本地内存结构(如
map[string][]string),Director内只做 O(1) 查表 + 随机/轮询选节点 - etcd 变更只驱动后台 goroutine 更新这个 map,绝不穿透到请求路径
- 如果路由规则本身也存 etcd(比如 /routes/api/user → user-service),那也要走同样模式:启动时全量加载 + watch 更新内存副本,
Director不碰网络


















