蓝鲸 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.go 的 syncDiffHosts() 是云同步"写入阶段"的唯一入口。它在 HostSyncor.Sync() 第 82 行被调用,负责把"云端数据与 CMDB 已有数据的差异"翻译为具体的写操作(新增 / 更新 / 删除)。这个函数是同步质量的核心保障。
Why — 为什么需要三分支差异处理?
云同步不是"全量覆盖",而是"差异同步"。每次同步时 cloud_server 从云 API 拉取全量主机列表(logics/cloud.go 的 GetCloudHostResource()),然后和 cc_HostBase 表中已有数据对比,计算出差量。只写差量有三个好处:减少 CMDB 写入压力、保留人工标注的字段不被覆盖、审计历史干净。
没有差异检测会发生什么?
- 每次同步全量覆盖,运维人员在 CMDB 里手动标注的主机分组、标签会被清除
- 已下线主机永远不会被清理,变成"僵尸主机"堆积在 CMDB
- 同步压力大,每次写入 MongoDB 触发大量事件,Watch 系统被刷屏
看 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.go 的 getDiffHosts()(第 59 行调用)。它内部调用 logics/cloud.go 的 GetCloudHostResource() 拉取云端数据,然后和 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 的监控告警会继续往这些"僵尸主机"发告警,浪费告警资源
- 资产盘点报告显示大量"正常"主机,但实际已全部下线
看 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.go 的 SetSyncResultStatus()(第 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,每次抖动都要手动触发同步
- 多节点同时重试,产生重复同步
看 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 丢失导致重复消费
看 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 集成

浙公网安备 33010602011771号