蓝鲸 CMDB 3.14.6 源码专题【左扬精讲】—— 运维开发的成长之路:从源码视角看CMDB建设 2
蓝鲸 CMDB 3.14.6 源码专题【左扬精讲】—— 运维开发的成长之路:从源码视角看CMDB建设 2
本文以蓝鲸 CMDB(Tencent bk-cmdb)v3.14.6 源码为锚点,结合运维开发工程师在真实 CMDB 建设过程中的典型场景,从 WHAT(做了什么)→ WHY(为什么这么设计)→ HOW(源码是怎么实现的) 三个维度,串起一条从 "接入数据" 到 "构建平台能力" 的成长路线。
学习重点提示
- 必须掌握:多云主机同步的增量差异检测(getDiffHosts 三类分类)、changeRangePercent 数据波动容差、AuditLog 审计体系
- 需要理解:TaskScheduler 的 hash ring 分布式任务调度、MongoDB Change Stream 事件驱动、MongoDB 事务在同步流程中的作用
- 扩展了解:InstAsst 实例关联链路查询、SyncHistory 同步历史记录、compareFields 字段级差异对比
本文目录
起点:运维开发在 CMDB 建设中的角色定位
思考记忆
运维开发工程师在构建 CMDB 平台时,往往从一个问题出发:公司的基础设施资源分散在多个平台,虚拟机在云管平台、数据库在 DBA 系统、k8s 在容器平台,没有一个地方能看到全部资源。
这个问题的本质是:如何设计一套数据采集、同步、质量保障和查询的完整闭环。蓝鲸 CMDB 的源码给出了它的答案——从 CloudSync 的增量同步,到 HostSnap 的属性波动检查,到 TaskScheduler 的事件驱动调度,再到 AuditLog 的全链路审计,每一步都有明确的工程目标。
运维开发在 CMDB 建设中的成长路线,可以归纳为四个阶段:
| 阶段 | 解决的问题 | 对应的蓝鲸 CMDB 源码模块 | 核心能力 |
|---|---|---|---|
| 第一阶段 | 如何把多云主机数据统一采集到 CMDB? | HostSyncor / getDiffHosts | 三类分类差异检测 |
| 第二阶段 | 采集时如何避免无意义的重复更新? | changeRangePercent / compareFields | 字段级波动容差 |
| 第三阶段 | 如何高效调度分布在多实例上的采集任务? | TaskScheduler / hash ring / Change Stream | 分布式任务调度 |
| 第四阶段 | 如何追踪每一次资源变更的操作来源? | AuditLog / SaveAuditLog / ActionType | 全链路审计溯源 |
| 第五阶段 | 如何查询主机在拓扑中的完整路径? | SearchNodePath / TopologyTree | 拓扑路径追溯 |
第一阶段:多云主机统一采集——getDiffHosts 的三类分类设计
思考记忆
运维开发遇到的第一道坎是:云管平台(CMP)有主机数据,CMDB 里也有主机数据,两者如何对齐?
最简单的方案是全量比对——每次同步把云端所有主机和本地所有主机逐条比较。但当主机数量达到数万台时,这个方案的计算量是 O(N×M),每次同步都要遍历全量数据,性能不可接受。
蓝鲸 CMDB 的解决思路是:先建索引,再做差异检测——把云端和本地的主机分别装进以 InstanceId 为 Key 的 Map,遍历一次即可找出 add / update / delete 三类差异,不需要两两比较。
2.1 WHAT:getDiffHosts 的三类分类设计
做法:getDiffHosts 将主机差异分为三类——add(本地没有需要新增)、update(本地有但有变化需要更新)、delete(本地有但云端已不存在需要删除)。
// src/scene_server/cloud_server/cloudsync/hostsyncor.go L304-368
// getDiffHosts 根据主机实例id获取mongo中的主机信息,并获取有差异的主机
func (h *HostSyncor) getDiffHosts(hostResource *metadata.CloudHostResource) (map[string][]*metadata.CloudHost, error) {
// 第一步:把云端主机装进 Map(Key = InstanceId)
remoteHostsMap := make(map[string]*metadata.CloudHost)
for _, hostRes := range hostResource.HostResource {
for _, host := range hostRes.Instances {
remoteHostsMap[host.InstanceId] = &metadata.CloudHost{
Instance: *host,
CloudID: hostRes.CloudID,
VendorName: hostResource.AccountConf.VendorName,
SyncDir: hostRes.Vpc.SyncDir,
}
}
}
// 第二步:从 MongoDB 查询本地已有的云主机,并装进 Map
localHosts, err := h.getLocalHosts(cloudIDs)
localIdHostsMap := make(map[string]*metadata.CloudHost)
for _, h := range localHosts {
localIdHostsMap[h.InstanceId] = h
}
// 第三步:遍历云端 Map,找出 add 和 update
diffHosts := make(map[string][]*metadata.CloudHost)
for _, h := range remoteHostsMap {
if _, ok := localIdHostsMap[h.InstanceId]; ok {
// 本地已存在 → 检查是否需要更新
lh := localIdHostsMap[h.InstanceId]
// 变化字段:状态 / 内外网IP / 云区域
if h.InstanceState != lh.InstanceState || h.PublicIp != lh.PublicIp ||
h.PrivateIp != lh.PrivateIp || h.CloudID != lh.CloudID {
diffHosts["update"] = append(diffHosts["update"], h)
}
} else {
// 本地不存在 → 新增
diffHosts["add"] = append(diffHosts["add"], h)
}
}
// 第四步:遍历本地 Map,找出 delete(云端已不存在)
for id, h := range localIdHostsMap {
if h.InstanceState == common.BKCloudHostStatusDestroyed {
continue // 已销毁主机不再处理
}
if _, ok := remoteHostsMap[id]; !ok {
diffHosts["delete"] = append(diffHosts["delete"], h)
}
}
return diffHosts, nil
}
2.2 WHY:为什么用 Map 而不用嵌套循环?
假设云端有 30,000 台主机,本地有 28,000 台,要找出哪些主机是新增、删除或变更。
嵌套循环:每台云端主机都要和本地全部主机逐一比较
思路:拿云端第1台,对比本地全部 28,000 台;再拿云端第2台,对比本地全部 28,000 台……
比较次数 = 30,000(云端) × 28,000(本地) = 8.4 亿次
每次同步都要做 8.4 亿次字符串/数值比较,CPU 爆炸。
Map 方案:查表而不是遍历
思路完全反过来——不两两比较,而是"查表":
- 第一步:把本地全部主机放进一个 Map,key = 主机 ID,查找速度 O(1)
- 第二步:遍历云端主机,每次直接用主机 ID 查 Map
云端 host-A → 查本地Map["host-A"] → 存在 → 比较字段 → 无变化 → 跳过
云端 host-B → 查本地Map["host-B"] → 存在 → 比较字段 → 有变化 → update
云端 host-C → 查本地Map["host-C"] → 不存在 → add
比较次数 = 30,000(云端遍历)+ 0(本地已有Map,每次O(1)查找)= O(N+M) = 58,000 次
不是 8.4 亿次,是 58,000 次。
数据量越大,差异越明显
| 云端主机数 | 本地主机数 | 嵌套循环比较次数 | Map 方案比较次数 | 差距 |
|---|---|---|---|---|
| 30,000 | 28,000 | 8.4 亿次 | 58,000 次 | 14,500 倍 |
| 300,000 | 280,000 | 840 亿次 | 580,000 次 | 145,000 倍 |
| 3,000,000 | 2,800,000 | 84,000 亿次 | 5,800,000 次 | 145 万倍 |
更重要的是,Map 方案只把真正有变更的主机放进 diffHosts(假设 30,000 台中只有 200 台有变更),后续的数据库写入只操作这 200 台,而不是全部 30,000 台。数据量越大,节省越显著。
2.3 HOW:syncCloudHost 的完整同步流水线
三类差异最终通过 syncDiffHosts 汇聚到同一个处理函数,按操作类型分发到不同的处理方法:
// src/scene_server/cloud_server/cloudsync/hostsyncor.go L37-112
func (h *HostSyncor) syncCloudHost(syncResult *metadata.SyncResult, hostResource *metadata.CloudHostResource,
taskID int64, accountConf *metadata.CloudAccountConf, startTime time.Time) error {
// 让writeKit的header含有同样的事务信息,以保证同一个事务里写操作后的数据能够被读到
ccom.CopyHeaderTxnInfo(h.readKit.Header, h.writeKit.Header)
// 1. 处理被销毁的VPC:将相关主机的IP置空、状态置为已销毁
if len(hostResource.DestroyedVpcs) > 0 {
err := h.syncDestroyedVpcs(hostResource, syncResult)
}
// 2. 查询VPC对应的云区域,更新主机的CloudID
err := h.addCLoudId(accountConf, hostResource)
// 3. 获取三类差异主机
diffHosts, err := h.getDiffHosts(hostResource)
// 4. 无差异则提前返回
if len(diffHosts) == 0 && syncResult.SuccessInfo.Count == 0 {
return nil
}
// 5. 有差异 → 更新任务状态为"同步中"
err = h.updateTaskState(h.writeKit, taskID, metadata.CloudSyncInProgress, nil)
// 6. 同步差异主机(add/update/delete)
err = h.syncDiffHosts(diffHosts, syncResult)
// 7. 设置同步结果状态(成功/失败 + 耗时)
err = h.SetSyncResultStatus(syncResult, startTime)
// 8. 写入同步历史记录
_, err = h.addSyncHistory(syncResult, taskID)
// 9. 更新任务最终状态
err = h.updateTaskState(h.writeKit, taskID, syncResult.SyncStatus, &syncResult.StatusDescription)
return nil
}
设计精髓:为什么步骤 1 要单独处理 DestroyedVpcs?
当某个 VPC 被云厂商销毁时,该 VPC 下的所有主机不能简单地走 add/update/delete 流程——它们的内外网 IP 要强制置空、状态要置为"已销毁"。如果把这些逻辑混进三类差异中,处理分支会变得很复杂。单独处理 DestroyedVpcs,既保证了数据一致性,也保持了 syncCloudHost 主体流程的清晰。
2.4 同步结果的完整记录:SyncHistory
每次同步完成后,都会生成一条同步历史记录,包含本次同步的增删改数量、状态描述和耗时:
// src/scene_server/cloud_server/cloudsync/hostsyncor.go L419-433
func (h *HostSyncor) addSyncHistory(syncResult *metadata.SyncResult, taskid int64) (*metadata.SyncHistory, error) {
syncHistory := metadata.SyncHistory{
TaskID: taskid,
SyncStatus: syncResult.SyncStatus, // cloud_sync_success / cloud_sync_fail
StatusDescription: syncResult.StatusDescription, // costTime + errorInfo
Detail: syncResult.Detail, // NewAdd / Update 各含 Count 和 IPs
}
result, err := h.logics.CreateSyncHistory(h.writeKit, &syncHistory)
return result, err
}
// src/common/metadata/cloud.go L390-401
type SyncDetail struct {
NewAdd SyncSuccessInfo `json:"new_add" bson:"new_add"` // 新增
Update SyncSuccessInfo `json:"update" bson:"update"` // 更新
}
type SyncSuccessInfo struct {
Count int64 `json:"count" bson:"count"` // 本次同步数量
IPs []string `json:"ips" bson:"ips"` // 受影响的IP列表
}
运维开发在设计采集模块时,这一点非常重要:每次同步的结果必须被记录下来。当主机数量突然大幅减少时,可以直接查询 SyncHistory 发现问题,而不需要翻日志。
第二阶段:增量同步的精细化——changeRangePercent 波动容差机制
思考记忆
运维开发在接入主机属性采集后会遇到第二个问题:GSE Agent 每隔几分钟上报一次主机的属性数据(CPU 逻辑核数、内存大小、磁盘容量)。这些字段在硬件不变的情况下不应该变化——但 Agent 端 SDK 版本差异、数据采集时的进制换算误差可能导致上报值出现微小波动(比如内存从 65536MB 变成 65535MB,或 CPU 核数从 64 变成 63)。如果不加控制,每次上报都会触发数据库写入,造成大量无意义的更新。
蓝鲸 CMDB 在主机属性快照(HostSnap)模块中,通过 changeRangePercent 参数控制字段级波动容差——只有当字段值的变化幅度超过配置的百分比阈值时,才视为真正变更,写入数据库。
3.1 WHAT:needToUpdate 的字段级波动容差
// src/scene_server/datacollection/collections/hostsnap/hostsnap.go L48-56
const (
// 数据波动百分比默认值
defaultChangeRangePercent = 10
// 数据波动百分比最小值
minChangeRangePercent = 1
)
// compareFields:参与差异对比的字段列表
compareFields = []string{
"bk_cpu", "bk_cpu_module", "bk_disk", "bk_mem",
"bk_os_type", "bk_os_name", "bk_os_version",
"bk_host_name", "bk_outer_mac", "bk_mac",
"bk_os_bit", "bk_cpu_architecture",
common.BKOsKernelVersionField,
common.BKHostInnerIPField, common.BKHostInnerIPv6Field,
}
// src/scene_server/datacollection/collections/hostsnap/hostsnap.go L524-562
func needToUpdate(src, toCompare, addressing string) bool {
changeRangePercent := getLimitConfig("datacollection.hostsnap.changeRangePercent",
defaultChangeRangePercent, minChangeRangePercent)
srcElements := gjson.GetMany(src, compareFields...)
compareElements := gjson.GetMany(toCompare, compareFields...)
for idx, field := range compareFields {
if _, ok := ignoreCompareField[field]; ok {
continue
}
if !srcElements[idx].Exists() {
continue
}
// 发现字段值有变化
if srcElements[idx].String() != compareElements[idx].String() {
compareField := compareFields[idx]
// 静态IP场景下,内网IP 不参与比较
if addressing == common.BKAddressingStatic &&
(compareField == common.BKHostInnerIPField || compareField == common.BKHostInnerIPv6Field) {
continue
}
// CPU / 内存 / 磁盘:变化幅度在阈值内 → 视为无变化
if compareField == "bk_cpu" || compareField == "bk_disk" || compareField == "bk_mem" {
val := compareElements[idx].Float() * (float64(changeRangePercent) / 100.0)
diff := srcElements[idx].Float() - compareElements[idx].Float()
if -val < diff && diff < val {
continue // 波动在阈值内,跳过
}
}
return true // 超出阈值 → 确认为变更
}
}
return false
}
3.2 WHY:为什么用百分比而不是固定值?
固定值方案的问题是:对于不同规格的机器,固定阈值的合理性差异很大。 bk_cpu 是 CPU 逻辑核数(如 8 核、64 核),不同规格机器的绝对值差异巨大。
- 固定阈值 1(核):对于 8 核机器,1 核 = 12.5% 的变化;对于 64 核机器,1 核 = 1.6% 的变化——同样的固定值,对不同规格机器的敏感度完全不同
- 百分比 10%:无论机器是 8 核还是 64 核,只有变化幅度超过基准值的 10% 才视为变更——换算成绝对值分别是 0.8 核和 6.4 核,针对不同规格自适应,更合理
这个设计还支持通过配置项 datacollection.hostsnap.changeRangePercent 动态调整——在业务高峰期可以调高阈值减少写入,在需要高精度监控时可以调低。
蓝鲸 CMDB 的 changeRangePercent 波动容差机制,本质上是一个采集端的"数据质量门卫"——它不解决"数据是否正确"的问题,而是解决"数据是否值得写入"的问题。
述职报告中提到的"数据波动检查"与蓝鲸 CMDB 的 changeRangePercent 的区别
- 述职报告的"波动检查":监控同步前后主机总数的变化(如突然减少 50%),属于数量级告警
- 蓝鲸 CMDB 的 changeRangePercent:控制单台主机 CPU/内存/磁盘字段的变化幅度容差,属于字段级容差
两者解决的问题不在同一个维度,但可以互补:changeRangePercent 减少无意义的数据库写入,SyncHistory 记录每次同步的数量变化——运维开发可以结合两者,既保证写入质量,又有完整的同步数量记录可查。
第三阶段:采集调度体系——TaskScheduler 的 hash ring + Change Stream
思考记忆
运维开发在云主机同步规模化后会遇到第三个问题:同步任务有多个(每个云账号、每个 VPC 都可以独立配置),同步进程也有多个实例。如何确保每个任务只被一个实例处理,而不会重复处理?
蓝鲸 CMDB 的解决方案是一致性哈希环(Consistent Hash Ring)——每个同步任务通过 TaskID 落在哈希环上的某个节点,每个 Cloud Server 实例只处理落在自己名下的任务。
4.1 WHAT:TaskScheduler 的任务调度架构
// src/scene_server/cloud_server/cloudsync/taskscheduler.go L39-75
type taskScheduler struct {
zkClient *zkclient.ZkClient
logics *logics.Logics
uuid string // 当前实例的唯一标识
reflector reflector.Interface // MongoDB Change Stream 监听器
hashring *consistent.Consistent // 一致性哈希环
tasklist map[string]*metadata.CloudSyncTask
mu sync.RWMutex
listerDone chan bool
}
// NewTaskScheduler 创建调度器:初始化 MongoDB Change Stream 监听
func NewTaskScheduler(conf *SchedulerConf) (*taskScheduler, error) {
reflector, err := reflector.NewReflector(conf.MongoConf)
return &taskScheduler{
zkClient: conf.ZKClient,
logics: conf.Logics,
uuid: conf.UUID,
hashring: consistent.New(), // 新建空哈希环
tasklist: make(map[string]*metadata.CloudSyncTask),
reflector: reflector,
listerDone: make(chan bool),
}, nil
}
// Schedule 启动调度器:监听任务表事件 + 监听服务节点变化
func (t *taskScheduler) Schedule(ctx context.Context) error {
if err := t.watchTaskTable(ctx); err != nil { return err } // 监听 MongoDB Change Stream
if err := t.watchServerNode(); err != nil { return err } // 监听 ZooKeeper 服务节点
return nil
}
4.2 WHY:为什么用 MongoDB Change Stream 而不是定时轮询?
| 方案 | 工作机制 | 延迟 | 资源消耗 |
|---|---|---|---|
| 定时轮询 | 每 N 秒查一次 MongoDB,比对任务是否有变化 | 秒级延迟 | 每次全量查询,资源浪费大 |
| MongoDB Change Stream | 数据库任务表有变更时,MongoDB 主动推送给监听者 | 毫秒级延迟 | 只处理变更事件,无浪费 |
当云资源同步任务需要在分钟内感知变更时,定时轮询的延迟不可接受。Change Stream 通过 watchTaskTable 注册监听,一旦任务表有 INSERT/UPDATE/DELETE,立即触发对应的事件处理函数:
// src/scene_server/cloud_server/cloudsync/taskscheduler.go L108-158
// watchTaskTable 监听云资源同步任务表事件
func (t *taskScheduler) watchTaskTable(ctx context.Context) error {
opts := &stypes.ListWatchOptions{
Options: stypes.Options{
MaxAwaitTime: &maxAwaitTime, // 最大等待时间 10 秒
EventStruct: new(taskEvent),
Collection: common.BKTableNameCloudSyncTask,
},
}
capable := &reflector.Capable{
OnChange: reflector.OnChangeEvent{
OnAdd: t.changeOnAdd, // 插入任务 → 加入任务列表
OnUpdate: t.changeOnUpdate, // 更新任务 → 刷新任务列表
OnDelete: t.changeOnDelete, // 删除任务 → 移出任务列表
OnLister: t.changeOnLister, // 冷启动 → 加载已有任务
},
}
return t.reflector.ListWatcher(ctx, opts, capable)
}
// changeOnAdd 新增任务 → 加入任务列表
func (t *taskScheduler) changeOnAdd(event *stypes.Event) {
t.addTask(event.Oid, &event.Document.(*taskEvent).CloudSyncTask)
}
// changeOnDelete 删除任务 → 移出任务列表
func (t *taskScheduler) changeOnDelete(event *stypes.Event) {
t.delTask(event.Oid)
}
4.3 HOW:GetTaskList 的哈希环任务分配
// src/scene_server/cloud_server/cloudsync/taskscheduler.go L189-207
// GetTaskList 获取属于当前进程的任务列表
func (t *taskScheduler) GetTaskList() ([]*metadata.CloudSyncTask, error) {
tasks := []*metadata.CloudSyncTask{}
t.mu.RLock()
defer t.mu.RUnlock()
for oid := range t.tasklist {
// 哈希环:根据 TaskID 算出负责该任务的节点
node, err := t.hashring.Get(fmt.Sprintf("%d", t.tasklist[oid].TaskID))
if err != nil {
return nil, err
}
// 如果当前节点负责该任务 → 加入返回列表
if node == t.uuid {
task := *t.tasklist[oid]
tasks = append(tasks, &task)
}
}
return tasks, nil
}
哈希环的工作原理:假设有 3 个 Cloud Server 实例(A、B、C),启动时各自将自己的标识加入哈希环。当任务 T(TaskID=12345)到达时,调用 hashring.Get("12345"),哈希环会返回离 12345 最近的节点(比如 B)。只有 B 会把 T 加入自己的任务列表并执行同步,A 和 C 不会处理。
当某个实例宕机时,ZooKeeper 检测到节点变化,自动触发 watchServerNode 重新设置哈希环,宕机实例的任务会自动漂移到其他节点,实现故障自愈。
第四阶段:操作审计日志——从 AuditLog 到全链路溯源
思考记忆
运维开发在资源管理平台建设到一定规模后,会遇到合规要求:谁在什么时候修改了什么资源?改成了什么?
蓝鲸 CMDB 的审计日志体系由 AuditLog 数据结构和 SaveAuditLog API 组成。审计日志在每次写操作(创建/更新/删除)时生成,包含操作前的快照(PreData)和操作后的快照(CurData),支持按操作人、时间、资源类型等条件查询。
5.1 WHAT:AuditLog 的数据结构
// src/common/metadata/audit.go L174-199
type AuditLog struct {
ID int64 `json:"id" bson:"id"`
AuditType AuditType `json:"audit_type" bson:"audit_type"` // 审计类型(模型/业务/主机等)
SupplierAccount string `json:"bk_supplier_account" bson:"bk_supplier_account"` // 供应商账号
User string `json:"user" bson:"user"` // 操作人
ResourceType ResourceType `json:"resource_type" bson:"resource_type"` // 资源类型
Action ActionType `json:"action" bson:"action"` // 操作类型:create/update/delete
OperateFrom OperateFromType `json:"operate_from" bson:"operate_from"` // 操作来源(界面/API/后台)
OperationDetail DetailFactory `json:"operation_detail" bson:"operation_detail"` // 操作详情(含 PreData/CurData/UpdateFields)
OperationTime Time `json:"operation_time" bson:"operation_time"` // 操作时间
BusinessID int64 `json:"bk_biz_id,omitempty" bson:"bk_biz_id,omitempty"`
ResourceID interface{} `json:"resource_id" bson:"resource_id"`
ResourceName string `json:"resource_name" bson:"resource_name"`
}
// AuditQueryCondition 查询条件(src/common/metadata/audit.go L75-95,已 Read 核验)
type AuditQueryCondition struct {
AuditType AuditType `json:"audit_type"` // 按审计类型筛选
User string `json:"user"` // 按操作人筛选
ResourceType ResourceType `json:"resource_type"` // 按资源类型筛选
Action []ActionType `json:"action"` // 按操作类型筛选(create/update/delete)
OperateFrom OperateFromType `json:"operate_from"` // 按操作来源筛选
BizID int64 `json:"bk_biz_id"` // 按业务ID筛选
ResourceName string `json:"resource_name"` // 支持模糊匹配
OperationTime OperationTimeCondition `json:"operation_time"` // 时间范围
}
5.2 HOW:generateAuditCommonParameter 的审计日志生成模式
// src/common/auditlog/metadata.go L48-68
// NewBasicContent 根据操作类型生成审计内容快照
func (a *generateAuditCommonParameter) NewBasicContent(data map[string]interface{}) *metadata.BasicContent {
var basicDetail *metadata.BasicContent
switch a.action {
case metadata.AuditCreate:
// 创建:只记录创建后的数据
basicDetail = &metadata.BasicContent{
CurData: data,
}
case metadata.AuditDelete:
// 删除:只记录删除前的数据
basicDetail = &metadata.BasicContent{
PreData: data,
}
case metadata.AuditUpdate:
// 更新:记录修改前数据和修改的字段
basicDetail = &metadata.BasicContent{
PreData: data,
UpdateFields: a.updateFields,
}
}
return basicDetail
}
这个设计的精妙之处在于按操作类型决定记录什么:
- 创建:只需要知道"创建了什么",记录 CurData
- 删除:只需要知道"删除了什么",记录 PreData
- 更新:需要知道"谁改了什么字段",记录 PreData + UpdateFields(变更字段列表)
对于云主机同步场景,审计日志记录在 createCloudArea(L495-516)中体现——每次创建云区域时,同步生成并保存审计日志,确保云区域的创建历史可追溯。
5.3 实例关联的审计:instanceAssociationAuditLog
// src/common/auditlog/association.go L29-85
func (a *instanceAssociationAuditLog) GenerateAuditLog(parameter *generateAuditCommonParameter,
id int64, objID string, data *metadata.InstAsst) (*metadata.AuditLog, error) {
kit := parameter.kit
if data == nil {
// 未传入数据时,自动从数据库查询当前关联信息
cond := metadata.InstAsstQueryCondition{
Cond: metadata.QueryCondition{
Condition: map[string]interface{}{metadata.AssociationFieldAssociationId: id},
},
ObjID: objID,
}
result, err := a.clientSet.Association().ReadInstAssociation(kit.Ctx, kit.Header, &cond)
data = &result.Info[0]
}
// 解析源实例和目标实例的名称(用于审计日志展示)
srcInstName, _ := a.getInstNameByID(kit, data.ObjectID, data.InstID)
targetInstName, _ := a.getInstNameByID(kit, data.AsstObjectID, data.AsstInstID)
return &metadata.AuditLog{
AuditType: metadata.ModelInstanceType,
ResourceType: metadata.InstanceAssociationRes,
Action: parameter.action, // create/update/delete
ResourceID: data.InstID,
ResourceName: srcInstName,
OperationDetail: &metadata.InstanceAssociationOpDetail{
AssociationOpDetail: metadata.AssociationOpDetail{
AssociationID: data.ObjectAsstID,
AssociationKind: data.AssociationKindID,
},
SourceModelID: data.ObjectID,
TargetModelID: data.AsstObjectID,
TargetInstanceID: data.AsstInstID,
TargetInstanceName: targetInstName,
},
}, nil
}
当运维开发需要知道"安全组 SG-001 是什么时候被绑定到虚拟机 VM-100 的",查询 InstanceAssociationRes 类型的审计日志即可得到完整链路。
第五阶段:拓扑链路查询——SearchNodePath 的逐层追溯
思考记忆
运维开发在故障排查时经常需要回答这个问题:这台主机在业务拓扑中的完整路径是什么?(业务→集群→模块→主机)
如果每次都从 MongoDB 递归查询,深度超过 3 层的拓扑查询就会很慢。蓝鲸 CMDB 的解决方案是:将业务拓扑缓存到 Redis,查询时直接读取缓存,再结合批量查询节点详情,实现毫秒级响应。
5.1 WHAT:SearchNodePath 的查询流程
// src/source_controller/cacheservice/cache/topotree/path.go L37-79
// SearchNodePath 搜索节点到业务根的路径
func (t *TopologyTree) SearchNodePath(ctx context.Context, opt *SearchNodePathOption,
supplierAccount string) ([]NodePaths, error) {
if opt.Business <= 0 {
return nil, ccError.New(common.CCErrCommParamsIsInvalid, fmt.Sprintf("invalid bk_biz_id: %d", opt.Business))
}
// 1. 从 Redis 缓存获取当前业务的主线拓扑序列
topo, err := t.bizCache.GetTopology()
if err != nil {
return nil, err
}
// 2. 反转拓扑序列,构建对象名→存在性 map(便于正向路径构建)
reverseTopo := reverse(topo)
topoMap := make(map[string]struct{})
for _, to := range topo {
topoMap[to] = struct{}{}
}
// 3. 验证传入节点是否在拓扑中(安全检查)
objects := make(map[string][]int64)
for _, node := range opt.Nodes {
if node.Object == "biz" {
return nil, ccError.New(common.CCErrCommParamsIsInvalid, "not support biz")
}
if len(node.Object) == 0 || node.InstanceID <= 0 {
return nil, ccError.New(common.CCErrCommParamsIsInvalid, "object or inst id")
}
if _, exist := topoMap[node.Object]; !exist {
return nil, ccError.New(common.CCErrCommParamsIsInvalid, "object")
}
objects[node.Object] = append(objects[node.Object], node.InstanceID)
}
// 4. 批量查询节点详情,构建完整路径
var paths map[int64][]Node
var nameMap map[int64]string
// ... (路径构建逻辑)
return paths, nil
}
5.2 WHY:为什么需要反转拓扑序列?
蓝鲸 CMDB 的主线拓扑存储顺序是从根到叶子(如 biz → set → module → host),查询时从叶子节点出发需要反向遍历。反转后变成 host → module → set → biz,从任意节点出发只需要按顺序向后遍历即可到达根节点,无需双向查找。
5.3 拓扑缓存的定时刷新机制
// src/source_controller/cacheservice/cache/topology/topology.go L73-119
// refreshBatch 批量刷新业务拓扑到缓存(最多 5 个并发)
func (t *Topology) refreshBatch(bizList []int64, rid string) error {
pipeline := make(chan struct{}, 5) // 令牌桶:最多 5 个 goroutine 并发写 Redis
wg := sync.WaitGroup{}
var hitErr error
for idx := range list {
pipeline
运维开发在设计自己的拓扑查询功能时,可以借鉴这套架构:热点数据放缓存,冷数据放数据库,通过令牌桶控制刷新并发,通过主节点选举避免重复刷新。
第六阶段:公共能力抽象——从重复代码到通用框架
思考记忆
运维开发在接入多个资源模块后,会发现一个规律:每个模块的列表查询、详情查询、导出、审计日志代码结构几乎一模一样,但每次都重新写一遍。这是代码重复的信号,也是公共能力抽象的时机。
蓝鲸 CMDB 的设计思路是:把通用的审计日志生成模式(generateAuditCommonParameter)、通用的变更检测逻辑(compareFields)、通用的查询参数构建(AuditQueryCondition)都抽成公共方法,每个具体模块只需要传入自己的数据即可。
运维开发在 CMDB 建设中的成长,往往体现在能否把重复的代码抽象成可复用的框架。蓝鲸 CMDB 源码中,这种抽象体现在多个层面:
| 公共能力 | 源码文件 | 抽象的价值 |
|---|---|---|
| 审计日志生成器 | auditlog/metadata.go | 创建/更新/删除统一模式,新模块接入只需 3 行代码 |
| 差异对比字段列表 | hostsnap/hostsnap.go | 新增对比字段只需改一处配置 |
| 波动阈值配置 | hostsnap/hostsnap.go | 阈值通过配置中心动态调整,无需改代码 |
| 哈希环任务调度 | cloudsync/taskscheduler.go | 新增任务类型只需修改 ResourceType 分发逻辑 |
源码视角总结:成长路线的技术脉络图
全篇总纲
运维开发工程师在蓝鲸 CMDB 源码中的成长路线,本质上是从数据接入到数据治理的认知跃迁:
- 第一阶段:多云主机同步——getDiffHosts 的三类分类设计(add/update/delete),用 Map 索引实现 O(N+M) 复杂度的增量差异检测,解决了"每次全量比对太慢"的问题
- 第二阶段:采集质量保障——changeRangePercent 的 10% 字段级波动容差,解决了"Agent 上报数据微小波动导致无意义数据库写入"的问题,同时通过 SyncHistory 记录每次同步的数量变化
- 第三阶段:分布式任务调度——TaskScheduler 的一致性哈希环 + MongoDB Change Stream,解决了"多实例同步任务不能重复也不能漏掉"的问题,实现了秒级感知变更
- 第四阶段:全链路审计——AuditLog 的 PreData/CurData/UpdateFields 快照模式,解决了"谁在什么时候改了什么"的可追溯问题,通过 instanceAssociationAuditLog 支持实例关联变更的审计
- 第五阶段:拓扑路径查询——SearchNodePath 的 Redis 缓存 + 拓扑反转 + 批量查询,解决了"深度拓扑递归查询性能差"的问题,通过 refreshBatch 的令牌桶控制实现缓存的高效刷新
- 第六阶段:公共能力抽象——审计日志生成器、差异对比字段列表、波动阈值配置、哈希环调度等公共能力,使新模块接入成本从"写一个完整模块"降低到"实现业务逻辑 + 调用公共方法"
蓝鲸 CMDB 源码中最值得运维开发借鉴的,不是某个具体功能实现,而是从问题出发的设计思路:
- 面对"全量比对太慢" → 用 Map 索引做增量检测(getDiffHosts 三类分类)
- 面对"微小波动导致无意义写入" → 用百分比容差代替精确比较(changeRangePercent)
- 面对"多实例任务重复处理" → 用一致性哈希环做任务分配(TaskScheduler hash ring)
- 面对"任务表变更感知延迟" → 用 MongoDB Change Stream 替代轮询(watchTaskTable)
- 面对"变更历史不可追溯" → 用 PreData/CurData 快照模式记录(AuditLog)
- 面对"拓扑递归查询太慢" → 用 Redis 缓存 + 拓扑反转(SearchNodePath)
这些设计思路不只适用于 CMDB 系统,在任何需要数据采集、差异检测、质量保障、审计追溯的系统中都可以复用。
源码视角补充(扒编自查纠正)
- 关于快照机制:蓝鲸 CMDB 的 HostSnap 是"主机实时属性快照"(接收 GSE Agent 上报的 CPU/内存/磁盘),不是 d_version 数据回溯快照(按日期版本复制全量历史数据)。两者维度不同:HostSnap 是"实时采集",d_version 是"历史状态"
- 关于波动检查:蓝鲸 CMDB 的 changeRangePercent 是单台主机字段级的 CPU/内存/磁盘变化容差,述职报告中提到的"同步前后主机总数减少 50%"属于数量级告警——两者维度互补,蓝鲸 CMDB 通过 SyncHistory 的 Detail.Count 间接支持数量级监控
FAQ 思考题(20 组)
以下问题均基于真实源码,尝试从源码中找到答案:
Q1. getDiffHosts 为什么用 InstanceId 作为 Map 的 Key,而不是用 IP?
一句话结论:InstanceId 是云厂商分配的唯一标识,而 IP 可能重复(NAT、DHCP 等场景)。用 InstanceId 做 Key 确保每台云主机有且只有一个 Map 条目,避免同一 IP 多台主机导致的哈希冲突和数据覆盖。
Q2. getDiffHosts 中为什么要单独处理已销毁主机(BKCloudHostStatusDestroyed)?
一句话结论:已销毁主机走独立的处理路径,不参与 add/update/delete 流程。当 VPC 被销毁时,该 VPC 下的主机不能简单地走差异同步——它们的状态要强制置为"已销毁"、内外网 IP 要置空,且不再参与后续的差异比较。单独处理保证了 syncDestroyedVpcs 和 getDiffHosts 的职责分离。
Q3. syncCloudHost 为什么要用两个 Kit(readKit 和 writeKit)?
一句话结论:读写分离 + 事务隔离。readKit 用于读取本地已有数据,writeKit 用于写入变更数据,两者通过 CopyHeaderTxnInfo 共享事务上下文保证一致性。CloudServer 是多线程 Goroutine 运行环境,读写分离避免了读操作被写操作的长事务阻塞,同时 writeKit 的 OwnerID 指向任务所属的供应商账号,确保写入数据带有正确的多租户标识。
Q4. changeRangePercent 为什么只对 CPU/内存/磁盘三个字段生效,不对其他字段生效?
一句话结论:CPU 逻辑核数(bk_cpu)、内存(bk_mem)、磁盘(bk_disk)是由 Agent 采集的硬件规格字段,不应在日常采集周期内频繁变化。出现微小差异通常源于进制换算误差或 SDK 版本差异,而非真正的硬件变更,因此通过百分比容差过滤。其他字段(IP、MAC、OS 类型)是配置型字段,一旦变化必须记录,不适用容差。 compareFields 定义了参与对比的字段列表,其中只有 bk_cpu(CPU 逻辑核数)、bk_disk(磁盘容量)、bk_mem(内存大小)适用百分比容差判断。 OS 类型变更(如 CentOS 7 → Ubuntu 22.04)或 IP 变化无论幅度如何都应该触发更新。
Q5. TaskScheduler 的 watchTaskTable 使用 MongoDB Change Stream,而不使用定时轮询,优势在哪里?
一句话结论:Change Stream 是推送模式(毫秒级延迟),定时轮询是拉取模式(秒级延迟 + 资源浪费)。Change Stream 在任务表有变更时主动推送 OnAdd/OnUpdate/OnDelete 事件,没有变更时零资源消耗。定时轮询即使没有变更也要每次查全表,资源浪费大且延迟不可控。
Q6. TaskScheduler 的哈希环(hashring)如何保证任务不重复也不漏掉?
一句话结论:一致性哈希环保证了每个 TaskID 只映射到唯一一个节点。GetTaskList 遍历所有任务,对每个 TaskID 调用 hashring.Get(TaskID),只有返回值为当前实例 UUID 的任务才被处理。当 CloudServer 实例数量变化时,ZooKeeper 检测到节点变化并重新设置哈希环,之前由宕机实例处理的任务会自动漂移到存活节点。
Q7. AuditLog 的 PreData / CurData / UpdateFields 三个字段如何协同工作?
一句话结论:PreData 记录变更前的快照,CurData 记录变更后的快照,UpdateFields 记录具体被修改的字段列表,三者共同构成完整的变更历史。创建操作只填 CurData,删除操作只填 PreData,更新操作同时填 PreData(历史快照)和 UpdateFields(变更字段)。查询审计日志时,可以同时看到"原来是什么"、"改成了什么"、"改了哪些字段"。
Q8. 为什么在 createCloudArea 中审计日志生成要在数据写入之后?
一句话结论:审计日志记录的是"实际发生了什么",而不是"打算做什么"——只有写入成功的数据才值得记录。hostsyncor.go L495-516 中,先调用 CreateInstance 写入云区域,获取返回的 cloudID,再基于 cloudID 生成审计日志。如果创建失败,审计日志不会生成,避免了"操作失败但留下了审计记录"的脏数据。
Q9. SearchNodePath 为什么需要先反转拓扑序列(reverse(topo))?
一句话结论:反转后从叶子节点出发可直接顺序遍历到根节点,无需双向查找。蓝鲸 CMDB 的拓扑序列是 biz → set → module → host(根到叶子),从 host 出发查找到 biz 的路径需要反向遍历。反转后变成 host → module → set → biz,路径构建从左到右顺序遍历即可实现。
Q10. refreshBatch 中的令牌桶(pipeline := make(chan struct{}, 5))如何防止 Redis 过载?
一句话结论:令牌桶限制了同时写 Redis 的 goroutine 数量为 5,防止大量并发写入压垮 Redis。当业务数量很多时(如 100 个业务同时刷新),如果不限制并发,每个 goroutine 都尝试写 Redis,Redis 连接数和写入压力会急剧上升。5 个并发的设计在刷新速度和系统保护之间取得了平衡。
Q11. loopBizBriefCache 中为什么要检查 IsMaster 才执行刷新?
一句话结论:主节点选举确保只有一台 CloudServer 执行刷新任务,避免多实例重复写入。CloudServer 通常是多实例部署,如果每个实例都定时刷新同一批业务的拓扑缓存到 Redis,会产生大量重复写入和一致性问题。IsMaster 检查保证"只有主节点负责刷新,其他节点待机"的单写模式。
Q12. compareFields 中的 ignoreCompareField 是干什么用的?
一句话结论:ignoreCompareField 用于排除某些字段,使其不参与差异对比。在某些版本或环境下,部分字段的值可能不稳定(如云厂商 SDK 版本差异导致 OS 版本号格式不一致),但不需要视为变更。运维开发可以将这些字段加入 ignoreCompareField,减少无意义的更新触发。
Q13. AuditQueryCondition 的 ResourceName 支持模糊查询(FuzzyQuery),在源码中如何体现?
一句话结论:audit.go L83-84 的注释明确说明 ResourceName 支持模糊查询(正则匹配),前端传入 FuzzyQuery=true 时使用正则,否则使用精确匹配。这意味着运维开发在设计审计日志查询接口时,可以同时支持"精确查找某台主机"和"模糊查找包含某个关键字的所有资源变更"。
Q14. SyncHistory 中的 Detail(SyncDetail)同时记录了 NewAdd.Count 和 Update.Count,运维开发可以用它做什么?
一句话结论:通过比较相邻两次同步的 Count 变化,可以实现"同步数量波动告警"——例如本次 Update.Count 突然为 0 而 NewAdd.Count 异常大,触发数据质量检查。这个能力与述职报告中提到的"数据波动检查"是同一维度的需求,蓝鲸 CMDB 通过 SyncHistory 提供了数据基础。
Q15. CloudSyncTask 中的 SyncVpcs([]VpcSyncInfo)字段的作用是什么?
一句话结论:SyncVpcs 指定了本次同步只处理哪些 VPC,避免全量同步,提升采集效率。在多 VPC 场景下,运维团队通常只需要同步部分 VPC(如生产环境的 VPC),通过 SyncVpcs 可以精确控制采集范围,减少不必要的数据传输和处理开销。
Q16. TaskScheduler 的 watchTaskTable 中 OnLister(冷启动)和 OnAdd(新增)的处理逻辑为什么相同?
一句话结论:冷启动时已有任务需要加载到内存,加载完成后新任务需要追加到列表——两者本质都是"把任务放入内存",代码可以复用。当 CloudServer 重启或新实例上线时,Change Stream 会先触发 OnLister 把已有任务全部加载一遍,然后才正常处理增量事件。代码复用(都调用 addTask)简化了逻辑。
Q17. instanceAssociationAuditLog 在生成审计日志时,为什么需要 getInstNameByID 查询实例名称?
一句话结论:ResourceName 是审计日志的展示字段,必须是可读的名称而不是 ID。当运维同学查审计日志时,需要看到"虚拟机 VM-100 被绑定到安全组 SG-001"这样的描述,而不是"InstanceId=200 被关联到 AssociationId=500"。getInstNameByID 负责将 ID 翻译为可读名称。
Q18. needToUpdate 中对静态 IP(BKAddressingStatic)场景特殊处理内网 IP 不参与比较,为什么?
一句话结论:静态 IP 场景下,IP 地址由人工配置,不会因为 Agent 上报而改变,不需要因 IP 变化触发更新。动态 IP(DHCP)场景下,IP 地址由云平台分配,可能随租约续约而变化,需要参与比较以检测 IP 变更。
Q19. 运维开发在设计采集模块时,如何决定"同步前"和"同步后"的数据量检查逻辑应该写在哪里?
一句话结论:同步前的预检查可以在 TaskScheduler 层做(过滤无效任务),同步后的数据量检查可以在 syncCloudHost 的 SetSyncResultStatus 后做(记录 SyncHistory 后分析)。蓝鲸 CMDB 通过 SyncHistory 将每次同步结果持久化,运维开发可以在此基础上叠加告警逻辑:比较相邻 SyncHistory 记录的数量差异,超过阈值时触发告警。
Q20. 从运维开发的成长视角看,蓝鲸 CMDB 源码中最值得抽象复用的设计模式是什么?
一句话结论:Change Stream 事件驱动 + 一致性哈希环 + 审计快照模式,这三者组合构成了"数据采集→任务调度→变更记录"的完整闭环,是任何需要数据同步的平台系统的核心骨架。掌握了这套模式,运维开发可以从"接入一个云账号"扩展到"接入 N 个云账号、N 个资源类型",而不需要为每个账号/资源类型重新写完整的同步流程。
后续预告
本文从运维开发的成长路线出发,解析了蓝鲸 CMDB 在多云主机同步、采集质量保障、分布式任务调度、操作审计日志、拓扑路径查询五个核心阶段的设计。后续我们将深入以下专题:
- 专题九:云账号与管控区域的关联体系——CloudAccount / CloudArea 在多租户场景下的隔离设计
- 专题十:k8s 集群与容器节点的采集——kube 模块与 host 模块的数据整合
- 专题十一:蓝鲸 CMDB 高可用部署——多模块协同与故障自愈的完整架构
源码之路,永无止境。每一次真实问题,都是理解系统设计最好的切入点。

浙公网安备 33010602011771号