资讯详情

资讯详情

Golang本地内存缓存实战:从手写LRU到高并发性能优化

做Go服务端这几年我遇到的第一个性能瓶颈很少出现在CPU上而是出现在数据链路上每个请求都要查一次配置、查一次用户属性、查一次外部接口单次几十毫秒并不夸张但QPS一上来连接池先被打满紧接着是响应变慢、超时、雪崩。这时候大家的第一反应是上远程缓存但远程缓存解决的是多实例共享的问题它没法把一次内存访问真正压到纳秒级。真正能把热数据顶在进程内的就是Golang本地内存缓存。所谓本地内存缓存就是把高频访问的数据放在应用的进程内存里用map或者更复杂的数据结构存起来配合过期时间、容量控制、淘汰策略和并发保护让程序优先命中本机内存。它和远程缓存不是替代关系而是上下游配合的关系本地缓存挡量远程缓存保底数据库兜底。这篇文章我会结合自己实际项目的经验从设计思路讲到手写实现再讲到开源组件选型和真实接入时的坑适合正在做Go服务端、遇到读压力想加缓存的开发者阅读。1. 先想清楚本地内存缓存到底解决什么问题1.1 本地缓存和远程缓存不是替代关系在业务系统里远程缓存的核心价值是“多实例共享状态”配置下发、登录态、分布式锁、跨服务热点数据都依赖一份集中存放的数据。它最大的优点是数据一致写入一次所有节点都能读到缺点是每次访问都要走网络。就算内网延迟只有0.1毫秒放大到每秒几万次请求网络IO、序列化、反序列化的开销依然非常可观。本地缓存是进程内的一份数据副本读取它不需要网络、不需要序列化一次map读写只要几十到几百纳秒。但它的代价也很明确每个实例各存一份副本一致性需要自己维护。写入一条数据后得由业务自己决定其他实例什么时候能拿到新值通常做法就是短TTL加主动失效。很多高并发服务现在的标准做法是两级缓存本地缓存挡第一波流量未命中再回源到远程缓存还查不到才走数据库。这层设计的价值非常大尤其是配置类、属性类、标签类这类读多写少的数据加一层本地缓存后远程缓存和数据库的压力能下降一个数量级。1.2 适合放本地缓存的数据长什么样我自己的判断标准有三条读QPS很高单条数据量不大。比如商品标签、用户属性开关、状态枚举单个数据也就几百字节到几KB。更新频率很低或者对更新延迟不敏感。允许几秒甚至几分钟的延迟都是可以接受的。没有强一致要求。同一个请求大概率落在同一个实例上即使偶尔走了不同实例短暂读到旧值也不会出事故。反过来如果数据会被频繁写入、且写后要求立刻读到新值就不太适合放本地缓存。比如余额扣减、库存扣减这类强一致场景能用本地缓存的地方非常有限更稳妥的是直接走远程缓存加数据库事务。还有一个容易忽略的点本地缓存救不了“查询本身很慢”的接口它只能缓解“重复查询特别多”的问题。如果一条SQL本身要跑几百毫秒加缓存只是把同一份慢结果多存了几份治标不治本。这种场景应该先去优化查询本身。2. 从零手写三步搭一个能用的本地缓存2.1 第一步先有一个并发安全的存储最基础的本地缓存就是一个map加一把锁。但这里有一个常见的选型分歧用sync.Map还是sync.RWMutex加普通map我的建议是缓存场景优先用RWMutex加map。sync.Map是为读多写少但key集合动态变化的特殊场景优化的语义比较复杂而且它没有容量控制、没有过期时间最终还是要自己在外面包一层。普通map加读写锁的好处是逻辑简单读锁并发度足够高写锁保证一致性后续加容量、加过期、加淘汰都容易。type item struct { value interface{} expire int64 } type LocalCache struct { mu sync.RWMutex items map[string]*item } func NewLocalCache() *LocalCache { return LocalCache{ items: make(map[string]*item), } } func (c *LocalCache) Set(key string, value interface{}, ttl time.Duration) { c.mu.Lock() defer c.mu.Unlock() c.items[key] item{ value: value, expire: time.Now().Add(ttl).UnixNano(), } } func (c *LocalCache) Get(key string) (interface{}, bool) { c.mu.RLock() defer c.mu.RUnlock() it, ok : c.items[key] if !ok { return nil, false } if it.expire 0 time.Now().UnixNano() it.expire { return nil, false } return it.value, true }每次Get时都检查一下过期时间这种叫惰性过期它不需要额外goroutine读取路径上顺手就把过期key挡掉了。为什么用UnixNano()而不是直接存time.Time因为纳秒时间戳是整数比较起来更快而且能让item结构体里的指针链更短后面讲GC的时候你会看到这个细节的影响。2.2 第二步过期清理惰性检查加后台扫描惰性过期有个问题已经过期的key如果一直没人读就永远占着内存。所以需要加一个后台清理goroutine定期扫描并删除过期数据。func (c *LocalCache) startCleanLoop(interval time.Duration) { ticker : time.NewTicker(interval) go func() { for range ticker.C { now : time.Now().UnixNano() c.mu.Lock() for k, it : range c.items { if it.expire 0 now it.expire { delete(c.items, k) } } c.mu.Unlock() } }() }清理间隔我一般设置成10秒到1分钟不建议太频繁。如果每秒钟都全量扫一遍大mapCPU开销和锁等待会超过收益尤其map里条目很多的时候全表遍历的代价很高。我自己的经验是30秒扫一次比较平衡。这里还要强调一点如果缓存条数达到几十万全量扫描时持有写锁的时间会很长可能导致读请求短暂阻塞。更稳妥的做法是把清理任务拆散分批扫描或者每次只扫描固定数量的key避免单次持锁时间过长。2.3 第三步加容量上限和简单淘汰策略没有容量上限的缓存迟早出事。最简单粗暴的方式是每次Set后检查len超过上限就不让写或者随机删但随机删对命中率伤害很大。工程上更常用的是LRU最近最少使用。它用一个双向链表维护访问顺序每次Get把节点移到链表头容量满时淘汰链表尾。type entry struct { key string value interface{} expire int64 } type LRUCache struct { mu sync.Mutex capacity int items map[string]*list.Element order *list.List } func NewLRUCache(capacity int) *LRUCache { return LRUCache{ capacity: capacity, items: make(map[string]*list.Element), order: list.New(), } } func (c *LRUCache) Get(key string) (interface{}, bool) { c.mu.Lock() defer c.mu.Unlock() el, ok : c.items[key] if !ok { return nil, false } if el.Value.(*entry).expire 0 time.Now().UnixNano() el.Value.(*entry).expire { c.removeElement(el) return nil, false } c.order.MoveToFront(el) return el.Value.(*entry).value, true } func (c *LRUCache) Set(key string, value interface{}, ttl time.Duration) { c.mu.Lock() defer c.mu.Unlock() if el, ok : c.items[key]; ok { el.Value.(*entry).value value el.Value.(*entry).expire time.Now().Add(ttl).UnixNano() c.order.MoveToFront(el) return } if c.order.Len() c.capacity { c.removeOldest() } c.items[key] c.order.PushFront(entry{ key: key, value: value, expire: time.Now().Add(ttl).UnixNano(), }) } func (c *LRUCache) removeOldest() { el : c.order.Back() if el nil { return } c.removeElement(el) } func (c *LRUCache) removeElement(el *list.Element) { e : el.Value.(*entry) delete(c.items, e.key) c.order.Remove(el) }这里用一把sync.Mutex而不是读写锁原因是list的MoveToFront和Remove都会修改结构Get路径也需要写操作没办法只靠读锁。如果并发量很高一个优化方向是引入分片把数据按hash分散到多个LRUCache实例上每个实例独立加锁。这个思路我在后面会展开。3. 手写缓存容易忽略的两个底层问题GC和锁3.1 为什么map越大越容易卡Go的GC是并发标记清除不会完全暂停程序但标记阶段仍然需要扫描堆上的指针。如果缓存map里存的是*item指针GC就要沿着指针往下扫描item结构体里的interface{}值。interface{}内部又有类型信息和数据指针链越长扫描越慢。当缓存条目达到百万级甚至千万级GC压力会直接反映到接口延迟的P99上。我见过一个实际案例一个服务里缓存了300万条用户标签每条都存的是指针指向的结构体结果每次GC要扫描半个堆接口P99从5毫秒飙升到40多毫秒。定位之后发现不是业务代码慢而是GC标记阶段占了大量CPU。解决方向有两个一是减少指针让缓存的value尽量是基本类型或紧凑结构体不要嵌套太多层二是用连续内存方案预先分配一块大内存把key和value都编码成字节切片放进去GC只需要看到一个大的[]byte不需要深入内部标记。很多高性能开源组件走的都是第二条路。如果自己实现最简单的方法是先把复杂结构体序列化成[]byte再缓存读取时再反序列化。代价是序列化有一点CPU开销收益是GC扫描的指针链条大幅缩短。对于单条数据很小的场景这个取舍非常划算。3.2 分片加锁比一把大锁强在哪里单锁map在并发量很高时锁会变成全局争抢热点尤其写多或淘汰频繁时更明显。一个通用做法是把缓存分成N个分片每个分片有自己的锁和map通过hash(key)决定数据落在哪个分片。这样理论并发能力接近N倍。type ShardCache struct { shards []*shard shardSize int } type shard struct { mu sync.RWMutex items map[string]*item } func NewShardCache(shardSize int) *ShardCache { c : ShardCache{shardSize: shardSize} for i : 0; i shardSize; i { c.shards append(c.shards, shard{items: make(map[string]*item)}) } return c } func (c *ShardCache) getShard(key string) *shard { h : fnv.New32a() h.Write([]byte(key)) idx : h.Sum32() % uint32(c.shardSize) return c.shards[idx] } func (c *ShardCache) Set(key string, value interface{}, ttl time.Duration) { s : c.getShard(key) s.mu.Lock() defer s.mu.Unlock() s.items[key] item{ value: value, expire: time.Now().Add(ttl).UnixNano(), } } func (c *ShardCache) Get(key string) (interface{}, bool) { s : c.getShard(key) s.mu.RLock() defer s.mu.RUnlock() it, ok : s.items[key] if !ok { return nil, false } if it.expire 0 time.Now().UnixNano() it.expire { return nil, false } return it.value, true }分片数量通常取CPU核数的倍数或者直接用64、256这类2的幂。用2的幂还有个好处取模可以改成位运算。但要注意分片不能解决容量上限和淘汰你仍然需要在每个分片内部实现LRU或者定期清理。我实际用下来分片在“key分布均匀”的场景下效果明显。但如果某个业务的热点key特别集中所有流量都hash到同一个分片分片再多也白搭。这也是后面要提到的热key问题的根源。3.3 淘汰策略要跟着访问模式走缓存淘汰不是越复杂越好而是越匹配越好。FIFO按进入顺序淘汰实现最简单但对偶发访问特别不友好。一个key刚被读了一次可能紧接着就被淘汰了。LRU按最近访问时间淘汰绝大多数业务系统默认选它因为它对“时间局部性”很敏感。LFU按访问频次淘汰适合“少数热门key稳定占据大量请求”的场景但实现复杂还容易把刚进入高频期的新key误判淘汰掉。我自己的经验是80%的场景简化LRU就够了。比如短视频信息流里的热点内容流量来得猛、去得也快LRU能很好地保留最近访问过的爆款。如果热点key非常稳定比如某些榜单前几位常年霸榜再考虑类LFU策略。不要一上来就整复杂的淘汰算法你会在参数调优上花掉大量时间。4. 现成组件怎么选要不要继续造轮子4.1 我为什么建议先别急着造轮子我不反对自己写毕竟自己写能学到很多东西。但如果你是要在生产环境快速稳住第一选择应该是用成熟的开源组件。自己维护一个缓存组件除了基本功能还要处理并发、过期、淘汰、GC优化、监控指标、内存上限、异常恢复任何一环出错排查成本都远高于直接调用现成方案。更关键的是本地缓存通常处于性能关键路径上。自己写的版本在低并发下看不出问题一旦压测到每秒几十万次请求锁竞争、GC停顿、热key问题会一起爆出来。成熟组件经过大量场景验证边界处理更完善性能曲线也更稳定。如果你确实想造轮子我建议先回答三个问题你的key基数会有多大value是结构体还是字节流QPS预期在什么量级这三个问题直接决定了你需要实现到什么程度。4.2 几类组件的核心差别从实现思路上本地缓存大体分三类类型代表实现核心优势要付出的代价标准库工具sync.Map简单、零依赖没有过期和容量淘汰不适合当业务缓存常规LRU缓存自己实现或通用LRU组件支持TTL、容量控制通用性好性能受GC影响锁竞争要自己调零GC缓冲池型缓存预分配连续内存、把数据编码成字节的组件GC压力极低适合大量小对象缓存的是序列化数据无法直接存复杂结构高命中率缓存采用近似LFU、支持高并发的组件命中率高边界情况处理完善包体更大内存模型更复杂没有绝对的好坏只有适不适合。如果缓存的数据本身就是二进制流、图片片段、序列化后的JSON缓冲池型方案最合适。如果缓存的是业务结构体需要直接读字段就得选支持泛型或interface的常规LRU组件。4.3 选型判断标准我选型时一般问自己五个问题数据对象是复杂结构体还是字节流如果是结构体还希望直接读字段就不要选缓冲池型。单条数据有多大平均几十KB的场景内存压力比条目数更关键需要更严格控制内存上限。QPS到什么量级低于每秒几万普通LRU已经够高于每秒几十万要重点关注锁模型和GC。是否需要分布式一致性需要的话本地缓存只能做尽力而为核心一致还是要靠远程。团队愿不愿意为特殊实现长期维护有些组件API偏底层集成成本高需要提前评估。还有一个我从不用但很推荐别人试的方法拿真实业务流量做回放压测。把线上请求日志里的key序列拉出来分别灌进几个候选组件里对比命中率和内存占用。这比看任何benchmark都更能说明问题。5. 实际接入一次本地缓存配合单飞的完整流程5.1 场景假设假设现在有个读接口根据用户ID查一份用户属性数据原始数据存在数据库里QPS约3万数据库压力已经告警。第一版先加了远程缓存延迟从数据库的20毫秒降到1毫秒但远程缓存的连接池和序列化仍然占了大量CPU。进一步优化就是在远程缓存前面加一层本地缓存。这个场景很典型单条数据小、读多写少、允许几秒延迟、用户ID天然分散。本地缓存的目标是让大部分请求根本不走网络。5.2 核心代码和参数设置先定义加载函数负责在本地缓存未命中时回源func loadUserAttr(ctx context.Context, uid int64) (UserAttr, error) { // 先查远程缓存 // 未命中再查数据库 return attr, nil }再定义本地缓存和单飞var localCache NewLRUCache(10000) var loadGroup singleflight.Group func GetUserAttr(ctx context.Context, uid int64) (UserAttr, error) { if v, ok : localCache.Get(fmt.Sprintf(user_attr_%d, uid)); ok { return v.(UserAttr), nil } v, err, _ : loadGroup.Do(fmt.Sprintf(user_attr_%d, uid), func() (interface{}, error) { if v, ok : localCache.Get(fmt.Sprintf(user_attr_%d, uid)); ok { return v, nil } attr, err : loadUserAttr(ctx, uid) if err ! nil { return nil, err } localCache.Set(fmt.Sprintf(user_attr_%d, uid), attr, 5*time.Minute) return attr, nil }) if err ! nil { return UserAttr{}, err } return v.(UserAttr), nil }这里有两个细节要注意。第一singleflight.Group必须复用假如每次请求都new一个Group单飞就失效了。第二Do回调里要再查一次缓存因为可能第一层Get没命中但另一个请求已经在回源过程中等锁结束后缓存已经有值了不二次查询会白白多一次加载。参数怎么定容量10000是基于单条属性1到2KB估算的10000条约20MB对服务内存来说是合理预算。TTL设为5分钟是为了在数据新鲜度和回源压力之间取平衡。如果TTL太短比如30秒热点数据频繁过期本地缓存形同虚设如果TTL太长比如30分钟数据更新后长时间读不到新值。5.3 过期时间记得加随机抖动另一个很容易踩的坑是所有key同时过期。假设凌晨0点批量更新了一批配置所有key的TTL都一样那么5分钟后它们会同时失效所有请求同时回源远程缓存和数据库瞬间被打满。解法是给TTL加随机抖动ttl : 5*time.Minute time.Duration(rand.Float64()*float64(30*time.Second))加30秒的随机量就够了目的是让过期时间散开而不是精确错峰。这个技巧成本极低收益极其明显我后面每个缓存项目都默认带上。5.4 上线后要盯的指标没有监控的缓存等于没有缓存。我上线后至少盯四个指标命中率本地缓存命中次数除以总请求次数理想状态稳定在90%以上。缓存条目数监控是否接近容量上限以及有没有内存泄漏迹象。淘汰次数如果淘汰次数很高说明容量设置不合理。回源次数本地缓存未命中后实际打到远程缓存的次数这个指标直接反映数据库压力。具体埋点可以用Prometheus的counter或gauge代码里在Get、Set、Evict的地方打点。你会发现命中率数据能帮你发现很多奇怪问题比如某个key的基数突然暴增、TTL配置失误、热点流量从稳定变分散这些都是从监控里最先看出来的。5.5 真实踩过的坑第一个坑把用户ID直接当key没做类型统一。用户ID在某一端是int64另一端转成字符串两边的hash结果不同看起来缓存没生效其实是在查同一份数据的两个副本。第二个坑把数据库错误也缓存了。某个下游服务临时返回error如果直接把error存进缓存等于把故障也复制到了内存里。服务恢复后很长一段时间请求仍然拿到旧的错误结果。我的处理原则是只缓存成功结果失败直接返回不缓存。第三个坑缓存value里包含了不可复制的字段。比如结构体里有sync.Mutex直接复制或序列化时会有问题。这类数据不适合直接放进本地缓存要么改成指针要么拆开只缓存需要的字段。第四个坑热点key更新后还拿旧值。本地缓存和多实例更新天然存在延迟窗口。如果业务不能接受几秒的旧值要么不缓存这条数据要么在写数据库后主动删除本地缓存用消息通知各实例失效。6. 常见故障排查清单6.1 缓存内存只涨不降怎么办先用pprof看heapgo tool pprof -inuse_space http://localhost:6060/debug/pprof/heap定位到LocalCache相关的内存对象。如果条目数没涨但内存涨大概率是value太大存了不该缓存的大数组或大字符串。如果条目数一直涨重点检查淘汰策略是否只在Set时触发以及后台清理goroutine是不是因为锁竞争被饿死了。还有一个容易忽略的问题map删除了key但value里的底层数组可能仍然被切片引用着导致内存无法释放。比如缓存一个很大的切片虽然只返回其中一小段但只要整个切片还被引用底层内存就释放不了。这种情况最好在写入前就做好截断和复制。6.2 命中率一直上不去先看key的基数。如果key数量远超容量LRU永远在淘汰刚写入的key命中率自然很低。解决方法是加大容量、升级成分段LRU或者合并key粒度把细粒度的查询合并成粗粒度数据。再看TTL。TTL太短会导致数据刚写入就过期回源频率高TTL太长会增加内存压力和多实例一致性问题。我的建议是先用一个较长的TTL跑一天观察命中率和内存占用再逐步缩短找到平衡点。最后看key分布。如果大量请求都是低频的随机key比如按时间戳查日活数据这种数据本身不适合做长期缓存本地缓存只能挡住很小一部分重复请求。6.3 多实例数据不一致怎么办这是本地缓存绕不开的话题。短TTL是兜底方案最省事但延迟窗口固定。如果业务需要更高实时性可以在写数据库成功后通过消息通道通知各实例主动删除本地对应key删除时带上版本号避免旧数据覆盖新数据。但要注意消息通知本身也是异步的仍然存在短暂窗口。只能做到最终一致做不到强一致。如果业务对一致性要求极高就不要用本地缓存直接走远程缓存。6.4 本地缓存到底能不能替代远程缓存我的结论很明确不能。本地缓存是远程缓存的补充不是替代。原因是本地缓存与实例绑定无法跨节点共享也不适合存储需要全局唯一的数据比如计数器、分布式锁、全局配置。但对读多写少、允许短暂不一致、单实例QPS很高的数据本地缓存可以省掉大量网络开销让整体架构更抗打。最终设计通常是这样请求先查本地缓存未命中查远程缓存再未命中才查数据库数据库写入后主动失效本地和远程的key。三层各司其职既保证了性能又保留了容灾能力。最后分享一点我自己的体会。我踩过的最大的坑不是缓存代码写错而是上线后没有监控。缓存这个组件好的时候悄无声息坏的时候也悄无声息命中率掉到60%接口看起来还是正常只是数据库压力悄悄变大了直到某天大流量把数据库打挂回头查监控才发现命中率早就恶化了。所以如果你要引入Golang本地内存缓存我强烈建议先把三件事做进去命中率指标、缓存条目数、淘汰和过期次数。哪怕代码再简单这三个指标也要暴露出去。另外在本地缓存和远程缓存之间不要过度设计先用最简单的LRU加TTL跑起来跑出瓶颈再针对性地换更高性能的实现。希望这份经验能帮你少踩几个坑。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →