Go 并发安全 map:sync.Map 和分片锁的各自适用场景
Go 并发安全 mapsync.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 的并发模型建立在Dont 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 的missLocked和dirtyLocked两个内部函数占了总耗时的 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 的核心结构只读 mapread无锁访问存放稳定数据。脏 mapdirty加锁访问存放新写入数据。每次读取先查 read命中直接返回无锁。未命中再加锁查 dirty。分片锁的核心结构多个 map 分片每个分片一把锁。key 哈希到不同分片减少锁竞争。分片数通常设为 32 或 64。四种方案对比方案读性能写性能内存开销复杂度sync.Map极高无锁读低频繁写入时中双 map低分片锁高分片内加读锁高分片内加写锁中多个 map中RWMutexmap中读锁竞争中写锁竞争低单 map最低skipmap高无锁读高无锁写高高sync.Map 的内存模型也要留意。因为它维护了两份 mapread 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 数 4RWMutex 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别费力不讨好。