平滑扩容需采用分段锁+原子指针切换:将缓存拆为2的幂个shard,扩容时新建双倍shard并逐个迁移,读操作双检新旧结构,写仅入新结构,hash统一用fnv/xxhash且索引取模,各shard独立lru,总容量由原子计数器控制。

缓存模块必须支持并发安全的键值替换
平滑扩容不是指“加机器”,而是指缓存容量动态调整时,不中断读写、不丢失数据、不引发 panic。Go 语言原生 sync.Map 不支持容量控制,也不能在扩容时保留旧桶结构;自己用 map + sync.RWMutex 又容易在 resize 期间出现读写竞争。真正可行的做法是分段锁(sharded map)+ 原子切换指针。
实操建议:
- 把缓存拆成固定数量(比如 64 或 256)个
shard,每个 shard 独立一把sync.RWMutex,避免全局锁争用 - 扩容时新建一组 shard(数量翻倍),逐个迁移老 shard 的键值对,迁移完后用
atomic.StorePointer原子替换顶层指针 - 读操作始终先查新结构,查不到再 fallback 到旧结构(双检机制),确保迁移中数据不丢
- 写操作只写新结构,但需保证旧结构在迁移完成前不被 GC —— 用
runtime.SetFinalizer或显式等待迁移结束
resize 过程中 key hash 计算必须兼容新旧分桶逻辑
如果扩容前后用相同 hash 函数但桶数变了,key 落在哪一 shard 就会变。若不统一映射规则,迁移时无法确定该把 key 从哪个旧 shard 搬到哪个新 shard。
实操建议:
- hash 函数固定用
fnv.New64a()或xxhash.Sum64(),避免用string直接转 int(不同 Go 版本可能结果不同) - 分桶索引统一用
hash % shardCount,且扩容时新 shard 数量必须是 2 的幂(如 64 → 128),这样旧桶 i 中的 key 在新结构里只会落在 i 或 i + oldCount 两个位置 - 迁移单个 shard 时,遍历其全部 key,重新计算新索引,写入对应新 shard;不要试图“复制整个 map”——那是竞态源头
驱逐策略不能依赖全局 LRU 链表
全局链表在 resize 时极难安全维护:多个 goroutine 同时移动节点、修改 prev/next 指针,极易出现环或断链。而且 LRU 本身与分片设计冲突 —— 热点 key 集中在某几个 shard,全局排序失去意义。
实操建议:
- 每个 shard 内部维护独立的 LRU(用
list.List+map[interface{}]*list.Element),淘汰只在本 shard 内发生 - 总容量限制靠外部计数器(
atomic.Int64)控制,每次写入前检查是否超限,超限则随机选一个非空 shard 触发局部淘汰 - 不实现精确的“满即淘汰”,而用“水位触发”:比如当前用量达 90% 时开始惰性淘汰,避免每次写都检查
- 注意
list.Element.Value是 interface{},别直接存大 struct,否则 GC 压力陡增;优先存指针或小结构体
测试 resize 场景必须覆盖写-迁移-读并发路径
很多实现能跑通单测,但在真实压测下失败,问题往往出在“迁移中写入新 key 却没同步到旧结构查询路径”或“读操作拿到 nil 指针”。Go 的 race detector 能抓一部分,但不够。
实操建议:
- 写一个 stress test:启动 10 个 goroutine 持续写,10 个持续读,再起一个定时器每 100ms 触发一次 resize(模拟频繁扩容)
- 在 resize 函数开头插入
runtime.Gosched(),强制调度,放大竞态窗口 - 所有对外暴露的方法(
Get/Set/Delete)入口处加if c.shards == nil { panic("nil shards") },快速暴露空指针问题 - 用
go test -race -count=10多轮运行,比单次更易暴露问题
平滑扩容真正的难点不在算法,而在 resize 期间所有路径的内存可见性与指针有效性 —— 多半 bug 来自忘了 atomic.LoadPointer 或误用非线程安全的 map 操作。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











