蓝鲸 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 枚举值:nonesrc_to_destdest_to_srcbidirectional
  • AssociationMapping 3 枚举值:1:11:nn:n
  • AssociationOnDeleteAction 3 枚举值:nonedelete_srcdelete_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 个关键字段:

  1. AssociationKindID:唯一标识(如 belong),由 cmdb 预置或管理员创建;
  2. SourceToDestinationNote:从源到目标的描述(如"运行于"),用于拓扑图上的箭头文字;
  3. Direction:方向枚举,决定前端如何渲染箭头;
  4. IsPre:是否预置,预置关联类型不能删除。

★ 关键事实:SourceToDestinationNoteDestinationToSourceNote两个描述字段。例如"备份"这个关联,从源 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 个关键字段:

  1. AssociationName:关联定义的唯一 ID,格式是 bk_obj_asst_id = "$ObjectID"_"$AsstID"_"$AsstObjID"(如 host_belong_module);
  2. ObjectID + AsstObjID:源模型 + 目标模型,决定哪两个模型能互相关联;
  3. AsstKindID:引用 AssociationKindAssociationKindID
  4. Mapping:1:1 / 1:n / n:n,决定写入时的并发策略;
  5. 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_deleteasst_idalias_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 个关键字段:

  1. InstID + ObjectID:源实例的"模型 + ID"组合;
  2. AsstInstID + AsstObjectID:目标实例的"模型 + ID"组合;
  3. ObjectAsstID:引用 Association,冗余存储避免 JOIN;
  4. 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 个关键设计:

  1. Association 内嵌:字段全复用MainlineAssociation 自动获得 AssociationNameMapping 等所有字段;
  2. ClassificationID + ObjectIcon + ObjectName:3 个"展示字段",用 bson:"-" 标记不进 MongoDB,只用于前端渲染;
  3. json:",inline":序列化时把 Association 字段"展开"到顶层(而不是嵌套的 association 子对象);
  4. 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不绑业务、不绑租户,这是为了"跨业务关联"也能加锁(如跨业务的"备份到")。

How — 校验链路 + 锁的源码视角

源码视角一: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 步校验链:

  1. 主键重复校验isExists 查 (InstID, AsstInstID, ObjectAsstID) 三元组是否已存在;
  2. 关联类型存在性associationModel.isExists 查 cc_ObjectAsst 表,确保 ObjectAsstID 有效;
  3. 源实例存在性IsInstanceExist(ObjectID, InstID),确保源实例未被删除;
  4. 目标实例存在性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. 第 1 步查关联ReadInstAssociation 一次拿所有相关 InstAsst
  2. 第 2 步按源/目标分组ObjectID == objIDobjSrcAsstArr,否则走 objDstAsstArr
  3. 第 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 个关键设计:

  1. 3 类结果分桶SuccessCreated(成功)、Repeated(重复)、Exceptions(异常),按 OriginIndex 对应原始入参;
  2. 重复检测CCErrorTopoAssociationAlreadyExist 专门给"批量时已有部分关联"用;
  3. 旁路审计NewInstanceAssociationAudit 单独生成审计日志,不入主事务
  4. 审计的"成功才记"if len(resp.SuccessCreated) == 0 { return resp, nil } 直接跳过审计生成;
  5. 审计失败不影响主结果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 步清理逻辑:

  1. 查所有相关关联$or 双向查 (X 作为源) 和 (X 作为目标) 的所有 InstAsst
  2. 提取关联实例 ID 集合:从 cc_InstAsst 中收集所有"对方实例的 ID";
  3. 批量校验存在性:用 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 个关键设计:

  1. querybuilder.QueryFilter:cmdb 自研的"嵌套条件构造器",支持 AND/OR/NOT/字段比较;
  2. DisableCounter: true:禁用 Count,因为前端已经知道总数;
  3. SetReadPreference(SecondaryPreferredMode)读从节点,拓扑查询走从库降低主库压力;
  4. SrcDetail + DstDetail:按需回填源/目标实例详情,避免不必要的实例查询;
  5. 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. AssociationKindAssociation 的区别是什么?

AssociationKind 是"关联类型"(如"运行于"),Association 是"关联定义"(如 host → process 的"运行于")。前者定义"什么样的关联",后者定义"哪个模型能关联到哪个模型"。一个 Kind 可以被多个 Association 引用(如"运行于"被 host → process、host → module 共享)。

Q2. MainlineAssociationAssociation 是什么关系?

MainlineAssociationAssociation 的内嵌扩展,额外携带 3 个展示字段(ClassificationIDObjectIconObjectName)。3 个字段用 bson:"-" 标记,不进 MongoDB,只用于前端拓扑图渲染。

Q3. InstAsst 为什么不只存 ID 还要存 ObjectAsstID

反范式设计,用存储换查询性能。如果只存 AssociationKindID,每次查询都要 JOIN cc_ObjectAsst 表拿 mapping。冗余 ObjectAsstID 后,SearchAssociationRelatedInst 直接走 $or 索引,无 JOIN

Q4. BizIDInstAsst 里是必填吗?

否,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. SearchAssociationInstSearchAssociationRelatedInst 的区别?

前者按条件查关联(不限定方向),后者按实例 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. 关联定义不存在时返回什么?

CCErrorTopoAsstKindIsNotExistsrc/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_idbk_obj_idfields。接口内部用 $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 资源池与空闲机:深入 toInternalModuletoNormalModule 的双路径差异,看"资源池 / 空闲机 / 故障机"如何在 cmdb 中区分。
  • #23 主机转移:专题分析 TransferHostToModuleTransferHostToInnerModule 的事务边界与并发控制。
  • #24 业务拓扑主线:深入 MainlineObjectTopo 模型,看业务 → 集群 → 模块 → 主机的递归查询。
posted @ 2026-07-08 22:58  左扬  阅读(1)  评论(0)    收藏  举报