1. 前言

你好哇!我是小翔。之前写了三篇 #Golang 并发编程 的文章了,这次来换换口味,开个 手撕源码 的新坑!一起来扒一扒 Go 语言高性能 local cache 库 bigcache,看看能不能把开源大佬们的骚操作带到项目里去装一装(?)

2. 为什么要学习开源项目

个人认为学习开源项目的收益:

  • 跟进社区,不做井底之蛙 看到一个开源项目,可以思考下:大佬们最近都在解决哪些问题?他们用到了哪些开源工具?我能拿到项目里用吗?这玩意有 bug 吗?要不要提个 issue 或者提个 PR 呢?

  • 面向原理编程 我们在实际项目中会用上很多开源库/框架,你是否好奇过它们的实现机制呢?理解用到的库的实现机制,能帮我们避开很多坑,堪称降维打击

  • 学习优秀的设计 优秀的开源项目经过了成千上万开发者的 review,质量一般会比公司赶进度赶出来的质量高得多得多,从中学习优秀的设计,再在实际项目中多用用,同事会感叹:

3. bigcache 简介

3.1 本地缓存与分布式缓存

缓存是系统提升并发能力、降低时延的利器,根据存储介质和使用场景,我们一般又会使用本地缓存与分布式缓存两种手段。本地缓存一般是在进程内的,最简单的,用 go 的 sync.Map 就能实现一个简单的并发安全的本地缓存了。常见的,将一些静态的、配置类的数据放置在本地缓存中,能有效降低到下游存储的压力。分布式缓存一般会用 redis 或 memcached 等分布式内存数据库来实现,能做到分布式、无状态。这次先研究下 bigcache 后续有机会再挖一挖这里。

3.2 bigcache 诞生背景

bigcache 的开发者是 allegro,是波兰的一个电商网站,参考资料中给出了他们的技术博客的原文,文中详细描述了他们问题的背景以及思考,值得研究。他们的需求主要是:

  • 用 HTTP 协议处理 GET POST 请求,body 不大

  • 10k rps(requests per second) 5k 读 5k 写

  • 缓存至少 10 分钟

  • 低延时:平均 5ms ,P99 < 10ms,P999 < 400ms
    总结一下,他们需要一个快速、支持过期淘汰、支持 RESTful api 的字典服务

开发团队经过了一番对比,选择了 go 语言(高并发度、带内存管理安全性比 C/C++ 好),抛弃了分布式缓存组件(redis/memcached/couchbase),主要理由是多一跳网络开销。这里我表示怀疑,P999 400ms 的时延其实不至于担心到 redis 网络那点时间,分布式环境下 local cache 不同机器间的数据不一致带来的 cache miss 可能更蛋疼。 最终开发团队选择了实现一个支持以下特性的内存缓存库:

  • 百万级缓存项时响应速度也很快

  • 并发安全

  • 支持设置过期时间

4. 关键设计

4.1 并发与 sharding

设计上如何做到并发安全呢?最简单的思路就是给 map 上一把 sync.RWMutex 即读写锁。然而当缓存项过多时,并发请求会造成锁冲突,因此需要降低锁粒度。bigcache 采用了分布式系统里常用的 sharding 思路,即将一个大 map 拆分成 N 个小 map,我们称为一个 shard(分片)。

bigcache.go 的声明,我们初始化得到的 BigCache,核心实际上是一个 []*cacheShard,缓存的写入、淘汰等核心逻辑都在 cacheShard 中了。

type BigCache struct {shards     []*cacheShardlifeWindow uint64clock      clockhash       Hasherconfig     ConfigshardMask  uint64close      chan struct{}
}

那么在写入一个 key value 缓存时,是如何做分片的呢?

func (c *BigCache) Set(key string, entry []byte) error {hashedKey := c.hash.Sum64(key)shard := c.getShard(hashedKey)return shard.set(key, hashedKey, entry)
}

这里会首先进行一次 hash 操作,将 string key hash 到一个 uint64 类型的 key。再根据这个数字 key 去做 sharding。

func (c *BigCache) getShard(hashedKey uint64) (shard *cacheShard) {return c.shards[hashedKey&c.shardMask]
}

这里把取余的操作用位运算来实现了,这也解释了为什么在使用 bigcache 的时候需要使用 2 的幂来初始化 shard num 了。

cache := &BigCache{shards:     make([]*cacheShard, config.Shards),lifeWindow: uint64(config.LifeWindow.Seconds()),clock:      clock,hash:       config.Hasher,config:     config,// config.Shards 必须是 2 的幂// 减一后得到一个二进制结果全为 1 的 maskshardMask:  uint64(config.Shards - 1),  close:      make(chan struct{}),
}

例如使用 1024 作为 shard num 时,mask 值为 1024 - 1 即二进制的 '111111111',使用 num & mask 时,即可获得 num % mask 的效果。

需要注意,这里的 hash 可能是会冲突的,虽然概率极小,当出现 hash 冲突时,bigcache 将直接返回结果不存在:

func (s *cacheShard) get(key string, hashedKey uint64) ([]byte, error) {s.lock.RLock()wrappedEntry, err := s.getWrappedEntry(hashedKey)if err != nil {s.lock.RUnlock()return nil, err}// 这里会将二进制 buffer 按顺序解开// 在打包时将 key 打包的作用就体现出来了// 如果这次操作的 key 和打包时的 key 不相同// 则说明发生了冲突,不会错误地返回另一个 key 的缓存结果if entryKey := readKeyFromEntry(wrappedEntry); key != entryKey {s.lock.RUnlock()s.collision()if s.isVerbose {s.logger.Printf("Collision detected. Both %q and %q have the same hash %x", key, entryKey, hashedKey)}return nil, ErrEntryNotFound}entry := readEntry(wrappedEntry)s.lock.RUnlock()s.hit(hashedKey)return entry, nil
}

4.2 cacheShard 与 bytes queue 设计

bigcache 对每个 shard 使用了一个类似 ringbufferBytesQueue 结构,定义如下:

type cacheShard struct {// hashed key => bytes queue indexhashmap     map[uint64]uint32entries     queue.BytesQueuelock        sync.RWMutexentryBuffer []byteonRemove    onRemoveCallbackisVerbose    boolstatsEnabled boollogger       Loggerclock        clocklifeWindow   uint64hashmapStats map[uint64]uint32stats        Stats
}

下图很好地解释了 cacheShard 的底层结构~

图片来自 https://medium.com/codex/our-go-cache-library-choices-406f2662d6b

在处理完 sharding 后,bigcache 会将整个 value 与 key、hashedKey 等信息序列化后存进一个 byte array,这里的设计是不是有点类似网络协议里的 header 呢?

// 将整个 entry 打包到当前 shard 的
// byte array 中
w := wrapEntry(currentTimestamp, hashedKey, key, entry, &s.entryBuffer)func wrapEntry(timestamp uint64, hash uint64, key string, entry []byte, buffer *[]byte) []byte {keyLength := len(key)blobLength := len(entry) + headersSizeInBytes + keyLengthif blobLength > len(*buffer) {*buffer = make([]byte, blobLength)}blob := *buffer// 小端字节序binary.LittleEndian.PutUint64(blob, timestamp)binary.LittleEndian.PutUint64(blob[timestampSizeInBytes:], hash)binary.LittleEndian.PutUint16(blob[timestampSizeInBytes+hashSizeInBytes:], uint16(keyLength))copy(blob[headersSizeInBytes:], key)copy(blob[headersSizeInBytes+keyLength:], entry)return blob[:blobLength]
}

这里存原始的 string key,我理解单纯是为了处理 hash 冲突用的。

每一个 cacheShard 底层的缓存数据都会存储在 bytes queue 中,即一个 FIFO 的 bytes 队列,新进入的 entry 都会 push 到末尾,如果空间不足,则会产生内存分配的过程,初始的 queue 的大小,是可以在配置中指定的:

func initNewShard(config Config, callback onRemoveCallback, clock clock) *cacheShard {// 1. 初始化指定好大小可以减少内存分配的次数bytesQueueInitialCapacity := config.initialShardSize() * config.MaxEntrySizemaximumShardSizeInBytes := config.maximumShardSizeInBytes()if maximumShardSizeInBytes > 0 && bytesQueueInitialCapacity > maximumShardSizeInBytes {bytesQueueInitialCapacity = maximumShardSizeInBytes}return &cacheShard{hashmap:      make(map[uint64]uint32, config.initialShardSize()),hashmapStats: make(map[uint64]uint32, config.initialShardSize()),// 2. 初始化 bytes queue,这里用到了上面读取的配置entries:      *queue.NewBytesQueue(bytesQueueInitialCapacity, maximumShardSizeInBytes, config.Verbose),entryBuffer:  make([]byte, config.MaxEntrySize+headersSizeInBytes),onRemove:     callback,isVerbose:    config.Verbose,logger:       newLogger(config.Logger),clock:        clock,lifeWindow:   uint64(config.LifeWindow.Seconds()),statsEnabled: config.StatsEnabled,}
}

注意到这点,在初始化时使用正确的配置,就能减少重新分配内存的次数了。

4.3 GC 优化

bigcache 本质上就是一个大的哈希表,在 go 里,由于 GC STW(Stop the World) 的存在大的哈希表是非常要命的,看看 bigcache 开发团队的博客的测试数据:

With an empty cache, this endpoint had maximum responsiveness latency of 10ms for 10k rps. When the cache was filled, it had more than a second latency for 99th percentile. Metrics indicated that there were over 40 mln objects in the heap and GC mark and scan phase took over four seconds.

缓存塞满后,堆上有 4 千万个对象,GC 的扫描过程就超过了 4 秒钟,这就不能忍了。

主要的优化思路有:

  1. offheap(堆外内存),GC 只会扫描堆上的对象,那就把对象都搞到栈上去,但是这样这个缓存库就高度依赖 offheap 的 malloc 和 free 操作了

  2. 参考 freecache 的思路,用 ringbuffer 存 entry,绕过了 map 里存指针,简单瞄了一下代码,后面有空再研究一下(继续挖坑

  3. 利用 Go 1.5+ 的特性:

当 map 中的 key 和 value 都是基础类型时,GC 就不会扫到 map 里的 key 和 value

最终他们采用了 map[uint64]uint32 作为 cacheShard 中的关键存储。key 是 sharding 时得到的 uint64 hashed key,value 则只存 offset ,整体使用 FIFO 的 bytes queue,也符合按照时序淘汰的需求,非常精巧。

经过优化,bigcache 在 2000w 条记录下 GC 的表现:

go version go version go1.13 linux/arm64

go run caches_gc_overhead_comparison.go Number of entries:  20000000
GC pause for bigcache:  22.382827ms
GC pause for freecache:  41.264651ms
GC pause for map:  72.236853ms

效果挺明显,但是对于低延时的服务来说,22ms 的 GC 时间还是很致命的,对象数还是尽量能控制住比较好。

5. 小结

认真学完 bigcache 的代码,我们至少有以下几点收获:

  • 可以通过 sharding 来降低资源竞争

  • 可以用位运算来取余数做 sharding (需要是 2 的整数幂 - 1)

  • 避免 map 中出现指针、使用 go 基础类型可以显著降低 GC 压力、提升性能

  • bigcache 底层存储是 bytes queue,初始化时设置合理的配置项可以减少 queue 扩容的次数,提升性能

参考资料

  • https://github.com/allegro/bigcache

  • 《allegro.tech blog - Writing a very fast cache service with millions of entries in Go》https://blog.allegro.tech/2016/03/writing-fast-cache-service-in-go.html

  • 《鸟窝 - 妙到颠毫: bigcache优化技巧》https://colobu.com/2019/11/18/how-is-the-bigcache-is-fast/

  • 《Stefanie Lai - Our Go Cache Library Choices》https://medium.com/codex/our-go-cache-library-choices-406f2662d6b

  • 《熊喵君的博客 - Golang 高性能 LocalCache:BigCache 设计与分析》https://pandaychen.github.io/2020/03/03/BIGCACHE-ANALYSIS/

  • https://github.com/coocood/freecache

  • https://github.com/glycerine/offheap 堆外内存

往期推荐

是什么让 Golang 如此受欢迎?语言创造者的回顾

一文告诉你Go 1.19都有哪些新特性

快速上手Thanos:高可用的 Prometheus

想要了解Go更多内容,欢迎扫描下方

手撕 Golang 高性能内存缓存库 bigcache! #4相关推荐

  1. 「GoCN酷Go推荐」高性能内存缓存 ristretto

    背景 ristretto 是 dgraph 团队开源的一款高性能内存缓存库,旨在解决高并发场景下的缓存性能和吞吐瓶颈.dgraph 专攻的方向是高性能图数据库,ristretto 就是其图数据库和 K ...

  2. 高性能 Java 缓存库 — Caffeine

    1.介绍 在本文中,我将介绍 Caffeine - 一个高性能的 Java 缓存库. 缓存和 Map 之间的一个根本区别在于缓存可以回收存储的 item. 回收策略为在指定时间删除哪些对象.此策略直接 ...

  3. java caffeine_高性能 Java 缓存库 — Caffeine

    1.介绍 在本文中,我将介绍 Caffeine - 一个高性能的 Java 缓存库. 缓存和 Map 之间的一个根本区别在于缓存可以回收存储的 item. 回收策略为在指定时间删除哪些对象.此策略直接 ...

  4. caffeine java_详细介绍高性能Java缓存库Caffeine

    1.介绍 在本文中,我们来看看Caffeine- 一个高性能的 Java 缓存库. 缓存和 Map 之间的一个根本区别在于缓存可以回收存储的 item. 回收策略为在指定时间删除哪些对象.此策略直接影 ...

  5. 万字Golang基础知识(肝爆三天三夜,手撕Golang基本语法结构)

    Golang基础知识 一. 初识Golang 1.1 Go的语法要求 1.1.1 token 1.2 变量和常量 1.2.1 变量 1.2.2 常量 1.3 基本数据类型 1.3.1 布尔类型 1.3 ...

  6. Java高性能本地缓存框架Caffeine

    文章目录 Java高性能本地缓存框架Caffeine 如何使用 缓存加载 手动加载 自动加载 手动异步加载 自动异步加载 过期策略 基于大小 基于时间 基于引用 Caffeine.weakKeys() ...

  7. iot架构 mqtt netty_Netty实现高性能IOT服务器(Groza)之手撕MQTT协议篇上

    前言 诞生及优势 MQTT由Andy Stanford-Clark(IBM)和Arlen Nipper(Eurotech,现为Cirrus Link)于1999年开发,用于监测穿越沙漠的石油管道.目标 ...

  8. Memcached:高性能的分布式内存缓存服务器

    Memcached:高性能的分布式内存缓存服务器 特征: u 协议简单: n 基于文本行的协议 u 基于libevent的事件处理: n 程序库,能实现连接数的增加,O(1)性能 u 内置内存存储方式 ...

  9. 2021年oppo哲库数字IC岗位手撕代码真题(含:握手信号、自动售卖机、序列发生器、根据RTL写verilog)

    大家好,最近汇总了2021年oppo哲库招聘手撕代码题目,本文章一共含有以下几个题目: 一,使用握手信号实现跨时钟域数据传输(verilog) 二,自动售卖机(verilog) 三,序列发生器(ver ...

最新文章

  1. get_sheet_of_light_result_object_model_3d算子说明
  2. Kali Linux 安全渗透教程第一更
  3. 【Python-ML】最小二乘法
  4. 计算机应用基础 聂敏,1. 在Excel中,公式“COUNT(C2:E3)”的含义是:( )。...
  5. linux下eclipse+pdt(PHP集成开发环境安装)
  6. solidworks无法获得下列许可standard_SolidWorks2020安装无法获得下列许可SOLIDWORKS Standard怎么解决?...
  7. ROS技术点滴 —— MoveIt!中的运动学插件
  8. 图像影音型计算机主板选择什么,电脑主板型号在哪里看? 每日一答
  9. 清新浪漫风情人节海报PSD分层模板!
  10. ELK+logstash配置日志报警
  11. spring Quartz基于配置文件和注解的实现
  12. Cadence Orcad capture导出像Visio规格的图纸图文教程及视频演示
  13. 排序算法(2)冒泡排序
  14. 垃圾邮件服务器 查询,exchange2013如果在服务器上查询到垃圾邮件的真实IP地址
  15. 当古典音乐遇上NFT
  16. cad动态块制作翻转_cad动态块拉伸制作方法,单开门动态块制作教程具体分析
  17. 中小企业信息安全:基本原则
  18. 六、图(上):六度空间
  19. python爬取煎蛋网妹子图
  20. FineReport 基础报表样式

热门文章

  1. STC-B学习板蜂鸣器播放音乐
  2. HTML调用PC摄像头【申明:来源于网络】
  3. UE4学习笔记 物体跟着spline移动
  4. linux系统挂载emc存储,Linux平台识别EMC存储磁盘
  5. 中断服务程序(ISR)
  6. 27.CF1004F Sonya and Bitwise OR 区间合并线段树
  7. Bitnami Redmine安装配置指南
  8. Intel RealSense(实感技术)概览
  9. mac 修改无扩展名文件的默认打开方式
  10. 值得一看的电影(转载自百度)