做后端开发的同学大概率都遇到过这种情况:单机 QPS 没到瓶颈,CPU 利用率也不高,可接口耗时就是忽高忽低。翻完监控常常发现,问题出在锁竞争上——多个协程或线程争抢同一把锁,谁都跑不快。
之前负责的交易服务,在下单高峰时 P99 延迟从 30ms 涨到 800ms。pprof 的 mutex profile 显示,有一把保护本地缓存的互斥锁被大量 Goroutine 等待。简化逻辑如下:
var cacheMu sync.Mutex
var localCache = map[string]*Item{}
func GetItem(key string) *Item {
cacheMu.Lock()
defer cacheMu.Unlock()
if it, ok := localCache[key]; ok {
return it
}
it := loadFromDB(key)
localCache[key] = it
return it
}这里读多写少,却用了排他锁,所有读请求被串行化,锁竞争自然严重。
最常见的优化是把 Mutex 换成 RWMutex,读操作不互斥:
var cacheMu sync.RWMutex
func GetItem(key string) *Item {
cacheMu.RLock()
it, ok := localCache[key]
cacheMu.RUnlock()
if ok {
return it
}
cacheMu.Lock()
defer cacheMu.Unlock()
if it, ok = localCache[key]; ok {
return it
}
it = loadFromDB(key)
localCache[key] = it
return it
}改完后读路径的锁竞争基本消失,P99 回到 40ms 内。注意双检锁里第二次检查不能省,否则可能重复加载。
有时一把大锁保护整个 map,可以按 key 分桶。下面用 32 个分片降低冲突:
const shards = 32
var buckets [shards]struct {
sync.RWMutex
m map[string]*Item
}取模定位分片,只锁对应桶。压测下冲突次数降了一个数量级。
如果是计数器类需求,atomic 比锁轻得多:
var reqCount int64
atomic.AddInt64(&reqCount, 1)对于简单数值,无锁原子操作避免调度开销,也谈不上锁竞争。
把热点锁换成合适结构,往往比加机器见效更快。
评论
加载中...