蓝鲸 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 注册。
- RefreshingLock 与 Locker 的差异:进程内 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 常量
目录
- CMDB 里的"主机锁定"到底锁什么——双层并发控制的总体定位
- 第一层:UI 灰锁 cc_HostLock 表结构与索引
- 第一层:UI 灰锁 HTTP 路由与事务边界
- 第一层:UI 灰锁核心服务层 host/lock.go 逐函数拆解
- 第二层:Redis 分布式锁 Locker / MLocker 接口与 key 模板
- 第二层:Locker 单键锁 — SetNX + xid 防误删
- 第二层:MLocker 多键锁 — TxPipeline + 部分成功回滚
- 真实场景 1:CreateTableModel 加锁 — coreservice:create:model:<ObjectID>
- 真实场景 2:CreateModelAttributes 加锁 — coreservice:create:model:<oid>:attr:<pid>
- 真实场景 3:关联实例互斥 — MLock(InstID, AsstInstID, ObjectAsstID)
- 真实场景 4:transfer-service 全量同步 master guard
- 真实场景 5:cacheservice 缓存重建 single-flight
- 真实场景 6:IAM 注册重试
- 进程内互斥:RefreshingLock 与 Locker 的差异
- 关键 key 模板与 Redis 前缀
- 常见反模式与避坑清单
- 可观测性:日志、rid 串联与监控指标
- FAQ:20 组高频疑问
- 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_at、version、heartbeat——行级锁设计是 "显式 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 行):
- ctx.DecodeInto(&input) 反序列化 HostLockRequest
- 空 id_list → CCErrCommParamsNeedSet
- AuthorizeByHostsIDs(..., meta.Update, ...) 鉴权
- AutoRunTxn(...) 包事务,把 s.Logic.LockHost(ctx.Kit, input) 包进来
- 无论成功失败,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
}
逻辑步骤:
- input.IDS = util.IntArrayUnique(input.IDS) —— 入口去重
- 用 bk_host_id IN [...] 查 cc_HostBase(也就是 cc_HostBase 不是 cc_HostLock),只取 host_id 字段(Fields 优化网络)
- diffHostLockID 计算"请求 ID 中不存在于 db"的差集,差集非空 → CCErrCommParamsIsInvalid
- 对每个 host_id 单独 Find(conds).Count() 检查 cc_HostLock,未锁过才追加到 insertDataArr
- 聚合 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_HostLock 里 bk_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_HostLock 查 bk_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?
- 三合一是因为原子性:SETNX 加 EXPIRE 分两步会出"加锁成功但未设过期"的死锁 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 决定是否补偿。
源码视角一: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)
并发场景还原:
- 用户 A 想给 (host=10, switch=20) 建关联
- 同时用户 B 想给 (host=20, switch=10) 建反向关联
- 如果只锁 Inst,不锁 AsstInst:两个人都能进入,A 写成功,B 反向写也会写成功 → 同一对实例出现两条等价关联
- 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 + ObjectAsstID、asstInstkey := ... 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. 进程内互斥:RefreshingLock 与 Locker 的差异
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. 常见反模式与避坑清单
- 不要用 cc_HostLock 做真实的写入互斥:它只是 UI 灰锁,可被任意 Update 权限用户解锁(src/source_controller/coreservice/core/host/lock.go L86-98 UnlockHost 完全不校验身份)。要做写互斥请走 src/common/lock 包的 Redis Locker。
- 不要在 MLock 调用前手动排 keys:当前 v3.14.6 MLock 内部不排序(src/common/lock/lock.go L128-134),调用方必须保证全局一致的排序,否则会出现死锁或活锁。
- 不要省略 defer locker.Unlock():因为 Unlock 内部做了 needUnlock 判断(src/common/lock/lock.go L196),未抢到的不会误删,但抢到锁时漏 defer 会泄漏。
- 不要在同一进程内反复 Lock 同一 key:isFirst 标志位会直接返回 "repeat lock"(src/common/lock/lock.go L62)。每个 goroutine 必须独立 NewLocker()。
- 不要把 cc_HostLock 当业务表查询:它仅 4 字段,没有 host 详情(如 IP、OS),前端要做 host 详情还得 join cc_HostBase。
- 不要假设 SetNX 100% 可靠:极端情况下 Redis 主从切换会丢锁(CMDB 【实测】未实现 Redlock 多 master 兜底)。这是上游 v3.15+ 才会补的事。
- 不要把 MLock 的 retry 设为 0:0 等于"失败就立刻返",会放大写入冲突异常。MLock 入口在 src/common/lock/lock.go L111,retry 形参由调用方传入(如 src/source_controller/coreservice/core/association/instance.go L237 传入 10)。
- 不要用 UI 锁做"权限校验":鉴权走 ac 模块(AuthManager.AuthorizeByHostsIDs),锁是互斥不是权限(src/scene_server/host_server/service/hostlock.go L41 与 L87)。
- 不要把 Unlock 当删锁写值用:Unlock 不做 CAS(UUID 校验),只在"确实是自己的锁"前提下安全。生产严谨场景应该用 Lua 实现 if get==uuid then del。
- 不要在 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 等不到锁
建议监控指标(【推测】):
- redis.lock.contention.count —— 不同 key 上锁失败次数
- redis.lock.hold.duration.p99 —— 锁持有时长分位数
- cc_HostLock.row.count —— db.cc_HostLock.count() 长期增长告警
- 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 事务,看一次"主机转移业务"如何穿越多重防线,仍保持最终一致。

浙公网安备 33010602011771号