蓝鲸 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 异常,导致请求失败
源码视角一:事务标识生成(GenSessionID)

事务标识由 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."

源码视角二:HTTP Header 注入(GenTxnCableAndSetHeader)

随后 GenTxnCableAndSetHeadersrc/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 强制使用默认值,避免过短的超时配置导致事务失败。

源码视角三:事务自动开启(AutoRunWithTxn)

当业务代码执行 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 中解析 TransactionIdHeaderTransactionTimeoutHeader;不存在则走非事务路径
  • PrepareTransaction:开启 MongoDB Session 并启动 Transaction
  • CmdbContextWithSession:将 session 注入到 ctx 中,使 MongoDB driver 把后续操作视为事务内的操作
源码视角四:PrepareTransaction 内部细节

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 字段。

源码视角五:事务号生成(GenTxnNumber)

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。

源码视角六:事务提交(CommitTransaction)

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 错误的关键防御。

源码视角七:事务回滚(AbortTransaction)

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 失败,setTxnErrorsrc/storage/dal/mongo/local/txn_manager.go 第 266-277 行)已经把 WriteConflictType 写入 Redis
  • GetTxnError 读取这个标记并返回 true,告诉上层调用方:"这次失败是因为写入冲突,请重试"
  • 其他错误返回 false,调用方不应再重试

本章小结

  • 事务常量TransactionIdHeaderTransactionTimeoutHeader 作为 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)
源码视角一:NextSequence 单个 ID 生成

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 返回递增后的新值,保证原子性
源码视角二:批量 ID 生成(NextSequences)

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 计数器集合的写次数,缓解热点问题。

源码视角三:序列号的表名归一化(redirectTable)

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 拼接,散落各处难以统一管理
源码视角一:AddColumn 实现

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 语义。

源码视角二:RenameColumn 实现

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 可以为空(重命名所有文档)。

源码视角三:DropColumn 与 DropColumns

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 关闭逻辑
源码视角一:AggregateOne 实现

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

源码视角二:AggregateAll 与 AllowDiskUse

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.goNewMgo 构造 mongo.Client
  • ID 生成器初始化:构造完成后从 cc_ConfigAdmin 集合读取 idGenStep

Why — 为什么配置与客户端构造要解耦?

问题一:运维场景的临时切换。当 MongoDB 出现故障时,运维需要快速使用新连接串重建连接。不修改结构化字段,直接传完整 URI 更灵活。

问题二:CMDB 自研事务链路的前提。第 89-91 行注释明确说 RetryWrites 必须为 false,因为 driver 自带的写重试机制会与 CMDB 的 txnNumber 重试语义冲突。

没有连接配置会发生什么?

  • 连接串、所有连接参数硬编码在源码中,无法适配不同环境(test/staging/prod)
  • driver 自带的 RetryWrites 与 TxnManager 重试逻辑会冲突,导致事务号错乱
  • ID 生成器步长无法动态调整,热点集合的写压力无法缓解
源码视角一:MongoConf 配置结构体

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 配置
}
源码视角二:NewMgo 连接建立

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 自研事务链路的核心前提。

源码视角三:ID 生成器步长初始化

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 步长

源码视角四:URI 构造(BuildURI)

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_ConfigAdminidGenStep,无需重启可调
  • URI 转义BuildURIurl.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 层指标,监控大盘缺失关键数据
  • 容量规划无法量化,只能凭经验扩容
源码视角一:3 类指标定义

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。

源码视角四:典型调用模式(Find.All)

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
}

固定模式:方法入口 collectOperCountdefer 收尾 collectOperDuration。出错路径再 collectErrorCount

本章小结

  • 3 类指标totalOperCounttotalErrorCountoperDuration
  • 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 直连”BuildURIsrc/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_idbk_biz_id。每种 ID 序列用一个文档,SequenceID 字段保存当前计数器值,通过 $inc 操作原子递增。

Q13. 分片表的 ID 怎么映射?

通过 redirectTable 把分片表名归一为基础表名cc_ObjectBase_xxx 这种分片表的 ID 请求会统一打到 cc_ObjectBase 序列号,避免每个分片维护独立计数器。

Q14. 监控的 collection 标签对应什么?

对应 MongoDB 集合名(如 cc_ObjectBasecc_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 清理 Redissrc/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 的集成
  • 请求生命周期:从路由匹配到响应序列化
  • 错误处理:统一的错误码体系与国际化
  • 限流熔断:在高并发场景下的稳定性保障

敬请期待!


posted @ 2026-07-14 19:44  左扬  阅读(16)  评论(0)    收藏  举报