从入门到掉坑:Go 内存池/对象池技术介绍(含GroupCache详解)( 三 )


3. BigCache相比分布式场景的 GroupCache , 如果你本地依然有千万级的 keys , 那推荐你用 bigcache 。 无数经验证明 , 超大 map 的内存池导致的 GC 延迟 , 是可以通过切 bigcache 解决的 。 那 bigcache 到底怎么做到的?
简单来说:shards map + map[uint]uint + []byte + free link = BigCache

  1. 定义 shards cache , 避免锁粒度过大
  2. map 里只存放 uint 避免指针
  3. 实现一个 queue 结构(实际是[]byte , 通过 uint 下标追加分配)
  4. 采用 free 链机制 , 删除保留空洞最后一起回收(这块逻辑还蛮复杂的 , 先留个不大不小的坑吧...)
其内存池定义如下:
type cacheShard struct {hashmapmap[uint64]uint32// key在entries中的位置entriesqueue.BytesQueue// 实际是[]byte , 新数据来了后copy到尾部}这样 GC 就变成了map 无指针+[]byte 结构的扫描问题了 , 因此性能会高出很多 。
坑 4: 两种方式(GroupCache 和 BigCache)对具体业务到底有多大影响?上面只是 map 实现内存池的模拟分析 , 以及两种典型 Cache 库的对比 。 如果你也和我一样 , 问自己“具体两种 Cache 对业务有多大影响呢”?那只能很高兴的对你说:欢迎来到坑底 -_-
我们线上大概需要单机缓存 1000 万左右的 keys 。 首先我尝试模拟业务 , 向两种 Cache 中插入 1000w 数据来测试 GC 停顿 。 然而因为实验代码或其他未知的坑 , 最后认为这个方法不太可侧
最后讨论 , 觉得还是用老办法 , 用 Prometheus 的 histogram 统计耗时分布 。 我们先统计底层存储(Redis)的耗时分布 , 然后再分别统计 BigCache 和 GroupCache 在写入 500w 数据后的实际情况 。 分析结论可知:
40ms 以上请求从 redis 数据看 , 40ms 以上请求占比0.08%;BigCache 的 40ms 以上请求占0.04%(即相反有一半以上超时请求被 Cache 挡住了) GroupCache 则是0.2% , 将这种长时间请求放大了1倍多(推测和 map 的锁机制有关)
10ms-40ms 请求redis 本身这个区间段请求占比24.11%;BigCache 则只有15.51% , 相当于挡掉了33%左右的高延迟请求(证明加热点 Cache 还是有作用的) GroupCache 这个区间段请求占比21.55% , 也比直接用 redis 来得好
详细数据分布:redis[0.1] 0.00%redis[0.5] 0.38%redis[1] 3.48%redis[5] 71.94%redis[10] 22.90%redis[20] 1.21%redis[40] 0.07%redis[ +Inf] 0.01%bigcache[0.1] 0.40%bigcache[0.5] 16.16%bigcache[1] 14.82%bigcache[5] 53.07%bigcache[10] 14.85%bigcache[20] 0.66%bigcache[40] 0.03%bigcache[ +Inf] 0.01%groupcache[0.1] 0.24%groupcache[0.5] 9.59%groupcache[1] 9.69%groupcache[5] 58.74%groupcache[10] 19.10%groupcache[20] 2.45%groupcache[40] 0.17%groupcache[ +Inf] 0.03%然而我们测完只能大致知道:本地使用 GroupCache 在 500w 量级的 keys 下 , 还是不如 BigCache 稳定的(哪怕 GroupCache 实现了 LRU 淘汰 , 但实际上因为有 Hot/Main Cache 的存在 , 内存利用效率上不如 BigCache)
分布式情况下 , GroupCache 和 BigCache 相比又有多少差距 , 这个就只能挖坑等大家一起跳了 。
4. 对象池与零拷贝在实际业务中 , 往往 map 中并不会存储 5000w 级的 keys 。 如果我们只有 50w 的 keys , GC 停顿就会骤减到 4ms 左右(其间 gc worker 还会并行工作 , 避免 STW) 。
例如无极(腾讯内部的一个配置服务)这类配置服务(或其他高频数据查询场景) , 往往需要 Get(key) 获取对应的结构化数据 。 而从 BigCache , CPU 消耗发现(如图) , 相比网络 IO 和 Protobuf 解析 , Get 占用0.78%、Set 占用0.9% , 基本可以忽略:
从入门到掉坑:Go 内存池/对象池技术介绍(含GroupCache详解)文章插图
【从入门到掉坑:Go 内存池/对象池技术介绍(含GroupCache详解)】CPU profile
因此优化的思路也很明确 , 我们参考 GroupCache 的 lru 实现 , 将 JSON 提前解析好 , 在业务侧 Get 时直接返回 struct 的指针即可 。 具体流程不复杂 , 直接 ppt 截图:
从入门到掉坑:Go 内存池/对象池技术介绍(含GroupCache详解)文章插图
zero-copy
我们把接口设计成注册的方式(注册需要解析 JSON 数据的结构) , 然后再 Get 时返回该结构的指针实现零拷贝 。 下面 benchmark 可以反映性能差异和内存分配情况(Client_Get 是实时 JSON 解析 , Filter_Get 是优化的对象池 API) , 可以切实看到0 allocs/op:
goos: linuxgoarch: amd64pkg: open-wuji/go-sdk/wujiclientBenchmarkClient_Get-810000001154 ns/op1.00 hits87 B/op3 allocs/opBenchmarkFilter_Get-84899364302 ns/op1.00 hits7 B/op1 allocs/opBenchmarkClient_GetParallel-88383149162 ns/op1.00 hits80 B/op2 allocs/opBenchmarkFilter_GetParallel-81305368091.4 ns/op1.00 hits0 B/op0 allocs/opPASSokopen-wuji/go-sdk/wujiclient 93.494sSuccess: Benchmarks passed.