蓝鲸 CMDB 3.14.6 源码专题【左扬精讲】— CMDB #11:数据质量保障 — 脏数据检测与告警机制

蓝鲸 CMDB 3.14.6 源码专题【左扬精讲】— CMDB #11:数据质量保障 — 脏数据检测与告警机制

在多源数据同步的链路中,脏数据是最常见的故障根因。

云平台的主机 IP 变了、规格变了、下线了,CMDB 里没有感知;同步任务失败后状态还是"成功";IDC 的主机被物理下线,CMDB 里还挂着这些"僵尸主机"。

这些问题的根源在于:同步链路缺乏有效的数据质量校验和异常感知机制。

本文从源码出发,讲解蓝鲸 CMDB 在云同步(cloud_server)和实例间同步(transfer-service)中分别如何做脏数据检测、异常状态上报和任务失败告警。

SRE 可以据此理解:什么时候数据会变脏、脏了之后系统会怎么反应、怎样配置告警才能第一时间感知问题。

云同步数据质量脏数据检测异常告警cloud_servertransfer-service

src/scene_server/cloud_server/cloudsync/hostsyncor.go               ← 同步差异检测与写库(add/update/delete 分支)
src/scene_server/cloud_server/logics/cloud.go                       ← 云端数据拉取与差异计算
src/scene_server/cloud_server/cloudsync/taskprocessor.go            ← 同步周期配置(SyncPeriodMinutesMin)
src/source_controller/transfer-service/sync/watch/watch.go          ← 源端增量同步 Watch 与 Token 游标
src/source_controller/transfer-service/sync/watch/token_handler.go  ← 增量同步 Token 持久化
src/source_controller/transfer-service/sync/dest_full_sync.go       ← 目标端全量同步重试
src/common/metadata/cloud.go                                        ← 同步状态常量定义

学习重点

  • 必须掌握:HostSyncor.syncDiffHosts 三分支逻辑;SyncStatus 状态机;addSyncHistory 同步历史记录
  • 必须掌握:Watch Token 游标持久化机制;TokenHandler 读写 cc_SrcSyncDataToken 表
  • 理解:全量同步失败重试机制;VPC 下线后主机状态置空处理

1. 脏数据的来源:同步链路中的 4 类风险

思考

在云同步和 CMDB 实例间同步中,数据变脏主要来自以下几个环节:

  • 云平台侧:主机被云厂商下线(Terminated),但 CMDB 不知道
  • 同步任务侧:同步任务执行中途失败,部分数据写了部分没写
  • ID 映射侧:源端 ID 变化后,目标端 ID 映射表没有同步更新
  • 网络抖动侧:云 API 调用超时,返回了部分数据

蓝鲸 CMDB 在两个同步路径中分别用不同的机制处理这些风险:cloud_server 用"差异同步"(只写差量),transfer-service 用"全量覆盖 + Token 断点续传"。两种机制各有适用场景,理解它们的差异才能正确配置告警策略。

2. 云同步差异检测:HostSyncor.syncDiffHosts 三分支

What — syncDiffHosts 在同步体系中扮演什么角色?

cloudsync/hostsyncor.gosyncDiffHosts() 是云同步"写入阶段"的唯一入口。它在 HostSyncor.Sync() 第 82 行被调用,负责把"云端数据与 CMDB 已有数据的差异"翻译为具体的写操作(新增 / 更新 / 删除)。这个函数是同步质量的核心保障。

Why — 为什么需要三分支差异处理?

云同步不是"全量覆盖",而是"差异同步"。每次同步时 cloud_server 从云 API 拉取全量主机列表(logics/cloud.goGetCloudHostResource()),然后和 cc_HostBase 表中已有数据对比,计算出差量。只写差量有三个好处:减少 CMDB 写入压力、保留人工标注的字段不被覆盖、审计历史干净。

没有差异检测会发生什么?

  • 每次同步全量覆盖,运维人员在 CMDB 里手动标注的主机分组、标签会被清除
  • 已下线主机永远不会被清理,变成"僵尸主机"堆积在 CMDB
  • 同步压力大,每次写入 MongoDB 触发大量事件,Watch 系统被刷屏
How — syncDiffHosts 的三分支源码解析

cloudsync/hostsyncor.go 第 371-417 行,三分支的逻辑非常清晰:

// diffhosts 的结构:map[string][]*metadata.CloudHost
// key = "add" | "update" | "delete",value = 主机列表
func (h *HostSyncor) syncDiffHosts(diffhosts map[string][]*metadata.CloudHost,
	syncResult *metadata.SyncResult) error {

	for op, hosts := range diffhosts {
		switch op {
		case "add":
			// 主机在云端存在,但 cc_HostBase 中不存在 → 插入
			result, err = h.addHosts(hosts)
			syncResult.Detail.NewAdd.Count += result.SuccessInfo.Count
			syncResult.Detail.NewAdd.IPs = append(syncResult.Detail.NewIPs, result.SuccessInfo.IPs...)

		case "update":
			// 主机在云端存在,CMDB 也存在,但属性变了 → 更新
			result, err = h.updateHosts(hosts)
			syncResult.Detail.Update.Count += result.SuccessInfo.Count
			syncResult.Detail.Update.IPs = append(syncResult.Detail.Update.IPs, result.SuccessInfo.IPs...)

		case "delete":
			// 主机在云端已被下线(Terminated)→ 将内外网 IP 置空,状态置为已销毁
			// 注意:不是物理删除,而是逻辑删除(保留审计追溯)
			hostIDs := make([]int64, 0)
			for _, h := range hosts {
				hostIDs = append(hostIDs, h.HostID)
			}
			result, err = h.deleteDestroyedHosts(hostIDs)
			syncResult.Detail.Update.Count += result.SuccessInfo.Count

		default:
			blog.Errorf("syncDiffHosts fail, op:%s is invalid, rid:%s", op, h.readKit.Rid)
			return fmt.Errorf("syncDiffHosts op:%s is invalid", op)
		}
		// 汇总到 syncResult:成功数 / 失败数 / 失败详情
		syncResult.SuccessInfo.Count += result.SuccessInfo.Count
		syncResult.FailInfo.Count += result.FailInfo.Count
		for ip, errinfo := range result.FailInfo.IPError {
			syncResult.FailInfo.IPError[ip] = errinfo
		}
	}
	return nil
}

一个关键细节:逻辑删除而非物理删除

delete 分支调用的 deleteDestroyedHosts() 不是执行 MongoDB 的 Delete,而是将主机的内外网 IP 字段置空、状态字段置为"已销毁"。这样做的原因是:保留审计追溯。SRE 事后追查时,可以从 cc_HostBase 表里看到哪些主机是被同步链路标记为下线状态的,而不是运维人员手动删除的。

差异计算的位置:getDiffHosts

diffhosts 的计算在 cloudsync/hostsyncor.gogetDiffHosts()(第 59 行调用)。它内部调用 logics/cloud.goGetCloudHostResource() 拉取云端数据,然后和 cc_HostBase 表对比,生成 add/update/delete 三个集合。差异判断的 key 是云端实例 ID(云厂商的唯一标识),而不是 CMDB 分配的 HostID。cc_HostBase 是主机的数据表(对应 BKTableNameBaseHost)。

3. VPC 下线:被删除的资源如何处理

What — VPC 下线检测在同步体系中扮演什么角色?

VPC 是云主机的网络容器。当一个 VPC 被云厂商删除(Destroyed = true)时,该 VPC 下所有主机都应该从 CMDB 里标记为下线。cloud_server 通过 getCloudHostResource() 里的 destroyedVpcsChan 通道来并发检测和收集被销毁的 VPC。

Why — 为什么需要在 VPC 维度检测销毁?

云 API(AWS DescribeInstances / 腾讯云 DescribeInstances)返回的实例列表是按 VPC 过滤的。如果某个 VPC 在云端被删除,DescribeVpcs 会返回空或异常,DescribeInstances 自然也查不到该 VPC 下的任何实例。CMDB 里这些主机的 IP 字段会通过 syncDestroyedVpcs() 被置空,状态置为已销毁。

没有 VPC 下线检测会发生什么?

  • VPC 被删除后,该 VPC 下的主机永远留在 CMDB 里,状态显示为"正常"但实际已不可达
  • SRE 的监控告警会继续往这些"僵尸主机"发告警,浪费告警资源
  • 资产盘点报告显示大量"正常"主机,但实际已全部下线
How — getInstancesInfoByVpc 的 VPC 销毁检测源码

logics/cloud.go 第 245-275 行,核心逻辑是:

func (lgc *Logics) getInstancesInfoByVpc(kit *rest.Kit, client cloudvendor.VendorClient,
	conf metadata.CloudAccountConf, vpc metadata.VpcSyncInfo,
	destroyedVpcsChan chan string) (*metadata.InstancesInfo, error) {

	// Step 1: 调用云 API 查询该 VPC 是否存在
	vpcInfo, err := client.GetVpcs(vpc.Region, &ccom.VpcOpt{
		BaseOpt: ccom.BaseOpt{
			Filters: []*ccom.Filter{{ccom.StringPtr("vpc-id"), ccom.StringPtrs([]string{vpc.VpcID})}},
			Limit:   ccom.MaxLimit,
		},
	})
	if err != nil {
		return nil, err
	}

	// Step 2: VPC 不存在(VPCSet 为空)→ 发送到 destroyedVpcsChan
	if len(vpcInfo.VpcSet) == 0 {
		destroyedVpcsChan 

销毁 VPC 的收集在 logics/cloud.go 第 297-352 行并发进行:wg3 goroutine 从 destroyedVpcsChan 读取,存入 destroyedVpcs map。最后在 HostSyncor.syncDestroyedVpcs()(第 221 行起)中,将这些 VPC 下的所有主机的 IP 置空。

4. 同步历史记录:审计与问题追溯

每次同步任务执行后,无论成功还是失败,cloud_server 都会写入一条同步历史记录到 cc_CloudSyncHistory 表。看 cloudsync/hostsyncor.go 第 95-99 行:

// 完成后记录同步历史(每次同步都会写,不管成功失败)
_, err = h.addSyncHistory(syncResult, taskID)
if err != nil {
	blog.Errorf("addSyncHistory fail, taskID: %d, err:%v, rid:%s",
		taskID, err, h.readKit.Rid)
}

同步历史的结构体里包含:同步时间戳、任务 ID、同步结果(新增 / 更新 / 删除数量)、失败详情(失败 IP + 错误信息)。SRE 在排查同步异常时,第一步就是查这个表。

SRE 运维提示

当告警通知"云同步任务失败"时,第一时间查 cc_CloudSyncHistory 表,看是哪个 VPC 的哪批主机同步失败。如果 syncResult.FailInfo.IPError 有内容,说明是部分主机写入 CMDB 失败;如果整行记录缺失,说明是同步任务根本没有执行到写库阶段。

5. 任务状态机:同步状态的完整生命周期

云同步任务(cc_CloudSyncTask)的状态常量定义在 src/common/metadata/cloud.go 第 98-100 行,实测只有以下三种:

const (
	CloudSyncSuccess    string = "cloud_sync_success"    // 同步成功
	CloudSyncFail       string = "cloud_sync_fail"       // 同步失败
	CloudSyncInProgress string = "cloud_sync_in_progress" // 同步中(差异同步中)
)

状态变更由 cloudsync/hostsyncor.goSetSyncResultStatus()(第 89 行调用)和 updateTaskState()(第 103 行调用)共同完成:

  • 同步开始前:置为 in_progress
  • 有差异要同步:保持 in_progress
  • 无差异且上次状态为 fail:自动恢复为 success(第 188-197 行,这个设计很巧妙:上次失败不代表这次失败,无差异时自动恢复)
  • 写入过程出错:置为 fail,并记录 ErrorInfo

6. 全量同步失败重试机制

What — transfer-service 的全量同步重试在同步体系中扮演什么角色?

transfer-service 的目标端(dest)定期执行全量同步,每次同步前会检查是否需要重试上一次失败的同步任务。重试机制由 dest_full_sync.go 第 54 行的 util.RetryWrapper(3, ...) 包裹。

Why — 为什么需要重试?

全量同步涉及多个资源类型(Biz / Set / Module / Host 等 11 种),每个类型的同步都可能因为网络抖动、MongoDB 连接超时等原因失败。不用重试的话,单次失败就永远停在错误状态,SRE 必须人工介入才能恢复。

没有重试会发生什么?

  • MongoDB 偶发抖动导致一次同步失败,数据永远停在旧版本
  • SRE 不得不 24 小时 oncall,每次抖动都要手动触发同步
  • 多节点同时重试,产生重复同步
How — dest_full_sync.go 重试与 Master 选举

src/source_controller/transfer-service/sync/dest_full_sync.go 第 33-50 行,全量同步的入口逻辑是:

// loopPullFullSyncData:目标端全量同步入口 goroutine
func (s *Syncer) loopPullFullSyncData() {
	ack := false
	for {
		// ① 检查本节点是否为 Master(多节点时只有 Master 执行同步)
		if !s.isMaster.IsMaster() {
			blog.V(4).Infof("loop pull full sync data, but not master, skip")
			time.Sleep(5 * time.Minute)
			ack = false
			continue
		}

		// ② 申请 Redis 分布式锁(防止 Master 切换后并发重复同步)
		locker := lock.NewLocker(redis.Client())
		locked, err := locker.Lock(types.FullSyncLockKey, time.Hour)
		if err != nil || !locked {
			blog.Errorf("do not get %s lock, err: %v, locked: %v",
				types.FullSyncLockKey, err, locked)
			time.Sleep(5 * time.Minute)
			continue
		}

		// ③ 批量获取需要同步的 ObjectID
		util.RetryWrapper(3, func() (bool, error) {
			objIDs, quotedObjIDs, err = s.metadata.GetCommonObjIDs()
			if err != nil {
				return true, err  // 重试
			}
			return false, nil  // 成功,停止重试
		})
		// ④ 对每种资源类型执行全量同步(循环内省略)
	}
}

一个关键细节:RetryWrapper 的返回值约定

transfer-service 至少部署 2 个实例做高可用。Master 选举保证同一时间只有一个节点在执行同步,但 Master 切换的瞬间(旧 Master 还没释放锁,新 Master 已经当选)如果没有锁保护,两个节点会同时执行同步,导致相同数据被重复写入。Redis 分布式锁兜住了这个窗口。

7. 增量同步 Token 游标:断点续传的保障

What — Token 游标在增量同步体系中扮演什么角色?

增量同步(incremental sync)不是轮询全量数据,而是监听源端 MongoDB 的变更(通过 Change Stream),只拉取变化的部分。Token 游标记录了"上次同步到哪个位置",下次同步从这里继续。游标存储在 cc_SrcSyncDataToken 表中。

Why — 为什么需要持久化 Token?

如果 Token 只存在内存里,transfer-service 重启后就丢失了,只能从头开始监听源端变更,这叫"全量同步"而非"增量同步"。对于日增百万级主机的 CMDB 实例,从头开始监听可能产生巨大的数据传输压力,甚至把目标端 CMDB 打挂。

没有 Token 持久化会发生什么?

  • transfer-service 重启后,Token 丢失,增量变全量,源端压力翻 10 倍
  • 目标端 CMDB 被动接收大量历史事件,可能触发 OOM
  • SRE 收到"同步延迟告警",但根因是 Token 丢失导致重复消费
How — TokenHandler 的读写逻辑

src/source_controller/transfer-service/sync/watch/token_handler.go,Token 存储结构:

// Token 存储在 cc_SrcSyncDataToken 表
// 表结构(TokenHandler 的写入逻辑):
type SrcSyncDataToken struct {
	ID         uint64     `bson:"id"`
	Type       string     `bson:"type"`        // 资源类型,如 "host"/"biz"/"set"
	Token      string     `bson:"token"`       // Change Stream 的 resume token
	StartAtTime time.Time `bson:"start_at_time"` // 本次同步窗口开始时间
}

// TokenHandler 负责:
// 1. 读取:GetToken(type) → 从 cc_SrcSyncDataToken 查某资源类型的当前游标
// 2. 写入:SetToken(type, token) → 将最新 resume token 写回表
// 3. 初始化:InitToken(type) → 表中无记录时插入第一条(从 0 开始)

增量同步的 Watch 流程(在 src/source_controller/transfer-service/sync/watch/watch.go)是:① 读取 Token → ② 用 Token 订阅 Change Stream → ③ 收到事件 → ④ 写入目标端 → ⑤ 更新 Token。如果 Step 4 或 Step 5 失败,Token 不会更新,下次会重复消费同一条事件("至少一次"语义)。这是分布式系统里的 trade-off:用重复消费换可靠性。

8. FAQ 20 问

常见疑问

以下是关于数据质量保障与脏数据检测的高频问题,每个问题都基于源码给出明确答案。

Q1. 差异同步和全量同步有什么区别?

一句话结论:差异同步只处理变更部分,全量同步每次拉取全部数据。差异同步(cloud_server)通过 getDiffHosts() 计算出 add/update/delete 三个集合,只写差量。全量同步(transfer-service dest_full_sync)每次遍历源端全量数据,不做差异对比,直接写入目标端。

Q2. 主机被云厂商下线后,CMDB 里会怎么处理?

一句话结论:通过 syncDestroyedVpcs() 将主机内外网 IP 置空,状态置为已销毁。cloudsync/hostsyncor.go 第 221 行起可以看到,VPC 被销毁后,该 VPC 下所有主机的 bk_host_innerip / bk_host_outerip 字段被置空,不做物理删除。

Q3. 同步任务失败后会自动重试吗?

一句话结论:会,全量同步用 RetryWrapper 重试 3 次。dest_full_sync.go 第 54 行可以看到,util.RetryWrapper(3, func() (bool, error) {...}) 包裹了获取同步 ObjectID 的逻辑。云同步(HostSyncor)层面目前没有代码级重试,但任务状态会被置为 fail,等待下次定时调度(默认 5 分钟一次)。

Q4. 同步过程中部分主机写入失败怎么办?

一句话结论:成功的继续写入,失败的记录到 FailInfo,继续处理后续主机。cloudsync/hostsyncor.go 第 408-413 行可以看到,syncResult.FailInfo.IPError map 记录了每个失败 IP 的错误详情,任务状态仍置为 success(因为有部分成功)。

Q5. 为什么上次同步失败后,下次无差异时会自动恢复为 success?

一句话结论:上次失败不代表这次失败,无差异说明数据已经同步完成。cloudsync/hostsyncor.go 第 188-197 行可以看到,if ret.Info[0].SyncStatus == CloudSyncFail 且无差异时,状态被更新为 CloudSyncSuccess,避免告警持续。

Q6. VPC 下线检测的原理是什么?

一句话结论:调用云 API 查询 VPC,如果 VpcSet 为空则判定为已销毁。logics/cloud.go 第 257-261 行可以看到,if len(vpcInfo.VpcSet) == 0 时将 VPC ID 发送到 destroyedVpcsChan,由独立 goroutine 收集后调用 syncDestroyedVpcs()

Q7. 同步历史的 FailInfo 记录了哪些信息?

一句话结论:失败 IP 到错误信息的映射 map,以及失败总数。cloudsync/hostsyncor.go 第 410-413 行可以看到,for ip, errinfo := range result.FailInfo.IPError { syncResult.FailInfo.IPError[ip] = errinfo } 将每台失败主机的 IP 和错误信息聚合到最终的 syncResult 中。

Q8. 增量同步的 Token 丢失后会发生什么?

一句话结论:下次同步从头监听源端变更,实质变成全量同步,可能压垮目标端。src/common/watch/watch.go 第 69 行可以看到,bk_start_from 和 bk_cursor 互斥校验,以及 src/source_controller/transfer-service/sync/watch/token_handler.go 可以看到,Token 丢失后没有 fallback 机制,必须重新全量拉取。

Q9. 多节点部署时,为什么需要 Master 选举?

一句话结论:避免多节点同时执行同步,导致数据重复写入。dest_full_sync.go 第 37 行可以看到,只有 s.isMaster.IsMaster() 返回 true 时才执行同步。Master 通过 ZooKeeper 选举产生。

Q10. Redis 分布式锁的作用是什么?

一句话结论:防止 Master 切换瞬间的同步并发。即使通过 ZooKeeper 选出了新 Master,旧 Master 可能还没释放锁(GC 暂停等原因)。Redis 分布式锁的 locker.Lock() 是最后一层保障。

Q11. cloud_server 支持哪几个云厂商的同步?

一句话结论:仅支持 AWS 和腾讯云。src/common/metadata/cloud.go 第 88-94 行可以看到,SupportedCloudVendors = []string{AWS, TencentCloud},对应字符串值 "1" 和 "2"。阿里云 / 华为云 / GCP / Azure 无任何适配器。

Q12. 同步任务可以配置哪些状态告警?

一句话结论:可以监控 syncStatus = fail 且超过 N 分钟未自动恢复的状态。cloudsync/hostsyncor.go 第 188-197 行可以看到,任务失败后如果无差异会自动恢复。因此告警应配置为"任务状态为 fail 持续超过 10 分钟",避免误报。

Q13. 同步过程中,SRE 能看到哪些关键指标?

一句话结论:同步历史中的 NewAdd / Update / Delete 数量,以及失败 IP 和错误信息。cloudsync/hostsyncor.go 第 109 行日志可以看到,sync taskid: %v, costTime: %ds, detail: %#v, failInfo: %#v 输出了完整同步结果。

Q14. addHosts / updateHosts / deleteDestroyedHosts 失败后,同步事务会回滚吗?

一句话结论:不会,每个操作独立提交,失败只记录 FailInfo。cloudsync/hostsyncor.go 第 371-417 行可以看到,syncDiffHosts 里的三个分支各自独立执行,没有用 MongoDB 事务包裹。这是设计选择:部分成功也算成功。

Q15. 同步频率是多少?

一句话结论:默认 5 分钟一次,由 SyncPeriodMinutes 配置。cloudsync/taskprocessor.go 第 23-24 行可以看到,SyncPeriodMinutesMin = 5 是最小值,实际由配置决定。

Q16. 源端 CMDB 的 Watch 模块和增量同步是什么关系?

一句话结论:Watch 监听源端 MongoDB 变更,增量同步从 Watch 事件中拉取数据写入目标端。Watcher 通过 MongoDB Change Stream 订阅变更,将事件 ID(Token)存储在 cc_SrcSyncDataToken 表。

Q17. 为什么 delete 操作是逻辑删除而不是物理删除?

一句话结论:保留审计追溯,运维人员可以追溯主机下线时间。cloudsync/hostsyncor.go 第 392-402 行可以看到,deleteDestroyedHosts 只置空 IP 字段,不调用 MongoDB Delete 操作。

Q18. 如果云 API 返回超时,部分 VPC 数据丢失,同步会怎么处理?

一句话结论:超时的 goroutine 向 errChan 发错,主函数等待 wg 超时后返回错误,任务状态置为 fail。logics/cloud.go 第 308-311 行可以看到,超时错误通过 errChan 传递,最终导致整次同步失败。

Q19. 如何扩展 cloud_server 支持新的云厂商?

一句话结论:实现 VendorClient 接口 + init() 自注册 + 更新白名单。cloudvendor/ 下新增文件,实现 NewVendorClient / GetRegions / GetVpcs / GetInstances / GetInstancesTotalCnt 四个方法,在 init() 中调用 Register("3", &client{}),再将 "3" 加入 SupportedCloudVendors

Q20. 同步链路中,SRE 最应该关注哪些告警?

一句话结论:任务状态 = fail 持续超过 10 分钟 + 同步历史中 FailInfo 有内容。满足以下任一条件时应该介入:1)cc_CloudSyncTask 中 syncStatus = fail 超过 10 分钟;2)cc_CloudSyncHistory 中某次同步的 FailInfo.Count > 0;3)同步耗时(costTime)突然翻倍(可能数据量异常)。

全篇总纲

蓝鲸 CMDB 的数据质量保障通过以下机制协同工作:

  • 差异同步:HostSyncor.syncDiffHosts 三分支(add/update/delete),只写差量,保护人工标注
  • VPC 下线检测:getInstancesInfoByVpc 对比云端 VPC 列表,销毁 VPC 下主机 IP
  • 状态机:3 种任务状态(cloud_sync_success / cloud_sync_fail / cloud_sync_in_progress),失败自动恢复
  • 重试机制:transfer-service 全量同步 RetryWrapper 3 次 + Redis 分布式锁防并发
  • Token 游标:cc_SrcSyncDataToken 持久化增量同步断点,丢失则变全量同步
  • 同步历史:cc_CloudSyncHistory 记录每次同步的 add/update/delete 数量和失败 IP

9. 后续预告

  • #12:Watch 事件订阅 — MongoDB Change Stream 推送机制与实时事件通知(cache-service watch 的完整链路解析)
  • #13:IAM 权限模型 — 资源层级权限控制与 RBAC 集成
posted @ 2026-07-06 16:54  左扬  阅读(20)  评论(0)    收藏  举报