您当前的位置:首页 > 电脑百科 > 程序开发 > 语言 > Go语言

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

时间:2023-09-18 10:33:23  来源:微信公众号  作者:翔叔架构笔记

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     []*cacheShard
    lifeWindow uint64
    clock      clock
    hash       Hasher
    config     Config
    shardMask  uint64
    close      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 的 mask
    shardMask:  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 使用了一个类似 ringbuffer 的 BytesQueue 结构,定义如下:

type cacheShard struct {
    // hashed key => bytes queue index
    hashmap     map[uint64]uint32
    entries     queue.BytesQueue
    lock        sync.RWMutex
    entryBuffer []byte
    onRemove    onRemoveCallback

    isVerbose    bool
    statsEnabled bool
    logger       Logger
    clock        clock
    lifeWindow   uint64

    hashmapStats map[uint64]uint32
    stats        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 + keyLength

    if 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.MaxEntrySize
    maximumShardSizeInBytes := 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:  20000000GC pause for bigcache:  22.382827msGC pause for freecache:  41.264651msGC 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 堆外内存

本文转载自微信公众号「 翔叔架构笔记」



Tags:Golang   点击:()  评论:()
声明:本站部分内容及图片来自互联网,转载是出于传递更多信息之目的,内容观点仅代表作者本人,不构成投资建议。投资者据此操作,风险自担。如有任何标注错误或版权侵犯请与我们联系,我们将及时更正、删除。
▌相关推荐
慢聊Golang协程池Ants实现原理
大家都知道goroutine 是 Go语言中的轻量级线程实现,由 Go 运行时(runtime)管理,Go 程序会智能地将 goroutine 中的任务合理地分配给每个 CPU。创建一个goroutine大小大概在2k左...【详细内容】
2023-12-27  Search: Golang  点击:(94)  评论:(0)  加入收藏
基于Go-Kit的Golang整洁架构实践
简介Go是整洁架构(Clean Architecture)的完美选择。整洁架构本身只是一种方法,并没有告诉我们如何构建源代码,在尝试用新语言实现时,认识到这点非常重要。自从我有了使用Ruby o...【详细内容】
2023-12-25  Search: Golang  点击:(154)  评论:(0)  加入收藏
Golang清晰代码指南
发挥易读和易维护软件的好处 - 第一部分嗨,开发者们,清晰的代码是指编写易于阅读、理解和维护的软件代码。它是遵循一组原则和实践,优先考虑清晰性、简单性和一致性的代码。清...【详细内容】
2023-12-18  Search: Golang  点击:(128)  评论:(0)  加入收藏
如何优雅的组织Golang项目结构
一个Go项目的结构设计始终遵循Go语言的简洁高效理念。一个合理和良好的布局可以提高代码的可读性,简化依赖管理,并优化编译过程。像cmd、internal和docs这样的目录是标准Go项...【详细内容】
2023-12-06  Search: Golang  点击:(169)  评论:(0)  加入收藏
从 Discord 的做法中学习 — 使用 Golang 进行请求合并
正如你可能之前看到的,Discord去年发布了一篇有价值的文章,讨论了他们成功存储了数万亿条消息。虽然有很多关于这篇文章的YouTube视频和文章,但我认为这篇文章中一个名为“数据...【详细内容】
2023-11-24  Search: Golang  点击:(257)  评论:(0)  加入收藏
使用Golang进行自动化的20个库
Golang,也被称为Go,是一种静态类型的编译型编程语言,由Robert Griesemer,Rob Pike和Ken Thompson在Google设计。它在2009年推出,旨在解决其他编程语言在并发编程、垃圾回收和代码...【详细内容】
2023-11-23  Search: Golang  点击:(204)  评论:(0)  加入收藏
Golang 中的 Bytes 包详解之 Bytes.Buffer
上篇文章详细讲解了一次性密码 OTP 相关的知识,基于时间的一次性密码 TOTP 是 OTP 的一种实现方式。这种方法的优点是不依赖网络,因此即使在没有网络的情况下,用户也可以生成密...【详细内容】
2023-11-07  Search: Golang  点击:(259)  评论:(0)  加入收藏
聊聊Golang饱受争议的Error
一、error是什么?在C中,返回错误通过errno.h中的错误代码来表示,比如0代表No error,也就是没有错误;2代表No such file or directory,也就是找不到指定路径的文件或文件夹;5代表Inp...【详细内容】
2023-11-06  Search: Golang  点击:(254)  评论:(0)  加入收藏
使用示例和应用程序全面了解高效数据管理的Golang MySQL数据库
Golang,也被称为Go,已经成为构建强大高性能应用程序的首选语言。在处理MySQL数据库时,Golang提供了一系列强大的库,简化了数据库交互并提高了效率。在本文中,我们将深入探讨一些...【详细内容】
2023-10-29  Search: Golang  点击:(307)  评论:(0)  加入收藏
Golang中的强大Web框架
揭示Fiber在Go Web开发中的特点和优势在不断发展的Web开发领域中,选择正确的框架可以极大地影响项目的效率和成功。介绍一下Fiber,这是一款令人印象深刻的Golang(Go语言)Web框架...【详细内容】
2023-10-26  Search: Golang  点击:(258)  评论:(0)  加入收藏
▌简易百科推荐
宝藏级Go语言开源项目——教你自己动手开发互联网搜索引擎
DIYSearchEngine 是一个能够高速采集海量互联网数据的开源搜索引擎,采用 Go 语言开发。Github 地址:https://github.com/johnlui/DIYSearchEngine运行方法首先,给自己准备一杯...【详细内容】
2024-03-12  OSC开源社区    Tags:Go语言   点击:(18)  评论:(0)  加入收藏
Go Gin框架实现优雅地重启和停止
在Web应用程序中,有时候我们需要重启或停止服务器,无论是因为更新代码还是进行例行维护。在这种情景下,我们需要保证应用程序的可用性和数据的一致性。这就需要优雅地关闭和重...【详细内容】
2024-01-30  源自开发者  微信公众号  Tags:Go   点击:(67)  评论:(0)  加入收藏
如何让Go程序以后台进程或daemon方式运行
本文探讨了如何通过Go代码实现在后台运行的程序。最近我用Go语言开发了一个WebSocket服务,我希望它能在后台运行,并在异常退出时自动重新启动。我的整体思路是将程序转为后台...【详细内容】
2024-01-26  Go语言圈  微信公众号  Tags:Go程序   点击:(60)  评论:(0)  加入收藏
深入Go底层原理,重写Redis中间件实战
Go语言以其简洁、高效和并发性能而闻名,深入了解其底层原理可以帮助我们更好地利用其优势。在本文中,我们将探讨如何深入Go底层原理,以及如何利用这些知识重新实现一个简单的Re...【详细内容】
2024-01-25  547蓝色星球    Tags:Go   点击:(66)  评论:(0)  加入收藏
Go 内存优化与垃圾收集
Go提供了自动化的内存管理机制,但在某些情况下需要更精细的微调从而避免发生OOM错误。本文将讨论Go的垃圾收集器、应用程序内存优化以及如何防止OOM(Out-Of-Memory)错误。Go...【详细内容】
2024-01-15  DeepNoMind  微信公众号  Tags:Go   点击:(61)  评论:(0)  加入收藏
Go函数指针是如何让你的程序变慢的?
导读Go 语言的常规优化手段无需赘述,相信大家也能找到大量的经典教程。但基于 Go 的函数值问题,业界还没有太多深度讨论的内容分享。本文作者根据自己对 Go 代码的使用与调优...【详细内容】
2024-01-15  腾讯云开发者  微信公众号  Tags:Go函数   点击:(86)  评论:(0)  加入收藏
Go编程中调用外部命令的几种场景
在很多场合, 使用Go语言需要调用外部命令来完成一些特定的任务, 例如: 使用Go语言调用Linux命令来获取执行的结果,又或者调用第三方程序执行来完成额外的任务。在go的标准库...【详细内容】
2024-01-09  suntiger    Tags:Go编程   点击:(101)  评论:(0)  加入收藏
Go 语言不支持并发读写 Map,为什么?
Go语言的map类型不支持并发读写的主要原因是并发读写会导致数据竞态(data race),这意味着多个 goroutine 可能同时访问并修改同一个 map,从而引发不确定的结果。在Go语言的设计...【详细内容】
2024-01-05  Go语言圈  微信公众号  Tags:Go 语言   点击:(77)  评论:(0)  加入收藏
Go微服务入门到容器化实践
Go微服务入门到容器化实践Go 是一门高效、现代化、快速增长的编程语言,非常适合构建 Web 应用程序。而 Docker 是一种轻量级的容器化技术,能够使得您的应用程序在任何地方运行...【详细内容】
2024-01-01  大雷家吃饭    Tags:Go微服务   点击:(62)  评论:(0)  加入收藏
你是否想知道如何应对高并发?Go语言为你提供了答案!
并发编程是当前软件领域中不可忽视的一个关键概念。随着CPU等硬件的不断发展,我们都渴望让我们的程序运行速度更快、更快。而Go语言在语言层面天生支持并发,充分利用现代CPU的...【详细内容】
2023-12-29  灵墨AI探索室  微信公众号  Tags:Go语言   点击:(107)  评论:(0)  加入收藏
站内最新
站内热门
站内头条