手撕 Golang 高性能内存缓存库 bigcache!

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 uint64clockclockhashHasherconfigConfigshardMaskuint64closechan 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{}),}


推荐阅读