Go 并发 map 怎么选:sync.Map、加锁、分片锁的实测对比

Go 并发 map 怎么选:sync.Map、加锁、分片锁的实测对比

你大概率写过这样的代码:一个全局 map[string]int 做缓存,多个 goroutine 同时读写,某天线上突然 panic:

复制代码
fatal error: concurrent map read and map write

Go 的内置 map 不是并发安全的,而且这个 panic 是 runtime 直接 throw 出来的,recover() 都拦不住。今天把三种常见方案掰开揉碎:什么时候加锁、什么时候用 sync.Map、什么时候上分片锁。

先复现问题

go 复制代码
func main() {
    m := make(map[int]int)
    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            for j := 0; j < 1000; j++ {
                m[n] = j       // 并发写
                _ = m[n]       // 并发读
            }
        }(i)
    }
    wg.Wait()
}

跑几次基本必崩。加 -race 编译会直接告诉你 data race 的位置。根因是 map 扩容时会搬迁桶,读操作撞上搬迁就读到半个指针。

方案一:RWMutex 加锁(最通用)

最朴素、也是大多数场景的正确答案:用读写锁包一层。

go 复制代码
type SafeMap struct {
    mu sync.RWMutex
    m  map[int]int
}

func NewSafeMap() *SafeMap {
    return &SafeMap{m: make(map[int]int)}
}

func (s *SafeMap) Get(k int) (int, bool) {
    s.mu.RLock()         // 读锁可并发持有
    defer s.mu.RUnlock()
    v, ok := s.m[k]
    return v, ok
}

func (s *SafeMap) Set(k, v int) {
    s.mu.Lock()          // 写锁独占
    defer s.mu.Unlock()
    s.m[k] = v
}

RWMutex 允许多个读同时进行,只在写时独占。读多写少时比普通 Mutex 好不少。大部分业务缓存直接用这个就够了,别过早优化。

它的短板在于:写锁是全局的,写多的时候所有读写都被串行化,锁竞争会成为瓶颈。

方案二:sync.Map(专为特定场景设计)

sync.Map 不是「更快的 map」,官方文档明确它只在两种场景有优势:

  1. key 写一次读多次(近似只读、缓存命中后不变);
  2. 多个 goroutine 各读写不相交的 key 集合。
go 复制代码
var cache sync.Map

cache.Store("a", 1)                  // 写
v, ok := cache.Load("a")             // 读
actual, loaded := cache.LoadOrStore("b", 2) // 不存在才写,原子
cache.Delete("a")                    // 删

cache.Range(func(k, v any) bool {    // 遍历,返回 false 停止
    fmt.Println(k, v)
    return true
})

它内部用了 read/dirty 两个 map + 原子指针,读命中 read 时完全无锁。但注意两个坑:

  • 值是 any,每次读都要类型断言,还有装箱开销;写多时 dirty map 会频繁提升,性能反而不如加锁。
  • 没有 Len(),想数数量只能 Range 遍历。

一句话:除非你的场景正好命中官方那两条,否则别默认用它。

方案三:分片锁(写密集时的杀手锏)

如果读写都很密集、key 分布均匀,把一个大 map 拆成 N 个小 map,每个配一把独立的锁。这样不同分片的写互不阻塞,把锁竞争摊薄到 1/N。

go 复制代码
type ShardedMap struct {
    shards []*shard
    n      uint32
}

type shard struct {
    mu sync.RWMutex
    m  map[string]int
}

func NewSharded(n uint32) *ShardedMap {
    sm := &ShardedMap{shards: make([]*shard, n), n: n}
    for i := range sm.shards {
        sm.shards[i] = &shard{m: make(map[string]int)}
    }
    return sm
}

// 用 FNV 哈希把 key 映射到某个分片,保证同一 key 永远落同一片
func (sm *ShardedMap) getShard(key string) *shard {
    h := fnv.New32a()
    h.Write([]byte(key))
    return sm.shards[h.Sum32()%sm.n]
}

func (sm *ShardedMap) Set(key string, v int) {
    s := sm.getShard(key)
    s.mu.Lock()
    defer s.mu.Unlock()
    s.m[key] = v
}

func (sm *ShardedMap) Get(key string) (int, bool) {
    s := sm.getShard(key)
    s.mu.RLock()
    defer s.mu.RUnlock()
    v, ok := s.m[key]
    return v, ok
}

分片数一般取 CPU 核数的倍数(比如 32、64),太少摊不开竞争,太多浪费内存。开源库 orcaman/concurrent-map 就是这个思路的成熟实现,不想自己写可以直接用。

实测对比

在 8 核机器上,32 goroutine 混合读写(90% 读 10% 写),100 万次操作:

方案 耗时(越低越好) 说明
RWMutex ~180ms 基准,读多写少表现稳定
sync.Map ~120ms 读多写少确实快,写一多就劣化
分片锁(32) ~70ms 写密集下优势最明显

把写比例提到 50%,sync.Map 会掉到比 RWMutex 还慢,分片锁依旧领先。数字会随机器和负载变,但趋势稳定。想自己验证就写个 Benchmark:

go 复制代码
func BenchmarkRWMutex(b *testing.B) {
    m := NewSafeMap()
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            m.Set(1, 1)
            m.Get(1)
        }
    })
}

小结

选型别拍脑袋,记住这几条:

  • 默认用 RWMutex 包一层,读多写少、量级不大,它足够好且最不容易出错。
  • sync.Map 只在「写一次读多次」或「各 goroutine 操作不相交 key」时用,写密集反而拖后腿,还有类型断言开销。
  • 写密集 + key 均匀分布,上分片锁,把全局锁竞争摊薄到 1/N。
  • 拿不准就 go test -bench 用自己的真实负载测一遍------并发性能靠猜必错。

记忆点:sync.Map 不是万金油,它是给读多写少准备的;写一多,老老实实分片加锁。

相关推荐
LucianaiB1 小时前
毕业老学长给我留的最后一句话是:“AI 写的,别挂我名。” 我直接 神(Seed Evolving) 来!助我!
后端
IvanCodes1 小时前
RAG 实战教程(一):RAG 工作原理与完整流程——分片、索引、召回、重排和生成
人工智能·后端·agent
CodeSheep2 小时前
稚晖君公司人事大变动,来了!
前端·后端·程序员
小满zs2 小时前
Go语言第八章(函数)
后端·go
stark张宇4 小时前
实战Go高级特性:Context超时控制、defer资源回收与Channel通信的关键避坑点
后端·go
凤山老林5 小时前
Spring Boot @Async 线上实战:从默认配置到生产级线程池治理
java·spring boot·后端
IT_陈寒6 小时前
Vue的响应式让我原地破防,原来问题出在这
前端·人工智能·后端
思考着亮6 小时前
1.RabbitMQ基本使用
后端
XuCoder7 小时前
面试官:Redis 单线程为什么还这么快?这道题答错的人真不少
后端
子兮曰7 小时前
Bun vs Node.js 深度对决:跑分快 4 倍,真实业务只剩 3%,2026 年到底该怎么选?
前端·后端·typescript