蓝鲸 CMDB 3.14.6 源码专题【左扬精讲】— CMDB #21 拓扑关联:InstAsst 关联模型、拓扑递归查询
蓝鲸 CMDB 3.14.6 源码专题【左扬精讲】— CMDB #21 拓扑关联:InstAsst 关联模型、拓扑递归查询
在蓝鲸 CMDB 中,"业务拓扑" 是用户感知最强的功能之一——从业务下钻到集群、模块、主机,每一层都依赖 "关联关系"。但底层支撑这一切的不是某种 "魔法",而是 3 个相互嵌套的 Go struct:AssociationKind(关联类型)、Association(关联定义)、InstAsst(实例关联),加上 1 套拓扑递归查询。
本篇以 v3.14.6 实测源码为主线,把 "实例关联" 这层最贴近业务的数据模型讲透:3 个 model 的字段语义、写入的并发控制、查询的 OR 双向匹配、批量审计的旁路写入。
必须掌握
- 3 个核心 model 的字段语义:AssociationKind(关联类型,4 字段)、Association(关联定义,10 字段)、InstAsst(实例关联,8 字段)。
- MainlineAssociation 的内嵌组合:用 Association 内嵌 + 3 个展示字段,零冗余表达"主线关联"。
- 写入的 3 锁并发控制:1:1 用 MLocker、1:n 用 Locker、n:n 走默认路径。
- SearchAssociationRelatedInst 的 OR 双向查询:同一条 MongoDB $or 同时匹配"作为源"和"作为目标"两个方向。
- 3 类校验的硬约束:主键重复、关联类型存在、源/目标实例存在。
- 审计的旁路写入:关联创建/删除走 NewInstanceAssociationAudit,事务外持久化。
了解即可
- AssociationDirection 4 枚举值:none、src_to_dest、dest_to_src、bidirectional。
- AssociationMapping 3 枚举值:1:1、1:n、n:n。
- AssociationOnDeleteAction 3 枚举值:none、delete_src、delete_dest。
v3.14.6InstAsst 关联模型AssociationKindAssociationMainlineAssociationSearchAssociationRelatedInstMLocker / Lockerauditlog.NewInstanceAssociationAudit
src/common/metadata/association.go ← AssociationKind / Association / MainlineAssociation / InstAsst 模型
src/scene_server/topo_server/logics/inst/association.go ← AssociationOperationInterface 接口 + CreateInstanceAssociation / DeleteInstAssociation
src/scene_server/topo_server/service/association.go ← SearchAssociationInst / SearchAssociationRelatedInst / CreateAssociationInst
src/source_controller/coreservice/core/association/instance.go ← CreateOneInstanceAssociation 1:1/1:n/n:n 三锁实现
src/common/auditlog/ ← NewInstanceAssociationAudit 关联审计
一、3 个 model + 1 个内嵌:关联体系的全部真相
cmdb 的"关联"在数据层是 3 张 MongoDB collection:
cc_AssociationKind ← 关联类型(如"运行于"、"部署在"),跨业务复用
cc_ObjectAsst ← 关联定义(host → process 的"运行于"),模型间关系
cc_InstAsst ← 实例关联(hostID=100 → processID=200 的"运行于"),实例间关系
What — 3 个 model 分别是什么?
这 3 层是一个金字塔结构:
- 第 1 层 AssociationKind(关联类型):定义"什么样的关联"(如"运行于"、"部署在"、"备份到"),由 cmdb 管理员创建,跨业务复用;
- 第 2 层 Association(关联定义):定义"哪个模型的实例能关联到哪个模型的实例"(如 host 模型能关联到 process 模型),由业务管理员创建;
- 第 3 层 InstAsst(实例关联):具体的主机 ID 和进程 ID 的关联关系,由终端用户操作。
这种 3 层结构是 cmdb 支持"自定义关联类型 + 跨模型关联"的基石——用户可以创建"备份到"、"部署在"、"依赖"等任意关联类型,而不需要修改 cmdb 代码。
Why — 为什么是 3 层而不是 1 层?
问题:如果只有 1 层"实例关联"
每个关联实例都得带"关联类型名"字段(如 asst_kind_name="运行于"),那么修改关联类型名要 UPDATE 全表,写放大严重;也无法做"关联方向"(src → dest vs dest → src)、"映射关系"(1:1 vs 1:n vs n:n)的强校验。
解决方案:3 层金字塔
- 第 1 层 AssociationKind 只存"关联类型的元数据"(方向、是否预置);
- 第 2 层 Association 存"模型间关系 + 映射约束"(1:1 / 1:n / n:n);
- 第 3 层 InstAsst 只存"实例 ID + 引用 type 1/2 的 ID",写时强校验,查询时 1 次 JOIN。
没有 3 层会发生什么?
- 没有第 1 层:所有关联共用一个类型,无法表达"运行于 vs 备份到"语义差异;
- 没有第 2 层:模型间关系无法约束,任意两个模型都能互相关联,数据脏;
- 没有第 3 层:无法用 $or 双向查询拓扑,关联查询要扫全表。
1.1 AssociationKind:关联类型
看 src/common/metadata/association.go 第 398-417 行的真实定义:
type AssociationKind struct {
ID int64 `field:"id" json:"id" bson:"id"`
AssociationKindID string `field:"bk_asst_id" json:"bk_asst_id" bson:"bk_asst_id"`
AssociationKindName string `field:"bk_asst_name" json:"bk_asst_name" bson:"bk_asst_name"`
OwnerID string `field:"bk_supplier_account" json:"bk_supplier_account" bson:"bk_supplier_account"`
SourceToDestinationNote string `field:"src_des" json:"src_des" bson:"src_des"`
DestinationToSourceNote string `field:"dest_des" json:"dest_des" bson:"dest_des"`
Direction AssociationDirection `field:"direction" json:"direction" bson:"direction"`
IsPre *bool `field:"ispre" json:"ispre" bson:"ispre"`
}
4 个关键字段:
- AssociationKindID:唯一标识(如 belong),由 cmdb 预置或管理员创建;
- SourceToDestinationNote:从源到目标的描述(如"运行于"),用于拓扑图上的箭头文字;
- Direction:方向枚举,决定前端如何渲染箭头;
- IsPre:是否预置,预置关联类型不能删除。
★ 关键事实:SourceToDestinationNote 和 DestinationToSourceNote 是两个描述字段。例如"备份"这个关联,从源 A 到目标 B 是"A 备份到 B",从 B 到 A 是"B 是 A 的备份",文字方向不同。
1.2 Association:关联定义
看 src/common/metadata/association.go 第 463-488 行:
type Association struct {
ID int64 `field:"id" json:"id" bson:"id"`
OwnerID string `field:"bk_supplier_account" json:"bk_supplier_account" bson:"bk_supplier_account"`
AssociationName string `field:"bk_obj_asst_id" json:"bk_obj_asst_id" bson:"bk_obj_asst_id"`
AssociationAliasName string `field:"bk_obj_asst_name" json:"bk_obj_asst_name" bson:"bk_obj_asst_name"`
ObjectID string `field:"bk_obj_id" json:"bk_obj_id" bson:"bk_obj_id"`
AsstObjID string `field:"bk_asst_obj_id" json:"bk_asst_obj_id" bson:"bk_asst_obj_id"`
AsstKindID string `field:"bk_asst_id" json:"bk_asst_id" bson:"bk_asst_id"`
Mapping AssociationMapping `field:"mapping" json:"mapping" bson:"mapping"`
OnDelete AssociationOnDeleteAction `field:"on_delete" json:"on_delete" bson:"on_delete"`
IsPre *bool `field:"ispre" json:"ispre" bson:"ispre"`
}
5 个关键字段:
- AssociationName:关联定义的唯一 ID,格式是 bk_obj_asst_id = "$ObjectID"_"$AsstID"_"$AsstObjID"(如 host_belong_module);
- ObjectID + AsstObjID:源模型 + 目标模型,决定哪两个模型能互相关联;
- AsstKindID:引用 AssociationKind 的 AssociationKindID;
- Mapping:1:1 / 1:n / n:n,决定写入时的并发策略;
- OnDelete:删除关联时是否级联删除源/目标实例。
看 src/common/metadata/association.go 第 492-523 行的 CanUpdate 方法:
func (a *Association) CanUpdate() (field string, can bool) {
if a.ID != 0 {
return "id", false
}
if len(a.OwnerID) != 0 {
return "bk_supplier_account", false
}
if len(a.AssociationName) != 0 {
return "bk_obj_asst_id", false
}
if len(a.ObjectID) != 0 {
return "bk_obj_id", false
}
if len(a.AsstObjID) != 0 {
return "bk_asst_obj_id", false
}
if len(a.Mapping) != 0 {
return "mapping", false
}
if a.IsPre != nil {
return "ispre", false
}
return "", true
}
这是个"白名单"反模式:默认禁止更新(return "", false 对所有未列出的字段),只允许更新 on_delete、asst_id、alias_name。这是为了防止误改"已经存在实例关联"的关联定义——一旦改了 mapping 从 1:1 到 1:n,存量数据会全部非法。
1.3 InstAsst:实例关联
看 src/common/metadata/association.go 第 550-571 行:
type InstAsst struct {
ID int64 `field:"id" json:"id,omitempty"`
InstID int64 `field:"bk_inst_id" json:"bk_inst_id,omitempty" bson:"bk_inst_id"`
ObjectID string `field:"bk_obj_id" json:"bk_obj_id,omitempty" bson:"bk_obj_id"`
AsstInstID int64 `field:"bk_asst_inst_id" json:"bk_asst_inst_id,omitempty" bson:"bk_asst_inst_id"`
AsstObjectID string `field:"bk_asst_obj_id" json:"bk_asst_obj_id,omitempty" bson:"bk_asst_obj_id"`
OwnerID string `field:"bk_supplier_account" json:"bk_supplier_account,omitempty" bson:"bk_supplier_account"`
ObjectAsstID string `field:"bk_obj_asst_id" json:"bk_obj_asst_id,omitempty" bson:"bk_obj_asst_id"`
AssociationKindID string `field:"bk_asst_id" json:"bk_asst_id,omitempty" bson:"bk_asst_id"`
BizID int64 `field:"bk_biz_id" json:"bk_biz_id,omitempty" bson:"bk_biz_id"`
}
4 个关键字段:
- InstID + ObjectID:源实例的"模型 + ID"组合;
- AsstInstID + AsstObjectID:目标实例的"模型 + ID"组合;
- ObjectAsstID:引用 Association,冗余存储避免 JOIN;
- BizID:实例所属业务 ID(仅作为冗余字段,用于按业务过滤的快速查询)。
注意:ObjectAsstID + AssociationKindID 是"双引用"——既引用了 Association 也引用了 AssociationKind。这是有意为之的反范式设计:用存储换查询,避免每次都 JOIN cc_ObjectAsst / cc_AssociationKind 两张表。
1.4 MainlineAssociation:主线关联的内嵌组合
看 src/common/metadata/association.go 第 541-548 行:
type MainlineAssociation struct {
Association `json:",inline"`
ClassificationID string `json:"bk_classification_id,omitempty" bson:"-"`
ObjectIcon string `json:"bk_obj_icon,omitempty" bson:"-"`
ObjectName string `json:"bk_obj_name,omitempty" bson:"-"`
}
4 个关键设计:
- Association 内嵌:字段全复用,MainlineAssociation 自动获得 AssociationName、Mapping 等所有字段;
- ClassificationID + ObjectIcon + ObjectName:3 个"展示字段",用 bson:"-" 标记不进 MongoDB,只用于前端渲染;
- json:",inline":序列化时把 Association 字段"展开"到顶层(而不是嵌套的 association 子对象);
- 0 字段冗余:所有"关系字段"都来自 Association,3 个展示字段是 1:1 对应模型的元数据,不污染 cc_ObjectAsst 表。
二、3 锁并发控制:1:1 vs 1:n vs n:n 的写入策略
不同 mapping 的关联写入需要不同的并发控制,看 src/source_controller/coreservice/core/association/instance.go 第 222-303 行的 CreateOneInstanceAssociation:
func (m *associationInstance) CreateOneInstanceAssociation(kit *rest.Kit,
inputParam metadata.CreateOneInstanceAssociation) (*metadata.CreateOneDataResult, error) {
mappingType, err := m.checkInstAsstCreateData(kit, inputParam)
if err != nil {
return nil, err
}
switch mappingType {
case metadata.OneToOneMapping:
mlocker := lock.NewMLocker(driverRedis.Client())
// if one instance is associated with itself, then lock this instance, or else lock both instance
instkey := lock.StrFormat(genAssoInstLockKey(inputParam.Data.InstID, inputParam.Data.ObjectAsstID))
lockKeys := []lock.StrFormat{instkey}
if inputParam.Data.InstID != inputParam.Data.AsstInstID {
asstInstkey := lock.StrFormat(genAssoInstLockKey(inputParam.Data.AsstInstID, inputParam.Data.ObjectAsstID))
lockKeys = append(lockKeys, asstInstkey)
}
locked, err := mlocker.MLock(kit.Rid, 10, time.Minute, lockKeys...)
if err != nil {
blog.Errorf("obtain lock failed. err: %v, rid: %s", err, kit.Rid)
return nil, kit.CCError.CCErrorf(common.CCERrrCoreServiceConcurrent)
}
if !locked {
return nil, kit.CCError.CCErrorf(common.CCERrrCoreServiceConcurrent)
}
defer func() {
if err := mlocker.MUnlock(); err != nil {
blog.Errorf("release lock failed, err: %v, rid: %s", err, kit.Rid)
}
}()
id, err := m.save(kit, inputParam.Data)
if err != nil {
return nil, err
}
return &metadata.CreateOneDataResult{Created: metadata.CreatedDataResult{ID: id}}, nil
case metadata.OneToManyMapping:
locker := lock.NewLocker(driverRedis.Client())
asstInstManyKey := genAssoInstLockKey(inputParam.Data.AsstInstID, inputParam.Data.ObjectAsstID)
locked, err := locker.Lock(lock.StrFormat(asstInstManyKey), time.Minute)
if err != nil {
return nil, kit.CCError.CCErrorf(common.CCERrrCoreServiceConcurrent)
}
if !locked {
return nil, kit.CCError.CCErrorf(common.CCERrrCoreServiceConcurrent)
}
defer func() {
if err := locker.Unlock(); err != nil {
blog.Errorf("release lock failed, err: %v,rid: %s", err, kit.Rid)
}
}()
id, err := m.save(kit, inputParam.Data)
if err != nil {
return nil, err
}
return &metadata.CreateOneDataResult{Created: metadata.CreatedDataResult{ID: id}}, nil
default:
id, err := m.save(kit, inputParam.Data)
if err != nil {
return nil, err
}
return &metadata.CreateOneDataResult{Created: metadata.CreatedDataResult{ID: id}}, nil
}
}
3 个分支对应 3 种锁策略:
| mapping | 锁类型 | 锁的 key | 原因 |
|---|---|---|---|
| 1:1(OneToOneMapping) | MLocker(多键) | 源 + 目标 2 个 key | 必须保证"源唯一 + 目标唯一",2 个实例都得锁 |
| 1:n(OneToManyMapping) | Locker(单键) | 目标 1 个 key | 源可以多个,但"目标必须唯一",只锁目标 |
| n:n(ManyToManyMapping) | 无锁 | — | 源、目标都可以多个,无需互斥 |
看 src/source_controller/coreservice/core/association/instance.go 第 216-220 行的锁 key 生成:
func genAssoInstLockKey(id int64, objectAsstID string) string {
return fmt.Sprintf("asso_inst:%d:%s", id, objectAsstID)
}
锁 key 是 asso_inst:实例ID:关联定义ID,不绑业务、不绑租户,这是为了"跨业务关联"也能加锁(如跨业务的"备份到")。
源码视角一:3 类校验的硬约束
看 src/source_controller/coreservice/core/association/instance.go 第 305-374 行的 checkInstAsstCreateData,3 类校验是顺序执行的:
inputParam.Data.OwnerID = kit.SupplierAccount
exists, err := m.isExists(kit, inputParam.Data.InstID, inputParam.Data.AsstInstID, inputParam.Data.ObjectAsstID,
inputParam.Data.ObjectID, inputParam.Data.BizID)
if err != nil {
return "", err
}
if exists {
return "", kit.CCError.Errorf(common.CCErrCommDuplicateItem, "association")
}
// check association kind
cond := mongo.NewCondition()
cond.Element(&mongo.Eq{Key: common.AssociationObjAsstIDField, Val: inputParam.Data.ObjectAsstID})
_, exists, err = m.associationModel.isExists(kit, cond)
if err != nil {
return "", err
}
if !exists {
return "", kit.CCError.Error(common.CCErrorTopoAsstKindIsNotExist)
}
// check association inst
exists, err = m.dependent.IsInstanceExist(kit, inputParam.Data.ObjectID, uint64(inputParam.Data.InstID))
if err != nil {
return "", err
}
if !exists {
return "", kit.CCError.Error(common.CCErrorAsstInstIsNotExist)
}
// check inst to asst
exists, err = m.dependent.IsInstanceExist(kit, inputParam.Data.AsstObjectID, uint64(inputParam.Data.AsstInstID))
if err != nil {
return "", err
}
if !exists {
return "", kit.CCError.Error(common.CCErrorInstToAsstIsNotExist)
}
4 步校验链:
- 主键重复校验:isExists 查 (InstID, AsstInstID, ObjectAsstID) 三元组是否已存在;
- 关联类型存在性:associationModel.isExists 查 cc_ObjectAsst 表,确保 ObjectAsstID 有效;
- 源实例存在性:IsInstanceExist(ObjectID, InstID),确保源实例未被删除;
- 目标实例存在性:IsInstanceExist(AsstObjectID, AsstInstID),确保目标实例未被删除。
4 步任一失败都会立即返回错误,不会进入加锁阶段。这是"先校验后加锁"的工程取舍:减少 Redis 锁的持有时间。
源码视角二:看 src/scene_server/topo_server/service/association.go 第 1135-1159 行的 service 层包装:
func (s *Service) CreateAssociationInst(ctx *rest.Contexts) {
request := &metadata.CreateAssociationInstRequest{}
if err := ctx.DecodeInto(request); err != nil {
ctx.RespAutoError(ctx.Kit.CCError.New(common.CCErrCommParamsInvalid, err.Error()))
return
}
var ret *metadata.RspID
txnErr := s.Engine.CoreAPI.CoreService().Txn().AutoRunTxn(ctx.Kit.Ctx, ctx.Kit.Header, func() error {
var err error
ret, err = s.Logics.InstAssociationOperation().CreateInstanceAssociation(ctx.Kit, request)
if err != nil {
return err
}
return nil
})
if txnErr != nil {
ctx.RespAutoError(txnErr)
return
}
ctx.RespEntity(ret)
}
3 层包装:service 解析 HTTP body → AutoRunTxn 包事务 → logic 层 CreateInstanceAssociation 业务编排 → coreservice 层 CreateOneInstanceAssociation 三锁分派。每一层都加"事务 + 错误捕获",任意一层失败都会回滚整个事务。
源码视角总结:1 条 4 步校验链 + 1 个 3 锁分派
- 1 条 4 步校验链:主键 → 类型 → 源实例 → 目标实例,任一失败立即返回;
- 1 个 3 锁分派:1:1 用 MLocker、1:n 用 Locker、n:n 不加锁,精确匹配业务并发需求。
避坑提醒(源码视角):
- 不要在 n:n 场景手动加锁:会浪费 Redis 资源,且对正确性无帮助;
- 不要在 1:1 场景用 Locker:1:1 需要保证"源唯一 + 目标唯一",单 Locker 只能锁一边,仍然会并发。
三、SearchAssociationRelatedInst:双向 OR 查询
"拓扑查询"是 cmdb 最高频的关联查询,需求是"给定一个实例,找到所有和它关联的实例",无论这个实例是源还是目标。看 src/scene_server/topo_server/service/association.go 第 872-939 行:
func (s *Service) SearchAssociationRelatedInst(ctx *rest.Contexts) {
request := &metadata.SearchAssociationRelatedInstRequest{}
if err := ctx.DecodeInto(&request); err != nil {
ctx.RespAutoError(ctx.Kit.CCError.Errorf(common.CCErrCommParamsInvalid, err.Error()))
return
}
if request.Condition.InstID == 0 || request.Condition.ObjectID == "" {
ctx.RespAutoError(ctx.Kit.CCError.Errorf(common.CCErrCommParamsNeedSet, "'bk_inst_id' & 'bk_obj_id'"))
return
}
if len(request.Fields) == 0 {
ctx.RespAutoError(ctx.Kit.CCError.Errorf(common.CCErrCommParamsNeedSet, "fields"))
return
}
request.Page.Sort = common.BKFieldID
if request.Page.Limit > common.BKMaxInstanceLimit {
ctx.RespAutoError(ctx.Kit.CCError.Errorf(common.CCErrCommPageLimitIsExceeded))
return
}
queryCond := &metadata.InstAsstQueryCondition{
ObjID: request.Condition.ObjectID,
Cond: metadata.QueryCondition{
Fields: request.Fields,
Page: request.Page,
Condition: mapstr.MapStr{
common.BKDBOR: []mapstr.MapStr{
{
common.BKObjIDField: request.Condition.ObjectID,
common.BKInstIDField: request.Condition.InstID,
},
{
common.BKAsstObjIDField: request.Condition.ObjectID,
common.BKAsstInstIDField: request.Condition.InstID,
},
},
},
},
}
res, err := s.Engine.CoreAPI.CoreService().Association().ReadInstAssociation(ctx.Kit.Ctx, ctx.Kit.Header, queryCond)
if err != nil {
blog.Errorf("search instance association failed, err: %v, query: %#v, rid: %s", err, queryCond, ctx.Kit.Rid)
ctx.RespAutoError(err)
}
ctx.RespEntity(res.Info)
}
关键在第 917-928 行的 $or 双分支:
Condition: mapstr.MapStr{
common.BKDBOR: []mapstr.MapStr{
{
common.BKObjIDField: request.Condition.ObjectID,
common.BKInstIDField: request.Condition.InstID,
},
{
common.BKAsstObjIDField: request.Condition.ObjectID,
common.BKAsstInstIDField: request.Condition.InstID,
},
},
},
这条 $or 表达的是:"在 cc_InstAsst 表中,匹配 (bk_obj_id=host, bk_inst_id=100) OR (bk_asst_obj_id=host, bk_asst_inst_id=100)"。等价 SQL:
SELECT * FROM cc_InstAsst
WHERE (bk_obj_id = 'host' AND bk_inst_id = 100)
OR (bk_asst_obj_id = 'host' AND bk_asst_inst_id = 100)
这是 cmdb 拓扑查询的唯一支持方式——通过 OR 双向匹配,无论当前实例是源还是目标都能查到。
★ 关键事实:这条 OR 查询不依赖 MongoDB 索引,因为 (bk_obj_id, bk_inst_id) 和 (bk_asst_obj_id, bk_asst_inst_id) 都是高基数组合。建议在 cc_InstAsst 表上建 2 个独立索引:{bk_obj_id: 1, bk_inst_id: 1} 和 {bk_asst_obj_id: 1, bk_asst_inst_id: 1}。cmdb 3.14.6 已经预置。
四、SearchInstAssociationUIList:UI 层的关联列表
除了"拓扑查询",前端经常需要"关联列表"——把关联数据按"源/目标"分组展示。看 src/scene_server/topo_server/logics/inst/association.go 第 133-196 行的 SearchInstAssociationUIList:
func (assoc *association) SearchInstAssociationUIList(kit *rest.Kit, objID string, query *metadata.QueryCondition) (
*metadata.SearchInstAssociationListResult, uint64, error) {
queryCond := &metadata.InstAsstQueryCondition{ObjID: objID}
if query != nil {
queryCond.Cond = *query
}
rsp, err := assoc.clientSet.CoreService().Association().ReadInstAssociation(kit.Ctx, kit.Header, queryCond)
if err != nil {
blog.Errorf("search instance association failed, query: %#v, err: %v, rid: %s", query, err, kit.Rid)
return nil, 0, err
}
objIDInstIDMap := make(map[string][]int64, 0)
objSrcAsstArr := make([]metadata.InstAsst, 0)
objDstAsstArr := make([]metadata.InstAsst, 0)
for _, instAsst := range rsp.Info {
objIDInstIDMap[instAsst.ObjectID] = append(objIDInstIDMap[instAsst.ObjectID], instAsst.InstID)
objIDInstIDMap[instAsst.AsstObjectID] = append(objIDInstIDMap[instAsst.AsstObjectID], instAsst.AsstInstID)
if instAsst.ObjectID == objID {
objSrcAsstArr = append(objSrcAsstArr, instAsst)
} else {
objDstAsstArr = append(objDstAsstArr, instAsst)
}
}
instInfo := make(map[string][]mapstr.MapStr, 0)
for instObjID, instIDArr := range objIDInstIDMap {
idField := metadata.GetInstIDFieldByObjID(instObjID)
fields := []string{metadata.GetInstNameFieldName(instObjID), idField}
if instObjID == common.BKInnerObjIDHost {
fields = append(fields, common.BKHostInnerIPv6Field)
}
input := &metadata.QueryCondition{
Condition: mapstr.MapStr{
idField: mapstr.MapStr{common.BKDBIN: instIDArr},
},
Page: metadata.BasePage{
Start: 0,
Limit: common.BKNoLimit,
},
Fields: fields,
}
instResp, err := assoc.clientSet.CoreService().Instance().ReadInstance(kit.Ctx, kit.Header, instObjID, input)
if err != nil {
blog.Errorf("search instance failed, query: %#v, err: %v, rid: %s", query, err, kit.Rid)
return nil, 0, err
}
instInfo[instObjID] = instResp.Info
}
result := &metadata.SearchInstAssociationListResult{}
result.Inst = instInfo
result.Association.Src = objSrcAsstArr
result.Association.Dst = objDstAsstArr
return result, rsp.Count, nil
}
3 个关键设计:
- 第 1 步查关联:ReadInstAssociation 一次拿所有相关 InstAsst;
- 第 2 步按源/目标分组:ObjectID == objID 走 objSrcAsstArr,否则走 objDstAsstArr;
- 第 3 步批量回填实例信息:$in 一次查所有关联到的实例名/IP,按模型分组返回。
这是 cmdb 列表展示的"N+1 查询优化"模式:先批量拿关联,再批量拿实例,避免 N+1 次单查。
五、批量创建 + 删除:旁路审计 + 1 事务
CreateManyInstAssociation 是高频场景——"用户从主机列表里勾选 100 台,然后批量绑定到 1 个模块"。看 src/scene_server/topo_server/logics/inst/association.go 第 374-414 行:
resp := metadata.NewManyInstAsstResultDetail()
for _, item := range res.Created {
resp.SuccessCreated[item.OriginIndex] = int64(item.ID)
}
for _, item := range res.Repeated {
itemObjID, _ := item.Data.Get(common.BKObjIDField)
itemAsstObjID, _ := item.Data.Get(common.BKAsstObjIDField)
resp.Error[item.OriginIndex] = kit.CCError.CCErrorf(common.CCErrTopoAssociationAlreadyExist,
itemObjID, itemAsstObjID).Error()
}
for _, item := range res.Exceptions {
resp.Error[item.OriginIndex] = item.Message
}
if len(resp.SuccessCreated) == 0 {
return resp, nil
}
// generate audit log.
audit := auditlog.NewInstanceAssociationAudit(assoc.clientSet.CoreService())
generateAuditParameter := auditlog.NewGenerateAuditCommonParameter(kit, metadata.AuditCreate)
var auditList []metadata.AuditLog
for _, asstID := range resp.SuccessCreated {
auditLog, err := audit.GenerateAuditLog(generateAuditParameter, asstID, request.ObjectID, nil)
if err != nil {
blog.Errorf("generate audit log failed, err: %s, rid: %s", err.Error(), kit.Rid)
return nil, kit.CCError.Error(common.CCErrAuditGenerateLogFailed)
}
auditList = append(auditList, *auditLog)
}
// save audit log.
err = audit.SaveAuditLog(kit, auditList...)
if err != nil {
blog.Errorf("save audit log failed, err: %v, rid: %s", err, kit.Rid)
return nil, kit.CCError.Error(common.CCErrAuditSaveLogFailed)
}
return resp, nil
5 个关键设计:
- 3 类结果分桶:SuccessCreated(成功)、Repeated(重复)、Exceptions(异常),按 OriginIndex 对应原始入参;
- 重复检测:CCErrorTopoAssociationAlreadyExist 专门给"批量时已有部分关联"用;
- 旁路审计:NewInstanceAssociationAudit 单独生成审计日志,不入主事务;
- 审计的"成功才记":if len(resp.SuccessCreated) == 0 { return resp, nil } 直接跳过审计生成;
- 审计失败不影响主结果:SaveAuditLog 失败返回错误,但已经成功的关联不会回滚。
注意:看 src/scene_server/topo_server/logics/inst/association.go 第 416-468 行的 DeleteInstAssociation,删除走"先查后删":先 ReadInstAssociation 拿全量待删数据,再 DeleteInstAssociation 删 MongoDB,最后用查到的全量数据生成审计(包含"删除前快照")。这是审计"删除时记录"谁关联到了谁""的标准做法。
六、CheckAssociations:级联清理脏数据
cmdb 有一个"实例删除时残留关联"的边缘情况:用户删了主机 X,但忘了删 X 关联的进程 P——X 在 cc_HostBase 没了,但 cc_InstAsst 里还残留 (X → P) 的记录。看 src/scene_server/topo_server/logics/inst/association.go 第 470-528 行的 CheckAssociations:
func (assoc *association) CheckAssociations(kit *rest.Kit, objectID string, instIDs []int64) error {
if len(instIDs) == 0 {
return nil
}
// get all associations for the instances
instAsstCond := &metadata.InstAsstQueryCondition{
Cond: metadata.QueryCondition{Condition: mapstr.MapStr{
common.BKDBOR: []mapstr.MapStr{
{common.BKObjIDField: objectID, common.BKInstIDField: mapstr.MapStr{common.BKDBIN: instIDs}},
{common.BKAsstObjIDField: objectID, common.BKAsstInstIDField: mapstr.MapStr{common.BKDBIN: instIDs}},
},
}},
ObjID: objectID,
}
associations, err := assoc.clientSet.CoreService().Association().ReadInstAssociation(kit.Ctx, kit.Header,
instAsstCond)
if err != nil {
return err
}
if len(associations.Info) == 0 {
return nil
}
instIDExistsMap := make(map[int64]bool)
for _, instID := range instIDs {
instIDExistsMap[instID] = true
}
asstObjInstIDsMap := make(map[string][]int64)
for _, asst := range associations.Info {
if asst.ObjectID == objectID && instIDExistsMap[asst.InstID] {
asstObjInstIDsMap[asst.AsstObjectID] = append(asstObjInstIDsMap[asst.AsstObjectID], asst.AsstInstID)
} else if asst.AsstObjectID == objectID && instIDExistsMap[asst.AsstInstID] {
asstObjInstIDsMap[asst.ObjectID] = append(asstObjInstIDsMap[asst.ObjectID], asst.InstID)
}
}
for asstObjID, asstInstIDs := range asstObjInstIDsMap {
query := &metadata.Condition{
Condition: mapstr.MapStr{
common.GetInstIDField(asstObjID): mapstr.MapStr{common.BKDBIN: asstInstIDs},
},
}
asstInstCnt, err := assoc.clientSet.CoreService().Instance().CountInstances(kit.Ctx, kit.Header, asstObjID,
query)
// ...
}
return nil
}
3 步清理逻辑:
- 查所有相关关联:$or 双向查 (X 作为源) 和 (X 作为目标) 的所有 InstAsst;
- 提取关联实例 ID 集合:从 cc_InstAsst 中收集所有"对方实例的 ID";
- 批量校验存在性:用 CountInstances 检查对方实例是否还存在,不存在的清理残留关联。
没有这一步会发生什么?
- 用户删除主机 X:cc_HostBase 删了,cc_InstAsst 残留 (X=100 → P=200);
- 后续 SearchAssociationRelatedInst(P=200) 返回的关联里会带 X=100 这个"幽灵主机 ID";
- 前端展示进程 P 的关联主机时,/api/v3/host/search?bk_host_id=100 返回 404,UI 出现空数据卡片。
七、SearchInstAssociationAndInstDetail:拓扑+实例的复合查询
最后一个场景是"拓扑 + 实例详情"复合查询——前端展示拓扑时需要"关联 + 源/目标实例的字段值"。看 src/scene_server/topo_server/service/association.go 第 941-1052 行的 SearchInstAssociationAndInstDetail:
func (s *Service) SearchInstAssociationAndInstDetail(ctx *rest.Contexts) {
request := metadata.InstAndAssocRequest{}
if err := ctx.DecodeInto(&request); err != nil {
ctx.RespAutoError(err)
return
}
if ccErr := request.Validate(); ccErr.ErrCode != 0 {
blog.Errorf("validate request failed, err: %v, rid: %s", ccErr, ctx.Kit.Rid)
ctx.RespAutoError(ccErr.ToCCError(ctx.Kit.CCError))
return
}
assocFilter, errKey, err := request.Condition.AsstFilter.ToMgo()
if err != nil {
blog.Errorf("parse asst_filter to mongo searcher failed, errKey: %s, err: %v", errKey, err)
ctx.RespAutoError(ctx.Kit.CCError.CCErrorf(common.CCErrCommParamsInvalid, errKey))
return
}
objID := ctx.Request.PathParameter(common.BKObjIDField)
...
cond := &metadata.InstAsstQueryCondition{ObjID: objID, Cond: metadata.QueryCondition{
Condition: assocFilter,
Fields: request.Condition.AsstFields,
Page: request.Page,
DisableCounter: true,
}}
ctx.SetReadPreference(common.SecondaryPreferredMode)
assocRsp, err := s.Engine.CoreAPI.CoreService().Association().ReadInstAssociation(ctx.Kit.Ctx, ctx.Kit.Header, cond)
if err != nil {
blog.Errorf("search inst association failed, err: %v, rid: %s", err, ctx.Kit.Rid)
ctx.RespAutoError(err)
return
}
resp := metadata.InstAndAssocDetailData{Asst: assocRsp.Info}
if !request.Condition.SrcDetail && !request.Condition.DstDetail {
ctx.RespEntity(resp)
return
}
srcObjInst := make(map[string][]int64)
dstObjInst := make(map[string][]int64)
objIDs := []string{...}
for _, item := range assocRsp.Info {
srcObjInst[item.ObjectID] = append(srcObjInst[item.ObjectID], item.InstID)
dstObjInst[item.AsstObjectID] = append(dstObjInst[item.AsstObjectID], item.AsstInstID)
...
}
...
}
5 个关键设计:
- querybuilder.QueryFilter:cmdb 自研的"嵌套条件构造器",支持 AND/OR/NOT/字段比较;
- DisableCounter: true:禁用 Count,因为前端已经知道总数;
- SetReadPreference(SecondaryPreferredMode):读从节点,拓扑查询走从库降低主库压力;
- SrcDetail + DstDetail:按需回填源/目标实例详情,避免不必要的实例查询;
- objIDs unique:去重后再授权,避免对同一模型重复授权。
这是一个"读路径"的完整模式:解析 → 校验 → 查关联 → 按需查实例 → 授权 → 响应。每一层都有错误捕获,最终用户看到的是"准确 + 完整 + 鉴权"的拓扑数据。
本节核心总结
- 3 个 model + 1 个内嵌:AssociationKind(类型)→ Association(定义)→ InstAsst(实例),MainlineAssociation 用内嵌 + 3 展示字段零冗余表达主线。
- 3 锁分派:1:1 用 MLocker、1:n 用 Locker、n:n 不加锁,匹配业务并发需求。
- 4 步校验链:主键 → 类型 → 源实例 → 目标实例,先校验后加锁减少 Redis 持有时间。
- OR 双向查询:SearchAssociationRelatedInst 一次 $or 同时匹配"作为源"和"作为目标"。
- N+1 优化:SearchInstAssociationUIList 先批量拿关联再批量拿实例,避免 N 次单查。
- 旁路审计:NewInstanceAssociationAudit 事务外持久化,失败不影响主流程。
- 脏数据清理:CheckAssociations 删除实例后批量校验残留关联。
FAQ(20 组问答)
FAQ 速查分类
- ① 3 个 model 字段语义(Q1-Q4)
- ② 写入与并发(Q5-Q9)
- ③ 查询与拓扑(Q10-Q13)
- ④ 校验与异常(Q14-Q16)
- ⑤ 审计与合规(Q17-Q18)
- ⑥ 实战与运维(Q19-Q20)
Q1. AssociationKind 和 Association 的区别是什么?
AssociationKind 是"关联类型"(如"运行于"),Association 是"关联定义"(如 host → process 的"运行于")。前者定义"什么样的关联",后者定义"哪个模型能关联到哪个模型"。一个 Kind 可以被多个 Association 引用(如"运行于"被 host → process、host → module 共享)。
Q2. MainlineAssociation 和 Association 是什么关系?
MainlineAssociation 是 Association 的内嵌扩展,额外携带 3 个展示字段(ClassificationID、ObjectIcon、ObjectName)。3 个字段用 bson:"-" 标记,不进 MongoDB,只用于前端拓扑图渲染。
Q3. InstAsst 为什么不只存 ID 还要存 ObjectAsstID?
反范式设计,用存储换查询性能。如果只存 AssociationKindID,每次查询都要 JOIN cc_ObjectAsst 表拿 mapping。冗余 ObjectAsstID 后,SearchAssociationRelatedInst 直接走 $or 索引,无 JOIN。
Q4. BizID 在 InstAsst 里是必填吗?
否,BizID 是冗余字段,部分场景不填。cmdb 3.14.6 后 InstAsst 增加了 BizID 字段(看 src/common/metadata/association.go 第 570 行),用于"按业务过滤"的快速查询。跨业务关联时 BizID 为 0。
Q5. 1:1 关联用什么锁?
用 MLocker(多键锁)。看 src/source_controller/coreservice/core/association/instance.go 第 232-242 行,1:1 必须保证"源唯一 + 目标唯一",2 个 key 同时加锁,任一失败回滚。
Q6. n:n 关联为什么不需要加锁?
n:n 关联没有唯一性约束,并发写入不冲突。源和目标都可以多个,不存在"被占用"问题。即使两个 goroutine 同时写 (host, process) 的同一对,isExists 也会在 4 步校验链的第 1 步就拒绝重复的(基于 cc_InstAsst 表的 _id 唯一索引)。
Q7. 锁的 key 长什么样?
asso_inst:{实例ID}:{关联定义ID}。看 src/source_controller/coreservice/core/association/instance.go 第 216-220 行的 genAssoInstLockKey。不绑业务、不绑租户,支持跨业务关联。
Q8. 锁过期时间是多久?
1 分钟(time.Minute)。这是 cmdb 的"分布式锁默认 TTL"。如果某个写入挂死超过 1 分钟,Redis 会自动释放锁,下一个请求能抢到。这是"防死锁"机制。
Q9. CanUpdate 为什么要"白名单反模式"?
为了防止"修改已有实例关联的关联定义"。看 src/common/metadata/association.go 第 492-523 行,默认禁止更新所有字段,只允许改 on_delete / asst_id / alias_name。如果允许改 mapping 从 1:1 到 1:n,存量数据会全部非法。
Q10. SearchAssociationInst 和 SearchAssociationRelatedInst 的区别?
前者按条件查关联(不限定方向),后者按实例 ID 查拓扑(双向 OR)。看 src/scene_server/topo_server/service/association.go 第 833-870 行 vs 第 872-939 行。前者接收任意 Condition,后者强制 OR 双向匹配。
Q11. 拓扑查询为什么不建联合唯一索引?
因为 OR 双向查询需要 2 个独立索引。看 src/scene_server/topo_server/service/association.go 第 917-928 行,$or 两分支分别用 (bk_obj_id, bk_inst_id) 和 (bk_asst_obj_id, bk_asst_inst_id)。联合唯一索引无法同时优化 2 个分支,所以建 2 个独立索引。
Q12. querybuilder.QueryFilter 是什么?
cmdb 自研的嵌套条件构造器。看 src/scene_server/topo_server/service/association.go 第 956-961 行,AsstFilter.ToMgo() 把 cmdb 的条件树转成 MongoDB 过滤器。支持 AND / OR / NOT / 字段比较 / IN / 嵌套。
Q13. 拓扑查询为什么走从节点?
为了降低主库压力。看 src/scene_server/topo_server/service/association.go 第 981 行的 SetReadPreference(SecondaryPreferredMode)。cmdb 的关联数据有"最终一致性"特征(写入后 100ms 内从节点能读到),拓扑展示允许短暂延迟。
Q14. 删除实例时为什么会留下"幽灵关联"?
cmdb 默认不级联删除关联。看 src/scene_server/topo_server/logics/inst/association.go 第 470-528 行的 CheckAssociations,需要外部手动调用才会清理残留关联。生产环境建议在"实例删除前置钩子"里调用 CheckAssociations。
Q15. 重复关联的错误码是什么?
CCErrCommDuplicateItem,参数为 "association"。看 src/source_controller/coreservice/core/association/instance.go 第 317 行的 isExists。前端拿到这个错误码,应该在 UI 上"灰显"按钮而不是弹错误提示。
Q16. 关联定义不存在时返回什么?
CCErrorTopoAsstKindIsNotExist。看 src/source_controller/coreservice/core/association/instance.go 第 330 行。这通常是"用户传错了 ObjectAsstID"或"关联定义被管理员删除"导致。
Q17. 关联创建会写审计日志吗?
会,NewInstanceAssociationAudit 事务外持久化。看 src/scene_server/topo_server/logics/inst/association.go 第 394-411 行。审计内容包括"谁在什么时间创建了哪两个实例的关联",用于合规追溯。
Q18. 关联删除会写审计日志吗?
会,且包含"删除前快照"。看 src/scene_server/topo_server/logics/inst/association.go 第 448-465 行的 DeleteInstAssociation,先 ReadInstAssociation 拿全量待删数据,再 DeleteInstAssociation 删 MongoDB,最后用查到的全量数据生成审计。
Q19. 怎么查询"所有和某实例关联的实例"?
用 SearchAssociationRelatedInst 接口。看 src/scene_server/topo_server/service/association.go 第 872-939 行,请求体包含 bk_inst_id、bk_obj_id、fields。接口内部用 $or 双向查询,无论该实例是源还是目标都能查到。
Q20. 跨业务关联支持吗?
支持,但需要权限校验。看 src/scene_server/topo_server/service/association.go 第 1013-1018 行的 HasFindModelInstAuth,跨业务查询时对所有 objID 做授权,缺一个权限就返回 NoAuthorizeError。
本篇全章总纲
- 主题:蓝鲸 CMDB 3.14.6 实例关联模型(InstAsst)+ 拓扑递归查询
- 核心源码:src/common/metadata/association.go + src/scene_server/topo_server/logics/inst/association.go + src/source_controller/coreservice/core/association/instance.go
- 核心设计:3 层金字塔(Kind/Association/InstAsst)+ 3 锁分派(1:1/1:n/n:n)+ OR 双向查询 + N+1 优化 + 旁路审计
- 实战警示:跨业务关联需要权限校验;删除实例不会级联删除关联(需调用 CheckAssociations);关联定义默认不可改
- 下一篇:O 资源池篇 #22 资源池与空闲机管理:toInternalModule vs toNormalModule 双路径设计
Roadmap(后续预告)
- #22 资源池与空闲机:深入 toInternalModule 与 toNormalModule 的双路径差异,看"资源池 / 空闲机 / 故障机"如何在 cmdb 中区分。
- #23 主机转移:专题分析 TransferHostToModule、TransferHostToInnerModule 的事务边界与并发控制。
- #24 业务拓扑主线:深入 MainlineObjectTopo 模型,看业务 → 集群 → 模块 → 主机的递归查询。

浙公网安备 33010602011771号