蓝鲸 CMDB 3.14.6 源码专题【左扬精讲】— CMDB #16:业务拓扑秒级查询的 Redis 缓存策略
蓝鲸 CMDB 3.14.6 源码专题【左扬精讲】— CMDB #16:业务拓扑秒级查询的 Redis 缓存策略
业务拓扑(主线拓扑)是 CMDB 中最高频的查询场景之一。
SRE 在告警自愈、故障定位、变更影响分析时,需要快速查看某个业务下有几层集群、多少模块、多少主机。如果每次都从 MongoDB 实时 JOIN 多张表,在业务规模较大时,延迟会达到数秒,严重影响体验。
蓝鲸 CMDB 的解决方案是:将业务拓扑全量预计算,写入 Redis,查询直接读缓存。
本文从 SRE 痛点出发,结合源码,讲清楚这套 "定时刷新 + 增量监听 + Master 独占" 的缓存体系是如何工作的。
蓝鲸CMDB 拓扑缓存 Redis 主从复制 Master选举 cacheservice SRE场景
src/source_controller/cacheservice/cache/topology/topology.go ← 业务拓扑缓存主逻辑(refreshBatch/loopBizBriefCache)
src/source_controller/cacheservice/cache/topology/client.go ← 查询入口(GetBizTopology)
src/source_controller/cacheservice/cache/topology/key.go ← Redis Key 定义与 TTL(24h)
src/source_controller/cacheservice/cache/topology/logic.go ← 拓扑生成(getBusinessTopology/getMainlineReverseRank)
src/source_controller/cacheservice/cache/topology/watch.go ← MongoDB Change Stream 增量监听(watchSet/onSetChange)
src/source_controller/cacheservice/cache/biz-topo/topo.go ← 完整拓扑树缓存(loopBizTopoCache/TopoKeyMap)
src/source_controller/cacheservice/cache/biz-topo/client.go ← 完整拓扑查询入口(GetBizTopo/RefreshBizTopo)
src/source_controller/cacheservice/cache/biz-topo/key/key.go ← 多类型拓扑 Key(TopoKeyMap)
src/source_controller/cacheservice/cache/biz-topo/key/kube.go ← Kube 拓扑 Key(namespace/TTL=3h/默认刷新间隔1h)
src/source_controller/cacheservice/cache/biz-topo/logics/topo/queue.go ← 刷新队列(bizRefreshQueue/Push)
src/source_controller/cacheservice/cache/biz-topo/types/types.go ← 数据结构(TopoType/KubeType/BizTopo/Node)
src/source_controller/cacheservice/cache/topology/types.go ← 数据结构(BizBriefTopology/Node/BizBase/defaultRefreshIntervalMinutes=15)
src/common/definitions.go ← 常量(BKCacheKeyV3Prefix="cc:v3:")
src/common/tablenames.go ← 常量(BKTableNameBaseApp="cc_ApplicationBase")
SRE 痛点:每次查拓扑都要 JOIN 5+ 张表,10+ 秒才能返回
在 CMDB 中,一个业务拓扑查询需要跨多张 MongoDB 表:cc_ApplicationBase(业务基础表)、cc_SetBase(集群表)、cc_ModuleBase(模块表)、cc_ModuleHostConfig(主机模块关系表),以及自定义层级表。每张表都要做一次查询,N 次 MongoDB 网络往返叠加索引扫描延迟,在有数十个业务、数百个集群的中大型场景下,单次拓扑查询轻易突破数秒。
而业务拓扑查询在前端是高频操作:打开业务拓扑树、切换节点展开、搜索框过滤,每一次交互都在触发新的拓扑查询。如果每次都打回 MongoDB,前端等待时间会严重影响 SRE 的故障定位效率。
一、业务拓扑缓存的读写分离架构
1.1 Why — 为什么要设计两套缓存?
核心矛盾:同一棵拓扑树,两种使用场景,两种性能需求
CMDB 的业务拓扑在系统中被两类不同的前端场景消费:
- 前端拓扑树渲染:打开业务拓扑树、展开节点、搜索过滤——每一步交互都在触发拓扑查询,要求极低延迟(毫秒级),但不需要主机数量(节点数量少,数据量小)
- 高级拓扑分析:展示拓扑中每个节点的主机计数、资源分布图——需要主机数量统计(Count),但查询频率低,数据量更大
如果只设计一套缓存,必然要在"体积小速度快"和"数据全带计数"之间做取舍,结果是两边都不讨好。
没有简要拓扑缓存(BizBriefTopology)会发生什么?
- 前端拓扑树每次展开都要打回 MongoDB:业务基础表 + 集群表 + 模块表 + N 张自定义层级表,N 次网络往返,在数十个业务、数百个集群的中大型场景下,单次拓扑查询轻易突破数秒
- 前端高频交互(节点展开/收起/搜索过滤)每次都触发数据库查询,MongoDB 从节点压力急剧上升,影响其他写入操作
- 即使有缓存,如果缓存包含主机数量,每次刷新都要对每个模块执行 COUNT 查询(查 cc_ModuleHostConfig 表),成本比纯结构查询高数倍——但前端拓扑树根本不需要这个数据,白白浪费
没有完整拓扑树缓存(BizTopo)会发生什么?
- 需要展示主机计数的场景(如资源分布仪表盘)只能实时查询:每个节点都要做一次 COUNT,假设一个业务有 100 个集群、500 个模块,就要执行 500 次 COUNT 查询
- k8s 拓扑(Pod/Deployment/Service)的计数逻辑与标准拓扑完全不同(COUNT 容器数量),如果混在一套缓存里,刷新逻辑会极其复杂,无法独立演进
- k8s 拓扑层级深(业务 → 集群 → 命名空间 → 工作负载 → Pod),刷新一次的成本远高于标准拓扑,需要更长的刷新间隔(1 小时 vs 15 分钟),如果共用一套缓存,刷新策略无法差异化配置
两套缓存的分工边界
- 简要拓扑:服务前端拓扑树渲染,QPS 极高,数据量小(只有节点结构),刷新间隔短(15 分钟),不含主机计数,MongoDB 查询成本低
- 完整拓扑树:服务高级拓扑分析,QPS 低,数据量大(含计数),刷新间隔长(k8s 默认 1 小时),每次刷新要额外 COUNT,成本高
两套缓存使用完全独立的 Redis namespace(cc:v3:topology:brief vs cc:v3:topology:kube),独立 TTL,独立刷新间隔,互不干扰。一台业务可以同时持有两套缓存。
1.2 What — 两套缓存的数据结构差异
两套缓存的本质区别在于 Node 结构的设计:
| 维度 | 简要拓扑(BizBriefTopology) | 完整拓扑树(BizTopo) |
|---|---|---|
| 源码文件 | src/source_controller/cacheservice/cache/topology/types.go | src/source_controller/cacheservice/cache/biz-topo/types/types.go |
| 节点标识字段 | Object string("set"/"module"/自定义层级名) | Kind string("set"/"module"/"kube"*) |
| 主机数量 | 无(Node 中无 Count 字段) | Count *int64(每个节点可选的主机/容器计数) |
| 节点分组 | Idle []*Node(空闲机池)和 Nodes []*Node(普通节点)分开存储 | Nodes []Node(全部节点平铺,再由 ParentID 重组为树) |
| 业务信息 | Biz *BizBase(含 OwnerID 供应商账号) | Biz *BizInfo(含业务级别主机总数 Count) |
| 适用场景 | 前端拓扑树快速渲染(无需计数) | 高级拓扑分析(需节点级别计数) |
| 支持的拓扑类型 | 只有标准拓扑(业务→集群→模块) | 可扩展(通过 TopoKeyMap 注册,目前支持 KubeType) |
| 计数来源 | 无 | 标准拓扑:cc_ModuleHostConfig COUNT k8s 拓扑:cc_ContainerBase COUNT(容器数作为 Workload 计数) |
BizBriefTopology 的 Node.Object 字段直接存储 CMDB 对象名(如 "set"、"module" 或自定义层级名),优点是结构极简,JSON 体积小,序列化/反序列化快。BizTopo 的 Node.Kind 字段则统一了标准拓扑和 k8s 拓扑的节点类型命名(k8s 中 Kind 对应 Deployment/StatefulSet 等),便于前端统一渲染。
1.3 How — 两套缓存的查询入口与读写模式
两套缓存共享同一种读写模式:Cache-Aside(旁路缓存):查询时先读 Redis,命中则直接返回;未命中则查 MongoDB 生成数据,再写回 Redis。
简要拓扑的查询入口在 src/source_controller/cacheservice/cache/topology/client.go 第 25-61 行的 GetBizTopology:
// src/source_controller/cacheservice/cache/topology/client.go 第 25-61 行
func (t *Topology) GetBizTopology(kit *rest.Kit, biz int64) (*string, error) {
// 1. 优先从 MongoDB 从节点读(不给主节点增加读压力)
kit.Ctx = util.SetDBReadPreference(kit.Ctx, common.SecondaryPreferredMode)
// 2. 先查 Redis 缓存
topology, err := t.briefBizKey.getTopology(kit.Ctx, biz)
if err == nil && len(*topology) != 0 {
return topology, nil // 命中缓存,直接返回
}
// 3. 缓存未命中,查 MongoDB 生成拓扑
topo, err := t.genBusinessTopology(kit.Ctx, biz)
if err != nil {
return nil, err
}
// 4. 写回 Redis(异步,不阻塞返回)
if err := t.briefBizKey.updateTopology(kit.Ctx, topo); err != nil {
blog.Errorf("refresh biz: %d topology cache failed, err: %v", biz, err)
}
dat, _ := json.Marshal(topo)
return &string(dat), nil
}
完整拓扑树的查询入口在 src/source_controller/cacheservice/cache/biz-topo/client.go 第 31-74 行的 GetBizTopo,逻辑与简要拓扑完全一致。关键区别在于:GetBizTopo 支持通过 topoType 参数选择拓扑类型(当前注册了 KubeType),通过 TopoKeyMap 找到对应的 Redis Key 和刷新策略:
// src/source_controller/cacheservice/cache/biz-topo/client.go 第 31-40 行
func (t *Topo) GetBizTopo(kit *rest.Kit, typ string, opt *types.GetBizTopoOption) (*string, error) {
kit.Ctx = util.SetDBReadPreference(kit.Ctx, common.SecondaryPreferredMode)
topoType := types.TopoType(typ)
topoKey, exists := key.TopoKeyMap[topoType]
if !exists {
blog.Errorf("biz topo type %s is invalid, rid: %s", topoType, kit.Rid)
return nil, kit.CCError.Errorf(common.CCErrCommParamsIsInvalid, "type")
}
// ...
}
SecondaryPreferredMode:读从节点但不阻塞
两套缓存在缓存未命中回源时,都使用 SecondaryPreferredMode——优先从 MongoDB 从节点读,不给主节点增加读压力。如果从节点不可用,自动降级到主节点。这个设置在两套缓存的 src/source_controller/cacheservice/cache/topology/client.go 第 27 行和 src/source_controller/cacheservice/cache/biz-topo/client.go 第 33 行均有一致的体现。
二、定时全量刷新:Master 独占 + goroutine 并发
2.1 What — 简要拓扑的定时刷新链路
简要拓扑的定时刷新由 src/source_controller/cacheservice/cache/topology/topology.go 中三个函数协同完成:
- loopBizBriefCache(第 145-165 行):Master 独占循环调度器,每隔 N 分钟触发一次全量刷新
- doLoopBizBriefTopologyToCache(第 167-188 行):遍历全量业务列表,逐个刷新
- refreshBatch(第 73-119 行):并发批量刷新多个业务的拓扑缓存
- refreshBizTopology(第 121-143 行):刷新单个业务的拓扑缓存
2.2 Why — 为什么需要 Master 选举?解决了什么问题?
cacheservice 通常部署多个实例实现高可用。如果不做控制,所有实例都会启动定时刷新循环,导致两个问题:
- 重复刷新浪费资源:N 个实例同时对同一批业务做拓扑计算,写入同一个 Redis Key,浪费 N 倍计算资源
- Redis 写入冲突:多个实例并发写入同一个 Key,后写入的会覆盖先写入的,但如果某个实例的计算还在进行中就被另一个实例的结果覆盖,会导致缓存数据在刷新周期内处于不一致状态
没有 Master 选举会发生什么?
- 多实例并发刷新 → MongoDB 查询压力翻 N 倍
- 同一业务的拓扑被不同实例交替覆盖 → 缓存数据在刷新过程中短暂不一致
- 大量无效计算浪费 CPU 和内存
Master 选举的检查点
在 src/source_controller/cacheservice/cache/topology/topology.go 第 150 行,循环每次迭代开始都会检查:
// src/source_controller/cacheservice/cache/topology/topology.go 第 146-154 行
func (t *Topology) loopBizBriefCache() {
blog.Infof("loop refresh biz brief topology task every %d minutes.", getBreifTopoCacheRefreshMinutes())
for {
if !t.checkMaster.IsMaster() {
blog.V(4).Infof("loop biz brief cache, but not master, skip.")
time.Sleep(time.Minute) // 非 Master,休眠 1 分钟后再检查
continue
}
interval := getBreifTopoCacheRefreshMinutes()
time.Sleep(time.Duration(interval) * time.Minute)
// ...
}
}
只有 Master 实例才会进入 time.Sleep 等待刷新间隔,其他实例每次循环都只做一次"IsMaster?"检查然后直接 continue,开销极小。
刷新间隔由 src/source_controller/cacheservice/cache/topology/logic.go 第 401-414 行的 getBreifTopoCacheRefreshMinutes 控制,默认 15 分钟(由 src/source_controller/cacheservice/cache/topology/types.go 第 113 行的常量 defaultRefreshIntervalMinutes = 15 定义),可通过配置项 cacheService.briefTopologySyncIntervalMinutes 覆盖,但最小值被硬编码为 2 分钟,防止配置错误导致刷新过于频繁:
// src/source_controller/cacheservice/cache/topology/logic.go 第 401-414 行
func getBreifTopoCacheRefreshMinutes() int {
duration, err := configcenter.Int("cacheService.briefTopologySyncIntervalMinutes")
if err != nil {
blog.Errorf("get brief biz topology cache refresh interval minutes failed, err: %v, use default value 15.", err)
return defaultRefreshIntervalMinutes // 15
}
if duration < 2 {
blog.Warnf("got invalid brief biz topology cache refresh interval minutes %d, < 2min, use default value 15.")
return defaultRefreshIntervalMinutes // 15
}
return duration
}
2.3 How — 定时刷新的完整时序
时序图:从循环启动到写入 Redis
Master cacheservice 实例
│
├─ loopBizBriefCache() [L146]
│ ├─ IsMaster() = true ? → 进入刷新流程
│ ├─ Sleep(N 分钟) [L157]
│ └─ doLoopBizBriefTopologyToCache(rid) [L162]
│ │
│ ├─ listAllBusiness(ctx) ──→ MongoDB cc_ApplicationBase [L171]
│ │ 分页查询,每次 100 条 [L373-399]
│ │
│ └─ for biz in allBiz:
│ ├─ time.Sleep(50ms) [L178] ← 控制刷新速率
│ └─ refreshBizTopology(biz, rid) [L180]
│ ├─ getBusinessTopology(ctx, bizID, ownerID) [L124]
│ │ ├─ getMainlineReverseRank() ──→ cc_ObjectAsst [L64]
│ │ ├─ genSetNodes() ──→ cc_SetBase [L75]
│ │ ├─ genModulesNodes() ──→ cc_ModuleBase [L190]
│ │ └─ genCustomNodes() ──→ 各层级自定义表 [L91]
│ └─ briefBizKey.updateTopology(ctx, topo) [L136]
│ └─ redis.Set("cc:v3:topology:brief:{biz_id}", topo, 24h) [L141]
│
└─ 非 Master 实例
└─ IsMaster() = false → Sleep(1分钟) → continue [L150-153]
并发控制:信号量限制 goroutine 数量
在 src/source_controller/cacheservice/cache/topology/topology.go 第 93-108 行,refreshBatch 使用 chan struct{} 作为信号量,将并发数硬编码为 5:
// src/source_controller/cacheservice/cache/topology/topology.go 第 73-119 行
func (t *Topology) refreshBatch(bizList []int64, rid string) error {
if len(bizList) == 0 {
return nil
}
filter := mapstr.MapStr{
common.BKAppIDField: mapstr.MapStr{
common.BKDBIN: bizList,
},
}
list := make([]*BizBase, 0)
err := t.db.Table(common.BKTableNameBaseApp).Find(filter).Fields(bizBaseFields...).All(context.Background(), &list)
if err != nil {
blog.Errorf("list biz detail failed, err: %v, rid: %s", err, rid)
return err
}
// set max goroutine number
pipeline := make(chan struct{}, 5) // 信号量,并发上限 5
wg := sync.WaitGroup{}
var hitErr error
for idx := range list {
pipeline
注意这里有一个微妙的行为:hitErr 是普通的非线程安全变量,只记录第一个遇到的错误,不保证所有 goroutine 的错误都能被捕获。这是性能和正确性之间的权衡——如果任何一个业务刷新失败,整个批量刷新任务报错。
相比之下,doLoopBizBriefTopologyToCache(定时全量刷新)不使用 refreshBatch,而是直接用 for range 串行遍历每个业务,在循环体内有 time.Sleep(50ms)(第 178 行)控制速率。这两种策略的区别是:事件触发的刷新(onSetChange/onModuleChange)追求批量并发速度,定时全量刷新追求稳定性。
三、拓扑生成:从 MongoDB 到内存树的构建过程
3.1 What — getBusinessTopology 如何组装拓扑树?
拓扑生成的核心函数是 src/source_controller/cacheservice/cache/topology/logic.go 第 28-52 行的 genBusinessTopology(调用方)和第 54-111 行的 getBusinessTopology,它的职责是:从 MongoDB 查出所有节点,按主线层级关系组装成树,返回空闲机池节点和普通节点两组。
关键设计:主线层级反向排序
// src/source_controller/cacheservice/cache/topology/logic.go 第 54-111 行
func (t *Topology) getBusinessTopology(ctx context.Context, biz int64, supplierAccount string) (
[]*Node, []*Node, error) {
// 第 1 步:从 cc_ObjectAsst 查出主线拓扑层级顺序
reverseRank, err := t.getMainlineReverseRank(ctx)
// reverseRank 结果例如: ["biz", "set", "module"](反向,即 biz 在最后)
var previousNodes map[int64][]*Node
var idleSetNodes map[int64][]*Node
// 第 2 步:从 set 层开始,自底向上逐层处理
for _, level := range reverseRank[2:] { // 从第3个元素开始,即 "set"
if level == "set" {
idleNodes, normalNodes, err := t.genSetNodes(ctx, biz)
idleSetNodes = idleNodes
previousNodes = normalNodes // 用于下一层
continue
}
if level == "biz" {
break // 到达业务层,退出
}
// 处理自定义层级(如集群、层级三等)
customNodes, err := t.genCustomNodes(ctx, biz, level, supplierAccount, previousNodes)
previousNodes = customNodes
}
// 第 3 步:分离空闲机池
inner, exists := idleSetNodes[biz]
if !exists {
return nil, nil, errors.New("invalid biz inner set topology")
}
return inner, previousNodes[biz], nil
}
3.2 Why — 为什么要从 MongoDB 实时计算,不能预先存好吗?
拓扑数据天然具有动态性:
- 运维操作(转移主机、新增模块、删除集群)随时发生,拓扑结构随之变化
- 业务拓扑层级本身也可能被管理员增删(CMDB 支持自定义拓扑层级)
- 缓存的目的是"加速读"而非"替代写",拓扑的最终一致性来源还是 MongoDB
因此 CMDB 选择"定期从 MongoDB 全量重建缓存"而非"每次变更时更新缓存"作为主刷新策略,原因有三:
- 变更频率 vs 查询频率不对称:拓扑变更(分钟级)远低于拓扑查询(秒级),缓存命中率极高
- 简化一致性模型:定期全量重建避免了增量更新的边界条件(如模块在刷新过程中被删除)
- 支持层级变更:如果管理员新增了自定义拓扑层级,缓存结构本身需要重建,全量刷新天然覆盖这种情况
3.3 How — 拓扑层级的反向排序如何工作?
src/source_controller/cacheservice/cache/topology/logic.go 第 338-371 行的 getMainlineReverseRank 负责从 cc_ObjectAsst 表中读取主线关联定义,按拓扑顺序排列层级:
// src/source_controller/cacheservice/cache/topology/logic.go 第 338-371 行
func (t *Topology) getMainlineReverseRank(ctx context.Context) ([]string, error) {
relations := make([]mainlineAssociation, 0)
filter := mapstr.MapStr{
common.AssociationKindIDField: common.AssociationKindMainline,
}
// 查询 cc_ObjectAsst,主线关联类型的所有层级关系
err := t.db.Table(common.BKTableNameObjAst).Find(filter).Fields(mainlineAsstFields...).All(ctx, &relations)
rank := make([]string, 0)
next := "biz"
rank = append(rank, next)
for _, relation := range relations {
if relation.AssociateTo == next {
rank = append(rank, relation.ObjectID)
next = relation.ObjectID
continue
}
// 复杂的图遍历找下一层 ...
}
return util.ReverseArrayString(rank), nil
// 结果例如: ["biz", "set", "module"](正向:biz→set→module)
// 反转后: ["module", "set", "biz"](反向,从叶子层开始)
}
最后一步 util.ReverseArrayString(rank) 将正向层级链反转,变成从叶子层(module)到根(biz)的顺序。这样在 getBusinessTopology 的循环中,从 reverseRank[2:] 开始(跳过前两个元素即 biz 和其下一层),恰好从 set 层开始自底向上构建。
四、MongoDB Change Stream:增量监听触发精准刷新
4.1 What — Change Stream 如何驱动拓扑缓存增量更新?
定时全量刷新解决了 "定期数据新鲜度" 问题,但如果要更快感知变更(分钟级刷新间隔太长),就需要 Change Stream(变更流)。CMDB 在 src/source_controller/cacheservice/cache/topology/topology.go 第 44-57 行的 NewTopology 初始化时,同时启动了三个 Change Stream 监听:
// src/source_controller/cacheservice/cache/topology/topology.go 第 34-62 行
func NewTopology(isMaster discovery.ServiceManageInterface, loopW stream.LoopInterface) (*Topology, error) {
t := &Topology{
db: mongodb.Client(),
rds: drvRedis.Client(),
loopW: loopW,
checkMaster: isMaster,
briefBizKey: newTopologyKey(),
}
if err := t.watchCustom(); err != nil {
blog.Errorf("topology watch custom failed, err: %v", err)
return nil, err
}
if err := t.watchSet(); err != nil {
blog.Errorf("topology watch set failed, err: %v", err)
return nil, err
}
if err := t.watchModule(); err != nil {
blog.Errorf("topology watch module failed, err: %v", err)
return nil, err
}
go t.loopBizBriefCache() // 定时全量刷新
return t, nil
}
每个 watchXXX 函数都注册了一个 MongoDB Change Stream 监听,监听特定集合的 INSERT/UPDATE/DELETE 事件。以 watchSet 为例(src/source_controller/cacheservice/cache/topology/watch.go 第 26-61 行):
// src/source_controller/cacheservice/cache/topology/watch.go 第 26-61 行
func (t *Topology) watchSet() error {
watchOpts := &types.WatchOptions{
Options: types.Options{
EventStruct: new(setBase), // 事件数据结构
Collection: common.BKTableNameBaseSet, // 监听 cc_SetBase
Filter: mapstr.MapStr{},
},
}
tokenHandler := newTokenHandler("set") // 持久化 watch 游标
startAtTime, err := tokenHandler.getStartWatchTime(context.Background())
watchOpts.StartAtTime = startAtTime
watchOpts.WatchFatalErrorCallback = tokenHandler.resetWatchToken
loopOptions := &types.LoopBatchOptions{
LoopOptions: types.LoopOptions{
Name: "topology cache with set",
WatchOpt: watchOpts,
TokenHandler: tokenHandler,
RetryOptions: &types.RetryOptions{
MaxRetryCount: 10,
RetryDuration: 1 * time.Second,
},
},
EventHandler: &types.BatchHandler{
DoBatch: t.onSetChange, // 事件处理函数
},
BatchSize: 50, // 每批最多 50 个事件
}
return t.loopW.WithBatch(loopOptions)
}
4.2 Why — 为什么需要 Change Stream 而不是定时刷新就够了?
定时全量刷新的局限性
- 默认刷新间隔 15 分钟,意味着一次模块删除后,最长需要 15 分钟才能反映到缓存中
- 在 15 分钟内,SRE 查看的拓扑树显示的是已被删除的模块,这是数据陈旧
- 对于生产环境的故障定位场景,"15 分钟的不一致窗口"可能是不可接受的
Change Stream 的价值:分钟级增量更新
- 当有集群/模块变更时,Change Stream 立即推送事件,缓存刷新延迟从"分钟级"降低到"秒级"
- 只刷新受影响的业务(通过 bizList = util.IntArrayUnique(bizList) 去重),避免了全量刷新
- 游标持久化在 cc_System 表(通过 tokenHandler),实例重启后可从上次位置继续,不丢事件
4.3 How — onSetChange 如何处理变更事件?
src/source_controller/cacheservice/cache/topology/watch.go 第 63-126 行的 onSetChange 处理集群变更事件:
// src/source_controller/cacheservice/cache/topology/watch.go 第 63-127 行
func (t *Topology) onSetChange(es []*types.Event) (retry bool) {
if len(es) == 0 {
return false
}
rid := es[0].ID()
bizList := make([]int64, 0)
for idx := range es {
one := es[idx]
var set *setBase
switch one.OperationType {
case types.Insert:
set = one.Document.(*setBase)
case types.Update:
// 只有 bk_parent_id 变更才处理(拓扑层级变化)
if _, exists := one.ChangeDesc.UpdatedFields[common.BKParentIDField]; !exists {
continue
}
set = one.Document.(*setBase)
case types.Delete:
// 从归档表恢复已删除集群的信息
filter := mapstr.MapStr{"oid": one.Oid, "coll": common.BKTableNameBaseSet}
archive := new(setArchive)
err := t.db.Table(common.BKTableNameDelArchive).Find(filter).One(context.TODO(), archive)
if err != nil { /* 处理错误 */ }
set = archive.Detail
default:
continue
}
blog.Infof("topology cache, received biz: %d, set: %d/%s, op: %s, rid: %s",
set.Business, set.ID, set.Name, one.ClusterTime.String(), rid)
bizList = append(bizList, set.Business)
}
bizList = util.IntArrayUnique(bizList) // 去重(同一业务的多个集群变更只刷新一次)
err := t.refreshBatch(bizList, rid) // 并发刷新受影响业务的缓存
if err != nil {
return true // 失败则重试整批事件
}
return false
}
一个关键设计:对于 Update 事件,只有 bk_parent_id 字段变更才处理(第 82-85 行)。这是因为 Change Stream 会触发大量 UPDATE 事件(如修改集群描述、更新名称),但只有影响拓扑结构的变更才需要刷新缓存。其他字段变更不影响拓扑结构,跳过刷新避免了无效计算。
五、完整拓扑树缓存:TopoKeyMap 多类型扩展
5.1 What — biz-topo 包如何支持多种拓扑类型?
简要拓扑缓存只服务标准的 "业务→集群→模块" 结构,但 CMDB 还需要支持 k8s 拓扑(Pod/Deployment/Service 等)。为此,src/source_controller/cacheservice/cache/biz-topo/topo.go 实现了第二套缓存逻辑:
// src/source_controller/cacheservice/cache/biz-topo/topo.go 第 40-65 行
type Topo struct {
isMaster discovery.ServiceManageInterface
watcher *watch.Watcher
}
func New(isMaster discovery.ServiceManageInterface, loopW stream.LoopInterface,
cacheSet *cache.CacheSet) (*Topo, error) {
t := &Topo{
isMaster: isMaster,
}
watcher, err := watch.New(loopW, cacheSet)
if err != nil {
return nil, fmt.Errorf("new watcher failed, err: %v", err)
}
t.watcher = watcher
// 为每种拓扑类型启动独立的定时刷新循环
for _, topoKey := range key.TopoKeyMap {
go t.loopBizTopoCache(topoKey)
}
return t, nil
}
TopoKeyMap 目前只注册了 KubeType(k8s 拓扑),但设计上是可扩展的——只需在对应文件中 init() 注册新的 Key 即可。每种拓扑类型独立运行自己的刷新循环,独立 TTL,独立刷新间隔。
k8s 拓扑的刷新间隔默认 1 小时(defaultKubeRefreshInterval = 1 * time.Hour),由 src/source_controller/cacheservice/cache/biz-topo/key/kube.go 第 31 行定义。如果 k8s 集群数量多、拓扑层级深,这个较长的间隔是合理的——k8s 拓扑变更频率远低于标准拓扑(Pod 的生命周期由 k8s 自己管理)。
5.2 Why — 为什么 k8s 拓扑和标准拓扑要分开?
k8s 拓扑和标准 CMDB 拓扑有几方面的本质差异:
- 层级结构不同:标准拓扑是"业务→集群→模块",k8s 拓扑是"业务→集群→命名空间→工作负载→Pod",层级更深
- 数据来源不同:标准拓扑来自 MongoDB 的 cc_SetBase/cc_ModuleBase 等表,k8s 拓扑来自 k8s API Server(或同步后的 MongoDB k8s 表)
- 变更频率不同:标准拓扑由运维人工管理,变更频率低;k8s 中 Pod 的创建和销毁非常频繁,如果用 Change Stream 监听,每秒可能触发成百上千次事件
- 主机计数逻辑不同:标准拓扑的模块主机数量来自 cc_ModuleHostConfig,k8s 拓扑的 Pod 数量来自 k8s API,两者的 COUNT 逻辑完全不同
因此两套缓存完全隔离是合理的设计。
5.3 How — 刷新队列如何避免并发覆盖?
src/source_controller/cacheservice/cache/biz-topo/logics/topo/queue.go 实现了一个基于内存队列的刷新机制,与定时全量刷新互补——用于接收 Change Stream 推送的增量刷新任务:
// src/source_controller/cacheservice/cache/biz-topo/logics/topo/queue.go 第 31-78 行
type bizRefreshQueue struct {
sync.Mutex
topoKey key.Key
bizIDs []int64
bizIDMap map[int64]struct{} // 用于去重
}
func (q *bizRefreshQueue) Run() {
for {
bizID, exists := q.Pop()
if !exists {
time.Sleep(time.Millisecond * 50) // 队列空,休眠 50ms 再试
continue
}
rid := util.GenerateRID()
err := TryRefreshBizTopoByCache(q.topoKey, bizID, rid)
if err != nil {
blog.Errorf("try refresh biz %d %s topo failed, err: %v, rid: %s", bizID, q.topoKey.Type(), err, rid)
time.Sleep(time.Millisecond * 100) // 失败后休眠 100ms 再试
}
}
}
func (q *bizRefreshQueue) Push(bizIDs ...int64) {
q.Lock()
defer q.Unlock()
for _, bizID := range bizIDs {
_, exists := q.bizIDMap[bizID]
if !exists {
q.bizIDs = append(q.bizIDs, bizID)
q.bizIDMap[bizID] = struct{}{} // 重复的 bizID 不会重复入队
}
}
}
bizIDMap 保证了同一个业务 ID 不会重复入队——如果短时间内有多个关于同一业务的变更事件,只会触发一次刷新。这防止了频繁变更导致的刷新风暴(thundering herd)问题。
六、缓存 TTL 与配置:24 小时兜底机制
6.1 TTL 的作用
简要拓扑缓存的 TTL 在 src/source_controller/cacheservice/cache/topology/key.go 第 114-120 行定义:
// src/source_controller/cacheservice/cache/topology/key.go 第 114-120 行
func newTopologyKey() *cacheKey {
return &cacheKey{
namespace: common.BKCacheKeyV3Prefix + "topology:brief",
// => "cc:v3:topology:brief"
ttl: 24 * time.Hour,
rds: drvRedis.Client(),
}
}
24 小时的 TTL 是一道"保险":如果 Master 选举出了问题(所有实例都认为自己不是 Master),定时刷新停止,那么最长 24 小时后,Redis 中的旧缓存会自动过期。下一次查询触发 cache-aside 模式(查 DB 写缓存),保证系统仍然可用但数据可能陈旧。SRE 至少不会遇到"完全无法查询拓扑"的故障。
k8s 拓扑的 TTL 是 3 小时(src/source_controller/cacheservice/cache/biz-topo/key/kube.go 第 39 行),比标准拓扑短很多——因为 k8s 环境中 Pod 生命周期短,3 小时前的拓扑数据可能已经和实际情况相差很大。
FAQ(20 组)
以下 20 组 Q&A 覆盖了业务拓扑缓存的核心知识点,涵盖设计决策、源码细节、运维实践三个维度。
Q1. 为什么缓存 key 的 namespace 要加 cc:v3: 前缀?
统一前缀便于 Redis 分类管理和隔离。在 src/common/definitions.go 第 1219 行,BKCacheKeyV3Prefix = "cc:v3:"。CMDB 的所有 Redis key(缓存、锁、消息队列等)都共享这个前缀,运维时可以用 KEYS "cc:v3:*" 快速查看所有缓存 key,按类型过滤。
Q2. 缓存未命中时为什么不直接返回错误,而是查 DB 回填?
cache-aside 模式,保证服务可用性。在 src/source_controller/cacheservice/cache/topology/client.go 第 38-50 行,缓存未命中时走 DB 回填逻辑。没有缓存不会导致请求失败——只是响应时间从"毫秒级"退化到"秒级"。这对 SRE 来说至少是可用的,而不是完全崩溃。
Q3. Master 选举失败(比如 ZooKeeper/etcd 全挂)会发生什么?
定时刷新停止,缓存依然可读,数据可能陈旧。所有实例的 IsMaster() 都返回 false,定时刷新循环全部进入 continue 分支。Change Stream 监听仍然正常工作(Change Stream 是 MongoDB 层面的,不依赖 Master 选举)。缓存可读但最长可能保持 24 小时不变。如果 TTL 也已过期,下一次查询触发 DB 回填。
Q4. 刷新过程中 MongoDB 压力有多大?
Master 节点独挑刷新压力,非 Master 实例无额外负担。在 src/source_controller/cacheservice/cache/topology/topology.go 第 150 行,每个非 Master 实例每秒只做一次 IsMaster() 检查然后 continue,几乎零开销。刷新计算全部在 Master 上执行,读请求通过 SecondaryPreferredMode 打向从节点,不影响主节点写入性能。
Q5. 为什么 refreshBatch 的并发上限是 5 而不是更多?
防止 MongoDB 连接池耗尽,保护数据库稳定性。在 src/source_controller/cacheservice/cache/topology/topology.go 第 94 行,pipeline := make(chan struct{}, 5)。5 个并发刷新 goroutine 意味着同时向 MongoDB 发起 5 组并发查询(每组包含业务表 + 集群表 + 模块表 + 若干自定义层级表)。如果并发数过高(如 50),在业务数量多时会造成 MongoDB 连接池耗尽,影响其他请求。
Q6. Change Stream 的游标为什么持久化到 cc_System 表而不是内存?
保证实例重启后不丢事件、不重复处理。在 src/source_controller/cacheservice/cache/topology/key.go 第 45-61 行,tokenHandler 将 watch 游标持久化到 cc_System 表(BKTableNameSystem)。如果 cacheservice 实例重启,读取上次保存的游标,从上次中断的位置继续监听,不会漏掉重启期间的变更事件。
Q7. 为什么在 onSetChange 中,只有 bk_parent_id 变更才触发刷新?
避免无意义的重复刷新,减少计算浪费。在 src/source_controller/cacheservice/cache/topology/watch.go 第 82-85 行,if _, exists := one.ChangeDesc.UpdatedFields[common.BKParentIDField]; !exists { continue }。Change Stream 触发所有 UPDATE 事件,但只有影响拓扑结构的变更(父节点 ID 变了,说明这个集群被移动到其他位置了)才需要刷新缓存。更新集群名称、描述等字段不影响拓扑结构,无需刷新。
Q8. 定时刷新中每个业务间隔 50ms 休眠是如何控制的?
time.Sleep(50 * time.Millisecond) 在遍历循环中逐业务执行。在 src/source_controller/cacheservice/cache/topology/topology.go 第 178 行,for _, biz := range all { time.Sleep(50 * time.Millisecond); err := t.refreshBizTopology(biz, rid) }。这是串行速率控制,不使用并发。50ms 休眠意味着每秒最多刷新 20 个业务,1000 个业务需要约 50 秒完成。
Q9. 简要拓扑缓存和完整拓扑树缓存能同时存在吗?
可以完全独立,互不干扰。两套缓存使用不同的 namespace(cc:v3:topology:brief vs cc:v3:topology:kube),存储不同结构的数据(BizBriefTopology vs BizTopo),服务于不同的 API。一个业务可以同时有简要拓扑缓存和完整拓扑树缓存。
Q10. 为什么 getMainlineReverseRank 需要遍历 cc_ObjectAsst 动态构建层级,而不是硬编码?
CMDB 支持自定义拓扑层级,层级结构是可配置的。在 src/source_controller/cacheservice/cache/topology/logic.go 第 338-371 行,拓扑层级从数据库动态读取。如果管理员在 CMDB 中新增了"层级三"这样的自定义拓扑层,getMainlineReverseRank 会自动感知,全量刷新时也会包含新层级的节点。硬编码层级会导致自定义层级无法正确渲染。
Q11. 当一个业务被删除时,它的拓扑缓存会怎样?
缓存 key 在 TTL 到期后自动删除,或者被新数据覆盖。Redis 的 Set 操作(src/source_controller/cacheservice/cache/topology/key.go 第 141 行)使用 TTL,24 小时后自动过期。没有主动清理逻辑——因为业务 ID 是唯一的,新业务不会复用被删除的业务 ID,所以旧缓存不会造成数据混淆。
Q12. 为什么 k8s 拓扑的默认刷新间隔是 1 小时,而不是 15 分钟?
k8s 拓扑计算成本高,变更频率相对低。k8s 拓扑需要查询 Pod/Deployment/Service 等多个 k8s 资源表(或 API),节点数量多,每次刷新开销大。同时 k8s 拓扑变更主要由 Pod 生命周期管理驱动,频率低于人工管理的标准拓扑。1 小时的刷新间隔在数据新鲜度和计算成本之间取得平衡。
Q13. 简要拓扑缓存和完整拓扑树缓存的数据结构有什么区别?
简要拓扑用 Object 字段标识类型,完整拓扑用 Kind 字段;后者支持 Count 主机数量。简要拓扑的 Node(src/source_controller/cacheservice/cache/topology/types.go 第 25-40 行)用 Object string 标识节点对象类型("set"/"module"/自定义层级);完整拓扑的 Node(src/source_controller/cacheservice/cache/biz-topo/types/types.go 第 44-59 行)用 Kind string 标识,且多了 Count *int64 字段存储主机数量。
Q14. 刷新间隔配置 cacheService.briefTopologySyncIntervalMinutes 最小值为什么是 2 分钟?
防止配置错误导致刷新过于频繁,压垮 MongoDB。在 src/source_controller/cacheservice/cache/topology/logic.go 第 408-410 行,if duration < 2 { return defaultRefreshIntervalMinutes }。如果配置成 0 或 1 分钟,刷新频率会非常高,在大量业务场景下会导致 MongoDB 持续高负载。
Q15. BizBase.OwnerID 字段(bk_supplier_account)的作用是什么?
多租户隔离,不同供应商账号的数据物理分离。在 src/source_controller/cacheservice/cache/topology/types.go 第 55 行,OwnerID string。CMDB 支持多供应商/多租户场景,每个供应商有独立的数据命名空间。genCustomNodes(src/source_controller/cacheservice/cache/topology/logic.go 第 114 行)需要传入 supplierAccount 来拼接正确的自定义层级表名:common.GetObjectInstTableName(object, supplierAccount)。
Q16. 为什么 refreshBatch 中用 hitErr 而不用 slice 收集所有错误?
性能优先:只要有一个业务刷新失败,整个批量任务视为失败。在 src/source_controller/cacheservice/cache/topology/topology.go 第 96 行,var hitErr error 是非线程安全的普通变量。任何一个 goroutine 写入 hitErr 后,下一次 wg.Wait() 触发时会立即返回错误。设计上选择"快速失败"而非"收集全部错误后再报告"——在缓存刷新场景下,部分失败意味着缓存可能不完整,快速失败可以触发立即重试。
Q17. Change Stream 监听集合变更时,为什么 Delete 事件要从 cc_DelArchive 归档表读取信息?
MongoDB Change Stream 在文档删除后不保留文档内容,只提供 OID。在 src/source_controller/cacheservice/cache/topology/watch.go 第 90-106 行,Delete 事件的 one.Document 为空,需要通过 one.Oid 去 cc_DelArchive 表(BKTableNameDelArchive)查询删除前的完整文档,以获取该集群所属的业务 ID,从而触发正确业务的缓存刷新。
Q18. 为什么 doLoopBizBriefTopologyToCache 使用串行遍历 + 50ms 休眠,而不是并发?
定时全量刷新追求稳定性和可预测性,不需要极限速度。在 src/source_controller/cacheservice/cache/topology/topology.go 第 177-187 行,for _, biz := range all { time.Sleep(50 * time.Millisecond); refreshBizTopology(...) }。相比 refreshBatch 的 5 并发,这个策略更保守——50ms 休眠使得 Master 节点的刷新负载始终可控,不会出现突发高负载。定时全量刷新的目标是"稳定保持缓存新鲜"而非"尽快追上变更"。
Q19. 缓存 key 的 TTL 设置为 24 小时后,如果刷新间隔配置失效会发生什么?
TTL 兜底保证最长 24 小时后缓存自动过期,下一次查询触发 DB 回填。如果刷新间隔配置读取失败(如配置中心不可用),getBreifTopoCacheRefreshMinutes(src/source_controller/cacheservice/cache/topology/logic.go 第 401-414 行)会返回默认值 15 分钟。即使定时刷新完全停止,24 小时 TTL 也会保证缓存不会永久过期。SRE 查询会触发 cache-aside 回填,系统降级为"无缓存"但仍然可用。
Q20. k8s 拓扑的 TTL 是 3 小时,比标准拓扑的 24 小时短很多,这是如何配置的?
不同 TopoType 可以有独立的 namespace 和 TTL,通过 TopoKeyMap 注册。在 src/source_controller/cacheservice/cache/biz-topo/key/kube.go 第 39 行,ttl: 3 * time.Hour 是 KubeType 的专属配置。标准拓扑使用 src/source_controller/cacheservice/cache/topology/key.go 第 117 行的 ttl: 24 * time.Hour。两套缓存体系完全独立,各自有自己的 Key 生成逻辑和 TTL。
本篇总结:业务拓扑缓存的五大设计原则
- 分层缓存:简要拓扑(无计数)和完整拓扑树(含计数)完全隔离,各司其职
- 定时全量 + 增量监听双轨:定时刷新保证基线新鲜度(默认 15 分钟),Change Stream 提供分钟级增量更新
- Master 独占刷新:多实例只让 Master 执行刷新,非 Master 实例零开销,真正实现了"高可用的同时节省资源"
- 并发控制:信号量限制 goroutine 并发数(5),串行刷新配合 50ms 休眠,保证数据库压力可控
- TTL 兜底:最长 24 小时(标准)/3 小时(k8s)自动过期,即使刷新停止也不会永久缓存过期数据
Roadmap 后续预告
- #17 云区域管理:多云/混合云 账号与区域管理 — 云账号 + 云区域分层管理,多云环境下的统一资源抽象
- #18 容器拓扑关联:Pod ↔ Host ↔ 机柜 全链路映射 — k8s Pod 与 CMDB 主机的关联关系如何建立和查询
- #19 主机锁定:并发控制与分布式锁 — 两台 SRE 同时修改同一台主机属性时,CMDB 如何防止数据覆盖

浙公网安备 33010602011771号