直接用 map[string]string 做缓存会因无淘汰机制导致内存只增不减,runtime.GC 无法回收,而 github.com/hashicorp/golang-lru/v2 提供线程安全、O(1) 复杂度的 LRU 实现,配合明确容量与合理分片可稳住内存水位并提升高并发性能。

为什么直接用 map[string]string 做缓存会出问题
因为没淘汰机制,内存只增不减。业务里反复拼接或解析的字符串(比如 URL path、JSON key 路径、SQL 模板)一旦缓存无上限,runtime.GC 都救不回来。LRU 不是银弹,但对“最近频繁用、历史很少用”的字符串场景,它能稳住内存水位。
用 github.com/hashicorp/golang-lru 实现最小可行缓存
别自己重造轮子——这个库轻量、线程安全、API 干净。它内部用双向链表 + map,Get 和 Put 都是 O(1)。
实操建议:
- 初始化时明确容量,比如
lru.New(1024),别用lru.New2Q或其他变种——2Q 对字符串这种小对象没收益,还多一层哈希判断 - 键必须是
string,值也建议是string;如果存结构体指针,得确保不会被外部修改,否则缓存语义就乱了 - 注意
OnEvicted回调:如果字符串背后关联了资源(比如临时文件名),这里才是释放时机,不是Get时
示例:
立即学习“go语言免费学习笔记(深入)”;
cache, _ := lru.New(512)
cache.Add("user:123:profile", `{"name":"alice","age":30}`)
if val, ok := cache.Get("user:123:profile"); ok {
// val 是 interface{},需类型断言
s := val.(string)
}
字符串比较开销大?用 unsafe.String 转换要小心
Go 1.20+ 支持 unsafe.String 快速把 []byte 转成 string 零拷贝,但缓存键若来自 unsafe.String,得确认底层字节切片生命周期比缓存长——否则缓存里存的是悬垂指针,读出来是脏数据或 panic。
常见错误现象:fatal error: unexpected signal during runtime execution 或返回空字符串。
安全做法:
- 只对静态字面量或明确持有所有权的
[]byte(比如从io.ReadFull读满的缓冲区)用unsafe.String - 从 HTTP body、数据库字段等来的字节,先
copy再转,别图快直接unsafe.String - 缓存键尽量用原始
string构造,而不是反复转来转去
并发读写下性能掉得厉害?加读写锁反而更慢
github.com/hashicorp/golang-lru 默认用 sync.RWMutex,但高并发 Get 场景下,读锁竞争仍明显。实测 QPS 过 5k 后,锁成为瓶颈。
替代方案:
- 用
lru.NewWithARC(ARC 变种),它把访问频次和时间双维度建模,热点字符串更难被淘汰,间接减少Getmiss 导致的锁争抢 - 或者拆分缓存:按前缀哈希分片,比如
shard[key[0]%4].Get(key),每个分片独立锁,吞吐翻倍 - 别用
sync.Map替代——它对字符串键的 hash 计算开销比原生map高,且不支持 LRU 淘汰
分片示例:
type ShardedCache struct {
shards [4]*lru.Cache
}
func (c *ShardedCache) Get(key string) (interface{}, bool) {
idx := int(key[0]) % 4
return c.shards[idx].Get(key)
}
缓存键的散列分布是否均匀,比算法本身更容易决定实际效果。一个长字符串前缀全一样(比如都以 /api/v1/ 开头),分片或哈希就失效了——这时候得提前截断或加盐。


















