蓝鲸 CMDB 3.14.6 源码专题【左扬精讲】—— storage/dal:MongoDB 实现深度解析
蓝鲸 CMDB 3.14.6 源码专题【左扬精讲】—— storage/dal:MongoDB 实现深度解析
在上一篇下篇预告中,我们已经梳理了 storage/dal 层的接口契约与设计意图。本篇作为正式篇,将带大家深入到 src/storage/dal/mongo/local/ 目录中,逐行精读 MongoDB 实现的 6 大核心模块:事务生命周期、ID 生成器、字段操作、聚合查询、配置加载与监控埋点。
src/storage/dal/dal.go ← DB 接口契约(事务、ID、Table)
src/storage/dal/types/types.go ← Table、Find、Filter 等接口契约
src/storage/dal/mongo/config.go ← MongoDB 配置结构体与 BuildURI 构造
src/storage/dal/mongo/local/mongo.go ← local.Mongo 实现 dal.DB 接口
src/storage/dal/mongo/local/txn_manager.go ← TxnManager 事务管理器(结合 Redis)
src/storage/dal/mongo/local/transaction.go ← CommitTransaction / AbortTransaction
src/storage/dal/mongo/local/metric.go ← mtc 监控埋点(Prometheus 指标)
蓝鲸 CMDBstorage/dalMongoDB 实现事务管理ID 生成器监控埋点
学习重点
- 必须掌握
- 事务生命周期:AutoRunWithTxn 自动事务封装 + Redis 事务号 + 幂等提交
- ID 生成器的 $inc 原子计数 + 强制 context.Background() 防事务干扰
- 字段操作的 DDL 语义映射(AddColumn/RenameColumn/DropColumn)
- 理解即可
- Prometheus 监控埋点 3 类指标 × 11 类操作的二维标签设计
- MongoDB 连接建立与连接池参数(RetryWrites=false 等)
目录
一、事务生命周期:从开启到提交/回滚
What — 事务在 dal 层是如何暴露与编排的?
在 src/storage/dal/dal.go 第 60-66 行中,dal.DB 接口只暴露 3 个事务相关方法:
- CommitTransaction(ctx, *TxnCapable) error — 提交事务
- AbortTransaction(ctx, *TxnCapable) (bool, error) — 取消事务(返回值 = 是否可重试)
- InitTxnManager(redis.Client) error — 注入事务管理器依赖
关键设计:接口本身不暴露 BeginTransaction。事务的开启是通过 TxnCapable(事务会话能力)在请求入口处生成,并由 AutoRunWithTxn 在 db 调用点自动接管。整个调用链通过 HTTP Header 在微服务之间传递。
Why — 为什么事务编排要这样设计?
问题一:CMDB 是微服务架构,多个服务节点可能同时处理同一业务请求,需要在全局范围内协调事务编号。Redis 天然适合作为分布式协调组件。
问题二:MongoDB driver 的 session.CommitTransaction() 在没有实际操作时会抛 NoSuchTransaction 错误。业务层需要"无操作也要安全提交"的幂等保证。
问题三:跨进程事务的标识传递。事务标识必须可序列化、可在 HTTP Header 中传输,使下游 Scene/Service Server 能识别并加入同一事务。
没有 TxnManager 会发生什么?
- 事务编号无法在节点间共享,可能产生重复编号,MongoDB 服务端会拒绝旧事务号
- 无法区分写入冲突(WriteConflict)和其他错误类型,导致无法实现优雅重试
- "无操作"业务流(纯计算 + 异步落库)会触发 NoSuchTransaction 异常,导致请求失败
事务标识由 src/storage/dal/mongo/local/txn_manager.go 第 295-303 行的 GenSessionID() 生成:
func GenSessionID() (string, error) {
// 直接复用 MongoDB driver 的 UUID,作为全局事务会话标识
id, err := uuid.New()
if err != nil {
return "", err
}
// 用 base64 标准编码把 UUID 二进制转换为可传输字符串
return base64.StdEncoding.EncodeToString(id[:]), nil
}
它直接复用了 MongoDB driver 的 UUID,作为全局唯一 SessionID。注释明确写到:"mongodb driver used this as it's mongodb session id, and we use it too."
随后 GenTxnCableAndSetHeader(src/storage/dal/mongo/local/txn_manager.go 第 305-332 行)将 SessionID 和超时时间写入 HTTP Header,跨服务传递:
func GenTxnCableAndSetHeader(header http.Header, opts ...metadata.TxnOption) (*metadata.TxnCapable, error) {
sessionID, err := GenSessionID() // 生成新的 SessionID
if err != nil {
return nil, fmt.Errorf("generate session id failed, err: %v", err)
}
var timeout time.Duration
if len(opts) != 0 {
if opts[0].Timeout < 30*time.Second { // 强制最小超时阈值 30s
timeout = common.TransactionDefaultTimeout
} else {
timeout = opts[0].Timeout
}
} else {
timeout = common.TransactionDefaultTimeout // 使用默认值
}
header.Set(common.TransactionIdHeader, sessionID) // 写入事务 ID
header.Set(common.TransactionTimeoutHeader, strconv.FormatInt(int64(timeout), 10)) // 写入超时秒数
cap := metadata.TxnCapable{ // 返回给上层使用
Timeout: timeout,
SessionID: sessionID,
}
return &cap, nil
}
重要规则:if opts[0].Timeout < 30*time.Second 强制使用默认值,避免过短的超时配置导致事务失败。
当业务代码执行 db.Table("cc_ObjectBase").Insert(ctx, doc) 时,dal 层会自动判断是否需要开启事务。看 src/storage/dal/mongo/local/txn_manager.go 第 231-264 行:
func (t *TxnManager) AutoRunWithTxn(ctx context.Context, cli *mongo.Client, cmd func(ctx context.Context) error) error {
cap, useTxn, err := parseTxnInfoFromCtx(ctx) // 从 ctx 中解析事务头信息
if err != nil {
return err
}
if !useTxn {
// 不是事务上下文,直接执行(性能开销最小)
return cmd(ctx)
}
session, err := t.PrepareTransaction(cap, cli) // 开启 mongo.Session 并 StartTransaction
if err != nil {
return err
}
sessCtx := CmdbContextWithSession(ctx, session) // 把 session 包装到 ctx 中
err = cmd(sessCtx) // 执行业务命令(所有 mongo 操作都会走事务)
if err != nil {
// 记录错误类型到 Redis,供后续判断是否需要重试
t.setTxnError(sessionKey(cap.SessionID), err)
return err
}
return nil
}
核心流程:
- parseTxnInfoFromCtx:从 ctx 中解析 TransactionIdHeader 和 TransactionTimeoutHeader;不存在则走非事务路径
- PrepareTransaction:开启 MongoDB Session 并启动 Transaction
- CmdbContextWithSession:将 session 注入到 ctx 中,使 MongoDB driver 把后续操作视为事务内的操作
src/storage/dal/mongo/local/txn_manager.go 第 131-162 行展示了完整的 session 初始化过程:
func (t *TxnManager) PrepareTransaction(cap *metadata.TxnCapable, cli *mongo.Client) (mongo.Session, error) {
sess, err := cli.StartSession() // 从连接池拿一个 mongo.Session
if err != nil {
return nil, fmt.Errorf("start session failed, err: %v", err)
}
err = sess.StartTransaction() // 显式启动事务(切换 driver 事务状态机)
if err != nil {
return nil, fmt.Errorf("start transaction %s failed: %v", cap.SessionID, err)
}
txnNumber, err := t.GenTxnNumber(cap.SessionID, cap.Timeout) // 从 Redis 拿自增事务号
if err != nil {
return nil, fmt.Errorf("generate txn number failed, err: %v", err)
}
info := &SessionInfo{ // 构造 SessionInfo 用于反射注入
TxnNubmer: txnNumber,
SessionID: cap.SessionID,
}
err = CmdbReloadSession(sess, info) // 注入 txnNumber 到 driver session 内部
if err != nil {
return nil, fmt.Errorf("reload transaction: %s failed, err: %v", cap.SessionID, err)
}
return sess, nil
}
关键技巧:第 156 行 CmdbReloadSession 通过 MongoDB driver 提供的反射注入机制,把 SessionID 和 txnNumber 注入 session 内部信息。这样 driver 后续的所有操作都会带上正确的 txnNumber 字段。
src/storage/dal/mongo/local/txn_manager.go 第 81-104 行的 GenTxnNumber:
func (t *TxnManager) GenTxnNumber(sessionID string, ttl time.Duration) (int64, error) {
key := sessionKey(sessionID).genKey() // 拼 Redis key,命名空间见第 34-36 行
pip := t.cache.Pipeline() // 使用 Redis Pipeline 一次发送多个命令
defer pip.Close()
if ttl == 0 {
ttl = common.TransactionDefaultTimeout // 默认 30s TTL
}
pip.SetNX(key, 0, ttl).Result() // SetNX:已存在则不动,保证幂等
incrBy := pip.IncrBy(key, 1) // IncrBy:原子自增 1
_, err := pip.Exec() // 一次性执行 Pipeline
if err != nil {
return 0, err
}
num := incrBy.Val() // 取自增后的值
return num, nil
}
Redis Key 命名空间见 src/storage/dal/mongo/local/txn_manager.go 第 34-36 行:
const (
transactionNumberRedisKeyNamespace = common.BKCacheKeyV3Prefix + "transaction:number:"
transactionErrorRedisKeyNamespace = common.BKCacheKeyV3Prefix + "transaction:error:"
)
为什么需要事务号? 这是 MongoDB driver 的内部机制:driver 会根据 txnNumber 字段来标识一次事务,服务端会用它来判断是新事务还是重试。同一个 SessionID 内的操作,每次 txnNumber 都会自增 1。
在 src/storage/dal/mongo/local/transaction.go 第 25-71 行:
func (c *Mongo) CommitTransaction(ctx context.Context, cap *metadata.TxnCapable) error {
rid := ctx.Value(common.ContextRequestIDField)
// 检查事务号是否存在;不存在说明本次请求没有任何 DB 操作走事务(可能是纯计算)
txnNumber, err := c.tm.GetTxnNumber(cap.SessionID)
if err != nil {
if redis.IsNilErr(err) {
blog.Infof("commit transaction: %s but no transaction need to commit, *skip*", cap.SessionID)
return nil // 幂等保护:直接跳过
}
return fmt.Errorf("get txn number failed, err: %v", err)
}
if txnNumber == 0 {
blog.Infof("commit transaction: %s but no transaction to commit, **skip**", cap.SessionID)
return nil // 幂等保护:txnNumber=0 也直接跳过
}
reloadSession, err := c.tm.PrepareTransaction(cap, c.dbc) // 重新准备 session(跨进程需要重建)
if err != nil {
blog.Errorf("commit transaction, but prepare transaction failed, err: %v", err)
return err
}
if err := CmdbPrepareCommitOrAbort(reloadSession); err != nil { // 重置 driver 事务状态
blog.Errorf("reset the commit transaction state failed, err: %v", err)
return err
}
err = reloadSession.CommitTransaction(ctx) // 真正调用 MongoDB driver 提交事务
if err != nil {
return fmt.Errorf("commit transaction: %s failed, err: %v", cap.SessionID, err)
}
err = c.tm.RemoveSessionKey(cap.SessionID) // 清理 Redis 中的 session key
return nil
}
幂等保护(第 32-43 行):若 Redis 中查不到事务号,说明本次请求没有任何 DB 操作在事务内执行(可能是纯计算),直接跳过提交;若事务号为 0,说明只有 SetNX 没 Incr(异常情况),也跳过提交。这两步是避免 MongoDB 抛出 NoSuchTransaction 错误的关键防御。
src/storage/dal/mongo/local/transaction.go 第 73-109 行的 AbortTransaction 与重试信号:
func (c *Mongo) AbortTransaction(ctx context.Context, cap *metadata.TxnCapable) (bool, error) {
rid := ctx.Value(common.ContextRequestIDField)
reloadSession, err := c.tm.PrepareTransaction(cap, c.dbc) // 重建 session(与 Commit 流程相同)
if err != nil {
blog.Errorf("abort transaction, but prepare transaction failed, err: %v", err)
return false, err
}
if err := CmdbPrepareCommitOrAbort(reloadSession); err != nil { // 重置事务状态
blog.Errorf("reset abort transaction state failed, err: %v", err)
return false, err
}
err = reloadSession.AbortTransaction(ctx) // 调用 MongoDB driver abort
if err != nil {
return false, fmt.Errorf("abort transaction: %s failed, err: %v", cap.SessionID, err)
}
err = c.tm.RemoveSessionKey(cap.SessionID) // 清理 Redis
if err != nil {
blog.Errorf("commit transaction, but delete txn session: %s key failed, err: %v", cap.SessionID, err)
}
errorType := c.tm.GetTxnError(sessionKey(cap.SessionID)) // 读取 setTxnError 写入的错误类型
switch errorType {
case WriteConflictType: // 只有 WriteConflict 才允许重试
return true, nil
}
return false, nil
}
AbortTransaction 返回的 bool 是一个重试信号:
- 若事务因 WriteConflict 失败,setTxnError(src/storage/dal/mongo/local/txn_manager.go 第 266-277 行)已经把 WriteConflictType 写入 Redis
- GetTxnError 读取这个标记并返回 true,告诉上层调用方:"这次失败是因为写入冲突,请重试"
- 其他错误返回 false,调用方不应再重试
本章小结
- 事务常量:TransactionIdHeader 与 TransactionTimeoutHeader 作为 HTTP Header 跨服务传递
- 事务步骤:PrepareTransaction = StartSession + StartTransaction + GenTxnNumber + CmdbReloadSession
- 自动封装:AutoRunWithTxn 根据 ctx 是否包含事务头自动决定是否走事务
- 提交幂等:通过 Redis 中的 GetTxnNumber==0 判断是否有实际操作,无操作直接跳过提交
- 回滚重试:AbortTransaction 返回 bool 表示是否 WriteConflict 可重试
二、ID 生成器:分布式唯一 ID 的 MongoDB 实现
What — CMDB 是怎么生成全局唯一 ID 的?
CMDB 中几乎所有实体(主机、业务、模型、实例等)都需要全局唯一 ID。传统做法是用 MySQL 的 AUTO_INCREMENT,但 MongoDB 中需要自研。dal 层通过 NextSequence(取单个)和 NextSequences(批量)提供该能力。
Why — 为什么不用 MongoDB 原生的 _id 自增?
MongoDB 默认的 _id 是 ObjectID(12 字节时间戳 + 机器码 + 计数器),不适合作为业务主键对外暴露给 API(如 bk_host_id)。CMDB 需要的是:
- 整数自增 ID:业务可读、对调用方友好
- 分布式唯一:多实例部署下不会产生重复
- 步长可调:通过 cc_ConfigAdmin 配置表动态调整步长,减少 MongoDB 写次数
没有 ID 生成器会发生什么?
- 业务层需要自己实现 ID 分配逻辑,代码分散且容易出现竞态
- 多实例部署下用本地计数器会产生 ID 冲突,破坏数据完整性
- 无法支持未来扩展为分布式唯一 ID 协议(如 Snowflake)
src/storage/dal/mongo/local/mongo.go 第 820-854 行的 NextSequence:
func (c *Mongo) NextSequence(ctx context.Context, sequenceName string) (uint64, error) {
sequenceName = c.redirectTable(sequenceName) // 分片表统一映射到基础表(见下文)
rid := ctx.Value(common.ContextRequestIDField)
start := time.Now()
defer func() {
blog.V(4).InfoDepthf(2, "mongo next-sequence cost %dms, rid: %v", time.Since(start)/time.Millisecond, rid)
}()
// 关键:使用 context.Background() 避免被上层事务 session 干扰
ctx = context.Background()
coll := c.dbc.Database(c.dbname).Collection("cc_idgenerator") // 专用计数器集合
Update := bson.M{
"$inc": bson.M{"SequenceID": c.conf.idGenStep}, // 原子自增步长
"$setOnInsert": bson.M{"create_time": time.Now()}, // 首次创建时记录创建时间
"$set": bson.M{"last_time": time.Now()}, // 每次更新 last_time
}
filter := bson.M{"_id": sequenceName}
upsert := true // 不存在则插入
returnChange := options.After // 返回递增后的新值
opt := &options.FindOneAndUpdateOptions{
Upsert: &upsert,
ReturnDocument: &returnChange,
}
doc := Idgen{}
err := coll.FindOneAndUpdate(ctx, filter, Update, opt).Decode(&doc) // 单次原子操作完成 upsert + 自增
if err != nil {
return 0, err
}
return doc.SequenceID, err // 返回自增后的新 SequenceID
}
关键技巧:
- 第 829 行 强制使用 context.Background()。注释明确写到:"防止产生相同的序列号",避免上层事务 session 干扰 $inc 操作的隔离级别
- 第 832-836 行 FindOneAndUpdate 配合 $inc 是经典的 MongoDB 计数器模式
- 第 840 行 ReturnDocument: options.After 让 MongoDB 返回递增后的新值,保证原子性
src/storage/dal/mongo/local/mongo.go 第 856-902 行的 NextSequences:
func (c *Mongo) NextSequences(ctx context.Context, sequenceName string, num int) ([]uint64, error) {
if num == 0 {
return make([]uint64, 0), nil // num=0 早返回空 slice
}
sequenceName = c.redirectTable(sequenceName) // 分片表映射
if c.conf.disableInsert && idgen.IsIDGenSeqName(sequenceName) {
return nil, errors.New("insertion is disabled") // 禁用插入检查
}
ctx = context.Background() // 同样强制脱离事务
coll := c.dbc.Database(c.dbname).Collection("cc_idgenerator")
Update := bson.M{
"$inc": bson.M{"SequenceID": num * c.conf.idGenStep}, // 推进 num*idGenStep 个 ID
"$setOnInsert": bson.M{"create_time": time.Now()},
"$set": bson.M{"last_time": time.Now()},
}
filter := bson.M{"_id": sequenceName}
upsert := true
returnChange := options.After
opt := &options.FindOneAndUpdateOptions{
Upsert: &upsert,
ReturnDocument: &returnChange,
}
doc := Idgen{}
err := coll.FindOneAndUpdate(ctx, filter, Update, opt).Decode(&doc)
if err != nil {
return nil, err
}
sequences := make([]uint64, num) // 按公式分摊连续 ID 段
for i := 0; i < num; i++ {
sequences[i] = uint64((i-num+1)*c.conf.idGenStep) + doc.SequenceID // 公式:(i-num+1)*step + 新增的 SequenceID
}
return sequences, nil
}
步长优化的意义:idGenStep 让单次 FindOneAndUpdate 操作能"预分配" idGenStep 个 ID。NextSequences 一次性预分配 num * idGenStep,然后按公式分摊到 sequences[0..num-1],保证分配出去的是一段连续 ID。这样减少 MongoDB 计数器集合的写次数,缓解热点问题。
src/storage/dal/mongo/local/mongo.go 第 811-818 行的 redirectTable:
func (c *Mongo) redirectTable(tableName string) string {
if common.IsObjectInstShardingTable(tableName) {
tableName = common.BKTableNameBaseInst // cc_ObjectBase_xxx → cc_ObjectBase
} else if common.IsObjectInstAsstShardingTable(tableName) {
tableName = common.BKTableNameInstAsst // cc_InstAsst_xxx → cc_InstAsst
}
return tableName
}
对分片表(cc_ObjectBase_xxx)的 ID 请求,统一映射到基础表 cc_ObjectBase 的序列号,避免每个分片维护独立计数器导致 ID 在分片之间重复。
本章小结
- 原子计数器:基于 cc_idgenerator 集合的 $inc + ReturnDocument=After
- 事务隔离:context.Background() 防上层事务 session 干扰
- 步长优化:从 cc_ConfigAdmin 读取 idGenStep,减少计数器集合写次数
- 批量预分配:NextSequences 按公式 (i-num+1)*idGenStep + SequenceID 分摊连续 ID 段
- 分片兼容:redirectTable 把分片表 ID 请求归一到基础表序列号
三、字段操作:AddColumn / RenameColumn / DropColumn
What — 这 4 个方法在 dal 层扮演什么角色?
CMDB 是动态元数据系统,模型的字段(属性)可以由管理员在线增删。这一组方法让 dal 层支持类似 SQL DDL 的字段管理,对业务透明:
- AddColumn — 给不包含某字段的文档添加该字段(幂等添加)
- RenameColumn — 基于 $rename 操作符重命名字段
- DropColumn — 删除单个字段(作用于全表)
- DropColumns — 按 filter 条件删除多个字段
Why — 为什么需要 DDL 级别的字段操作?
问题一:动态元数据。CMDB 允许用户在线创建/修改模型属性(如新增一个 "机房" 字段),底层 MongoDB 集合需要相应加上这个 key。
问题二:业务层不应直接拼 MongoDB 原生操作符。将这些操作封装在 dal 抽象层,让上层只关心逻辑含义(添加字段),不必关心使用什么 MongoDB 操作符。
问题三:大型集合的字段操作必须高效。幂等设计避免重复操作浪费 IO。
没有这层封装会发生什么?
- 业务代码被迫直接使用 $set/$rename/$unset 操作符,技术细节泄漏到业务层
- AddColumn 重复执行会覆盖已有字段(业务风险)
- 字段操作需要复杂的 filter 拼接,散落各处难以统一管理
src/storage/dal/mongo/local/mongo.go 第 1069-1088 行的 AddColumn:
func (c *Collection) AddColumn(ctx context.Context, column string, value interface{}) error {
mtc.collectOperCount(c.collName, columnOper) // 监控埋点:字段操作计数
start := time.Now()
defer func() {
mtc.collectOperDuration(c.collName, columnOper, time.Since(start)) // 监控埋点:操作耗时
}()
selector := dtype.Document{column: dtype.Document{"$exists": false}} // 过滤条件:字段不存在
datac := dtype.Document{"$set": dtype.Document{column: value}} // 使用 $set 设置默认值
return c.tm.AutoRunWithTxn(ctx, c.dbc, func(ctx context.Context) error {
_, err := c.dbc.Database(c.dbname).Collection(c.collName).UpdateMany(ctx, selector, datac)
if err != nil {
mtc.collectErrorCount(c.collName, columnOper) // 监控埋点:错误计数
return err
}
return nil
})
}
核心设计:filter 是 {column: {$exists: false}},所以只有那些不包含该字段的文档会被更新,已存在的字段不会被覆盖。这是 MongoDB 的“幂等添加字段”模式,符合 DDL 语义。
src/storage/dal/mongo/local/mongo.go 第 1090-1112 行的 RenameColumn:
func (c *Collection) RenameColumn(ctx context.Context, filter types.Filter, oldName, newColumn string) error {
mtc.collectOperCount(c.collName, columnOper) // 监控埋点
if filter == nil {
filter = dtype.Document{} // filter 允许为空(重命名所有文档)
}
start := time.Now()
defer func() {
mtc.collectOperDuration(c.collName, columnOper, time.Since(start)) // 监控埋点
}()
datac := dtype.Document{"$rename": dtype.Document{oldName: newColumn}} // 使用 $rename 操作符
return c.tm.AutoRunWithTxn(ctx, c.dbc, func(ctx context.Context) error {
_, err := c.dbc.Database(c.dbname).Collection(c.collName).UpdateMany(ctx, filter, datac)
if err != nil {
mtc.collectErrorCount(c.collName, columnOper) // 监控埋点
return err
}
return nil
})
}
使用 MongoDB 原生的 $rename 操作符一次性完成所有匹配文档的字段重命名。注意 filter 可以为空(重命名所有文档)。
src/storage/dal/mongo/local/mongo.go 第 1114-1133 行的 DropColumn:
func (c *Collection) DropColumn(ctx context.Context, field string) error {
mtc.collectOperCount(c.collName, columnOper) // 监控埋点
start := time.Now()
defer func() {
mtc.collectOperDuration(c.collName, columnOper, time.Since(start)) // 监控埋点
}()
datac := dtype.Document{"$unset": dtype.Document{field: ""}} // 使用 $unset 置空字段
return c.tm.AutoRunWithTxn(ctx, c.dbc, func(ctx context.Context) error {
_, err := c.dbc.Database(c.dbname).Collection(c.collName).UpdateMany(ctx, dtype.Document{}, datac)
if err != nil {
mtc.collectErrorCount(c.collName, columnOper) // 监控埋点
return err
}
return nil
})
}
再看 src/storage/dal/mongo/local/mongo.go 第 1135-1159 行的批量版本 DropColumns:
func (c *Collection) DropColumns(ctx context.Context, filter types.Filter, fields []string) error {
mtc.collectOperCount(c.collName, columnOper) // 监控埋点
start := time.Now()
defer func() {
mtc.collectOperDuration(c.collName, columnOper, time.Since(start)) // 监控埋点
}()
unsetFields := make(map[string]interface{})
for _, field := range fields { // 把多个字段名拼成 map
unsetFields[field] = ""
}
datac := dtype.Document{"$unset": unsetFields} // 一次性 $unset 多个字段
return c.tm.AutoRunWithTxn(ctx, c.dbc, func(ctx context.Context) error {
_, err := c.dbc.Database(c.dbname).Collection(c.collName).UpdateMany(ctx, filter, datac)
if err != nil {
mtc.collectErrorCount(c.collName, columnOper) // 监控埋点
return err
}
return nil
})
}
两点细节:
- DropColumn 没有 filter 参数,作用于全表所有文档
- DropColumns 接受 filter,按条件删除多个字段。两者都用 $unset 操作符
本章小结
- DDL 语义映射:$exists + $set = AddColumn(幂等)
- 字段重命名:$rename = RenameColumn(一次操作完成所有文档)
- 字段删除:$unset + 空字符串值 = DropColumn/DropColumns
- 统一埋点:4 个方法都通过 columnOper 标签上报 Prometheus 指标
四、聚合查询:AggregateOne / AggregateAll
What — 聚合查询的两个入口方法有什么差异?
在 src/storage/dal/types/types.go 第 43-44 行的接口契约:
- AggregateOne(ctx, pipeline, result) error — 只取聚合结果的第一条,失败时返回 types.ErrDocumentNotFound
- AggregateAll(ctx, pipeline, result, opts...) error — 取全部结果到 result slice,支持 AllowDiskUse 选项
Why — 为什么聚合查询要拆分为两个方法?
问题一:大多数聚合场景只需要第一条(如统计业务总数、取分组第一条)。用 FindOne + aggregate() 能在 MongoDB 服务端短路返回第一条,避免遍历整个 cursor。
问题二:内存压力。AggregateAll 处理大数据量时,MongoDB 默认会把所有文档加载到内存。开启 AllowDiskUse 允许超出内存限制时使用临时磁盘。
没有聚合入口会发生什么?
- 业务层被迫使用通用 Find 接口实现分组统计,效率低且代码冗长
- 无法启用 MongoDB 原生的 AllowDiskUse 选项处理超大数据集
- 上层必须自己处理 cursor 关闭逻辑
src/storage/dal/mongo/local/mongo.go 第 1222-1247 行的 AggregateOne:
func (c *Collection) AggregateOne(ctx context.Context, pipeline interface{}, result interface{}) error {
mtc.collectOperCount(c.collName, aggregateOper) // 监控埋点
start := time.Now()
defer func() {
mtc.collectOperDuration(c.collName, aggregateOper, time.Since(start)) // 监控埋点
}()
opt := getCollectionOption(ctx) // 从 ctx 提取 session 选项
return c.tm.AutoRunWithTxn(ctx, c.dbc, func(ctx context.Context) error {
cursor, err := c.dbc.Database(c.dbname).Collection(c.collName, opt).Aggregate(ctx, pipeline)
if err != nil {
mtc.collectErrorCount(c.collName, aggregateOper) // 监控埋点
return err
}
defer cursor.Close(ctx) // 退出前关闭 cursor
for cursor.Next(ctx) { // 短路:遍历到第一条就 return
return cursor.Decode(result)
}
return types.ErrDocumentNotFound // 遍历结束没结果
})
}
注意第 1241-1244 行的短路返回:循环中第一条就 return cursor.Decode(result),避免遍历整个 cursor;遍历结束没结果则返回 ErrDocumentNotFound。
src/storage/dal/mongo/local/mongo.go 第 1187-1220 行的 AggregateAll:
func (c *Collection) AggregateAll(ctx context.Context, pipeline interface{}, result interface{},
opts ...*types.AggregateOpts) error {
mtc.collectOperCount(c.collName, aggregateOper) // 监控埋点
start := time.Now()
defer func() {
mtc.collectOperDuration(c.collName, aggregateOper, time.Since(start)) // 监控埋点
}()
var aggregateOption *options.AggregateOptions
for _, opt := range opts { // 遍历 opts 提取 AllowDiskUse
if opt == nil {
continue
}
if opt.AllowDiskUse != nil {
aggregateOption = &options.AggregateOptions{AllowDiskUse: opt.AllowDiskUse}
}
}
opt := getCollectionOption(ctx)
return c.tm.AutoRunWithTxn(ctx, c.dbc, func(ctx context.Context) error {
cursor, err := c.dbc.Database(c.dbname).Collection(c.collName, opt).Aggregate(ctx, pipeline, aggregateOption)
if err != nil {
mtc.collectErrorCount(c.collName, aggregateOper) // 监控埋点
return err
}
defer cursor.Close(ctx) // 退出前关闭 cursor
return decodeCursorIntoSlice(ctx, cursor, result) // 工具函数:把 cursor 解码到 result slice
})
}
AllowDiskUse 是 MongoDB 3.4+ 提供的特性,允许聚合管道在内存不足时使用临时磁盘文件。当处理大数据量聚合(如统计全量主机)必须开启。
基于 src/storage/dal/types/types.go 第 43-44 行的接口契约,标准用法:
// 示例 1: 统计每个业务的实例数量(取第一条 = 全量统计)
pipeline := []bson.M{
{"$group": bson.M{"_id": "$bk_biz_id", "count": bson.M{"$sum": 1}}},
}
var result map[string]interface{}
db.Table("cc_ObjectBase").AggregateOne(ctx, pipeline, &result)
// 示例 2: 跨集合 join(AllowDiskUse 防内存溢出)
var results []map[string]interface{}
opts := types.NewAggregateOpts().SetAllowDiskUse(true)
db.Table("cc_ObjectBase").AggregateAll(ctx, pipeline, &results, opts)
本章小结
- 聚合入口:AggregateOne 取第一条;AggregateAll 取全部到 slice
- 短路优化:AggregateOne 在 for 循环中遇到第一条即 return
- 空结果:AggregateOne 未找到时返回 ErrDocumentNotFound
- 磁盘溢出保护:AllowDiskUse=true 允许大数据量聚合使用临时磁盘
五、配置加载:MongoDB 配置解析与连接建立
What — dal 层如何建立 MongoDB 连接?
MongoDB 连接建立涉及三个关键点:
- 配置加载:从 src/storage/dal/mongo/config.go 读取 MongoDB URI、副本集名、连接池等
- 客户端构造:使用 src/storage/dal/mongo/local/mongo.go 的 NewMgo 构造 mongo.Client
- ID 生成器初始化:构造完成后从 cc_ConfigAdmin 集合读取 idGenStep
Why — 为什么配置与客户端构造要解耦?
问题一:运维场景的临时切换。当 MongoDB 出现故障时,运维需要快速使用新连接串重建连接。不修改结构化字段,直接传完整 URI 更灵活。
问题二:CMDB 自研事务链路的前提。第 89-91 行注释明确说 RetryWrites 必须为 false,因为 driver 自带的写重试机制会与 CMDB 的 txnNumber 重试语义冲突。
没有连接配置会发生什么?
- 连接串、所有连接参数硬编码在源码中,无法适配不同环境(test/staging/prod)
- driver 自带的 RetryWrites 与 TxnManager 重试逻辑会冲突,导致事务号错乱
- ID 生成器步长无法动态调整,热点集合的写压力无法缓解
src/storage/dal/mongo/local/mongo.go 第 65-75 行的 MongoConf:
type MongoConf struct {
TimeoutSeconds int // 操作超时秒数
MaxOpenConns uint64 // 最大连接数(映射到 driver MaxPoolSize)
MaxIdleConns uint64 // 最小空闲连接数(映射到 driver MinPoolSize)
URI string // 完整 MongoDB URI
RsName string // 副本集名称(必须设置)
SocketTimeout int // Socket 超时秒数
DisableInsert bool // 是否禁用某些表的插入(迁移期使用)
TLS *ssl.TLSClientConfig // TLS 配置
}
src/storage/dal/mongo/local/mongo.go 第 78-102 行的 NewMgo:
func NewMgo(config MongoConf, timeout time.Duration) (*Mongo, error) {
connStr, err := connstring.Parse(config.URI) // 解析 URI,提取 dbname 等
if nil != err {
return nil, err
}
if config.RsName == "" { // 副本集名称必填
return nil, fmt.Errorf("mongodb rsName not set")
}
socketTimeout := time.Second * time.Duration(config.SocketTimeout)
maxConnIdleTime := 25 * time.Minute // 硬编码 25 分钟空闲超时
appName := common.GetIdentification() // 客户端标识(便于服务端排查慢日志)
// ★ 重要:禁用 driver 写重试,与 TxnManager 事务号机制冲突
// do not change this, our transaction plan need it to false.
// it's related with the transaction number(eg txnNumber) in a transaction session.
disableWriteRetry := false
conOpt := options.ClientOptions{
MaxPoolSize: &config.MaxOpenConns, // 连接池上限
MinPoolSize: &config.MaxIdleConns, // 保活连接数
ConnectTimeout: &timeout, // 建立连接超时
SocketTimeout: &socketTimeout, // 单个 socket 读写超时
ReplicaSet: &config.RsName, // 副本集名称
RetryWrites: &disableWriteRetry, // 关键:禁用写重试
MaxConnIdleTime: &maxConnIdleTime, // 25 分钟回收空闲连接
AppName: &appName, // 服务端日志标识
}
// 后续省略:TLS 配置、mongo.NewClient、client.Connect() 等
// ...
}
关键设计点:RetryWrites=false 关闭的真正原因是 CMDB 自己用 txnNumber 管理重试语义,若 MongoDB driver 也开启 RetryWrites 会导致事务号错乱(同一个 txnNumber 被 client 端重发)。这是 CMDB 自研事务链路的核心前提。
src/storage/dal/mongo/local/mongo.go 第 145-150 行的 initIDGenerator 片段(接 NewMgo 流程):
func (c *Mongo) initIDGenerator() (int, error) {
ctx := context.Background() // 非事务上下文
cond := map[string]interface{}{"_id": common.ConfigAdminID} // 查询 cc_ConfigAdmin._id = "config"
confData := make(map[string]string)
// 从 cc_ConfigAdmin 集合读取 idGenerator.step 配置项
// 若未配置则使用默认步长
// ...
}
从 cc_ConfigAdmin 集合(_id = common.ConfigAdminID)的文档中读取 idGenerator.step 配置项。若未配置则使用默认步长。这允许运维在不重启服务的情况下动态调整 ID 步长。
src/storage/dal/mongo/config.go 第 64-79 行的 BuildURI:
func (c Config) BuildURI() string {
if c.Connect != "" { // 运维场景优先用完整 URI
return c.Connect
}
if !strings.Contains(c.Address, ":") && len(c.Port) > 0 { // 补全 Address:Port
c.Address = c.Address + ":" + c.Port
}
c.User = url.QueryEscape(c.User) // 用户名 URL 转义
c.Password = url.QueryEscape(c.Password) // 密码 URL 转义
uri := fmt.Sprintf("mongodb://%s:%s@%s/%s?authMechanism=%s", // 拼接完整 URI
c.User, c.Password, c.Address, c.Database, c.Mechanism)
return uri
}
注意 url.QueryEscape 对用户名/密码做了 URL 转义,避免特殊字符(如 @、%、:)导致 URI 解析失败。
本章小结
- 配置契约:MongoConf 包含连接池、URI、副本集名、Socket 超时、TLS 等
- 关键配置:RetryWrites=false 是 CMDB 自研事务链路的核心前提
- 硬编码:maxConnIdleTime = 25 * time.Minute — 超过 25 分钟空闲连接自动回收
- 动态步长:从 cc_ConfigAdmin 读 idGenStep,无需重启可调
- URI 转义:BuildURI 用 url.QueryEscape 防止用户名密码特殊字符破坏 URI
六、监控埋点:mtc 模块的指标采集
What — mtc 是 dal 层的统一监控入口吗?
src/storage/dal/mongo/local/metric.go 实现了 dal 层的 Prometheus 监控埋点。它把所有 DB 操作归类为 11 种操作类型,采集 3 类指标:
- 操作计数:mongo_total_operate_count(CounterVec)
- 错误计数:mongo_total_error_count(CounterVec)
- 操作耗时:mongo_operate_duration_seconds(HistogramVec)
Why — 为什么需要埋点而不仅仅靠日志?
问题一:可观测性。CMDB 是高并发元数据服务,仅靠 grep 日志无法实时了解每个集合的健康度、热点表、慢查询分布。
问题二:SLA 监控。Prometheus 指标可以接入告警系统,自动发现 P99 延迟劣化、错误率飙升等问题。
问题三:容量规划。Histogram 分桶让运维能直接看到 P95/P99 在哪一档。
没有埋点会发生什么?
- 故障定位只能依赖业务报错日志和 trace,无法按集合/操作类型聚合
- Prometheus 抓不到 dal 层指标,监控大盘缺失关键数据
- 容量规划无法量化,只能凭经验扩容
src/storage/dal/mongo/local/metric.go 第 24-56 行的 initMongoMetric:
func initMongoMetric() {
once.Do(func() { // sync.Once 保证全局单例
mtc = new(mongoMetric)
// 指标 1:总操作计数
mtc.totalOperCount = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: metrics.Namespace,
Subsystem: "mongo",
Name: "total_operate_count",
Help: "the total operate count with mongodb",
}, []string{"collection", "operation"}) // 两个标签维度
metrics.Register().MustRegister(mtc.totalOperCount) // 注册到 Prometheus
// 指标 2:总错误计数
mtc.totalErrorCount = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: metrics.Namespace,
Subsystem: "mongo",
Name: "total_error_count",
Help: "the total operate error count with mongodb",
}, []string{"collection", "operation"})
metrics.Register().MustRegister(mtc.totalErrorCount)
// 指标 3:操作耗时(Histogram)
mtc.operDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Namespace: metrics.Namespace,
Subsystem: "mongo",
Name: "operate_duration_seconds",
Help: "the cost second duration with one mongodb operation",
Buckets: []float64{0.02, 0.04, 0.06, 0.08, 0.1, 0.3, 0.5, 0.7, 1, 5, 10, 20, 30, 60},
}, []string{"collection", "operation"})
metrics.Register().MustRegister(mtc.operDuration)
})
}
桶分布设计:Histogram 的桶为 [0.02, 0.04, 0.06, 0.08, 0.1, 0.3, 0.5, 0.7, 1, 5, 10, 20, 30, 60] 秒。前几个桶(0.02-0.1)密集分布,适合追踪 CMDB 这类高频低延迟的元数据操作;后续 0.3-60 覆盖慢查询场景。
src/storage/dal/mongo/local/metric.go 第 60-72 行的 11 类操作枚举:
const (
findOper oper = "find" // 查询
insertOper oper = "insert" // 插入
updateOper oper = "update" // 更新
upsertOper oper = "upsert" // upsert
deleteOper oper = "delete" // 删除
distinctOper oper = "distinct" // distinct
aggregateOper oper = "aggregate" // 聚合
countOper oper = "count" // count
columnOper oper = "column" // 字段操作(4 个方法共用)
indexCreateOper oper = "create_index" // 索引创建
indexDropOper oper = "drop_index" // 索引删除
)
11 类操作类型,覆盖所有 dal 层方法。注意 columnOper 把 4 个字段操作(AddColumn/RenameColumn/DropColumn/DropColumns)合并为一类。
src/storage/dal/mongo/local/metric.go 第 83-114 行的 3 个采集函数:
func (m *mongoMetric) collectOperCount(collection string, operation oper) {
if m == nil { // 空指针保护
return // 单测场景可能 mtc 未初始化
}
m.totalOperCount.With(prometheus.Labels{
"collection": collection, // 集合名标签
"operation": string(operation), // 操作类型标签
}).Inc()
}
func (m *mongoMetric) collectErrorCount(collection string, operation oper) {
if m == nil { // 空指针保护
return
}
m.totalErrorCount.With(prometheus.Labels{
"collection": collection,
"operation": string(operation),
}).Inc()
}
func (m *mongoMetric) collectOperDuration(collection string, operation oper, duration time.Duration) {
if m == nil { // 空指针保护
return
}
m.operDuration.With(prometheus.Labels{
"collection": collection,
"operation": string(operation),
}).Observe(duration.Seconds()) // 记录耗时(秒)
}
空指针保护:3 个方法都有 if m == nil { return },保证单元测试场景(未初始化 mtc)下不会 panic。
以 src/storage/dal/mongo/local/mongo.go 的查询 Find(行号 446 起)为例展示标准埋点模式:
func (f *Find) All(ctx context.Context, result interface{}) error {
mtc.collectOperCount(f.collName, findOper) // 入口:操作计数 +1
rid := ctx.Value(common.ContextRequestIDField)
start := time.Now()
defer func() {
mtc.collectOperDuration(f.collName, findOper, time.Since(start)) // defer:耗时记录
}()
// 省略:实际查询逻辑、cursor 操作、错误处理中的 collectErrorCount
}
固定模式:方法入口 collectOperCount,defer 收尾 collectOperDuration。出错路径再 collectErrorCount。
本章小结
- 3 类指标:totalOperCount、totalErrorCount、operDuration
- 11 类操作:find / insert / update / upsert / delete / distinct / aggregate / count / column / create_index / drop_index
- 二维标签:{collection, operation},可下钻到任意集合+操作组合
- 14 个分桶:密集分布在 0.02-0.1 秒,覆盖高频低延迟场景
- 空指针保护:if m == nil 保护单测场景
七、FAQ(20 组)
FAQ — 精选 20 问,深入理解 storage/dal 层 MongoDB 实现的设计细节
Q1. CMDB 为什么关闭 MongoDB driver 的 RetryWrites?
CMDB 自研事务链路依赖 txnNumber 自管理重试语义。若 driver 也开启 RetryWrites,driver 会把 "事务号已用过的事务" 作为新事务重发,导致 MongoDB 服务端拒绝。src/storage/dal/mongo/local/mongo.go 第 89-91 行明确注释:"do not change this, our transaction plan need it to false"。
Q2. NextSequence 为什么强制用 context.Background()?
避免被上层事务 session 干扰,防止产生相同的序列号。src/storage/dal/mongo/local/mongo.go 第 830 行注释写得很清楚。若 NextSequence 在事务内调用,driver 会复用同一个 session,导致 $inc 操作的隔离级别与预期不符。
Q3. CommitTransaction 如何保证幂等?
通过 Redis 中的事务号做 "是否有事务需提交" 判断。若 Redis 中查不到 txnNumber 或 txnNumber==0,说明本次请求没有任何 DB 事务操作,直接 skip 提交,避免 MongoDB 抛出 NoSuchTransaction 错误。
Q4. AbortTransaction 返回的 bool 是什么?
重试信号。当事务因 WriteConflict 失败时返回 true,告诉上层调用方可以重试。其他错误返回 false,不应重试。判断依据是 setTxnError 写入 Redis 的 WriteConflictType 标记。
Q5. AddColumn 不会覆盖已有字段吗?
不会。filter 是 {column: {$exists: false}},只有不包含该字段的文档会被更新。这是 MongoDB 的 “幂等添加字段” 模式,符合 DDL 的语义。
Q6. AggregateOne 与 AggregateAll 怎么选?
AggregateOne 只取第一条;AggregateAll 取全部到 slice。前者失败时返回 ErrDocumentNotFound;后者支持 AllowDiskUse 处理大数据量聚合。
Q7. idGenStep 的值从哪里来?
从 cc_ConfigAdmin 配置表中读取。NewMgo 启动时调用 initIDGenerator()(src/storage/dal/mongo/local/mongo.go 第 145 行起)从 _id = common.ConfigAdminID 的文档中读取 idGenerator.step 字段,允许运维不重启动态调整。
Q8. 为什么 Connect 字段优先于 Address/Port/User/Password?
支持运维场景的 “完整 URI 直连”。BuildURI(src/storage/dal/mongo/config.go 第 64 行)首行就是 if c.Connect != "" { return c.Connect }。当 Connect 非空时,所有其他连接参数被忽略,便于运维临时切换数据库而不修改结构化字段。
Q9. GenTxnCableAndSetHeader 为什么强制 30s 最小超时?
避免过短的超时配置导致事务失败。src/storage/dal/mongo/local/txn_manager.go 第 314 行 if opts[0].Timeout < 30*time.Second { timeout = common.TransactionDefaultTimeout },防止业务代码误传小超时值。
Q10. CmdbReloadSession 的作用是什么?
通过 MongoDB driver 提供的反射机制,把 SessionID 和 txnNumber 注入 session 内部。这样 driver 后续的所有操作都会带上正确的 txnNumber 字段,服务端用它来判断是新事务还是重试。
Q11. CmdbPrepareCommitOrAbort 的作用是什么?
重置 MongoDB driver 的事务状态机。Commit/Abort 前必须先重置状态,才能正确调用 driver 的 Commit/Abort,否则会因状态错乱抛错。
Q12. cmdb_idgenerator 集合的 _id 是什么?
_id 是序列号名(如 bk_host_id、bk_biz_id)。每种 ID 序列用一个文档,SequenceID 字段保存当前计数器值,通过 $inc 操作原子递增。
Q13. 分片表的 ID 怎么映射?
通过 redirectTable 把分片表名归一为基础表名。cc_ObjectBase_xxx 这种分片表的 ID 请求会统一打到 cc_ObjectBase 序列号,避免每个分片维护独立计数器。
Q14. 监控的 collection 标签对应什么?
对应 MongoDB 集合名(如 cc_ObjectBase、cc_HostBase)。运维通过 {collection="cc_ObjectBase", operation="find"} 这样的标签组合就能下钻到具体集合的查询性能。
Q15. 11 类 oper 都覆盖了哪些方法?
find/insert/update/upsert/delete/distinct/aggregate/count/column/create_index/drop_index。其中 columnOper 合并了 AddColumn/RenameColumn/DropColumn/DropColumns 这 4 个字段操作为一类。
Q16. Histogram 桶为什么前几个密集?
适合追踪高频低延迟场景。[0.02, 0.04, 0.06, 0.08, 0.1, ...] 前几个桶密集分布,因为 CMDB 的元数据操作大多数在 100ms 内完成,需要精细粒度。
Q17. mtc 何时初始化?
通过 sync.Once 在包加载时初始化。src/storage/dal/mongo/local/metric.go 第 26 行的 once.Do(func(){...}) 保证全局只有一个 mongoMetric 实例,避免重复注册 Prometheus 指标。
Q18. 事务失败时的清理?
Commit/Abort 后会调用 RemoveSessionKey 清理 Redis。src/storage/dal/mongo/local/transaction.go 第 63 / 94 行。如果清理失败也只 log 不返回错误,因为 Redis key 有 TTL,过期后自动删除。
Q19. AutoRunWithTxn 不使用事务时会怎样?
直接走非事务路径,性能开销最小。src/storage/dal/mongo/local/txn_manager.go 第 238-241 行 if !useTxn { return cmd(ctx) }。普通 HTTP 调用无事务头时直接执行,不创建 session。
Q20. MongoConf 与 Config 的关系?
Config 是 yaml 配置反序列化结构;MongoConf 是传给 driver 的入参。src/storage/dal/mongo/config.go 第 81-92 行 GetMongoConf() 把 Config 转成 MongoConf,前者面向配置反序列化,后者面向驱动入参。两层结构解耦,配置解析和驱动构建互不影响。
FAQ 总结
- 事务:AutoRunWithTxn 自动接管、Redis 事务号协调、幂等提交、WriteConflict 重试信号
- ID 生成器:$inc 原子计数 + context.Background() 隔离 + 步长可配置
- 字段操作:$exists/$rename/$unset 三操作符实现 DDL 语义
- 配置加载:RetryWrites=false + 25 分钟空闲回收 + URI 优先
- 监控埋点:3 指标 × 11 类操作 × 二维标签 + 空指针保护
八、Roadmap 预告
下篇预告:《#24 深入 services/server:服务层架构》
在下一篇中,我们将走出 storage/dal 层,向上探索蓝鲸 CMDB 的服务层:
- HTTP/gRPC Server 启动流程:Engine 注册与中间件链
- 服务注册与发现:与 Zookeeper/Consul 的集成
- 请求生命周期:从路由匹配到响应序列化
- 错误处理:统一的错误码体系与国际化
- 限流熔断:在高并发场景下的稳定性保障
敬请期待!

浙公网安备 33010602011771号