蓝鲸 CMDB 3.14.6 源码专题【左扬精讲】—— #07 主机转移:带服务实例自动清除的批量转移
蓝鲸 CMDB 3.14.6 源码专题【左扬精讲】—— #07 主机转移:带服务实例自动清除的批量转移
在 SRE 日常运维中,主机转移是最频繁的操作之一。
一台主机从测试环境 "订单服务模块" 转生产、从故障机转移到待回收模块,每一步都涉及多个关联数据的同步更新:模块关系、服务实例、审计日志、主机构性属性。任何一个环节出错,都会导致数据不一致。
蓝鲸 CMDB 的 TransferHostWithAutoClearServiceInstance 接口,正是为解决这类场景而生的核心原子操作。它在一个事务内完成主机模块关系变更、服务实例自动清除和新建、以及主属性自动应用,是 CMDB 批量操作设计的集大成者。
主机转移 服务实例 事务一致性 模块拓扑 批量操作 HostApply
src/scene_server/host_server/service/transfer.go ← TransferHostWithAutoClearServiceInstance 主入口(L54-107)
src/scene_server/host_server/service/transfer.go ← generateTransferPlan 转移计划算法(L459-523)
src/scene_server/host_server/service/transfer.go ← preTransferPlans 准备转移计划(L377-457)
src/scene_server/host_server/service/transfer.go ← transferHostWithAutoClearServiceInstance 事务执行(L187-265)
src/scene_server/host_server/service/transfer.go ← upsertServiceInstance 服务实例增删改(L267-333)
src/scene_server/host_server/service/transfer.go ← updateHostApplyByRule 主属性自动应用(L335-376)
src/scene_server/host_server/service/transfer.go ← TransferHostWithAutoClearServiceInstancePreview 预览(L819-876)
src/common/metadata/hostserver.go ← TransferHostWithAutoClearServiceInstanceOption 参数结构体(L890-909)
学习重点
- 必须掌握:转移计划的计算逻辑(generateTransferPlan)、事务执行链路(AutoRunTxn)、内置模块冲突校验
- 必须掌握:服务实例自动清除时机与 upsertServiceInstance 的分批并发写入
- 必须理解:转移预览接口(Preview)的设计意图——为什么在真正执行前需要先预览
- 理解:HostApply 属性自动应用在转移中的触发时机
目录
- SRE 痛点:主机转移为什么这么复杂
- 转移参数结构:从 API 到源码的映射
- 转移计划生成:generateTransferPlan 核心算法
- 预校验:validateTransferHostWithAutoClearServiceInstanceOption
- 事务执行链路:transferHostWithAutoClearServiceInstance
- 服务实例自动清除:upsertServiceInstance 的分批写入
- 转移预览接口:TransferHostWithAutoClearServiceInstancePreview
- 源码视角总结:3 个 1 设计原则
- FAQ 20 问
- Roadmap 后续预告
一、SRE 痛点:主机转移为什么这么复杂
在 CMDB 中,主机不是孤立存在的。一台主机可能属于多个模块(Module),每个模块下有服务实例(Service Instance),每个服务实例关联进程配置(Process Template)。当主机从一个模块转移到另一个模块时,理论上应该:
- 更新主机-模块关系(cc_ModuleHostConfig)
- 清除主机在原模块下的服务实例(因为服务实例绑定的是模块+主机)
- 在新模块有服务模板的情况下,自动生成新的服务实例
- 触发主机的属性自动应用规则(HostApply)
- 记录完整的审计日志
如果这些步骤分散在多个接口里调用,要么出现数据不一致,要么运维人员需要写复杂的业务编排代码。TransferHostWithAutoClearServiceInstance 的价值,就是把这一整串操作封装为一个原子事务接口。
一个典型的 SRE 场景是:订单服务主机下线。主机从 订单服务 模块转移到 空闲机(IdleModule),这个过程需要:
- 清除主机在 订单服务 下的服务实例(进程配置不能再属于这台主机了)
- 更新主机-模块关系,移除订单服务,加入空闲机
- 触发空闲机模块的属性规则(可能清空订单服务特有属性)
- 记录审计日志(谁、什么时间、从哪个模块转到了哪个模块)
核心设计思想
CMDB 在这里采用了 "转移计划(TransferPlan)" 模式:先将用户的意图(从哪些模块移除,加入哪些模块)翻译为每台主机的具体操作计划,再一次性执行。这种设计的优势是——可以在执行前预览(Preview),让 SRE 知道这次转移会影响到哪些服务实例,避免误操作。
二、转移参数结构:从 API 到源码的映射
理解一个接口的第一步,是理解它的参数结构。TransferHostWithAutoClearServiceInstanceOption 定义在 src/common/metadata/hostserver.go 第 890-909 行,它包含了控制转移行为的所有参数。
// src/common/metadata/hostserver.go L890-909
type TransferHostWithAutoClearServiceInstanceOption struct {
HostIDs []int64 `json:"bk_host_ids"`
RemoveFromModules []int64 `json:"remove_from_modules,omitempty"`
AddToModules []int64 `json:"add_to_modules,omitempty"`
// 主机从 RemoveFromModules 移除后如果不再属于其它模块,默认转移到空闲机模块
// DefaultInternalModule 支持调整这种默认行为,可设置成待回收模块或者故障机模块
DefaultInternalModule int64 `json:"default_internal_module,omitempty"`
// IsRemoveFromAll if set, remove host from all of its current modules
IsRemoveFromAll bool `json:"is_remove_from_all"`
Options TransferOptions `json:"options,omitempty"`
}
每个字段的含义(从源码注释提炼):
| 字段 | JSON 字段 | 含义 | SRE 理解 |
|---|---|---|---|
| HostIDs | bk_host_ids | 要转移的主机 ID 列表 | 哪些主机要被转移 |
| RemoveFromModules | remove_from_modules | 从哪些模块移除 | 主机离开哪些模块 |
| AddToModules | add_to_modules | 加入哪些模块 | 主机进入哪些模块 |
| DefaultInternalModule | default_internal_module | 默认内置模块 | 移除全部模块后默认转入(空闲机/故障机/待回收模块之一) |
| IsRemoveFromAll | is_remove_from_all | 是否从全部模块移除 | true=覆盖更新,false=增量更新 |
| Options | options | 扩展选项(含服务实例和主机属性规则) | 更精细的控制 |
内置模块术语
- 空闲机(IdleModule):SystemIdleModuleKey="idle",对应内置模块 ID,UI 展示名为"空闲机"
- 故障机(FaultModule):SystemFaultModuleKey="fault",UI 展示名为"故障机"
- 空闲机池(内置集群):DefaultResSetName="空闲机池",是容纳内置模块的顶层集群
- 待回收模块:对应 DefaultRecycleModuleName
三、转移计划生成:generateTransferPlan 核心算法
转移计划生成是整个接口的核心算法,也是最容易出错的地方。源码将这个逻辑抽离为独立的 generateTransferPlan 函数(src/scene_server/host_server/service/transfer.go 第 459-523 行,它接收主机当前所在模块、移除模块列表、目标模块列表,返回具体的操作计划。
// src/scene_server/host_server/service/transfer.go L459-523
// generateTransferPlan 计算主机将从哪个模块移除,添加到哪个模块,最终在哪些模块
// param currentIn: 主机当前所属模块
// param removeFrom: 从哪些模块中移除
// param addTo: 添加到哪些模块
// param defaultInternalModuleID: 默认内置模块ID
func generateTransferPlan(currentIn []int64, removeFrom []int64, addTo []int64,
defaultInternalModuleID int64) metadata.HostTransferPlan {
removeFromModuleMap := make(map[int64]struct{})
for _, moduleID := range removeFrom {
removeFromModuleMap[moduleID] = struct{}{}
}
// 主机最终所在模块列表:当前模块中不在移除列表且不在新增列表中的
realRemoveModuleMap := make(map[int64]struct{})
finalModules := make([]int64, 0)
finalModuleMap := make(map[int64]struct{})
currentModuleMap := make(map[int64]struct{})
for _, moduleID := range currentIn {
currentModuleMap[moduleID] = struct{}{}
if _, exists := finalModuleMap[moduleID]; exists {
continue
}
// 如果在移除列表中
if _, exists := removeFromModuleMap[moduleID]; exists {
if _, exists := realRemoveModuleMap[moduleID]; !exists {
realRemoveModuleMap[moduleID] = struct{}{}
}
continue
}
// 保留在最终模块列表
finalModuleMap[moduleID] = struct{}{}
finalModules = append(finalModules, moduleID)
}
// 新增模块:加入新增列表中不在当前模块和最终模块中的模块
realAddModules := make([]int64, 0)
for _, moduleID := range addTo {
if _, exists := finalModuleMap[moduleID]; exists {
continue
}
finalModuleMap[moduleID] = struct{}{}
finalModules = append(finalModules, moduleID)
delete(realRemoveModuleMap, moduleID)
if _, exists := currentModuleMap[moduleID]; exists {
continue
}
realAddModules = append(realAddModules, moduleID)
}
realRemoveModules := make([]int64, 0)
for moduleID := range realRemoveModuleMap {
realRemoveModules = append(realRemoveModules, moduleID)
}
// 如果最终模块为空,转移到默认内置模块(空闲机)
if len(finalModules) == 0 {
finalModules = []int64{defaultInternalModuleID}
}
return metadata.HostTransferPlan{
FinalModules: finalModules,
ToRemoveFromModules: realRemoveModules,
ToAddToModules: realAddModules,
}
}
这个算法的精妙之处在于用三个 map 完成了集合运算:
- removeFromModuleMap:O(1) 查找是否在移除列表
- finalModuleMap:去重 + 判断是否已在最终模块中
- realRemoveModuleMap:记录真正被移除的模块(而非只是被覆盖的)
关键逻辑:如果主机被移出所有模块(finalModules 为空),自动转入 defaultInternalModuleID(空闲机),这是 preTransferPlans 函数(src/scene_server/host_server/service/transfer.go 第 414-417 行,查出来的空闲机模块 ID:
// src/scene_server/host_server/service/transfer.go L404-421
innerModules, ccErr := s.getInnerModules(kit, bizID)
...
for _, module := range innerModules {
innerModuleIDMap[module.ModuleID] = struct{}{}
innerModuleIDs = append(innerModuleIDs, module.ModuleID)
// DefaultResModuleFlag == 1 表示空闲机模块
if module.Default == int64(common.DefaultResModuleFlag) {
defaultInternalModuleID = module.ModuleID
}
}
小贴士:IsRemoveFromAll 的作用
当 IsRemoveFromAll=true 时,validateTransferHostWithAutoClearServiceInstanceOption(第 626-647 行)会先通过 GetDistinctField 查询主机当前所在的所有模块,然后把结果填充到 RemoveFromModules,相当于"先查后删"的覆盖更新语义。
四、预校验:validateTransferHostWithAutoClearServiceInstanceOption
主入口 TransferHostWithAutoClearServiceInstance(src/scene_server/host_server/service/transfer.go 第 54-107 行,在做任何操作之前,第一步就是调用 validateTransferHostWithAutoClearServiceInstanceOption(src/scene_server/host_server/service/transfer.go 第 614-666 行),这个校验函数拦截了几乎所有会导致数据不一致的非法参数组合。
// src/scene_server/host_server/service/transfer.go L614-666
func (s *Service) validateTransferHostWithAutoClearServiceInstanceOption(kit *rest.Kit, bizID int64,
option *metadata.TransferHostWithAutoClearServiceInstanceOption) errors.CCErrorCoder {
if option == nil || len(option.HostIDs) == 0 {
return kit.CCError.CCErrorf(common.CCErrCommParamsNeedSet, "bk_host_ids")
}
if option.IsRemoveFromAll {
// 动态查询主机当前所在的所有模块,填充到 RemoveFromModules
moduleFilter := &metadata.DistinctFieldOption{...}
rawModuleIDs, ccErr := s.CoreAPI.CoreService().Common().GetDistinctField(...)
option.RemoveFromModules = moduleIDs
}
// 规则1:remove_from_modules 和 add_to_modules 不能同时为空
if len(option.RemoveFromModules) == 0 && len(option.AddToModules) == 0 {
return kit.CCError.CCErrorf(common.CCErrCommParamsNeedSet, "remove_from_modules or add_to_modules")
}
// 规则2:add_to_modules 和 default_internal_module 不能同时指定
if option.DefaultInternalModule != 0 && len(option.AddToModules) != 0 {
return kit.CCError.CCErrorf(common.CCErrCommParamsInvalid, "add_to_modules & default_internal_module")
}
// 规则3:add_to_modules 中的模块必须存在
if len(option.AddToModules) != 0 {
return s.validateModules(kit, bizID, option.AddToModules, "add_to_modules")
}
// 规则4:default_internal_module 必须是内置模块
if option.DefaultInternalModule != 0 {
return s.validateModules(kit, bizID, []int64{option.DefaultInternalModule}, "default_internal_module")
}
return nil
}
校验规则中最核心的是第 649-651 行的"不能同时为空"检查——因为没有指定任何转移方向,转移就毫无意义。而内置模块冲突的校验,则是在 preTransferPlans 的第 444-452 行完成的:
// src/scene_server/host_server/service/transfer.go L443-452
for _, moduleID := range transferPlan.FinalModules {
if _, exists := innerModuleIDMap[moduleID]; !exists {
continue
}
// 内置模块必须是唯一的(主机不能同时属于多个内置模块)
if finalModuleCount != 1 {
return nil, nil, kit.CCError.CCError(common.CCErrHostTransferFinalModuleConflict)
}
transferPlan.IsTransferToInnerModule = true
}
互斥模块校验的源码逻辑
在转移计划生成后,如果最终模块列表中包含内置模块(空闲机/故障机/待回收),则 finalModuleCount 必须恰好为 1——主机不能同时属于"空闲机"和"故障机"。这是 CMDB 模型层的硬约束,防止主机拓扑数据出现逻辑矛盾。
五、事务执行链路:transferHostWithAutoClearServiceInstance
通过校验后,preTransferPlans 生成每台主机的具体操作计划(src/scene_server/host_server/service/transfer.go 第 377-457 行,然后 parseTransferPlans(src/scene_server/host_server/service/transfer.go 第 109-185 行)将多个主机的计划聚合为两类操作:转移到内置模块 vs 转移到普通模块。最后在 AutoRunTxn 事务中执行核心逻辑。
// src/scene_server/host_server/service/transfer.go L96-107
txnErr := s.Engine.CoreAPI.CoreService().Txn().AutoRunTxn(ctx.Kit.Ctx, ctx.Kit.Header, func() error {
return s.transferHostWithAutoClearServiceInstance(ctx.Kit, bizID, option, transToInnerOpt,
transToNormalPlans, svcInstMap, hostIDs)
})
if txnErr != nil {
ctx.RespAutoError(txnErr)
return
}
ctx.RespEntity(nil)
AutoRunTxn 是 CMDB 的标准事务封装——如果事务函数返回 error,整个事务回滚。这确保了"要么全部成功,要么全部不执行"的原子性。
// src/scene_server/host_server/service/transfer.go L187-265
func (s *Service) transferHostWithAutoClearServiceInstance(...) error {
// Step 1: 生成审计日志(变更前快照)
audit := auditlog.NewHostModuleLog(s.CoreAPI.CoreService(), option.HostIDs)
if err := audit.WithPrevious(kit); err != nil {
return err
}
// Step 2: 转移到内置模块(空闲机/故障机/待回收)
if transToInnerOpt != nil {
res, err := s.CoreAPI.CoreService().Host().TransferToInnerModule(kit.Ctx, kit.Header, transToInnerOpt)
if err != nil {
return err
}
}
// Step 3: 转移到普通模块(并发执行,最多 20 个 goroutine)
pipeline := make(chan bool, 20)
wg := sync.WaitGroup{}
for _, plan := range transToNormalPlans {
if firstErr != nil {
break
}
pipeline
事务执行链路分 6 个步骤,每一步都有明确的业务含义:
| 步骤 | 操作 | 涉及数据 | 关键点 |
|---|---|---|---|
| Step 1 | 生成审计日志(变更前) | cc_AuditLog | WithPrevious 记录主机-模块关系快照 |
| Step 2 | 转移到内置模块 | cc_ModuleHostConfig | TransferToInnerModule,单次调用 |
| Step 3 | 转移到普通模块 | cc_ModuleHostConfig | 并发执行,最多 20 goroutine |
| Step 4 | 创建/更新服务实例 | cc_ServiceInstance | upsertServiceInstance,分批写入 |
| Step 5 | 触发主属性规则 | cc_HostBaseAttr | updateHostApplyByRule |
| Step 6 | 保存审计日志 | cc_AuditLog | SaveAudit 记录变更后状态 |
六、服务实例自动清除:upsertServiceInstance 的分批写入
服务实例(Service Instance)是 CMDB 中"模块 + 主机 + 进程配置"的绑定实体。当主机从一个模块转移到另一个模块时,原模块下的服务实例需要被清除;在新模块有服务模板的情况下,还需要自动创建新的服务实例。upsertServiceInstance(src/scene_server/host_server/service/transfer.go 第 267-333 行, 负责这个逻辑。
// src/scene_server/host_server/service/transfer.go L267-333
func (s *Service) upsertServiceInstance(kit *rest.Kit, bizID int64,
svcInstMap map[int64]map[int64][]metadata.ProcessInstanceDetail) error {
// 按模块聚合:一个模块下的多个主机+进程统一创建
moduleSvcInstMap := make(map[int64][]metadata.CreateServiceInstanceDetail)
for hostID, moduleProcMap := range svcInstMap {
for moduleID, processes := range moduleProcMap {
moduleSvcInstMap[moduleID] = append(moduleSvcInstMap[moduleID],
metadata.CreateServiceInstanceDetail{HostID: hostID, Processes: processes})
}
}
// 并发创建,最多 20 个 goroutine
pipeline := make(chan bool, 20)
wg := sync.WaitGroup{}
for moduleID, svcInst := range moduleSvcInstMap {
pipeline = common.BKMaxUpdateOrCreatePageSize {
tmpInstances = instances[start : start+common.BKMaxUpdateOrCreatePageSize]
} else {
tmpInstances = instances[start:total]
}
svrInstOpt.Instances = tmpInstances
_, ccErr := s.CoreAPI.ProcServer().Service().CreateServiceInstance(...)
}
}(&metadata.CreateServiceInstanceInput{...})
}
wg.Wait()
return firstErr
}
这里的分批逻辑有双重并发:
- 第一层:按模块分 goroutine(不同模块互不干扰)
- 第二层:每个模块内部按 BKMaxUpdateOrCreatePageSize=100 分批
这种设计的好处是:即使转移 1000 台主机,每个主机有多个服务实例,也不会一次性发起 1000 个 CreateServiceInstance 调用,而是通过批次控制 + 模块聚合,将数据库写入压力分散开。
服务实例清除的时机
注意源码中并没有显式调用"删除服务实例"的操作。服务实例的清除是通过 DisableAutoCreateSvcInst=true 参数来实现的——当主机从模块移除时,CMDB 的 TransferToNormalModule 底层会自动清除该主机在该模块下的服务实例关联。这是一种"通过转移触发自动清理"的隐式设计,而非显式删除。
七、转移预览接口:TransferHostWithAutoClearServiceInstancePreview
在真正执行转移之前,SRE 往往需要知道:这次转移会影响哪些服务实例?会清除哪些进程配置?TransferHostWithAutoClearServiceInstancePreview(src/scene_server/host_server/service/transfer.go 第 818-876 行 提供了这个能力。
// src/scene_server/host_server/service/transfer.go L818-876
func (s *Service) TransferHostWithAutoClearServiceInstancePreview(ctx *rest.Contexts) {
// 参数解析 + 校验(与主接口相同)
option := metadata.TransferHostWithAutoClearServiceInstanceOption{}
...
if ccErr := s.validateTransferHostWithAutoClearServiceInstanceOption(...); ccErr != nil { ... }
// 生成转移计划
transferPlans, ccErr := s.generateTransferPlans(ctx.Kit, bizID, option)
...
// 查询将被清除的服务实例
if len(removeModuleIDs) > 0 {
moduleHostSrvInstMap, err = s.getRemovedServiceInstance(ctx, bizID, removeModuleIDs, option)
...
}
// 查询新模块的服务模板(如果有)
if len(addModuleIDs) > 0 {
moduleServiceTemplateMap, err = s.getModuleServiceTemplate(ctx, bizID, addModuleIDs)
...
}
// 组装预览结果
previews := getPreviewsResult(transferPlans, moduleServiceTemplateMap, moduleHostSrvInstMap)
ctx.RespEntity(previews)
}
预览接口的核心价值在于 getPreviewsResult(第 878-924 行)——它为每台主机生成一个 HostTransferPreview,包含:
- ToRemoveFromModules:将从哪些模块移除,以及在这些模块下有哪些服务实例将被清除
- ToAddToModules:将加入哪些模块,以及这些模块关联的服务模板是什么
- HostApplyPlan:转移后将触发哪些主属性规则
这样 SRE 在执行前就能清楚地知道"这次转移的副作用",避免误操作导致服务实例意外丢失。
八、源码视角总结:3 个 1 设计原则
蓝鲸 CMDB 的 TransferHostWithAutoClearServiceInstance 接口,完整展示了 CMDB 批量操作设计的精华。我们直接从源码里提炼出 3 个"1"设计原则:
源码视角一:一个转移计划模式
读 src/scene_server/host_server/service/transfer.go 第 459-523 行的 generateTransferPlan,会发现它用一个纯函数处理所有转移场景:输入(当前模块、移除列表、目标列表)→ 输出(真正移除列表、真正新增列表、最终模块列表)。这种"先计划后执行"的模式,使得预览(Preview)成为可能。SRE 可以先通过 Preview 接口看到转移结果,再决定是否真正执行。
源码视角二:一个事务保证原子性
读 src/scene_server/host_server/service/transfer.go 第 96-107 行和第 187-265 行,会发现整个转移链路被包裹在 AutoRunTxn 事务中。从主机-模块关系变更,到服务实例增删,到主属性更新,再到审计日志——任何一步失败都会导致全部回滚。这是 CMDB 保证数据一致性的核心机制。
源码视角三:一个并发控制策略
读 src/scene_server/host_server/service/transfer.go 第 213 行和第 283 行,会发现 CMDB 用 pipeline := make(chan bool, 20) 实现了带并发上限的批量操作。20 个 goroutine 同时处理不同的转移计划/服务实例写入,避免了雪崩效应。这种"小并发上限 + WaitGroup 等待"的模式,在 CMDB 的批量操作中多处出现。
源码视角四:内置模块冲突的硬校验
读 src/scene_server/host_server/service/transfer.go 第 444-452 行,会发现 CMDB 在生成转移计划后,会检查最终模块中是否包含内置模块(空闲机/故障机/待回收)。如果包含,则要求 finalModuleCount == 1——主机不能同时属于多个互斥的内置模块。这是 CMDB 模型层的拓扑一致性保障。
源码视角总结:3 个 1 设计原则
- 1 个转移计划模式:先计划后执行,支持预览
- 1 个事务原子性:AutoRunTxn 全包,任何失败全部回滚
- 1 个并发控制策略:chan bool(20) 限制并发上限,防止雪崩
避坑提醒(源码视角):
- 不要省略 Preview 步骤:服务实例清除是不可逆的,执行前务必通过 Preview 确认影响范围
- 不要同时指定 add_to_modules 和 default_internal_module:校验会直接返回错误,两个参数互斥
- 不要期望转移内置模块的同时还能保留其他普通模块:主机转入空闲机/故障机后,会从所有普通模块移除(内置模块不允许与其他模块共存)
本篇总结
- TransferHostWithAutoClearServiceInstance 是 CMDB 批量主机转移的核心接口,一个事务内完成模块关系变更、服务实例自动清除/创建、主属性自动应用、审计日志记录
- generateTransferPlan 用三个 map 实现了高效的集合运算,生成每台主机的具体操作计划
- 内置模块冲突校验 确保主机不能同时属于多个互斥的内置模块(空闲机/故障机/待回收)
- Preview 接口 在执行前展示影响范围,是避免误操作的关键安全机制
- 并发控制 用 chan bool(20) 限制并发上限,配合分批写入(100/批)确保大批量操作的稳定性
FAQ 20 问
以下 20 组 Q&A 基于源码中的注释、函数名、参数结构和校验逻辑整理。所有问答均可通过 Read/Grep 源码验证。
Q1. TransferHostWithAutoClearServiceInstance 和普通的 TransferHostAcrossBiz 有什么区别?
核心区别在于"服务实例是否自动清除"。TransferHostWithAutoClearServiceInstance 专门处理模块内的转移场景,自动清除原模块下的服务实例、创建新模块的服务实例;而 TransferHostAcrossBiz 处理跨业务的转移,逻辑更简单(只改主机-模块关系,不涉及服务实例)。 从源码看,TransferHostWithAutoClearServiceInstance 多了 svcInstMap 参数和 upsertServiceInstance 调用(transfer.go L267-333),这是普通转移接口没有的。
Q2. 主机从订单服务模块转移到空闲机,为什么服务实例会自动清除?
服务实例的清除是通过"不创建"来实现的,而非显式删除。 在 upsertServiceInstance 中,svcInstMap 只包含新模块的服务实例信息(transfer.go L83-90),原模块的服务实例不会出现在这个 map 中。当 TransferToNormalModule 被调用时,底层会清理主机在原模块下的服务实例关联——这是一种"数据以转移后的状态为准"的隐式清除设计。
Q3. IsRemoveFromAll=true 是什么意思?和指定 RemoveFromModules 有什么区别?
IsRemoveFromAll=true 表示覆盖更新语义,RemoveFromModules=主机当前所在的所有模块。 源码 validateTransferHostWithAutoClearServiceInstanceOption(L626-647)会先通过 GetDistinctField 查询主机当前所在的所有模块,然后填充到 RemoveFromModules。区别在于:显式指定 RemoveFromModules 是增量更新(只从指定模块移除),而 IsRemoveFromAll 是强制覆盖(从所有模块移除)。
Q4. DefaultInternalModule 参数的作用是什么?
指定主机移除全部模块后默认转入的内置模块(空闲机/故障机/待回收之一)。 当 generateTransferPlan(transfer.go L514-516)发现 finalModules 为空时,会自动设置为 DefaultInternalModule。默认情况下这个值是空闲机模块(通过查询 BKDefaultField == DefaultResModuleFlag 得到 ID)。
Q5. 主机转移事务失败后会怎样?
整个事务回滚,所有变更都不会生效。 AutoRunTxn(transfer.go L96-107)封装了事务上下文,如果 transferHostWithAutoClearServiceInstance 返回任何 error,MongoDB 的事务就会回滚。这意味着主机-模块关系、服务实例、审计日志都会恢复到转移前的状态。
Q6. 为什么预览接口(Preview)和执行接口的参数完全一样?
因为 Preview 就是"只读的转移计划生成",不执行真正的数据库写入。 TransferHostWithAutoClearServiceInstancePreview(transfer.go L818-876)调用了 generateTransferPlans 和 getRemovedServiceInstance,但最终只返回预览数据而不调用 transferHostWithAutoClearServiceInstance。这使得 SRE 可以用同一套参数先看结果再决定是否执行。
Q7. 主机转移最多支持多少台?源码里有上限吗?
HostIDs 是 []int64 切片,没有硬编码上限,但实际受 MongoDB 单次查询和事务大小的限制。 源码中 preTransferPlans(transfer.go L386-387)使用了 BKNoLimit 作为查询限制,意味着理论上可以传入任意多台主机。但并发控制 pipeline=20(第 213 行)和服务实例分批 BKMaxUpdateOrCreatePageSize=100(第 308 行)确保了大批量操作不会压垮数据库。
Q8. 服务实例的"清除"是在哪一步发生的?
在 TransferToNormalModule 和 TransferToInnerModule 的底层实现中。 主机被转移到新模块后,CMDB 的 Host 模块底层会自动清理 cc_ModuleHostConfig 中该主机在原模块的记录,并级联清除服务实例关联。transfer.go 中没有显式的 DELETE ServiceInstance 调用,这是通过"转移操作触发关联清理"的隐式设计。
Q9. 如果新模块没有绑定服务模板,服务实例会怎样?
不会自动创建服务实例,但也不会报错。 源码 upsertServiceInstance(transfer.go L267-333)中的 svcInstMap 来自 API 请求参数中的 Options.ServiceInstanceOptions。如果请求中没有提供进程配置信息,则 svcInstMap 为空,不会有任何服务实例被创建。这是"按需创建"而非"自动推断"的设计。
Q10. 内置模块(空闲机/故障机/待回收)之间有什么区别?
它们是互斥的内置模块,代表主机不同的运维状态。 空闲机(IdleModule)是主机下线后的默认归属,表示资源可用;故障机(FaultModule)表示主机发生故障待处理;待回收模块表示主机即将下线待回收。源码 definitions.go 中分别对应 SystemIdleModuleKey="idle"、SystemFaultModuleKey="fault"、DefaultRecycleModuleName。
Q11. 主机从普通模块转移到空闲机时,审计日志记录了什么?
记录了转移前后主机-模块关系的变化快照。 transferHostWithAutoClearServiceInstance(transfer.go L196-200)中先调用 audit.WithPrevious(kit) 记录变更前状态,转移完成后再调用 audit.SaveAudit(kit) 记录变更后状态。AuditLog 会记录每台主机从哪些模块移除、加入了哪些模块、操作人、操作时间。
Q12. 并发控制中的 pipeline=20 是怎么工作的?
通过无缓冲 channel 实现信号量,控制同时运行的 goroutine 数量。 pipeline
Q13. 服务实例分批大小 BKMaxUpdateOrCreatePageSize=100 是在哪里定义的?
定义在 common 包中,是 CMDB 全局统一的批量操作分页大小常量。 transfer.go L308 中使用了 common.BKMaxUpdateOrCreatePageSize 来限制每批服务实例的数量。这个常量在 CMDB 中多处使用(如 CreateServiceInstances 等),确保了各模块批量操作的分页策略一致。
Q14. 主机转移过程中,如果某台主机的模块转移失败,会影响其他主机吗?
在事务内,所有主机的转移要么全部成功,要么全部失败。 虽然 transferToNormalModule 是按 plan 并发执行的(transfer.go L221-237),但所有操作都在同一个 AutoRunTxn 事务内。如果任意一台主机的转移失败,事务会回滚,所有已执行的操作都会被撤销。
Q15. 为什么需要 preTransferPlans 预生成转移计划,而不是直接在主逻辑中计算?
因为 Preview 接口也需要相同的计划生成逻辑。 preTransferPlans(transfer.go L377-457)将转移计划生成抽离为独立函数,使得 Preview(调用 generateTransferPlans -> preTransferPlans)和真正执行(调用 preTransferPlans)可以共享同一套逻辑,避免了逻辑重复和潜在的不一致。
Q16. updateHostApplyByRule 在什么时机触发?
在主机模块关系变更完成后、审计日志保存前触发。 transferHostWithAutoClearServiceInstance(transfer.go L250-257)中,只有当 ruleOpt.Changed=true 时才会调用 updateHostApplyByRule。Changed 标志由 generateHostApplyPlans(transfer.go L680-735)设置,当新模块存在启用的主属性规则时为 true。
Q17. 主机从 A 模块转移到 B 模块,A 和 B 都有服务模板,会发生什么?
A 模块的服务实例被清除,B 模块的服务实例被创建。 svcInstMap 只包含新模块(B)的服务实例信息(来自请求参数 Options.ServiceInstanceOptions)。UpsertServiceInstance 在 B 模块下创建新的服务实例;TransferToNormalModule 在底层清理 A 模块的服务实例关联。
Q18. TransferHostWithAutoClearServiceInstancePreview 返回的 HostApplyPlan 包含哪些内容?
包含将在转移后应用到主机的属性规则列表。 getPreviewsResult(transfer.go L878-924)中,HostTransferPreview 直接引用了 transferPlan.HostApplyPlan。这个计划由 generateHostApplyPlans(transfer.go L680-735)从目标模块的 HostApplyRule 生成,包含了需要更新到主机的属性及其预期值。
Q19. 如果 add_to_modules 和 remove_from_modules 中有重复的模块,会怎样?
该模块的服务实例不会被删除然后重新添加。 generateTransferPlan(transfer.go L502-504)中,当遍历 addTo 模块时,会先检查是否已在 finalModuleMap 中,如果已在最终模块列表中(即该模块同时在 RemoveFromModules 中),则从 realRemoveModuleMap 中删除,不会产生"移除再加入"的中间状态。
Q20. 为什么 getInnerModules 要过滤掉 DefaultFlagDefaultValue 的模块?
因为只有 DefaultField != DefaultFlagDefaultValue 的模块才是内置模块。 getInnerModules(transfer.go L591-595)中查询条件是 BKDefaultField != common.DefaultFlagDefaultValue,目的是找出业务下所有内置模块(空闲机/故障机/待回收),它们用于判断主机转移目的地是否为内置模块。
全篇总纲
TransferHostWithAutoClearServiceInstance 是 CMDB 批量操作设计的最佳范例,它用"转移计划模式"实现了可预览的批量操作,用"事务原子性"保证了数据一致性,用"并发控制"确保了大批量操作的稳定性。理解了这个接口的设计思想,就能触类旁通地理解 CMDB 其他批量操作接口。
Roadmap 后续预告
下篇预告:#08 服务实例管理:进程配置模板与批量部署
服务实例(Service Instance)是 CMDB 中连接"主机"与"进程配置"的核心载体。一台主机加入绑定服务模板的模块后,如何自动生成服务实例?服务模板的进程模板是如何定义的?服务实例与进程实例之间的关系是怎样的?下篇将深入 proc_server 的 serviceinstance.go,揭开服务实例管理的完整实现链路。

浙公网安备 33010602011771号