分片你的锁:对6种Go缓存设计进行基准测试

Lobsters Hottest 工具

摘要

本文在不同工作负载下对六种Go内存缓存设计进行了基准测试,发现使用256个锁的分片映射在单互斥锁和读写锁方法中表现最佳,尤其是在多核系统上。

<p><a href="https://lobste.rs/s/oaq69a/shard_your_locks_benchmarking_6_go_cache">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/06/27 13:53

# 分片你的锁:6种 Go 缓存设计的基准测试 来源:https://strebkov.dev/posts/shard-your-locks/ 我以六种方式构建了同一个内存 `string → string` 缓存,仅使用 Go 标准库,并在读密集型、均衡型和写密集型负载下,在 1 到 8 个核心上进行了基准测试。排名取决于工作负载——而其中一个“显而易见”的方案在核心数增加时**反而变慢**。 **TL;DR:** 分片你的锁。一个 256 路条带化映射(`sharded`)是全面赢家——在 8 核上比单个 `sync.Mutex` 快**高达 8 倍**——而且代码大约只有 15 行。`sync.RWMutex` 作为“读操作争用”的条件反射式修复方案是一个陷阱:它在超过两个核心后对读操作几乎没有帮助,并且对于写操作**比普通 mutex 更慢**。 ## 候选方案 | 缓存方案 | 思路 | 一句话总结 | |----------|------|------------| | `naive` | 普通 `map`,无锁 | 非线程安全——并发写会崩溃进程。仅作为基准线。 | | `mutex` | 一个 `sync.Mutex` | 简单、正确、不可扩展。 | | `rwmutex` | 一个 `sync.RWMutex` | 并行读、互斥写。 | | `syncmap` | `sync.Map` | 标准库自带的并发映射。 | | `sharded` | 256 个分片,各持一个 mutex | 锁条带化。键通过哈希路由。 | | `cow` | 通过 `atomic.Pointer` 实现写时复制 | 无锁读;每次写操作复制整个映射。 | 所有六个方案满足同一个接口,因此一个测试框架以完全相同的方式驱动它们。 代码:github.com/kluyg/in-memory-cache (https://github.com/kluyg/in-memory-cache) ## 我是如何测量的(简短版) 使用 `testing.B` + `b.RunParallel`,100 万个键,GOMAXPROCS 从 1 扫描到 8,运行在 20 核 i7-14700K 上。每个数据点是 10 次运行的中位数,用 `benchstat` 总结;波动幅度大多为 ±0–3%。下面的吞吐量是 `1000 / (ns/op)`,单位为百万次操作/秒——越高越好。我测量的是缓存**进程内**性能,而非通过 HTTP:net/http + JSON 耗费微秒级时间,这会将我追求的纳秒级差异淹没。 14700K 是**混合**芯片——8 个性能核(带超线程)加上 12 个能效核——因此不固定核心的扫描是个陷阱:当 GOMAXPROCS 上升时,操作系统可能将 goroutine 溢出到 E 核或超线程兄弟核上并在运行中迁移,这会混淆扩展曲线。因此,进程被固定到每个物理 P 核的一个线程(亲和性掩码 `0x5555`);每个 GOMAXPROCS 步骤增加一个真实的 P 核。固定操作在某些地方改变了绝对数值 10–25%,但所有排名和曲线形状保持不变。 一个故意忽略的维度:**值大小在这里无关紧要。** Go 字符串是不可变的,所以 `Set` 存储一个 16 字节的头,从不触碰值字节——64 B 和 16 KB 的基准测试结果相同(0 B/op)。值大小影响内存和 GC,不影响操作吞吐量。 ## 结果 各读/写比例下的吞吐量与核心数的关系,均匀分布 ![吞吐量与核心数]() 请阅读斜率,而不仅仅是高度: - **`sharded` 和 `cow` 向上攀升;`mutex` 是平的。** 核心越多,吞吐量越高——除非你选择了单个锁。 - **`cow` 在只读场景下表现优异**(8 核时 87 Mops/s,完全无锁读),但**一旦出现写操作就消失不见**——在三个有写操作的图中它被钉在 ≈0,因为每个 `Set` 都会复制整个百万条目的映射。 - **`sharded` 是唯一在所有图中都接近顶部的设计方案。** ### 显而易见的修复方案却逆向扩展 将每个设计方案标准化为其自身单核吞吐量,故事就更清晰了: ![缩放效率,只读]() - **`mutex` 低于 1×**——在 8 核时仅为单核速度的 0.66×。读操作无法并行执行,持有锁的缓存行在核心间乒乓传递。你增加了硬件却损失了性能。 - **`rwmutex` 在约 2× 处趋于平稳。** 共享的读者计数器成为新的争用点;在大约 4 个核心后它就不再提升。 - **`sharded` 达到 6.9×,而 `cow` 和 `syncmap` 跟踪甚至略微超过理想的 8× 线**(无锁读取因更大的聚合缓存而获得额外收益)。注意:`syncmap` 优秀的**斜率**掩盖了其较差的基线——它的绝对速度仍然比 `sharded` 慢。 ### 偏斜并不简单等于“更差” 实际缓存会看到 Zipfian 访问——少数热键承担大部分流量。常见假设是偏斜有害。实际情况更有趣: ![偏斜加速因子,8 核]() 高于 1× 表示在偏斜下**更快**。**几乎所有场景下读操作都变快**——热键留在 CPU 缓存中(`mutex` 读加速 1.6×,`syncmap` 加速 1.9×)。显著的例外是 **`sharded` 在均衡混合负载下为 0.82×——偏斜使其变慢**:热键碰撞到少数分片上,这些分片上的锁产生争用,而其余分片空闲。 `cow` 是对照组:其均衡混合负载的柱状图约为 1.03×,基本持平。这正是设计方案写操作成本**与分布无关**的特征——它在每次 `Set` 时复制整个映射,无论哪个键被更改,因此键分布无法影响它。偏斜仅在分布改变**工作落地点**(缓存行、分片)时才会影响数字;`cow` 的统一复制成本不受影响。其影响方向取决于你的设计方案和写操作比例。 ### 数据(8 核,ns/op,越低越好) **均匀分布:** | mix | mutex | rwmutex | syncmap | sharded | cow | |-------------|-------|---------|---------|---------|-----------| | 只读 | 168 | 53 | 30 | **11.5**| 21 | | 读密集型 | 168 | 259 | 37 | **22** | 12,000,000| | 均衡 | 190 | 282 | 57 | **24** | 46,500,000| | 写密集型 | 208 | 222 | 73 | **25** | 82,500,000| **Zipfian 分布 (s=1.1):** | mix | mutex | rwmutex | syncmap | sharded | cow | |-------------|-------|---------|---------|---------|-----------| | 只读 | 106 | 49 | 16 | 17 | **7** | | 读密集型 | 112 | 225 | 24 | **24** | 9,040,000 | | 均衡 | 126 | 183 | 46 | **29** | 45,100,000| | 写密集型 | 131 | 142 | 68 | **32** | 84,000,000| 那些 `cow` 写操作列里的八位数字是真实的,整个列的单位是 ns:一次写操作复制整个百万条目映射,比替代方案慢约 106 倍。`82,500,000` ns 是 **82 毫秒**——每次 `Set` 操作。这是无锁读取的代价。 ## 赢家,仅需几行代码 `sharded` 只是 N 个独立的映射,每个映射后面有自己的锁。键的哈希值选择分片,因此不同键上的操作几乎从不触碰同一个锁——争用大致减少 N 倍: ```go 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 ``` ```go const shards = 256 // 2 的幂,这样我们可以用掩码代替取模 type part struct { mu sync.Mutex m map[string]string } type Sharded struct{ parts [shards]*part } func (c *Sharded) at(key string) *part { h := uint64(14695981039346656037) // FNV-1a for i := 0; i < len(key); i++ { h = (h ^ uint64(key[i])) * 1099511628211 } return c.parts[h&(shards-1)] } func (c *Sharded) Get(key string) (string, bool) { p := c.at(key) p.mu.Lock() v, ok := p.m[key] p.mu.Unlock() // 显式调用,不用 defer(此处提升 8%——见下方注释) return v, ok } func (c *Sharded) Set(key, value string) { p := c.at(key) p.mu.Lock() p.m[key] = value p.mu.Unlock() } ``` 这就是全部思想。[真实版本](https://github.com/kluyg/in-memory-cache/blob/main/sharded.go) 添加了 `Delete`/`Len`,并将每个分片填充到自己的缓存行上(这样锁定一个分片不会弹跳相邻分片的缓存行),但核心逻辑就在这里。 关于那个显式的 `Unlock`:Go 1.14 的开放编码 defer 使得单次叶子 `defer` **便宜**——零额外分配——但并非免费。在 Go 1.26 上测量,这里的 `defer p.mu.Unlock()` 相比显式解锁(11.6 → 12.6 ns, p < 0.001)花费了 **+8%(约 1 ns)**。单独看微不足道,但在这样热的路径上,8% 就是 8%,因此热方法显式解锁。你可以自己重新运行:[defer_test.go](https://github.com/kluyg/in-memory-cache/blob/main/defer_test.go)。 ### 为什么是 256 个分片? 足以消除争用,又不至于浪费内存。在 8 核均衡混合负载下扫描分片数量,吞吐量急剧攀升至约 256 然后趋于平坦: ![分片数量与吞吐量]() 从 1 个锁到 256 个锁实现了 **9 倍提升**(4.6 → 43 Mops/s)。超过 256 后收益骤降:1024 个分片带来 +13%,4096 个分片仅 +18%——而分片数量是 4 倍和 16 倍,每个分片额外占用一个 map、一个 mutex 和一个缓存行。256 正好位于拐点。(根据你的核心数和写操作比例进行调整;扫描只需一次基准测试。) ## 实际应该使用什么 - **默认使用 `sharded`。** 在所有场景下最好或接近最好,随核心数扩展,编写简单。这是大多数并发映射的答案。 - **对于读多写少乃至只读数据使用 `cow`**——配置快照、路由表、功能开关。无与伦比的读性能,但仅当写操作罕见且可批量时使用。绝不要用于写密集型负载。 - **仅在特定场景使用 `sync.Map`**——键稳定,一次写入永久读取,或者 goroutine 访问不相交的键集。除此之外它表现平庸,并且**会分配内存**(接口装箱:40–72 B/op)。 - **`sync.RWMutex`:很少使用。** 它仅在读密集型、低核心数的狭窄角落获胜,并且对于写操作比普通 mutex 更差。 - **普通 `mutex` 在争用低或核心数少时足够好。** 不要在没有测量需求的情况下采用复杂方案。 - **绝不要在 goroutine 间使用 naive map**——Go 运行时会故意用 `concurrent map writes` 致命错误崩溃进程。 ## 让我惊讶的三件事 1. **更多核心使得单 mutex 缓存更慢。** 负扩展真实存在,原因就是锁本身的缓存行争用。 2. **`RWMutex` 是一个半吊子方案,在写操作上适得其反。** 读者计数的簿记开销一旦有写操作加入,其节省就不足以弥补。 3. **偏斜是一把双刃剑**,并非统一的惩罚——它通过缓存局部性加速读操作,同时集中写操作争用。 完整代码、原始 `benchstat` 输出以及一键重现所有结果(在你自己的硬件上)的命令都在[仓库](https://github.com/kluyg/in-memory-cache)中。

相似文章

No Slop Grenade

Hacker News Top

Redis与Memcached的比较,涵盖数据结构、性能、可扩展性和运维考量,以帮助选择正确的缓存解决方案。

Shard - 实现10倍KV缓存压缩

Reddit r/LocalLLaMA

Shard是一个即插即用的HuggingFace缓存,通过使用PCA加int4量化处理K(键),以及Hadamard旋转加向量量化处理V(值),为Llama-3.1-8B实现了10倍的KV缓存压缩,且在基准测试中无精度损失。