Go 并发安全 map:sync.Map 和分片锁的各自适用场景

📅 2026/7/10 1:09:41 👁️ 阅读次数 📝 编程学习
Go 并发安全 map:sync.Map 和分片锁的各自适用场景

Go 并发安全 map:sync.Map 和分片锁的各自适用场景

一、所有 map 都换成了 sync.Map,性能反而降了 40%

看到"并发安全 map"五个字,有人第一反应是全部换成 sync.Map。
线上服务替换后,QPS 从 8000 降到了 4800。
排查发现 sync.Map 在频繁写入的场景下表现很差。
更关键的是,代码里大量使用m["key"] = value赋值。
这个操作在 sync.Map 里变成了m.Store("key", value)
而 sync.Map 的 Store 在大量不同 key 写入时,内部有锁竞争。

sync.Map 的文档有明确提示。
"The Map type is optimized for two common use cases:
when the entry for a given key is only ever written once but read many times."
翻译:适合写少读多的场景。

为什么会出现这个性能反转?这要从 Go 的并发哲学说起。
Go 的并发模型建立在"Don't communicate by sharing memory; share memory by communicating"的理念上。
但很多时候这个理念没法完全贯彻——尤其是在高频数据共享的场景。

sync.Map 的设计思路非常巧妙。
它内部维护了两个 map:一个只读的readmap 和一个脏的dirtymap。
读取时优先走read(无锁),只在read中 miss 时才加锁去dirty里找。
这种设计在"写一次、读多次"的场景堪称完美——比如缓存配置表、字典数据。

但问题是:当写入频繁且 key 不断增长时,dirtymap 会频繁被提升为readmap,同时一个新的dirty被创建。
这个提升操作需要拷贝数据,是一个 O(n) 的操作,在高并发下成为瓶颈。

我们在线上用 pprof 做了火焰图分析。
发现 CPU 耗时中,sync.Map 的missLockeddirtyLocked两个内部函数占了总耗时的 28%。
这就是把 sync.Map 用错地方的直接证据。

二、四种并发 map 方案的内部机制对比

Go 有四种并发安全 map 方案。
各有适用场景,不能一概而论。

flowchart TB A[并发安全的 map 选什么?] --> B{特征判断} B -->|"写入一次<br/>读取多次"| C[sync.Map] B -->|"读写频繁<br/>Key 范围大"| D[分片锁 map] B -->|"Key 已知<br/>数量固定"| E[普通 map + sync.RWMutex] B -->|"超高并发<br/>Key 极多"| F[skipmap / 无锁结构] C --> G[适用: 缓存配置/字典] D --> H[适用: 会话管理/计数] E --> I[适用: 小规模局部使用] F --> J[适用: 千万级 Key 的场景]

sync.Map 的核心结构:
只读 map(read):无锁访问,存放稳定数据。
脏 map(dirty):加锁访问,存放新写入数据。
每次读取先查 read,命中直接返回(无锁)。
未命中再加锁查 dirty。

分片锁的核心结构:
多个 map 分片,每个分片一把锁。
key 哈希到不同分片,减少锁竞争。
分片数通常设为 32 或 64。

四种方案对比:

方案读性能写性能内存开销复杂度
sync.Map极高(无锁读)低(频繁写入时)中(双 map)
分片锁高(分片内加读锁)高(分片内加写锁)中(多个 map)
RWMutex+map中(读锁竞争)中(写锁竞争)低(单 map)最低
skipmap高(无锁读)高(无锁写)

sync.Map 的内存模型也要留意。
因为它维护了两份 map(read + dirty),在 dirty 被提升后,旧的 read 不会立即释放。
GC 的压力比普通 map 略大,虽然对于大多数场景可以忽略,但在内存敏感的服务中值得注意。

三、分片锁 map 的 Go 实现

下面的实现将一个大 map 拆成多个分片,每个分片有自己的 RWMutex。
通过 FNV hash 将 key 路由到对应分片,将全局锁竞争降低到分片内的锁竞争。

package concurrent import ( "hash/fnv" "sync" ) // ShardedMap 分片锁并发安全 map type ShardedMap struct { shards []*shard shardMask uint32 } type shard struct { mu sync.RWMutex data map[string]interface{} } // NewShardedMap 创建分片 map // numShards 必须是 2 的幂次方 func NewShardedMap(numShards int) *ShardedMap { if numShards <= 0 || (numShards&(numShards-1)) != 0 { numShards = 32 // 默认 32 分片 } sm := &ShardedMap{ shards: make([]*shard, numShards), shardMask: uint32(numShards - 1), } for i := 0; i < numShards; i++ { sm.shards[i] = &shard{ data: make(map[string]interface{}), } } return sm } func (sm *ShardedMap) getShard(key string) *shard { h := fnv.New32a() h.Write([]byte(key)) return sm.shards[h.Sum32()&sm.shardMask] } // Get 获取值 func (sm *ShardedMap) Get(key string) (interface{}, bool) { shard := sm.getShard(key) shard.mu.RLock() defer shard.mu.RUnlock() val, ok := shard.data[key] return val, ok } // Set 设置值 func (sm *ShardedMap) Set(key string, value interface{}) { shard := sm.getShard(key) shard.mu.Lock() shard.data[key] = value shard.mu.Unlock() } // Delete 删除 func (sm *ShardedMap) Delete(key string) { shard := sm.getShard(key) shard.mu.Lock() delete(shard.data, key) shard.mu.Unlock() } // Range 遍历所有元素 func (sm *ShardedMap) Range(f func(key string, value interface{}) bool) { for _, shard := range sm.shards { shard.mu.RLock() for k, v := range shard.data { if !f(k, v) { shard.mu.RUnlock() return } } shard.mu.RUnlock() } } // Len 元素总数 func (sm *ShardedMap) Len() int { count := 0 for _, shard := range sm.shards { shard.mu.RLock() count += len(shard.data) shard.mu.RUnlock() } return count }

性能基准测试

为了量化对比,下面的 Benchmark 同时测试了 sync.Map 和 ShardedMap 在纯读和纯写场景下的表现。

package concurrent import ( "fmt" "sync" "testing" ) func BenchmarkSyncMap_Load(b *testing.B) { var m sync.Map m.Store("key", "value") b.RunParallel(func(pb *testing.PB) { for pb.Next() { m.Load("key") } }) } func BenchmarkShardedMap_Get(b *testing.B) { m := NewShardedMap(32) m.Set("key", "value") b.RunParallel(func(pb *testing.PB) { for pb.Next() { m.Get("key") } }) } func BenchmarkSyncMap_Store(b *testing.B) { var m sync.Map b.RunParallel(func(pb *testing.PB) { i := 0 for pb.Next() { m.Store(fmt.Sprintf("key-%d", i), i) i++ } }) } func BenchmarkShardedMap_Set(b *testing.B) { m := NewShardedMap(32) b.RunParallel(func(pb *testing.PB) { i := 0 for pb.Next() { m.Set(fmt.Sprintf("key-%d", i), i) i++ } }) }

实际跑出来的结果(MacBook Pro M1, Go 1.22, 8 核):

  • 纯读(Load/Get): sync.Map 比 ShardedMap 快约 2.3 倍——符合预期,sync.Map 的无锁 read map 在纯读场景有绝对优势。
  • 纯写(Store/Set,不同 key): ShardedMap 比 sync.Map 快约 1.8 倍——因为分片锁将锁竞争分散到了 32 个分片上。
  • 纯写(Store/Set,相同 key): 两者相近——同一 key 的竞争无论如何都无法避免。

分片数的选择也是一个需要权衡的点。
32 分片适合大多数中规模场景(几千到几十万 key)。
如果你的 key 数量在百万级别,64 或 128 分片的吞吐量会更好。
但分片数不是越多越好——每个分片都有内存开销,分片数翻倍意味着初始内存占用翻倍。

四、选型决策流程

sync.Map 适合:
缓存配置表、字典数据;
写入一次、读取频繁的场景;
key 范围相对稳定的场景。

分片锁 map 适合:
高频读写并存的会话管理;
计数器和统计累加;
key 范围不断增长的场景。

RWMutex + map 适合:
并发度不高(< 4 个 goroutine 同时访问);
key 数量少(< 1000)的局部使用;
代码简单优先的场景。

不适合并发 map 的场景:
需要批量原子操作的场景(用单 map + Mutex);
需要排序遍历的场景(分片 map 的顺序不保证)。

在实际选型中,我的决策优先级是这样排列的:
先看读写比例。如果读比例 > 90%,sync.Map 大概率是最优解。
再看并发度。如果同时访问的 goroutine 数 < 4,RWMutex + map 就够用,代码最简单。
最后看 key 增长趋势。如果 key 数量稳定在几千以内且不增长,sync.Map 的优势更明显;如果 key 持续增长到几十万甚至百万级,分片锁方案更合适。

另外有一个容易被忽略的坑:sync.Map 的 Range 操作在遍历期间如果发生大量写入,可能导致读到的数据不一致。
sync.Map 的 Range 不保证强一致性,只保证"在遍历开始时的快照 + 部分新写入"。
如果你需要严格的一致性遍历,需要配合额外的锁或者直接用分片锁方案。

五、总结

并发安全 map 没有万能方案,需要按场景选型。
sync.Map 适合写少读多的缓存场景。
分片锁 map 适合读写并重的高频操作。
普通 RWMutex + map 适合低并发的简单场景。
选型关键看三个指标:读写比例、并发度、key 规模。

一句话总结选型原则:
如果你的 map 是"写一次、读一辈子"的配置缓存,用 sync.Map;
如果你的 map 是"时时刻刻都在读写"的会话管理,用分片锁;
如果你的 goroutine 不超过 4 个,用一个普通的 map + RWMutex,别费力不讨好。