蓝鲸 CMDB 3.14.6 源码专题【左扬精讲】— CMDB #12:Watch 事件订阅 — MongoDB Change Stream 推送机制与实时事件通知
蓝鲸 CMDB 3.14.6 源码专题【左扬精讲】— CMDB #12:Watch 事件订阅 — MongoDB Change Stream 推送机制与实时事件通知
主机 IP 变了、业务拓扑调整了模块归属,这些变更发生后,监控系统怎样才能在秒级内感知?轮询不行——太慢,而且会把 CMDB 打挂。蓝鲸 CMDB 用的是 MongoDB Change Stream + 自研 Watch 事件订阅体系,实现变更的实时推送。
本文从源码出发,讲解 CMDB 事件订阅体系的完整链路:Change Stream 如何监听 MongoDB 变更、ChainNode 事件链如何组织和存储、WatchClient 如何处理 Cursor/StartFrom 两种游标模式、以及订阅者如何从 Redis 和 MongoDB 中获取事件详情。SRE 可以据此理解:我的监控系统应该调用哪个 API、传什么参数、如何处理事件重复消费的问题。
WatchChange Stream事件订阅Cursorcache-service实时通知
src/common/watch/watch.go ← WatchEventOptions / WatchResp / WatchEventDetail 定义
src/common/watch/types.go ← EventType 常量(create/update/delete) / ChainNode 结构体
src/storage/stream/event/watch.go ← MongoDB Change Stream 底层实现
src/source_controller/cacheservice/event/key.go ← 各资源类型的 Key 定义(HostKey / BizKey / SetKey 等)
src/source_controller/cacheservice/event/watch/watch.go ← WatchWithStartFrom 核心逻辑
src/source_controller/cacheservice/event/watch/client.go ← getLatestEvent / getEventDetail 读取逻辑
学习重点
- 必须掌握:WatchEventOptions 的三个关键字段(bk_event_types / bk_fields / bk_cursor);ChainNode 的五个核心字段(ID / ClusterTime / Oid / EventType / Cursor)
- 必须掌握:WatchWithStartFrom 的三种返回分支(无事件 / 从头查 / 游标续读);getEventDetail 的 Redis → MongoDB fallback 策略
- 理解:MongoDB Change Stream resume token 机制;TTL 秒级与游标有效性;Watch 长连接超时机制
目录导航
1. Watch 事件订阅体系:整体架构
思考
在 SRE 监控场景下,"主机 IP 变了"这个事件发生后,监控系统(Prometheus / Grafana / 自研告警平台)需要尽快知道。但 CMDB 是一个写多读少的系统,轮询查询会造成严重的读压力。
CMDB 的解决方案是:让订阅者"等通知",而不是"主动去问"。具体机制是:CMDB 的写入操作触发 MongoDB Change Stream 事件,cache-service 将事件写入 cc 事件链(Chain)表,订阅者通过 HTTP Long Polling 拉取。
整个 Watch 体系分为三层:
| 层次 | 组件 | 职责 |
|---|---|---|
| 数据层 | MongoDB Change Stream | 监听所有集合的写操作(insert / update / replace / delete) |
| 存储层 | ChainNode 事件链表(cc_WatchChain_*) | 将 Change Stream 事件结构化存储,供多次消费 |
| 接口层 | cache-service watch API | 接收订阅请求,返回事件列表(含 Cursor) |
三层分工的设计意图:Change Stream 是实时的一性次管道(断连后 token 失效),所以需要 ChainNode 表做持久化,让订阅者可以多次消费、游标续读。
2. MongoDB Change Stream:变更监听的底层实现
What — Change Stream 在 Watch 体系中扮演什么角色?
Change Stream 是 MongoDB 3.6+ 引入的特性,本质是对 oplog 的高级封装,提供实时、有序、可续读的变更流。CMDB 在 src/storage/stream/event/watch.go 中封装了 Change Stream 的调用,对上层屏蔽了管道构造、错误恢复等细节。
Why — 为什么选择 Change Stream 而不是轮询或直接监听 oplog?
对比轮询:轮询需要定时 SELECT,频率和读压力成正比。Change Stream 是推送模式,有变更才通知,零空载读压力。
对比直接监听 oplog:oplog 是内部格式,字段不透明,且不同 MongoDB 版本格式可能变化。Change Stream 提供稳定的 API 接口,且支持 resume token(断连续读)。
没有 Change Stream 会发生什么?
- SRE 监控系统只能轮询,每次轮询全量查一次 MongoDB,大规模集群下读压力是灾难级的
- 变更通知延迟 = 轮询间隔,最快也要等一个轮询周期(通常 30 秒以上)
- CMDB 自身也依赖 Change Stream 做 transfer-service 的增量同步(Watch 模块),没有它增量同步退化为全量同步
看 src/storage/stream/event/watch.go 第 31-60 行,Change Stream 的核心调用:
// Watch:MongoDB Change Stream 的封装入口
// ctx:生命周期控制;opts:管道和选项配置
func (e *Event) Watch(ctx context.Context, opts *types.WatchOptions) (*types.Watcher, error) {
if err := opts.CheckSetDefault(); err != nil {
return nil, err
}
eventChan := make(chan *types.Event, types.DefaultEventChanSize)
go func() {
// 构造 MongoDB Aggregation Pipeline(过滤条件在这里组装)
pipeline, streamOptions := generateOptions(&opts.Options)
// 对指定集合建立 Change Stream
stream, err := e.client.
Database(e.database).
Collection(opts.Collection). // 如 "cc_HostBase"
Watch(ctx, pipeline, streamOptions)
// 遍历 Change Stream 事件,写入 eventChan
for stream.Next(ctx) {
var event types.Event
if err := stream.Decode(&event); err != nil {
blog.Errorf("decode change stream event failed, err: %v", err)
continue
}
eventChan
一个关键细节:Change Stream 的 resume token
MongoDB Change Stream 会为每个事件生成一个 resume token(本质上是一个 opaque 的 BSON 文档)。这个 token 通过 ChainNode.Cursor 字段持久化到 cc_WatchChain 表。订阅者下次调用 Watch API 时传 bk_cursor,cache-service 从 ChainNode 表里找到对应位置,从下一条继续读。这解决了"长连接断开后如何续读"的问题。
3. ChainNode 事件链:变更的组织与存储结构
What — ChainNode 在 Watch 体系中扮演什么角色?
ChainNode 是 CC(Cache Service)存储每条变更事件的最小单元。每一次 MongoDB 写操作(insert / update / delete)都会生成一条 ChainNode 记录,写入 cc_WatchChain_{资源类型} 表。它同时承载了"元数据"(事件类型 / 资源 ID / 时间戳)和"定位信息"(Cursor / Token)。
看 src/common/watch/types.go 第 60-80 行,最权威的 ChainNode 定义:
// ChainNode:事件链节点,一次变更对应一条记录
type ChainNode struct {
// ID:自增主键,用于顺序遍历(按 ID ASC 排序即时间序)
ID uint64 `json:"id" bson:"id"`
// ClusterTime:MongoDB 集群时间(逻辑时钟),是事件发生的真实时间
ClusterTime types.TimeStamp `json:"cluster_time" bson:"cluster_time"`
// Oid:变更文档的 _id,即 MongoDB 中那条被修改的文档的 ObjectID
Oid string `json:"oid" bson:"oid"`
// EventType:变更类型(create / update / delete)
EventType EventType `json:"type" bson:"type"`
// Token:MongoDB Change Stream 的 resume token,与 Cursor 一一对应
Token string `json:"token" bson:"token"`
// Cursor:CC 自研的游标字段,由 Token 哈希生成,1:1 映射
Cursor string `json:"cursor" bson:"cursor"`
// InstanceID:被变更的实例 ID(如主机 ID),便于按 ID 过滤
InstanceID int64 `json:"inst_id,omitempty" bson:"inst_id,omitempty"`
// SubResource:子资源 ID(如自定义模型 ID),用于 ObjectBase 类型的事件过滤
SubResource []string `json:"bk_sub_resource,omitempty" bson:"bk_sub_resource,omitempty"`
// SupplierAccount:开发商账号,用于多租户隔离
SupplierAccount string `json:"bk_supplier_account" bson:"bk_supplier_account"`
}
ClusterTime vs Token:为什么需要两个时间字段?
ClusterTime 是 MongoDB 逻辑时钟,精确到毫秒,适合做时间范围查询(如"查询最近 5 分钟的事件")。Token 是 MongoDB Change Stream 的 opaque token,格式不透明,但可以精确地从任意位置续读。在 CMDB 中,Cursor = Token 的哈希值(1:1 映射),两个字段各司其职。
ChainNode 表的命名
每个资源类型对应一张 ChainNode 表,命名规则是 cc_WatchChain_{资源名},如 cc_WatchChain_host。定义在 src/source_controller/cacheservice/event/key.go 各 Key 的 namespace 字段。
4. WatchKey:六种资源类型的 Key 定义
cache-service 的 Watch 模块为每种资源类型定义了独立的 WatchKey,包含集合名、TTL、字段校验器等信息。看 src/source_controller/cacheservice/event/key.go 第 31-52 行,对主机资源最完整的 Key 定义:
// watchCacheNamespace = common.BKCacheKeyV3Prefix + "watch:" = "cc:v3:watch:"
// 完整 namespace = "cc:v3:watch:host"
// 完整 collection = "cc_HostBaseWatchChain"(collection + "WatchChain")
var HostKey = Key{
namespace: watchCacheNamespace + "host",
collection: common.BKTableNameBaseHost, // = "cc_HostBase"
ttlSeconds: 6 * 60 * 60, // TTL = 6 小时,游标有效期 6 小时
generalResCacheKey: general.HostKey,
// 字段校验器:写入 ChainNode 前校验文档是否包含必填字段
validator: func(doc []byte) error {
fields := gjson.GetManyBytes(doc, hostFields...)
for idx := range hostFields {
if !fields[idx].Exists() {
return fmt.Errorf("field %s not exist", hostFields[idx])
}
}
return nil
},
// 实例展示名:用于日志和告警,如 "192.168.1.1:0"(IP:云区域ID)
instName: func(doc []byte) string {
fields := gjson.GetManyBytes(doc, hostFields...)
return fields[1].String() + ":" + fields[2].String()
},
// 实例 ID:从文档中提取主机 ID
instID: func(doc []byte) int64 {
return gjson.GetBytes(doc, common.BKHostIDField).Int()
},
}
六种内置资源类型的 Key 定义一览:
| Key 变量名 | Collection(变更来源) | ChainNode 表 | TTL |
|---|---|---|---|
| HostKey | cc_HostBase | cc_HostBaseWatchChain | 6 小时 |
| BizKey | cc_ApplicationBase | cc_ApplicationBaseWatchChain | 6 小时 |
| SetKey | cc_SetBase | cc_SetBaseWatchChain | 6 小时 |
| ModuleKey | cc_ModuleBase | cc_ModuleBaseWatchChain | 6 小时 |
| ObjectBaseKey | cc_ObjectBase | cc_ObjectBaseWatchChain | 6 小时 |
| ProcessRelationKey | cc_ProcessInstanceRelation | cc_ProcessInstanceRelationWatchChain | 6 小时 |
TTL = 6 小时的含义是:游标(Cursor / Token)的有效窗口是 6 小时。如果订阅者拿着一个 7 小时前的 Cursor 去查,会得到"无事件"响应(因为那条 ChainNode 记录已被 MongoDB TTL Index 删除)。SRE 在配置监控系统时必须注意:轮询间隔不能超过 6 小时。
5. WatchWithStartFrom:游标与时间戳两种订阅方式
What — WatchWithStartFrom 在 Watch 体系中扮演什么角色?
WatchWithStartFrom 是 cache-service 对外暴露的核心 Watch 方法,处理两种订阅方式:1)传 bk_start_from(Unix 时间戳),从该时间点之后的事件开始读;2)传 bk_cursor(游标),从指定位置精确续读。它在 src/source_controller/cacheservice/event/watch/watch.go 第 53 行开始定义。
Why — 为什么需要两种订阅方式?
bk_start_from 适合"首次订阅"的场景,监控系统刚接入,不知道上次看到哪了,按时间窗口拉取即可。bk_cursor 适合"断线重连"的场景,监控系统拿着上次返回的 Cursor 从断开位置续读,不需要知道具体时间。两种方式不能同时使用(校验在 watch.go 第 68-70 行)。
没有两种订阅方式会发生什么?
- 监控系统每次都要从某个固定时间开始查,产生大量重复事件
- 长连接断开后,无法精确续读,只能从上次时间戳继续,可能漏事件或重复消费
看 src/source_controller/cacheservice/event/watch/watch.go 第 53-159 行,完整流程有三个分支:
// 常量定义
const (
timeoutWatchLoopSeconds = 20 // HTTP 长连接超时 20 秒
loopInternal = 250 * time.Millisecond // 每次轮询间隔 250ms
eventStep = 200 // 每次最多返回 200 条事件
)
func (c *Client) WatchWithStartFrom(kit *rest.Kit, key event.Key,
opts *watch.WatchEventOptions) ([]*watch.WatchEventDetail, error) {
// === 分支一:bk_start_from 超出了 TTL 有效期 ===
// diff > key.TTLSeconds() → 游标已过期,返回 NoEventCursor
diff := time.Now().Unix() - opts.StartFrom
if diff < 0 || diff > key.TTLSeconds() {
return nil, kit.CCError.CCErrorf(common.CCErrCommParamsInvalid, "bk_start_from")
}
// === 分支二:无事件(游标在有效期内但无新事件)===
// 从 ChainNode 表查最新一条记录的时间
tailNode, exists, err := c.getLatestEvent(kit, key)
if !exists { // 表为空
return []*watch.WatchEventDetail{{Cursor: watch.NoEventCursor}}, nil
}
// 最新事件时间 <= StartFrom → 无新事件,返回 NoEventCursor
if int64(tailNode.ClusterTime.Sec) <= opts.StartFrom {
return []*watch.WatchEventDetail{{Cursor: watch.NoEventCursor}}, nil
}
// === 分支三:有事件,从 StartFrom 时间点开始遍历 ===
// 查第一条 ClusterTime > StartFrom 的 ChainNode
filter := map[string]interface{}{
common.BKClusterTimeField: map[string]interface{}{
common.BKDBGT: metadata.Time{Time: time.Unix(opts.StartFrom, 0).Local()},
},
}
node := new(watch.ChainNode)
err = c.watchDB.Table(key.ChainCollection()).
Find(filter).Sort(common.BKFieldID).One(kit.Ctx, node) // ID ASC = 时间正序
// 按 eventStep 批量查询后续 ChainNode,构造 WatchEventDetail 返回
searchOpt := &searchFollowingChainNodesOption{
id: node.ID,
limit: eventStep, // 最多 200 条
types: opts.EventTypes,
key: key,
}
events := c.searchFollowingChainNodes(searchOpt)
return events, nil
}
长连接超时机制:20 秒
HTTP 订阅请求默认最多等待 20 秒(timeoutWatchLoopSeconds)。在这 20 秒内,如果累计满 200 条事件或有新事件到达,立即返回;如果 20 秒内没有新事件,也立即返回 NoEventCursor。这样设计既保证了实时性(最多 250ms 延迟),又避免了 HTTP 连接永久挂起。
bk_cursor 游标模式的处理逻辑
游标模式不走 StartFrom 分支,而是通过 WatchWithCursor()(第 160 行起)。它根据 Cursor 找到对应的 ChainNode ID,从该 ID + 1 的位置继续遍历,逻辑与分支三一致,只是起点由 Cursor 决定而非时间戳。
6. getEventDetail:Redis → MongoDB 降级读取策略
What — getEventDetail 在 Watch 体系中扮演什么角色?
ChainNode 只存储了元数据(谁变了、什么时候变的),但没有存储变更的详细数据(变前值 / 变后值)。getEventDetail() 的职责是根据 ChainNode 的 Oid,从 Redis 缓存或 MongoDB 原始集合中拉取变更详情,返回给订阅者。
Why — 为什么需要 Redis 降级策略?
变更详情是高频读取的数据(每次 Watch 都要查),如果每次都从 MongoDB 原始集合查,CMDB 的读压力会翻倍。Redis 作为前置缓存,热点数据直接从缓存命中,缓存未命中才降级查 MongoDB。这是典型的 Cache-Aside 模式。
没有 Redis 降级会发生什么?
- 每次 Watch 都要读 MongoDB,Watch 监控系统的接入量 × 事件频率 = MongoDB 额外读压力
- 高并发下 MongoDB 成为瓶颈,Watch 接口响应变慢,监控系统超时
- 缓存层还可以对热点主机(如核心业务主机)的变更做本地缓存,进一步减少网络开销
看 src/source_controller/cacheservice/event/watch/client.go 第 96-126 行:
// getEventDetail:根据 ChainNode 获取变更详情
// 降级策略:Redis 缓存 → MongoDB 原始集合
func (c *Client) getEventDetail(kit *rest.Kit, node *watch.ChainNode,
fields []string, key event.Key) (*string, bool, error) {
coll := key.Collection()
switch coll {
// 特殊处理:主机身份类事件走专用查询
case event.HostIdentityKey.Collection():
details, err := c.getHostIdentityEventDetailWithNodes(kit, []*watch.ChainNode{node})
if err != nil { return nil, false, err }
return getFirstEventDetail(details)
case event.BizSetRelationKey.Collection():
details, err := c.getBizSetRelationEventDetailWithNodes(kit, []*watch.ChainNode{node})
if err != nil { return nil, false, err }
return getFirstEventDetail(details)
default:
// 标准路径:先从 Redis 缓存读
detail, err := c.getEventDetailFromRedis(kit, node, fields, key)
if err == nil {
return detail, true, nil // 缓存命中
}
// Redis 未命中,打 error 日志,降级到 MongoDB 直接查
blog.Errorf("get event detail from redis failed, will get from db directly, err: %v", err)
return c.getEventDetailFromMongo(kit, node, fields, key)
}
}
一个关键细节:为什么 HostIdentity 和 BizSetRelation 是特殊处理?
主机和业务集关联的变更详情比较复杂,涉及多个关联字段,不能简单地从 cc_HostBase 表里按 Oid 查一条记录。需要根据 Node.InstID(主机 ID)做额外的关联查询,生成完整的变更前后对比数据。这两个特殊 case 是 watch 模块里代码量最大的部分。
7. SRE 实践:监控系统如何接入 Watch 事件
以 Prometheus 为例,讲解如何接入 CMDB Watch 事件订阅。
Step 1:首次订阅(使用 bk_start_from)
// POST /api/v3/watch/host
// 首次订阅,从当前时间往前推 5 分钟开始读(兼容 TTL=6小时)
Request:
{
"bk_event_types": ["update"], // 只关心变更事件,不关心创建/删除
"bk_fields": ["bk_host_innerip", "bk_os_name", "bk_host_id"],
"bk_start_from": 1751803200, // Unix 时间戳(秒)
"bk_resource": "host"
}
Response(有事件时):
{
"bk_watched": true,
"bk_events": [
{
"bk_cursor": "eyJjbHVzdGVyX3RpbWUiOi4uLn0=", // 下次续读用这个
"bk_resource": "host",
"bk_event_type": "update",
"bk_detail": "{\"bk_host_innerip\":\"192.168.1.100\",\"bk_os_name\":\"linux\"}"
}
]
}
Response(无事件时):
{
"bk_watched": false,
"bk_events": []
}
Step 2:长连接轮询(每次传上次返回的 bk_cursor)
// 后续轮询:拿上次的 bk_cursor 精确续读
// 轮询间隔建议 5-10 秒(不要超过 6 小时,否则游标过期)
Request:
{
"bk_event_types": ["create", "update", "delete"],
"bk_fields": ["bk_host_innerip", "bk_os_name", "bk_host_id"],
"bk_cursor": "eyJjbHVzdGVyX3RpbWUiOi4uLn0=", // 上次返回的游标
"bk_resource": "host"
}
Step 3:游标过期处理
当返回的 bk_cursor 对应的 ChainNode 已被 TTL 删除(超过 6 小时),CMDB 会返回 NoEventCursor。此时监控系统应该用 bk_start_from 重新初始化(从当前时间往前推 5 分钟)。这是游标模式退化到时间戳模式的唯一出口。
注意:SRE 常见误区
- 不要用 bk_start_from 做常规轮询:每次都从时间点扫描 ChainNode 表,大量重复扫描历史记录
- 不要忽略 bk_event_types 过滤:只关心 update 就只传 update,减少无效数据传输
- 不要把轮询间隔设到 5 小时:虽然小于 TTL(6 小时),但 CMDB 服务器时间漂移可能导致游标提前过期
8. FAQ 20 问
常见疑问
以下是关于 Watch 事件订阅体系的高频问题,每个问题都基于源码给出明确答案。
Q1. Watch 订阅和轮询的本质区别是什么?
一句话结论:Watch 是推送模式(Long Polling),变更发生后由 CMDB 主动通知;轮询是拉取模式,监控系统主动查询。Watch 通过 HTTP 长连接(20 秒超时)实现,变更发生时被立即返回;轮询则无论有没有变更都要定时查询。
Q2. bk_cursor 和 bk_start_from 哪个更好用?
一句话结论:首次订阅用 bk_start_from,断线重连用 bk_cursor。从 watch/watch.go 第 68-70 行可以看到,两者互斥,不能同时使用。bk_start_from 适合不知道上次位置的情况,bk_cursor 适合精确续读。
Q3. 游标(Cursor)有效期的限制是多少?
一句话结论:6 小时。从 event/key.go 第 34 行可以看到,TTL 秒级 = 6 * 60 * 60。游标过期后 CMDB 返回 NoEventCursor,监控系统需要用 bk_start_from 重新初始化。
Q4. 为什么 Watch 返回的事件最多只有 200 条?
一句话结论:eventStep 默认值是 200,防止一次返回过多数据撑爆 HTTP 响应。从 event/watch/watch.go 第 50 行可以看到,eventStep = 200。如果事件超过 200 条,剩下的在下一次轮询中继续返回。
Q5. 变更详情(bk_detail)从哪里读取?
一句话结论:优先从 Redis 缓存读取,未命中则降级到 MongoDB 原始集合。从 event/watch/client.go 第 97-126 行可以看到,getEventDetail 优先调用 getEventDetailFromRedis,失败才查 MongoDB。
Q6. Change Stream 断连后如何续读?
一句话结论:通过 ChainNode.Token(resume token)续读。Token 由 MongoDB Change Stream 原生生成,存储在 ChainNode 表中。订阅者下次调用时传 bk_cursor,cache-service 从 ChainNode 表找到对应记录,从下一条继续遍历。
Q7. Watch 支持哪几种事件类型?
一句话结论:create / update / delete / unknown 四种。从 src/common/watch/types.go 第 24-33 行可以看到,EventType 是 string 类型,四个常量值:create / update / delete / unknown。
Q8. HostIdentity 和 BizSetRelation 为什么走特殊处理?
一句话结论:因为它们的变更详情需要多表关联查询,不能直接按 Oid 查单表。从 event/watch/client.go 第 102-114 行可以看到,这两个 case 调用专用方法 getHostIdentityEventDetailWithNodes / getBizSetRelationEventDetailWithNodes。
Q9. 长连接超时 20 秒内没有事件会怎样?
一句话结论:立即返回 NoEventCursor,监控系统收到 bk_watched=false 的响应。从 event/watch/watch.go 第 44 行可以看到,timeoutWatchLoopSeconds = 20。20 秒内无新事件则超时返回,监控系统收到空事件列表后立即发起下一次请求。
Q10. 为什么 HostKey 的 validator 要校验 IP/云区域 ID 等必填字段?
一句话结论:防止不完整的主机数据写入 ChainNode,导致 Watch 消费者拿到脏数据。从 event/key.go 第 36-43 行可以看到,validator 用 gjson.GetManyBytes 检查 hostFields(bk_host_innerip / bk_host_id / bk_cloud_id)是否都存在。
Q11. EventType 和 MongoDB OperType 的映射关系是什么?
一句话结论:Insert→create / Replace/Update→update / Delete→delete。从 src/common/watch/types.go 第 46-57 行可以看到,ConvertOperType() 函数将 MongoDB 的 types.Insert/types.Replace/types.Update/types.Delete 映射为 CMDB 的 create/update/delete/unknown。
Q12. bk_sub_resource 字段是干什么用的?
一句话结论:过滤自定义模型实例变更时,指定只看哪些 ObjectID 的事件。从 event/key.go 第 74-77 行可以看到,SubResource 字段用于 ObjectBase 类型的 Watch,比如只想订阅 bk_obj_id=nginx_config 的实例变更。
Q13. 如果 bk_start_from 超出了 TTL 范围会怎样?
一句话结论:返回参数错误,diff > key.TTLSeconds()。从 event/watch/watch.go 第 60-63 行可以看到,diff = 当前时间 - StartFrom,如果超过 6 小时则拒绝请求。
Q14. ClusterTime 和 MongoDB 物理时间有什么区别?
一句话结论:ClusterTime 是 MongoDB 逻辑时钟(混合逻辑时钟),比物理时间更精确且全局有序。ChainNode 用 ClusterTime 做时间范围查询,确保多副本环境下事件顺序一致性。Token 是 Change Stream 的 resume token,1:1 映射到 Cursor。
Q15. 如何用 Watch 实现"主机下线立即告警"?
一句话结论:订阅 bk_event_types=["delete"],bk_resource="host",收到 delete 事件后立即触发告警。从 event/watch/watch.go 第 84-92 行可以看到,isNodeHitEventType() 过滤器会按 EventType 过滤,只返回匹配的事件。
Q16. change stream goroutine 断连后会自动重连吗?
一句话结论:Change Stream goroutine 本身不会自动重连,但订阅者的下次轮询会从游标续读。从 storage/stream/event/watch.go 第 56-59 行可以看到,stream.Next() 返回 false 时 goroutine 就退出了。重连逻辑由订阅者(cache-service API)控制,不在 Change Stream goroutine 内部。
Q17. 为什么 eventStep 限制为 200?
一句话结论:防止单次响应数据量过大导致 HTTP 链路超时或 OOM。从 event/watch/watch.go 第 50 行可以看到,eventStep 是硬编码常量 200。如果订阅者需要更多事件,分多次轮询即可。
Q18. 同一个 Cursor 会被多个订阅者同时使用吗?
一句话结论:会,Cursor 是 ChainNode 表的游标,不是独占锁。这意味着同一个主机的变更可能被多个监控系统(Prometheus / Grafana / 自研)同时消费,这是设计意图——事件可以被多方同时订阅,无需互斥。
Q19. 为什么 getEventDetailFromRedis 失败后会降级到 MongoDB?
一句话结论:Redis 缓存只是性能优化,不是正确性保障。降级到 MongoDB 保证能拿到数据。从 event/watch/client.go 第 117-124 行可以看到,Redis 失败时打 error 日志但继续查 MongoDB,不影响返回正确性。
Q20. transfer-service 的 Watch 模块和 cache-service 的 Watch 有什么区别?
一句话结论:transfer-service Watch 是增量同步的源端推送(推送到传输介质),cache-service Watch 是对外暴露的事件订阅 API(面向外部消费者)。transfer-service 的 Watch(src/source_controller/transfer-service/sync/watch/watch.go)将变更事件推送到 /api/sync/publish,cache-service 的 Watch(src/source_controller/cacheservice/event/watch/watch.go)接收外部订阅者的 HTTP 请求,返回事件。
全篇总纲
蓝鲸 CMDB 的 Watch 事件订阅体系通过以下机制协同工作:
- Change Stream:MongoDB 原生变更监听,监听 insert/update/delete/replace,生成 resume token
- ChainNode:事件链节点表(cc_WatchChain_*),持久化变更事件,支持 TTL(6 小时)和游标续读
- WatchKey:六种资源类型各自的 Key 定义,包含集合名、TTL、字段校验器
- WatchWithStartFrom:三种返回分支(无事件 / 从时间点遍历 / 游标续读),HTTP 长连接 20 秒超时
- getEventDetail:Redis 缓存优先,降级 MongoDB,确保变更详情可读
- Transfer-service Watch:源端增量同步推送,与 cache-service Watch 服务不同消费者
9. 后续预告
- #13:IAM 权限模型 — 资源层级权限控制与 RBAC 集成
- #14:动态分组 — 按条件自动归类主机与智能资源池

浙公网安备 33010602011771号