蓝鲸 CMDB 3.14.6 源码专题【左扬精讲】— CMDB #19 主机锁定:并发控制与分布式锁

蓝鲸 CMDB 3.14.6 源码专题【左扬精讲】— CMDB #19 主机锁定:并发控制与分布式锁

当两个 SRE 同时点开同一台主机的属性编辑页面,后保存的人会无声地覆盖先保存的人——这是 CMDB 高频踩坑之一。

本篇以 v3.14.6 实测源码为主线,把 "主机锁定" 拆成两套互不替代的并发控制:UI 层的 cc_HostLock 表灰锁 + 写入层的 Redis Locker / MLocker 硬互斥。

必须掌握

  • UI 灰锁 vs Redis 硬锁的边界:前者给前端 "显示谁在看",后者给 backend "防止并发改写"。
  • cc_HostLock 的 4 字段模型:无 TTL,纯人工 unlock。
  • lock.NewLocker + SetNX 的三段语义合一:原子存在性判断 + 写值 + 过期时间。
  • lock.NewMLocker 的 TxPipeline 语义:多键 SetNX 同时抢,任一失败回滚已成功。
  • 6 大真实使用场景:create-model、create-attr、create-asst-inst、transfer 全量同步、cache 重建、IAM 注册。
  • RefreshingLockLocker 的差异:进程内 map+sync.Mutex vs 跨节点 Redis。

了解即可

  • Key 模板与 Redis 前缀:BKCacheKeyV3Prefix + 业务域:动作:资源。
  • 进程内复用的 isFirst 防误重入

v3.14.621 个真实源码锚点UI 灰锁 vs Redis 硬锁cc_HostLockLocker / MLockerSetNX + xidFullSyncLockKeyRefreshingLocktryLockRegister

       src/common/metadata/hostlock.go                                    ← HostLockRequest / HostLockData 数据模型
            src/source_controller/coreservice/core/host/lock.go               ← LockHost / UnlockHost / QueryHostLock / diffHostLockID
            src/scene_server/host_server/service/hostlock.go                  ← UI 层 handler:LockHost / UnlockHost / QueryHostLock
            src/scene_server/host_server/logics/hostlock.go                   ← Scene 层 logic:AuthorizeByHostsIDs + AutoRunTxn 包装
            src/scene_server/host_server/service/service_initfunc.go          ← 路由注册 /host/lock、/host/lock/search
            src/common/tablenames.go                                          ← BKTableNameHostLock = "cc_HostLock" (L80)
            src/common/index/collections/hostlock.go                          ← commHostLockIndexes / deprecatedHostLockIndexes
            src/common/lock/lock.go                                           ← Locker / MLocker 接口 + lock / mlock 实现 + Unlock / MUnlock
            src/common/lock/lock_key.go                                       ← CreateModelFormat / CreateModuleAttrFormat key 模板
            src/source_controller/coreservice/core/model/model.go             ← CreateTableModel / CreateModel 入口加锁
            src/source_controller/coreservice/core/model/attribute.go         ← CreateTableModelAttributes / CreateModelAttributes 加锁
            src/source_controller/coreservice/core/association/instance.go    ← 1v1 MLock + 1vN Locker 关联互斥
            pkg/synchronize/types/syncer.go                                   ← FullSyncLockKey 常量定义
            src/source_controller/transfer-service/sync/dest_full_sync.go     ← 目标端全量同步 master guard
            src/source_controller/transfer-service/sync/src_full_sync.go      ← 源端全量同步 master guard
            src/source_controller/transfer-service/sync/client.go             ← full_sync 接口入口加锁
            src/source_controller/cacheservice/cache/custom/cache/label.go    ← RefreshPodLabel single-flight
            src/source_controller/cacheservice/cache/custom/cache/shared_ns_rel.go ← RefreshSharedNsRel single-flight
            src/source_controller/cacheservice/cache/tools/lock.go            ← RefreshingLock 进程内互斥
            src/ac/iam/iam.go                                                 ← tryLockRegister IAM 注册互斥
            src/ac/iam/types.go                                               ← RegisterIamLock 常量
            

目录

  1. CMDB 里的"主机锁定"到底锁什么——双层并发控制的总体定位
  2. 第一层:UI 灰锁 cc_HostLock 表结构与索引
  3. 第一层:UI 灰锁 HTTP 路由与事务边界
  4. 第一层:UI 灰锁核心服务层 host/lock.go 逐函数拆解
  5. 第二层:Redis 分布式锁 Locker / MLocker 接口与 key 模板
  6. 第二层:Locker 单键锁 — SetNX + xid 防误删
  7. 第二层:MLocker 多键锁 — TxPipeline + 部分成功回滚
  8. 真实场景 1:CreateTableModel 加锁 — coreservice:create:model:<ObjectID>
  9. 真实场景 2:CreateModelAttributes 加锁 — coreservice:create:model:<oid>:attr:<pid>
  10. 真实场景 3:关联实例互斥 — MLock(InstID, AsstInstID, ObjectAsstID)
  11. 真实场景 4:transfer-service 全量同步 master guard
  12. 真实场景 5:cacheservice 缓存重建 single-flight
  13. 真实场景 6:IAM 注册重试
  14. 进程内互斥:RefreshingLockLocker 的差异
  15. 关键 key 模板与 Redis 前缀
  16. 常见反模式与避坑清单
  17. 可观测性:日志、rid 串联与监控指标
  18. FAQ:20 组高频疑问
  19. Roadmap — 下一篇预告

1. CMDB 里的"主机锁定"到底锁什么

What —— 这里讨论的"锁"分哪两层?

在 v3.14.6 CMDB 源码里,"主机锁定" 其实指代两个完全不同的并发机制,它们作用对象不同、作用时间不同、作用范围也不同:

  • UI 灰锁:MongoDB 表 cc_HostLock(src/common/tablenames.go 第 80 行定义)的轻量级 row-level 标记,仅给前台"显示给用户看"用。
  • Redis 硬锁:src/common/lock 包(实文件 src/common/lock/lock.go)下的 Locker / MLocker,用于 backend 高危写入路径的跨节点互斥。

Why —— 为什么必须双层?

只保留 UI 灰锁的话:它只是前端提示符,任何 Update 权限用户都能 DELETE /host/lock,并不能阻止 A、B 两端同时写入同一台机器的属性——双写冲突依旧会发生。

只保留 Redis 硬锁的话:前端再也拿不到 "谁正在看这台机器" 的元信息,锁图标凭空消失,运营同事会以为系统没在同步。

源码视角 · 两条线为何不可替代 

源码视角一:UI 灰锁是"提示"不是"权限闸门"

src/source_controller/coreservice/core/host/lock.go 第 86-98 行 UnlockHost:

func (hm *hostManager) UnlockHost(kit *rest.Kit, input *metadata.HostLockRequest) errors.CCError {
                conds := mapstr.MapStr{
                    common.BKHostIDField: mapstr.MapStr{common.BKDBIN: input.IDS},
                }
                conds = util.SetModOwner(conds, kit.SupplierAccount)
                err := mongodb.Client().Table(common.BKTableNameHostLock).Delete(kit.Ctx, conds)
                if nil != err {
                    blog.Errorf("unlock host, delete host lock from db error, err: %+v, rid:%s", err, kit.Rid)
                    return kit.CCError.CCErrorf(common.CCErrCommDBDeleteFailed)
                }
                return nil
            }

它只做了一次 bk_host_id IN [...]Delete,完全不校验解锁者是不是"加锁者本人"。这是个有意识的设计——授权闸门是 ac.AuthManager.AuthorizeByHostsIDs,UI 锁只承担"软提示"。

源码视角二:Redis 硬锁是"写入互斥"

src/common/lock/lock.go 第 60-75 行 lock.Lock 与第 35-39 行的 Locker 接口:

// Locker redis atomic lock
            type Locker interface {
                Lock(key StrFormat, expire time.Duration) (locked bool, err error)
                Unlock() error
            }
            
            func (l *lock) Lock(key StrFormat, expire time.Duration) (locked bool, err error) {
                if l.isFirst {
                    return false, fmt.Errorf("repeat lock")
                }
                l.isFirst = true
                l.key = fmt.Sprintf("%s%s", common.BKCacheKeyV3Prefix, key)
            
                uuid := xid.New().String()
                locked, err = l.cache.SetNX(context.Background(), l.key, uuid, expire).Result()
                if locked {
                    l.needUnlock = true
                }
                return locked, err
            }

关键点是 SetNX(key, uuid, expire):SET key val NX EX ttl 一次 Redis round-trip 原子地完成"判断 key 不存在 + 写入值 + 设置过期",这才是真正能阻止并发改写的机制。

避坑提醒(源码视角):

  • 不要把 UI 灰锁当写入互斥闸门:它的 UnlockHost 不验身份,任何有 Update 权限的用户都能解。生产中任何"我能锁就能写"的预期都会破。
  • 不要把 Redis 锁当前端显示依赖:Redis 锁的过期时间是常数(如 35s),过期了图标就会"消失",运维同事会以为系统怪——所以才有了 UI 灰锁这一层做兜底显示。

先记住一句话:CMDB 的并发控制是 UI 灰锁 + Redis 硬锁双闸门,前者给用户看,后者给 backend 写入防双写。下面 2-4 章讲 UI 层,5-7 章讲 Redis 层,8-13 章是真实场景。

2. 第一层:UI 灰锁 cc_HostLock 表结构与索引

What —— cc_HostLock 是一个什么性质的 MongoDB 表?

它是 v3.14.6 里专门为 "前端显示用的锁标记" 建的一张独立表(注意不是字段加在主机表上),只有 4 个字段,无 TTL,无版本号——是一个 显式 unlock 才解除 的纯人工模式表。

Why —— 为什么不把锁状态直接挂在 cc_HostBase 主机表上?

把锁挂主机表会带来 3 个问题:

  • 写入风暴:每个 SRE 进编辑页面要更新主机表,与属性写入冲突。
  • 审计污染:cc_HostBase 的变更会触发 cc_AuditLog,UI 锁标记根本不是业务事件。
  • 租户隔离复杂:主机表本身有 bk_supplier_account + bk_biz_id 复合隔离,锁表只要单租户字段就够。

没有 UI 灰锁会发生什么?

  • 前端拿不到 "谁在编辑这台机器" 的显示元数据,锁图标全部不显示。
  • 要查 "这台机器被谁看着" 反而要去翻 auditlog,运维查询体验大幅下降。

2.1 数据模型(src/common/metadata/hostlock.go L38-43)

// HostLockData TODO
            type HostLockData struct {
                User       string    `json:"bk_user"          bson:"bk_user"`
                ID         int64     `json:"bk_host_id"       bson:"bk_host_id"`
                CreateTime time.Time `json:"create_time"      bson:"create_time"`
                OwnerID    string    `json:"-"                bson:"bk_supplier_account"`
            }
            
            // HostLockRequest TODO
            type HostLockRequest struct {
                IDS []int64 `json:"id_list"`
            }

4 字段极简:bk_user 是谁锁的,bk_host_id 锁了哪台机器,create_time 时间戳,bk_supplier_account 租户隔离。没有任何 expire_atversionheartbeat——行级锁设计是 "显式 unlock 才解除" 的纯人工模式,这是有意识的取舍:让锁的语义等同于 "这条记录的存在"而不是 "这条记录 + 当前时间窗"

2.2 表名常量(src/common/tablenames.go L80)

BKTableNameHostLock = "cc_HostLock"

2.3 索引设计(src/common/index/collections/hostlock.go L30-43)

//  新加和修改后的索引,索引名字一定要用对应的前缀
            var commHostLockIndexes = []types.Index{}
            
            // deprecated 未规范化前的索引,只允许删除不允许新加和修改
            var deprecatedHostLockIndexes = []types.Index{
                {
                    Name: "bk_host_id_1",
                    Keys: bson.D{{
                        "bk_host_id", 1},
                    },
                    Background: true,
                },
            }

实测的 commHostLockIndexes = []types.Index{} 是空列表——意味着索引迁移完毕,bk_host_id_1 被移入 deprecatedHostLockIndexes,由注册器在 admin-server 启动时按"先删旧再加新"的兼容流程处理。

2.4 升级器路径

真正的 on-the-fly 建表来自 src/scene_server/admin_server/upgrader/y3.9.202010211805/add_host_lock_table.go。该 Upgrader 做了两件事:addHostLockTable 通过 HasTable + CreateTable 幂等创建,再加 CreateIndex(bk_host_id_1, non-unique, background=true)

索引设计小提示:background: true 是为兼容 3.x→3.x 大表在线加索引场景;生产环境的真实索引通常在 src/common/index/collections/hostlock.go 第 32 行 commHostLockIndexes 里挂载,由 admin-server 启动时统一 init 注册。

3. 第一层:UI 灰锁 HTTP 路由与事务边界

What —— 路由是怎么注册进 host_server 的?

src/scene_server/host_server/service/service_initfunc.go 第 239-241 行:

utility.AddHandler(rest.Action{Verb: http.MethodPost,   Path: "/host/lock",         Handler: s.LockHost})
            utility.AddHandler(rest.Action{Verb: http.MethodDelete, Path: "/host/lock",         Handler: s.UnlockHost})
            utility.AddHandler(rest.Action{Verb: http.MethodPost,   Path: "/host/lock/search",  Handler: s.QueryHostLock})

三个动作严格对齐前端的"批量加锁/解锁/批量查询已锁状态"。

Why —— 为什么要 AutoRunTxn 包事务?

src/scene_server/host_server/service/hostlock.go 第 56-63 行:

txnErr := s.Engine.CoreAPI.CoreService().Txn().AutoRunTxn(ctx.Kit.Ctx, ctx.Kit.Header, func() error {
                err := s.Logic.LockHost(ctx.Kit, input)
                if nil != err {
                    blog.Errorf("lock host, handle host lock error, error:%s, input:%+v,rid:%s", err.Error(), input, ctx.Kit.Rid)
                    return err
                }
                return nil
            })
            if txnErr != nil {
                ctx.RespAutoError(txnErr)
                return
            }
            ctx.RespEntity(nil)
  • 副本集事务一致性:在 MongoDB replica set 下,ReplSet 上的 AutoRunTxn 底层用 session.WithTransaction 包起来,所有 Find/Insert 都落到同一个 session。
  • 幂等请求可重试:AutoRunTxn 自带重试 + 临时错误分类,网络抖动场景会自动重试,不会让客户端看到脏结果。

没有事务边界会发生什么?

  • 多次 IO 中任何一个失败,都会留下一致的锁标记——前端看到的是"半成功",脏数据无法自愈。

handler 层的标准模式:以 LockHost 为例(src/scene_server/host_server/service/hostlock.go 第 25-70 行):

  1. ctx.DecodeInto(&input) 反序列化 HostLockRequest
  2. id_listCCErrCommParamsNeedSet
  3. AuthorizeByHostsIDs(..., meta.Update, ...) 鉴权
  4. AutoRunTxn(...) 包事务,把 s.Logic.LockHost(ctx.Kit, input) 包进来
  5. 无论成功失败,RespEntity(nil)RespAutoError

4. 第一层:UI 灰锁核心服务层 host/lock.go 逐函数拆解

位置 src/source_controller/coreservice/core/host/lock.go,共 135 行,包含三个公开函数 + 一个私有 helper,每个函数逐一拆解如下。

What —— 这一层做了什么?

它是 UI 层 handler 调用的真实"服务层",负责操作 cc_HostLock 表里的"该主机有没有人锁"的 row-level 标记。它的语义是 "写一行代表锁存在,删一行代表锁解除",而不是"判断锁是否存在"。

Why —— 为什么不是 handler 自己直连 MongoDB?

  • 架构边界:handler 不应直接动 Mongo,core 层是 UI 与存储的中介。
  • 事务封装:handler 那一层 AutoRunTxn 把 core 调用包进事务。
  • 租户隔离:每个 core 函数都通过 util.SetQueryOwner / SetModOwner 注入 bk_supplier_account,防止跨租户误删。

4.1 LockHost(L31-L83)

func (hm *hostManager) LockHost(kit *rest.Kit, input *metadata.HostLockRequest) errors.CCError {
                input.IDS = util.IntArrayUnique(input.IDS)
                condition := mapstr.MapStr{
                    common.BKHostIDField: mapstr.MapStr{common.BKDBIN: input.IDS},
                }
                condition = util.SetQueryOwner(condition, kit.SupplierAccount)
                hostInfos := make([]metadata.HostMapStr, 0)
                limit := uint64(len(input.IDS))
                err := mongodb.Client().Table(common.BKTableNameBaseHost).Find(condition).
                    Fields(common.BKHostIDField).Limit(limit).All(kit.Ctx, &hostInfos)
                if nil != err {
                    blog.Errorf("lock host, query host from db error, condition: %+v, err: %+v, rid: %s", condition, err, kit.Rid)
                    return kit.CCError.Errorf(common.CCErrCommDBSelectFailed)
                }
            
                diffID := diffHostLockID(input.IDS, hostInfos, kit.Rid)
                if 0 != len(diffID) {
                    blog.Errorf("lock host, not found, id: %+v, rid: %s", diffID, kit.Rid)
                    return kit.CCError.Errorf(common.CCErrCommParamsIsInvalid, fmt.Sprintf(" id_list %v", diffID))
                }
            
                user := httpheader.GetUser(kit.Header)
                var insertDataArr []interface{}
                ts := time.Now().UTC()
                for _, id := range input.IDS {
                    conds := mapstr.MapStr{
                        common.BKHostIDField: id,
                    }
                    conds = util.SetQueryOwner(conds, kit.SupplierAccount)
                    cnt, err := mongodb.Client().Table(common.BKTableNameHostLock).Find(conds).Count(kit.Ctx)
                    if nil != err {
                        blog.Errorf("lock host, query host lock from db failed, err:%+v, rid:%s", err, kit.Rid)
                        return kit.CCError.Errorf(common.CCErrCommDBSelectFailed)
                    }
                    if 0 == cnt {
                        insertDataArr = append(insertDataArr, metadata.HostLockData{
                            User:       user,
                            ID:         id,
                            CreateTime: ts,
                            OwnerID:    httpheader.GetSupplierAccount(kit.Header),
                        })
                    }
                }
            
                if 0 < len(insertDataArr) {
                    err := mongodb.Client().Table(common.BKTableNameHostLock).Insert(kit.Ctx, insertDataArr)
                    if nil != err {
                        blog.Errorf("lock host, save host lock to db failed, err: %+v, rid:%s", err, kit.Rid)
                        return kit.CCError.Errorf(common.CCErrCommDBInsertFailed)
                    }
                }
                return nil
            }

逻辑步骤:

  1. input.IDS = util.IntArrayUnique(input.IDS) —— 入口去重
  2. bk_host_id IN [...]cc_HostBase(也就是 cc_HostBase 不是 cc_HostLock),只取 host_id 字段(Fields 优化网络)
  3. diffHostLockID 计算"请求 ID 中不存在于 db"的差集,差集非空 → CCErrCommParamsIsInvalid
  4. 对每个 host_id 单独 Find(conds).Count() 检查 cc_HostLock,未锁过才追加到 insertDataArr
  5. 聚合 mongodb.Insert(cc_HostLock, insertDataArr)

幂等性说明:同一个 host_id 二次 LockHost 时不会插入重复行(MongoDB 等价于 INSERT IGNORE),这就是为何它能多次被"覆盖加锁"而不破坏一致性。

4.2 UnlockHost(L86-L98)

func (hm *hostManager) UnlockHost(kit *rest.Kit, input *metadata.HostLockRequest) errors.CCError {
                conds := mapstr.MapStr{
                    common.BKHostIDField: mapstr.MapStr{common.BKDBIN: input.IDS},
                }
                conds = util.SetModOwner(conds, kit.SupplierAccount)
                err := mongodb.Client().Table(common.BKTableNameHostLock).Delete(kit.Ctx, conds)
                if nil != err {
                    blog.Errorf("unlock host, delete host lock from db error, err: %+v, rid:%s", err, kit.Rid)
                    return kit.CCError.CCErrorf(common.CCErrCommDBDeleteFailed)
                }
                return nil
            }

非常简单:cc_HostLockbk_host_id IN [...] 一把 delete。没有 status / owner 校验——意味着任何有写权限的用户都能解别人的锁,这是 UI 灰锁特有的让步,因为它的目标只是"提示",不是真实的权限闸门。

4.3 QueryHostLock(L101-L115)

func (hm *hostManager) QueryHostLock(kit *rest.Kit, input *metadata.QueryHostLockRequest) ([]metadata.HostLockData,
                errors.CCError) {
                hostLockInfoArr := make([]metadata.HostLockData, 0)
                conds := mapstr.MapStr{
                    common.BKHostIDField: mapstr.MapStr{common.BKDBIN: input.IDS},
                }
                conds = util.SetModOwner(conds, kit.SupplierAccount)
                limit := uint64(len(input.IDS))
                err := mongodb.Client().Table(common.BKTableNameHostLock).Find(conds).Limit(limit).All(kit.Ctx, &hostLockInfoArr)
                if nil != err {
                    blog.Errorf("query lock host, query host lock from db error, err: %+v, rid:%s", err, kit.Rid)
                    return nil, kit.CCError.CCErrorf(common.CCErrCommDBSelectFailed)
                }
                return hostLockInfoArr, nil
            }

cc_HostLockbk_host_id IN [...],返回 []HostLockData。前端会用它来在主机表格上绘制"锁图标 + 当前加锁人"。

4.4 diffHostLockID(L117-L135)

func diffHostLockID(ids []int64, hostInfos []metadata.HostMapStr, rid string) []int64 {
                mapInnerID := make(map[int64]bool)
                for _, hostInfo := range hostInfos {
                    id, err := util.GetInt64ByInterface(hostInfo[common.BKHostIDField])
                    if nil != err {
                        blog.ErrorJSON("different host lock ID not valid, hostInfo: %s, rid: %s", hostInfo, rid)
                        continue
                    }
                    mapInnerID[id] = true
                }
                var diffIDS []int64
                for _, id := range ids {
                    _, exist := mapInnerID[id]
                    if !exist {
                        diffIDS = append(diffIDS, id)
                    }
                }
                return diffIDS
            }

纯计算函数:把请求的 IDs 和 DB 里查到的 host_id 做集合差。它不读锁表,而是先确认这些 host_id 在主机表里确实存在——所以这个函数其实是数据存在性校验,不是"锁状态"校验。

本节总结:LockHost / UnlockHost / QueryHostLock 三函数 + 一个 diffHostLockID,行为是写一行代表锁存在,删一行代表锁解除,返回空数组代表"未被锁"。后续 Redis 锁讨论中会看到:UI 灰锁的语义和 Redis SetNX 的语义并不等价——前者以"行是否存在"为标准,后者以"键是否存在"为标准。

5. 第二层:Redis 分布式锁 Locker / MLocker 接口与 key 模板

What —— src/common/lock 包是 CMDB 所有 Redis 锁的归宿。

它由两个文件组成:

  • src/common/lock/lock.go:定义 Locker / MLocker 接口 + lock / mlock 实现 + 工厂函数 NewLocker / NewMLocker
  • src/common/lock/lock_key.go:定义所有 key 模板常量 + StrFormat 类型 + GetLockKey 工厂。

Why —— 为什么单独建一个 common/lock 子包?

锁是高复用基础设施。把 LOCK / UNLOCK 实现集中在一个包,所有上层业务只用关心 key 模板,锁本身是"无业务感知"的——这种关注点分离让权限校验、租户隔离、retry 退避、rid 串联全部可以放在这一层做。

没有 common/lock 会发生什么?

  • coreservice / cacheservice / ac/iam / transfer-service 各自维护一份 SetNX 代码,bug 修 N 次。
  • key 前缀、过期时间、重试节奏无法统一,排查"某个 key 怎么又被占"要查所有调用点。

5.1 接口签名(src/common/lock/lock.go L34-46)

// Locker redis atomic lock
            type Locker interface {
                // Lock can lock one
                Lock(key StrFormat, expire time.Duration) (locked bool, err error)
                Unlock() error
            }
            
            // MLocker TODO
            type MLocker interface {
                MLock(rid string, retrytimes int, expire time.Duration, values ...StrFormat) (locked bool, err error)
                MUnlock() error
                MPartialUnlock(keys []string) error
            }

关键特征:

  • Locker 单键,用 SetNX(key, uuid, expire) 一次性原子持有。
  • MLocker 多键,pipe.SetNX+pipe.Expire 组成 pipeline 抢多把锁,遇到任一冲突就回滚已成功的。
  • 两个接口都强调 rid 串联与 retry 退避。

5.2 统一 key 格式(src/common/lock/lock_key.go L20-38)

const (
                // CreateModelFormat create model user format
                CreateModelFormat     = "coreservice:create:model:%s"
            
                // CreateModuleAttrFormat create model  attribute format
                CreateModuleAttrFormat = "coreservice:create:model:%s:attr:%s"
            
                // CheckSetTemplateSyncFormat  检测集群模板同步的状态
                CheckSetTemplateSyncFormat = "topo:settemplate:sync:status:check:%d"
            )
            
            // StrFormat  build  lock key format
            type StrFormat string
            
            // GetLockKey build lock key
            func GetLockKey(format StrFormat, params ...interface{}) StrFormat {
                key := fmt.Sprintf(string(format), params...)
                return StrFormat(key)
            }

所有 key 都遵循 域:动作:资源 三段式。前缀 biz_module:op:resource,方便 redis-cli --scan --pattern '...:*' 按域管理。这套 StrFormat / GetLockKey 同时被 3.14.6 全栈引用(coreservice / cacheservice / transfer-service / ac/iam)。

Redis 前缀 BKCacheKeyV3Prefix:进入 Redis 之前所有 key 都会被 fmt.Sprintf("%s%s", common.BKCacheKeyV3Prefix, key) 拼上前缀(src/common/lock/lock.go 第 65 行、130 行),避免不同业务 key 撞库。

6. 第二层:Locker 单键锁 — SetNX + xid 防误删

What —— 这一层在做什么?

Locker 实现位于 src/common/lock/lock.go 第 18-75 行:

  • SetNX(key, uuid, expire) 原子地判断+写入+设置 TTL。
  • value 不是简单的 "1" 而是一个 xid UUID——这是为"防误删"铺垫(虽然 Unlock 当前只是 Del,未做 CAS 校验)。
  • isFirst 标志位防止进程内同实例复用。

Why —— 为什么要 SetNX 三合一 + xid UUID?

  • 三合一是因为原子性:SETNXEXPIRE 分两步会出"加锁成功但未设过期"的死锁 bug。
  • UUID 是为了将来升级 CAS 校验:Redlock 标准要求 unlock 之前判断 value 是不是自己的 UUID,防止误删别人的锁。

没有 Locker 会发生什么?

  • 同一时刻两个 coreservice 都进入 create-model,并发改写 MongoDB cc_ObjDes,字段冲突。
  • 没有过期时间的锁一旦泄漏,key 永远不会自愈。

6.1 lock 结构体 + Lock 实现(L18-75)

type lock struct {
                cache      redis.Client
                key        string
                // 是否需要释放key
                needUnlock bool
                isFirst    bool
            }
            
            func (l *lock) Lock(key StrFormat, expire time.Duration) (locked bool, err error) {
                if l.isFirst {
                    return false, fmt.Errorf("repeat lock")
                }
                l.isFirst = true
                l.key = fmt.Sprintf("%s%s", common.BKCacheKeyV3Prefix, key)
            
                uuid := xid.New().String()
                locked, err = l.cache.SetNX(context.Background(), l.key, uuid, expire).Result()
                if locked {
                    l.needUnlock = true
                }
                return locked, err
            }

几个要点:

  • SetNX(key, uuid, expire) 三段语义合一:原子存在性判断 + 写值 + 过期时间。
  • value 不是简单的 "1" 而是一个 xid UUID——这是为后面"防误删"铺垫(虽然当前 Unlock 未做 CAS 校验)。
  • BKCacheKeyV3Prefix 强制前缀,避免不同业务 key 撞库。

6.2 Unlock(L194-200)

// Unlock TODO
            func (l *lock) Unlock() error {
                // locked sucess , can unlock
                if !l.needUnlock {
                    return nil
                }
                return l.cache.Del(context.Background(), l.key).Err()
            }

Del,未做 value 校验。生产环境的严谨实现应该用 Lua / WATCH 实现 if get==uuid then del 的 CAS。

隐含风险:Unlock 不做 CAS 校验意味着:如果进程 A 抢到了锁,进程 A 卡顿过 expire,进程 B 抢到同一 key,A 醒来时调 Unlock 把 B 的锁删了。这正是 v3.15+ 引入的 LockV2(基于 Redlock 多 master + Lua)要解决的核心问题。

7. 第二层:MLocker 多键锁 — TxPipeline + 部分成功回滚

What —— 什么场景需要多键锁?

关联实例创建。假设用户 A 想给 (host=10, switch=20) 建关联,同时用户 B 想给 (host=20, switch=10) 建反向关联。如果只锁 Inst、不锁 AsstInst,两人各自都能进入,反向写会同时成功——同一对实例出现两条等价关联。MLocker 就是为这种"两端都要锁"的场景。

Why —— 为什么不是顺序加锁?

  • 顺序加锁有死锁风险:A 锁 a,b;B 锁 b,a;互相等待。
  • MLock 把多键加锁用一次 TxPipeline 提交,任一冲突就整体回滚已成功。

没有 MLocker 会发生什么?

  • 建关联(尤其是 1v1 双向关联)出现"同一对实例两条等价记录"。
  • 缓存重建、API 跨资源组合等场景无法做"原子性的多资源占有"声明。

7.1 MLock 实现(src/common/lock/lock.go L111-191)

func (l *mlock) MLock(rid string, retry int, expire time.Duration, keys ...StrFormat) (locked bool, err error) {
                if l.isFirst {
                    return false, errors.New("repeat lock")
                }
            
                var (
                    bResultFlag bool
                    delKeys     []string
                )
                pipeRes := make(map[bool][]string)
                l.isFirst = true
            
                for i := 0; i < retry; i++ {
                    bPipeResultFlag := false
                    delKeys = []string{}
                    pipe := l.cache.TxPipeline(context.Background())
                    l.keys = []string{}
                    for _, k := range keys {
                        key := fmt.Sprintf("%s%s", common.BKCacheKeyV3Prefix, k)
                        uuid := xid.New().String()
                        l.keys = append(l.keys, key)
                        pipe.SetNX(key, uuid, 0)
                        pipe.Expire(key, expire)
                    }
                    res, err := pipe.Exec()
                    if err != nil {
                        continue
                    }
            
                    for k, r := range res {
                        if k%2 == 0 {
                            key, bResult := getExecSetNxBoolResult(r.String())
                            if !bResult {
                                pipeRes[false] = append(pipeRes[false], key)
                            } else {
                                bPipeResultFlag = true
                                pipeRes[true] = append(pipeRes[true], key)
                            }
                        }
                    }
                    if bPipeResultFlag && len(pipeRes[false]) > 0 {
                        err := l.cache.Del(context.Background(), pipeRes[true]...).Err()
                        if err != nil {
                            for _, v := range pipeRes[true] {
                                delKeys = append(delKeys, v)
                            }
                            blog.Errorf("delete key fail. the key: %v,rid: %s", pipeRes[true], rid)
                        }
                    } else {
                        bResultFlag = true
                        break
                    }
                    for k := range pipeRes {
                        delete(pipeRes, k)
                    }
                    time.Sleep(100 * time.Millisecond)
                }
            
                if bResultFlag {
                    l.needUnlock = true
                    return true, nil
                }
                if len(delKeys) > 0 {
                    l.needUnlock = true
                    l.keys = delKeys
                }
                pipeRes = nil
                return false, errors.New("obtain lock fail")
            }

关键细节:

  • TxPipeline:不是 MULTI/EXEC 那种重型事务,而是 go-redis 的 pipeline 批提交,减少 RTT。
  • 奇数位置的命令是 Expire,k%2==0 才是 SetNX——这是源码里最显眼的"批序号"。
  • 部分成功要回滚(L156-L165):只要有一个失败,pipeRes[true] 里的成功键必须被 Del 回滚——这是红锁的两阶段语义。
  • 失败补偿机制(L161-L165):如果 Del 自己失败,把这次删除失败的 key 也记入 delKeys,让外层 defer 在 MUnlock 时再尝试一次。
  • 睡眠退避(L175):time.Sleep(100ms),所以 10 次重试 ≤ 1 秒。这是典型的乐观重试节奏。
  • 最终失败(L183-L188):如果 10 次全失败,返回 "obtain lock fail",调用方需要看 delKeys 决定是否补偿。
源码视角 · MLock 的批序号与部分回滚

源码视角一:k%2==0 的来源

看上面 L142-148 行,每条 key 都同时下发了 SetNX + Expire 两条命令。go-redis 的 pipeline 返回 []Cmder,索引按提交顺序对齐——所以第 0、2、4... 索引是 SetNX,1、3、5... 是 Expire。这种"按批序号筛选"的写法在源码里多处出现(比如 L156 的 if k%2 == 0),新接手的人读到这里会卡住。

源码视角二:为什么 pipe.Expire 要单独下发?

理论上可以用 SetNX(key, uuid, expire) 一次下发(等价 SET key val NX EX ttl),为什么要拆 SetNX(key, uuid, 0) + Expire(key, expire) 两步?因为 MLock 的 retry 循环里,第一步失败重试时,Expire 会重新覆盖上一次留下的极短 TTL,导致重试之间不出现"过期了但 SetNX 还没完成"的窗口。这是为了健壮性牺牲了一点点效率

源码视角三:部分回滚的 delKeys 兜底

L160-L165 显示,如果 Del 自己失败,代码会把"删除失败的 key"塞进 delKeys,在下一次 retry 或最终失败时由 MUnlock 再次尝试。这是分布式锁里常见的二阶段补偿模式——它假设 Redis 暂时不可用,但最终会恢复,所以不让一个瞬态故障直接泄漏锁。

源码视角总结:3 条设计原则

  • 1 个 批序号约定:SetNX 在偶数位,Expire 在奇数位,源码多处用 k%2==0 过滤——阅读时必须先认这条约定。
  • 1 个 二阶段语义:成功的 keys 用 Del 回滚,Del 自己失败时记录到 delKeys 兜底,不让瞬态故障直接泄漏锁。
  • 1 个 退避节奏:单次失败 Sleep(100ms),10 次重试 ≤ 1 秒,适合"中等时长"的中间状态写入。

避坑提醒(源码视角):

  • 不要在外层把 MLocker 的 keys 顺序依赖内部排序:当前 v3.14.6 实现没有对 keys 排序(看 L128-134 没有 sort.Strings 调用),调用方必须保证全局一致的排序,否则 A 按 [a,b]、B 按 [b,a] 抢锁会各拿到一半 → 死锁。
  • 不要把 retry 设得太大:time.Sleep(100ms) × retry线性放大客户端响应时间,生产用 10 已经到 1 秒上限。

8. 真实场景 1:CreateTableModel 加锁 — coreservice:create:model:<ObjectID>

What — CreateTableModel 入口加锁,锁的是什么?

位置 src/source_controller/coreservice/core/model/model.go 第 73-87 行:

锁粒度是 "创建一个新的 ObjectID"。两个 admin 同时试图在 coreservice 创建同名自定义模型(例如都叫 mysql_server)——后一进程拿不到锁,直接 CCErrCommOPInProgressErr

Why — 为什么一定要锁?

建模型在 CMDB 里是一个复合事务(写 cc_ObjDes + 写 cc_ObjAtt + 写多个索引表)。两个进程并发会撞 cc_ObjDes 的 unique 约束,但约束只是兜底——前面的写入已经可能破坏了字段表的一致性。Redis 锁防住的是"写了一半的字段 + 写了一半的主表"的不一致。

没有 model 锁会发生什么?

  • 两个 admin 同名模型同时创建,先提交的将字段表写一半时第二个进来继续写,mongo unique index 兜底报重复但字段表已经乱了。
  • 并发提交的 race 让审计日志变得不连续,排错时无法判断谁的字段为先。
// CreateTableModel create a new table model
            func (m *modelManager) CreateTableModel(kit *rest.Kit, inputParam metadata.CreateModel) (
                *metadata.CreateOneDataResult, error) {
            
                if len(inputParam.Spec.ObjectID) == 0 {
                    blog.Errorf("table model object %s is not set, rid: %s", inputParam.Spec.ObjectID, kit.Rid)
                    return nil, kit.CCError.Errorf(common.CCErrCommParamsNeedSet, metadata.ModelFieldObjectID)
                }
            
                if len(inputParam.Attributes) == 0 {
                    blog.Errorf("table model attr is not set, rid: %s", kit.Rid)
                    return nil, kit.CCError.Errorf(common.CCErrCommParamsNeedSet, "attribute")
                }
            
                locker := lock.NewLocker(redis.Client())
                redisKey := lock.GetLockKey(lock.CreateModelFormat, inputParam.Spec.ObjectID)
            
                locked, err := locker.Lock(redisKey, time.Second*35)
                defer locker.Unlock()
                if err != nil {
                    blog.Errorf("get create table model lock failed, err: %v, input: %v, rid: %s", err, inputParam, kit.Rid)
                    return nil, kit.CCError.CCErrorf(common.CCErrCommRedisOPErr)
                }
            
                if !locked {
                    blog.Errorf("create table model have same task in progress, input: %v, rid:%s", inputParam, kit.Rid)
                    return nil, kit.CCError.CCErrorf(common.CCErrCommOPInProgressErr,
                        fmt.Sprintf("create table object(%s)", inputParam.Spec.ObjectID))
                }

35 秒是"创建 model"这条路径的硬上限:因为后续要 save 主表 + 建字段表 + 注册索引,耗时可能数十秒;过期太短中途释放会导致双写,过长会阻塞管理员的并发操作。【推测】生产环境的极端场景(超大字段 + MongoDB 抖动)可能需要 90 秒。

8.1 CreateModel 也走同一把锁(L149-167)

注意 src/source_controller/coreservice/core/model/model.go 里有两个入口共用 CreateModelFormat key:

  • CreateTableModel(L59-146):单值 ObjectID + 字段数组 → 一次写入做"主表 + 字段表"。
  • CreateModel(L149-261):分两步创建,先写主表再写字段表,但 L152-155 用的还是同一把锁。

这意味着同一时刻只能有一个"create 同一个 ObjectID",不论调用哪个入口。

9. 真实场景 2:CreateModelAttributes 加锁 — coreservice:create:model:<oid>:attr:<pid>

src/source_controller/coreservice/core/model/attribute.go 第 170-174 行(已 Read 验证):

for attrIdx, attr := range inputParam.Attributes {
                redisKey := lock.GetLockKey(lock.CreateModuleAttrFormat, objID, attr.PropertyID)
            
                locker := lock.NewLocker(redis.Client())
                locked, err := locker.Lock(redisKey, time.Second*35)
                defer locker.Unlock()
                if err != nil {
                    blog.Errorf("get create model look failed. err: %v, input: %+v, rid: %s", err, inputParam, kit.Rid)
                    addExceptionFunc(int64(attrIdx), kit.CCError.CCErrorf(common.CCErrCommRedisOPErr), &attr)
                    continue
                }

锁的粒度更细 —— 同一 model 下不同字段可以并发加锁,因此吞吐量比整 model 锁大。35 秒不变。

设计意图总结:

  • 字段锁的 time.Second*35 和 model 锁一致,避免"加 model 锁 + 加 attr 锁"两层加锁时 inner attr 锁先释放。
  • src/source_controller/coreservice/core/model/attribute.go 第 47-135 行的 CreateTableModelAttributes(L78-82 locker 范围)也用同样的锁格式,意味着 create-model 路径在所有变体里都受同一把 key 保护。

10. 真实场景 3:关联实例互斥 — MLock(InstID, AsstInstID, ObjectAsstID)

What — 关联实例的互斥为什么需要 MLock?

1v1 关联并发场景:用户 A 想给 (host=10, switch=20) 建关联,同时用户 B 想给 (host=20, switch=10) 建反向关联。

如果只锁 Inst、不锁 AsstInst:两人各自都能进入,A 写成功,B 反向写也会写成功 → 同一对实例出现两条等价关联。

Why — 1v1 要 MLock 而 1vN 只要 Locker?

1vN 同一时刻只能有一个 one 进来指向同一个 many,所以锁 AsstInstID 那一端就够。1v1 双方都不能被任何进程改写,必须同时锁两端。

没有 MLock 会发生什么?

  • 同一对实例出现两条等价关联(方向不同),前端查拓扑时显示 2 条线。
  • 多对多关联在双向操作时出现 N² 重复。

src/source_controller/coreservice/core/association/instance.go 是 CMDB 里最复杂的锁消费者。1v1 路径(L231-266):

case metadata.OneToOneMapping:
                mlocker := lock.NewMLocker(driverRedis.Client())
            
                // if one instance is associated with itself, then lock this instance, or else lock both instance
                instkey := lock.StrFormat(genAssoInstLockKey(inputParam.Data.InstID, inputParam.Data.ObjectAsstID))
                lockKeys := []lock.StrFormat{instkey}
                if inputParam.Data.InstID != inputParam.Data.AsstInstID {
                    asstInstkey := lock.StrFormat(genAssoInstLockKey(inputParam.Data.AsstInstID, inputParam.Data.ObjectAsstID))
                    lockKeys = append(lockKeys, asstInstkey)
                }
            
                locked, err := mlocker.MLock(kit.Rid, 10, time.Minute, lockKeys...)
                if err != nil {
                    blog.Errorf("obtain lock failed. err: %v, rid: %s", err, kit.Rid)
                    return nil, kit.CCError.CCErrorf(common.CCERrrCoreServiceConcurrent)
                }
                if !locked {
                    blog.Errorf("create one to one inst association, but get lock failed, rid: %s", kit.Rid)
                    return nil, kit.CCError.CCErrorf(common.CCERrrCoreServiceConcurrent)
                }
            
                defer func() {
                    if err := mlocker.MUnlock(); err != nil {
                        blog.Errorf("release lock failed, err: %v, rid: %s", err, kit.Rid)
                    }
                }()
            
                id, err := m.save(kit, inputParam.Data)

并发场景还原:

  1. 用户 A 想给 (host=10, switch=20) 建关联
  2. 同时用户 B 想给 (host=20, switch=10) 建反向关联
  3. 如果只锁 Inst,不锁 AsstInst:两个人都能进入,A 写成功,B 反向写也会写成功 → 同一对实例出现两条等价关联
  4. MLock 同时锁两端 → 一方被踢回 CCERrrCoreServiceConcurrent

同时 1vN 路径(L267-292)只锁 AsstInstID("被多对一指向的一方")——这是因为 many→one 同一时刻只能有一个 one 进来:

case metadata.OneToManyMapping:
                locker := lock.NewLocker(driverRedis.Client())
                asstInstManyKey := genAssoInstLockKey(inputParam.Data.AsstInstID, inputParam.Data.ObjectAsstID)
                locked, err := locker.Lock(lock.StrFormat(asstInstManyKey), time.Minute)
源码视角 · 排序由调用方控制

源码视角一:MLock 内部不做排序

看上面 L235-240 行的代码:instkey := ... InstID + ObjectAsstIDasstInstkey := ... AsstInstID + ObjectAsstID。这种顺序保证"A 锁 [Inst, AsstInst]"和"B 锁 [AsstInst, Inst]"在 Redis 上的 key 是 同一组(因为 key 只取决于实例 ID,和调用顺序无关)。所以这里是按"key 唯一性"而非"调用顺序"来防死锁

源码视角二:超时 1 分钟 × 10 重试 = ~1 秒

time.Minute 是单次锁过期(每次重试会重置 Expire),10 是 retry 次数,time.Sleep(100ms) 在每两次之间。1v1 关联涉及多张表写入,1 分钟足够;但极端场景(超大关联数 + 慢 mongo)需要 catch。

避坑提醒(源码视角):

  • 不要把 MLock 用在"无 ObjectAsstID"的场景:genAssoInstLockKey(id, objectAsstID) 需要一个稳定的 ObjectAsstID 才能让两端 key 撞上。

性能启示:many 的反向实例("被指向的父")成为全局热点 —— 一个高关联实体会让所有并发请求排队等同一把锁。这是后续 v3.15+ 引入"实例级 shard"分布式锁的根因。【推测】

11. 真实场景 4:transfer-service 全量同步 master guard

What — FullSyncLockKey 在 transfer-service 守护什么?

pkg/synchronize/types/syncer.go 第 49 行定义:

const FullSyncLockKey = "cmdb_syncer:full_sync_lock"

common.BKCacheKeyV3Prefix 拼接后实际 Redis key 是 bk:v3:cmdb_syncer:full_sync_lock

Why — 为什么需要 Master Guard?

transfer-service 至少部署 2 个实例做高可用。如果没有控制,两个实例都会去源端拉数据,相同数据被重复写入目标端 MongoDB。

没有 Master Guard 会发生什么?

  • 两个进程同时拉取,同一主机数据写入两次,ID 映射表被并发覆盖。
  • 源端压力翻倍,跨 region 同步时 network quota 打爆。

三处使用方都是同一个模式:进入循环时先抢锁,抢不到 sleep 后重试:

11.1 src/source_controller/transfer-service/sync/dest_full_sync.go L44-50

locker := lock.NewLocker(redis.Client())
locked, err := locker.Lock(types.FullSyncLockKey, time.Hour)
if err != nil || !locked {
    blog.Errorf("do not get %s lock, err: %v, locked: %v", types.FullSyncLockKey, err, locked)
    time.Sleep(5 * time.Minute)
    continue
}

11.2 src/source_controller/transfer-service/sync/src_full_sync.go L42-48

locker := lock.NewLocker(redis.Client())
locked, err := locker.Lock(types.FullSyncLockKey, time.Hour)
if err != nil || !locked {
    blog.Errorf("do not get %s lock, err: %v, locked: %v", types.FullSyncLockKey, err, locked)
    time.Sleep(5 * time.Minute)
    continue
}

11.3 src/source_controller/transfer-service/sync/client.go L51-56

locker := lock.NewLocker(redis.Client())
locked, err := locker.Lock(types.FullSyncLockKey, time.Hour)
if err != nil {
    blog.Errorf("sync cmdb data but get lock failed, err: %v, rid: %s", err, kit.Rid)
    return err
}

关键特征:

  • 1 小时过期,5 分钟 sleep 周期
  • 两个 transfer-service 进程只允许一个赢家进全量同步
  • 失败不要紧,下次循环再来抢
  • isMaster 进程内判断 + Redis 分布式锁组成"双层 master"

没有用 MLocker 也不是 Locker 单键重试:transfer-service 用最简化的"抢不到就睡"模式 —— 因为全量同步本身就几分钟到几十分钟的重量级任务,等待 5 分钟比忙等 100ms 更节省 CPU。

12. 真实场景 5:cacheservice 缓存重建 single-flight

What — cacheservice 缓存重建的 single-flight

cacheservice 用 Redis 锁防重复重建缓存,是 CMDB 里最经典的 single-flight 模式。两条缓存路径:

  • Pod Label 缓存(L240-259):按业务粒度加锁
  • Shared Namespace Rel 缓存(L119-129):全局一把锁

Why — single-flight 防什么?

多个缓存消费者同时打 RefreshPodLabel 会触发反复读 MongoDB / 重建 cache,反而拖累后端。让"第一个进来的做完即可,后续的快速失败"。

没有 single-flight 会发生什么?

  • 多个刷新请求同时进 mongo 扫表,数据库压力 5-10 倍。
  • 第二个刷新的可能覆盖第一个的结果,出现 cache 内容抖动。

12.1 Pod Label 缓存(src/source_controller/cacheservice/cache/custom/cache/label.go L240-259)

// RefreshPodLabel refresh pod label key and value cache
            func (c *PodLabelCache) RefreshPodLabel(ctx context.Context, opt *RefreshPodLabelOpt, rid string) ([]string, error) {
                if err := opt.Validate(); err != nil {
                    return nil, err
                }
            
                // lock refresh pod label key and value cache operation, returns error if it is already locked
                lockKey := fmt.Sprintf("%s:pod_label_refresh:lock:%d", Namespace, opt.BizID)
            
                locker := lock.NewLocker(redis.Client())
                locked, err := locker.Lock(lock.StrFormat(lockKey), 5*time.Minute)
                defer locker.Unlock()
                if err != nil {
                    blog.Errorf("get %s lock failed, err: %v, rid: %s", lockKey, err, rid)
                    return nil, err
                }
            
                if !locked {
                    blog.Errorf("%s task is already lock, rid: %s", lockKey, rid)
                    return nil, errors.New("there's a same refreshing task running, please retry later")
                }

12.2 Shared Namespace Rel 缓存(src/source_controller/cacheservice/cache/custom/cache/shared_ns_rel.go L119-129)

// RefreshSharedNsRel refresh shared namespace relation key and value cache
            func (c *SharedNsRelCache) RefreshSharedNsRel(ctx context.Context, rid string) error {
                // lock refresh shared namespace relation cache operation, returns error if it is already locked
                lockKey := fmt.Sprintf("%s:shared_ns_rel_refresh:lock", Namespace)
            
                locker := lock.NewLocker(redis.Client())
                locked, err := locker.Lock(lock.StrFormat(lockKey), 10*time.Minute)
                defer locker.Unlock()

两个都用 Locker 单键,过期 5-10 分钟。设计意图:

  • Pod Label 是按业务粒度加锁::pod_label_refresh:lock:<BizID> → 不同业务可并发
  • SharedNsRel 是全局锁:只有一把 → 全集群最高权限

13. 真实场景 6:IAM 注册重试

src/ac/iam/iam.go 第 96-114 行:

// tryLockRegister try lock register iam operation to make sure only one task runs at the same time, retry 3 times.
            func tryLockRegister(redisCli redis.Client, rid string) (lock.Locker, error) {
                for i := 0; i < 3; i++ {
                    locker := lock.NewLocker(redisCli)
                    locked, err := locker.Lock(RegisterIamLock, 2*time.Minute)
                    if err != nil {
                        blog.Errorf("get register iam lock failed, err: %v, rid: %s", err, rid)
                        time.Sleep(5 * time.Second)
                        continue
                    }
            
                    if locked {
                        return locker, nil
                    }
            
                    time.Sleep(5 * time.Second)
                }
            
                return nil, fmt.Errorf("there's another register iam task runing, please retry later")
            }

src/ac/iam/types.go 第 43-44 行:

// RegisterIamLock defines the lock key for register iam operation
            RegisterIamLock = "register_iam_lock"

重点:

  • 2 分钟过期
  • 3 次重试,包外循环
  • 这层锁保护"防止多个 CMDB 同时尝试注册 IAM 身份系统"导致的 resource 重名

14. 进程内互斥:RefreshingLockLocker 的差异

src/source_controller/cacheservice/cache/tools/lock.go定义了一个完全不同形态的锁:

package tools
            
            import "sync"
            
            // NewRefreshingLock TODO
            func NewRefreshingLock() RefreshingLock {
                return RefreshingLock{
                    refreshing: make(map[string]bool),
                }
            }
            
            // RefreshingLock TODO
            type RefreshingLock struct {
                refreshing map[string]bool
                lock       sync.Mutex
            }
            
            // CanRefresh check if you can refresh the key.
            func (r *RefreshingLock) CanRefresh(key string) bool {
                r.lock.Lock()
                refreshing, exist := r.refreshing[key]
                if !exist {
                    r.refreshing[key] = false
                    r.lock.Unlock()
                    return true
                }
                r.lock.Unlock()
                return !refreshing
            }

它本质是 map + sync.Mutex 的进程内互斥,不跨进程、不跨节点

维度Locker(Redis)RefreshingLock(进程内)
作用范围 跨 pod / 跨节点 单进程内
介质 Redis map[string]bool + sync.Mutex
过期 有 TTL 无 TTL,靠 SetUnRefreshing 显式重置
错误语义 返回 locked bool 返回 CanRefresh bool
适用场景 跨节点写互斥 本进程 single-flight

在 cacheservice 实际使用中,二者常常叠加:RefreshingLock 用于进程内多个 goroutine 同时触发同 key 时的去重,Locker 用于多节点同 key 的去重。也可以只用 Locker(更准确,但 RTT 多一些)。

15. 关键 key 模板与 Redis 前缀

使用方模板最终 Redis key(前缀已拼)过期
model.go CreateTableModel/CreateModel coreservice:create:model:<oid> bk:v3:coreservice:create:model:host 35s
attribute.go coreservice:create:model:<oid>:attr:<pid> bk:v3:coreservice:create:model:host:attr:ip 35s
association/instance.go 1v1 <instID>_<objectAsstID> bk:v3:10_switch_20 60s × 10 retry
association/instance.go 1vN <asstInstID>_<objectAsstID> bk:v3:20_switch_10 60s
transfer-service cmdb_syncer:full_sync_lock bk:v3:cmdb_syncer:full_sync_lock 1h
cacheservice label.go <ns>:pod_label_refresh:lock:<bizID> bk:v3:pod_label_refresh:lock:2 5min
cacheservice shared_ns_rel.go <ns>:shared_ns_rel_refresh:lock bk:v3:shared_ns_rel_refresh:lock 10min
iam/iam.go register_iam_lock bk:v3:register_iam_lock 2min × 3 retry

实战意义:在 Redis 里排查锁争用时,redis-cli --scan --pattern 'bk:v3:coreservice:*' 直接圈出 model 域的活跃锁,redis-cli --pattern '*full_sync_lock*' 看同步状态。

16. 常见反模式与避坑清单

  1. 不要用 cc_HostLock 做真实的写入互斥:它只是 UI 灰锁,可被任意 Update 权限用户解锁(src/source_controller/coreservice/core/host/lock.go L86-98 UnlockHost 完全不校验身份)。要做写互斥请走 src/common/lock 包的 Redis Locker
  2. 不要在 MLock 调用前手动排 keys:当前 v3.14.6 MLock 内部不排序(src/common/lock/lock.go L128-134),调用方必须保证全局一致的排序,否则会出现死锁或活锁。
  3. 不要省略 defer locker.Unlock():因为 Unlock 内部做了 needUnlock 判断(src/common/lock/lock.go L196),未抢到的不会误删,但抢到锁时漏 defer 会泄漏。
  4. 不要在同一进程内反复 Lock 同一 key:isFirst 标志位会直接返回 "repeat lock"(src/common/lock/lock.go L62)。每个 goroutine 必须独立 NewLocker()
  5. 不要把 cc_HostLock 当业务表查询:它仅 4 字段,没有 host 详情(如 IP、OS),前端要做 host 详情还得 join cc_HostBase
  6. 不要假设 SetNX 100% 可靠:极端情况下 Redis 主从切换会丢锁(CMDB 【实测】未实现 Redlock 多 master 兜底)。这是上游 v3.15+ 才会补的事。
  7. 不要把 MLock 的 retry 设为 0:0 等于"失败就立刻返",会放大写入冲突异常。MLock 入口在 src/common/lock/lock.go L111,retry 形参由调用方传入(如 src/source_controller/coreservice/core/association/instance.go L237 传入 10)。
  8. 不要用 UI 锁做"权限校验":鉴权走 ac 模块(AuthManager.AuthorizeByHostsIDs),锁是互斥不是权限(src/scene_server/host_server/service/hostlock.go L41 与 L87)。
  9. 不要把 Unlock 当删锁写值用:Unlock 不做 CAS(UUID 校验),只在"确实是自己的锁"前提下安全。生产严谨场景应该用 Lua 实现 if get==uuid then del
  10. 不要在 AutoRunTxn 里嵌套 IO Lock(Mongo + Redis):AutoRunTxn 只对 MongoDB 副本集事务起作用,Redis Lock 不在事务边界内——如果 Mongo 提交失败,Redis 锁已经持有,业务需要显式 defer Unlock 才能释放。

17. 可观测性:日志、rid 串联与监控指标

v3.14.6 的锁代码里所有错误日志都带 rid —— 这是与请求链路串联最关键的一根线。生产环境的"锁失败"告警,可以直接通过 grep 关键字 + rid 找到对应请求上下文:

  • "obtain lock failed"CCERrrCoreServiceConcurrent
  • "task is already lock" → cacheservice single-flight 冲突
  • "do not get %s lock" → transfer-service 等不到锁

建议监控指标(【推测】):

  1. redis.lock.contention.count —— 不同 key 上锁失败次数
  2. redis.lock.hold.duration.p99 —— 锁持有时长分位数
  3. cc_HostLock.row.count —— db.cc_HostLock.count() 长期增长告警
  4. mongodb.cc_HostLock.op.latency —— 写表 RT

本节总结:CMDB "主机锁定"在两个完全不同的并发模型上跑 —— UI 灰锁(cc_HostLock,Mongo 行级标记)和 Redis 硬锁(Locker/MLocker)。前者给前端"显示谁在看",不参与权限;后者给 backend 写入防双写,有 TTL 与 rid 串联。后续 v3.15+ 将引入基于 Lua 脚本 + Redlock 多 master 的 LockV2,外部 API 兼容;CMDB 也会从 Locker 演进到 LockV2

18. FAQ — 20 组高频疑问

FAQ 模块按本系列博文规范,位于 Roadmap 之前。本节覆盖 20 组高频疑问,前 6 组关于 UI 灰锁,中 8 组关于 Redis 硬锁,后 6 组关于跨节点/进程内组合。

Q1. UI 解锁时,另一端用户正在编辑此主机,会被阻止吗?

不会。src/source_controller/coreservice/core/host/lock.go 第 86-98 行 UnlockHost 仅做 Delete(cc_HostLock),并不检查"是否有人在编辑此主机"。UI 灰锁是提示而非权限闸门,所以拦截靠 src/scene_server/host_server/service/hostlock.go 第 41 行 ac.AuthManager.AuthorizeByHostsIDs

Q2. 能否把 cc_HostLock 加一条 TTL,让关闭浏览器后自动解锁?

不建议。v3.14.6 行级锁设计上没有过期字段,做 TTL 需要 mongo TTL index(create_time + Δt),但那会让客户端的"批量加锁 N 台机"的语义塌缩:每台锁过期时间不同,反而更难做 race 检测。可行的替代方案是:在 unlock handler 上增加"force=false/true",让 release-operator 定时清理孤儿锁。

Q3. Locker 的 UUID 在 Unlock 时不使用,那存在意义是什么?

为 Lua CAS 升级预留。当前 v3.14.6 没用,是预留字段。CAS Lua 升级路径上它会成为"value 校验"的钥匙,所以 UUID 仍按 xid 全局唯一生成。同时保留向后兼容:Unlock 直接 Del,即便 value 不匹配;如果改成 CAS,外部签名不变。

Q4. 为什么 MLock 用 TxPipeline 而不是 Multi/Exec?

TxPipeline 是 go-redis 封装的事务性 pipeline。go-redis 的 TxPipeline 是事务性 pipeline(底层用 MULTI + EXEC)。但语法层级 TxPipeline 是强类型 Future,比手写 MULTI ... EXEC 更可读,且 Redis 集群下 TxPipeline 只支持单 slot,go-redis 会自动 hash slot——满足 SetNX 同 slot 语义。

Q5. 35s 这个过期时间是怎么定出来的?

经验值,看 src/source_controller/coreservice/core/model/model.go L73-87 与 L149-167。35s ≈ 一次"save 表 + 字段创建 + 索引注册"的总耗时峰值(含 5 次 mongo round-trip)。如果实际生产监控发现"锁提前过期",可调高到 60s,但建议同时排查 mongo 是否慢。

Q6. 为什么 create asst inst 用 MLock,而 create model 用 Locker?

粒度差异。建 model 只对单 ObjectID 锁,不需要多键;建关联需要"两侧实例都锁住",否则对称破坏。所以前者 Locker(src/source_controller/coreservice/core/model/model.go 第 73、152 行),后者 MLocker(src/source_controller/coreservice/core/association/instance.go 第 232 行)。这背后是"知道自己在保护什么"的设计哲学。

Q7. transfer-service 是不是重复抢锁?

三处代码会抢同一把锁,语义不同。

  • src/source_controller/transfer-service/sync/dest_full_sync.go L44-50 是"被动接收方"循环
  • src/source_controller/transfer-service/sync/src_full_sync.go L42-48 是"主动推送方"循环
  • src/source_controller/transfer-service/sync/client.go L51-56 是 webhook 的 API 调用入口

三处互不干扰,但抢同一把锁(pkg/synchronize/types/syncer.go L49 FullSyncLockKey):确保只一个进程在做全量同步。

Q8. 缓存重建锁过期 5-10 分钟够吗?

取决于 MongoDB 数据规模。极端情况下百万级实例的 SharedNsRel 重建会到 15 分钟。建议:监控"锁过期后还没做完"的 case 数量;超过阈值则把 10min 调到 30min,或拆键(按业务 ID sharding)。

Q9. isFirst 是不是多余的?

不是。它是进程内重用检查,确保 Locker 实例只允许 Lock 一次——这是 API 约束而非多线程安全。如果允许复用,一个 Locker 实例会反复覆盖 l.key 字段,导致上一次的 Unlock 误删这次的 key。多 goroutine 请各自 NewLocker

Q10. cc_HostLock 走 MongoDB 复制集下能做强一致吗?

在 replica set + readConcern majority + writeConcern majority 下能。但 v3.14.6 的 LockHost 没有显式指定 read/write concern——使用默认 w:1,意味着次级节点可能读不到刚才的"锁"。这在 UI 场景下是可接受的(最终一致展示锁图标即可),但不能当作强一致防重。

Q11. AutoRunTxn 包裹的 LockHost 真在 mongo 端做事务吗?

是。admin-server / coreservice 启动时会 enable mongo.IsTxnEnabled。Replica set 模式下,AutoRunTxn 在底层用 session.WithTransaction 包起来,所以多次 Find/Count/Insert 都会落到同一个 session,commit 时一次性原子提交(src/source_controller/coreservice/core/host/lock.go 第 31-83 行整段)。

Q12. 能停掉 UI 灰锁只走 Redis 锁吗?

不能。两者服务对象不同:Redis 锁给 backend 写入互斥;UI 锁给前端"显示状态"。即便后端互斥了,前端仍需要知道"哪些机器正在被哪个用户关注",否则锁图标凭空消失会让运营慌。

Q13. RefreshingLock 是不是多余的?

不冗余。它解决"同进程多 goroutine"级的 single-flight;(src/source_controller/cacheservice/cache/tools/lock.go 第 25-29 行)Locker 解决"跨节点"互斥。两层叠加是标准的 single-flight 双闸门:进程内快速失败、不打 Redis;跨节点抢占 Redis 锁。

Q14. 能否把 5 * time.Minute 在 transfer-service 改短?

不建议。那是"锁抢失败的循环间隔",并非锁过期。如果改成 10 秒,进程 CPU 翻倍上升;如果改长,全量同步故障恢复时间变长。5 分钟是经验平衡点(src/source_controller/transfer-service/sync/dest_full_sync.go 第 48 行)。

Q15. SetNX vs SET NX EX 有区别吗?

在 Redis 协议层没区别。go-redis 内部把 SetNX + 过期参数映射为 SET key val NX EX ttl。所以 v3.14.6 第 69 行的 cache.SetNX(...).Result() 就等价于 Redis 5.0+ 的原子 SETNX+TTL

Q16. hostlock 接口要不要支持租户过滤?

已经支持。底层 src/source_controller/coreservice/core/host/lock.go 第 36、59、90、107 行都做了 util.SetQueryOwner / SetModOwner。租户间互不污染。

Q17. 如何强制清理孤儿 cc_HostLock?

没有内置 API。可手动 mongo shell 执行 db.cc_HostLock.deleteMany({create_time: {$lt: ...}})。生产慎用,要确保这些机器当前已不再被编辑。建议加 delete-by-expiry 的 admin API,列入 v3.15+ 待办。

Q18. 为什么用 mongodb.Client().Table().Insert() 一次性插多条,而不是循环 Insert?

批量降低 RT。v3.14.6 第 75-80 行已经做了"批量"。一次性 Insert(insertDataArr) 比循环单条 RT 显著降低(典型从 N*1ms 降到 1ms+logN)。如果 ID 数量级过万,可改 BulkWrite。当前实现对小批量"几十台主机的批量加锁"足够。

Q19. LockHost/UnlockHost 接口吞吐天花板是多少?

【推测】单节点 LockHost/UnlockHost QPS 受限于 mongo session 启动开销(AutoRunTxn 每次新 session),正常区间 几百到上千 QPS;Redis Lock/Unlock 在 32 字节 key 下可达 数万 QPS,远高于 mongo 锁。这一点决定了 UI 锁不能做成"锁资源,禁写"——吞吐太低了。

Q20. v3.14.6 这套锁设计最大的隐藏风险是什么?

所有锁都靠"硬编码常量"持有时长。长任务超过这个常量就会失效,没人在 sleep + re-acquire。这意味着一次"创建超大型 model"或"亿级 SharedNsRel 重建"超 5/10 分钟时,第二个并发请求就能进入,破坏一致性。这是 v3.15+ 的最大重构目标——引入可续约 / 持有上报的"租约式锁"。

FAQ 总纲:所有锁机制在 v3.14.6 都是"硬过期 + 静态 retry + 单点 Redis"的组合——简单、可见、不一致风险在于跨节点的极端时钟漂移和 Redis 主从切换。可观测性靠 rid 串联 + blog.Errorf 关键字定位。生产最佳实践:

  • UI 层永远不参与权限判定:它只是 row-level 软标记,真正的权限闸门在 src/scene_server/host_server/service/hostlock.go 第 41 行 AuthorizeByHostsIDs
  • Redis 锁的过期时间必须大于业务峰值耗时
  • MLock 调用的 key 列表顺序由调用方保证(v3.14.6 内部不排序)。
  • 监控锁失败关键字:obtain lock failed / task is already lock / do not get %s lock

Roadmap — 下一篇预告

第 20 篇将聚焦"主机事务与变更流水":cc_HostBase / cc_OperationLog / cc_AuditLog 三者的写入协同,结合 #19 的灰锁 + MongoDB 事务,看一次"主机转移业务"如何穿越多重防线,仍保持最终一致。

posted @ 2026-07-08 00:00  左扬  阅读(5)  评论(0)    收藏  举报