您现在的位置是:网站首页> 编程资料编程资料
使用Go实现健壮的内存型缓存的方法_Golang_
2023-05-26
382人已围观
简介 使用Go实现健壮的内存型缓存的方法_Golang_
使用Go实现健壮的内存型缓存
本文介绍了缓存的常见使用场景、选型以及注意点,比较有价值。
译自:Implementing robust in-memory cache with Go
内存型缓存是一种以消费内存为代价换取应用性能和弹性的方式,同时也推迟了数据的一致性。在使用内存型缓存时需要注意并行更新、错误缓存、故障转移、后台更新、过期抖动,以及缓存预热和转换等问题。
由来
缓存是提升性能的最便捷的方式,但缓存不是万能的,在某些场景下,由于事务或一致性的限制,你无法重复使用某个任务的结果。缓存失效是计算机科学中最常见的两大难题之一。
如果将操作限制在不变的数据上,则无需担心缓存失效。此时缓存仅用于减少网络开销。然而,如果需要与可变数据进行同步,则必须关注缓存失效的问题。
最简单的方式是基于TTL来设置缓存失效。虽然这种方式看起来逊于基于事件的缓存失效方式,但它简单且可移植性高。由于无法保证事件能够即时传递,因此在最坏的场景中(如事件代理短时间下线或过载),事件甚至还不如TTL精确。
短TTL通常是性能和一致性之间的一种折衷方式。它可以作为一道屏障来降低高流量下到数据源的负载。
Demo应用
下面看一个简单的demo应用,它接收带请求参数的URL,并根据请求参数返回一个JSON对象。由于数据存储在数据库中,因此整个交互会比较慢。
下面将使用一个名为plt的工具对应用进行压测,plt包括参数:
cardinality- 生成的唯一的URLs的数据,会影响到缓存命中率group- 一次性发送的URL相似的请求个数,模拟对相同键的并发访问。
go run ./cmd/cplt --cardinality 10000 --group 100 --live-ui --duration 10h --rate-limit 5000 curl --concurrency 200 -X 'GET' 'http://127.0.0.1:8008/hello?name=World&locale=ru-RU' -H 'accept: application/json'
上述命令会启动一个client,循环发送10000个不同的URLs,每秒发送5000个请求,最大并发数为200。每个URL会以100个请求为批次将进行发送,用以模仿单个资源的并发,下面展示了实时数据:

Demo应用通过CACHE环境变量定义了三种操作模式:
none:不使用缓存,所有请求都会涉及数据库naive:使用简单的map,TTL为3分钟advanced:使用github.com/bool64/cache 库,实现了很多特性来提升性能和弹性,TTL也是3分钟。
Demo应用的代码位于:github.com/vearutop/cache-story,可以使用make start-deps run命令启动demo应用。
在不使用缓存的条件下,最大可以达到500RPS,在并发请求达到130之后DB开始因为 Too many connections而阻塞,这种结果不是最佳的,虽然并不严重,但需要提升性能。

使用advanced缓存的结果如下,吞吐量提升了60倍,并降低了请求延迟以及DB的压力:

go run ./cmd/cplt --cardinality 10000 --group 100 --live-ui --duration 10h curl --concurrency 100 -X 'GET' 'http://127.0.0.1:8008/hello?name=World&locale=ru-RU' -H 'accept: application/json'
Requests per second: 25064.03 Successful requests: 15692019 Time spent: 10m26.078s Request latency percentiles: 99%: 28.22ms 95%: 13.87ms 90%: 9.77ms 50%: 2.29ms
字节 VS 结构体
哪个更佳?
取决于使用场景,字节缓存([]byte)的优势如下:
- 数据不可变,在访问数据时需要进行解码
- 由于内存碎片较少,使用的内存也较少
- 对垃圾回收友好,因为没有什么需要遍历的
- 便于在线路上传输
- 允许精确地限制内存
字节缓存的最大劣势是编解码带来的开销,在热点循环中,编解码导致的开销可能会非常大。
结构体的优势:
- 在访问数据时无需进行编码/解码
- 更好地表达能力,可以缓存那些无法被序列化的内容
结构体缓存的劣势:
- 由于结构体可以方便地进行修改,因此可能会被无意间修改
- 结构体的内存相对比较稀疏
- 如果使用了大量长时间存在的结构体,GC可能会花费一定的时间进行遍历,来确保这些结构体仍在使用中,因此会对GC采集器造成一定的压力
- 几乎无法限制缓存实例的总内存,动态大小的项与其他所有项一起存储在堆中。
本文使用了结构体缓存。
Native 缓存
使用了互斥锁保护的map。当需要检索一个键的值时,首先查看缓存中是否存在该数据以及有没有过期,如果不存在,则需要从数据源构造该数据并将其放到缓存中,然后返回给调用者。
整个逻辑比较简单,但某些缺陷可能会导致严重的问题。
并发更新
当多个调用者同时miss相同的键时,它们会尝试构建数据,这可能会导致死锁或因为缓存踩踏导致资源耗尽。此外如果调用者尝试构建值,则会造成额外的延迟。
如果某些构建失败,即使缓存中可能存在有效的值,此时父调用者也会失败。

可以使用低cardinality和高group来模拟上述问题:
go run ./cmd/cplt --cardinality 100 --group 1000 --live-ui --duration 10h --rate-limit 5000 curl --concurrency 150 -X 'GET' 'http://127.0.0.1:8008/hello?name=World&locale=ru-RU' -H 'accept: application/json'

上图展示了使用naive缓存的应用,蓝色标志标识重启并使用advanced缓存。可以看到锁严重影响了性能(Incoming Request Latency)和资源使用(DB Operation Rate)。
一种解决方案是阻塞并行构建,这样每次只能进行一个构建。但如果有大量并发调用者请求各种键,则可能会导致严重的锁竞争。
更好的方式是对每个键的构建单独加锁,这样某个调用者就可以获取锁并执行构建,其他调用者则等待构建好的值即可。

后台更新
当缓存过期时,需要一个新的值,构建新值可能会比较慢。如果同步进行,则可以减慢尾部延迟(99%以上)。可以提前构建那些被高度需要的缓存项(甚至在数据过期前)。如果可以容忍老数据,也可以继续使用这些数据。
这种场景下,可以使用老的/即将过期的数据提供服务,并在后台进行更新。需要注意的是,如果构建依赖父上下文,则在使用完老数据之后可能会取消上下文(如满足父HTTP请求),如果我们使用这类上下文来访问数据,则会得到一个context canceled错误。
解决方案是将上下文与父上下文进行分离,并忽略父上下文的取消行为。
另外一种策略是主动构建那些即将过期的缓存项,而无需父请求,但这样可能会因为一直淘汰那些无人关心的缓存项而导致资源浪费。
同步过期
假设启动了一个使用TTL缓存的实例,由于此时缓存是空的,所有请求都会导致缓存miss并创建值。这样会导致数据源负载突增,每个保存的缓存项的过期时间都非常接近。一旦超过TTL,大部分缓存项几乎会同步过期,这样会导致一个新的负载突增,更新后的值也会有一个非常接近的过期时间,以此往复。
这种问题常见于热点缓存项,最终这些缓存项会同步更新,但需要花费一段时间。
对这种问题的解决办法是在过期时间上加抖动。
如果过期抖动为10%,意味着,过期时间为0.95 * TTL 到1.05 * TTL。虽然这种抖动幅度比较小,但也可以帮助降低同步过期带来的问题。
下面例子中,使用高cardinality 和高concurrency模拟这种情况。它会在短时间内请求大量表项,以此构造过期峰值。
go run ./cmd/cplt --cardinality 10000 --group 1 --live-ui --duration 10h --rate-limit 5000 curl --concurrency 200 -X 'GET' 'http://127.0.0.1:8008/hello?name=World&locale=ru-RU' -H 'accept: application/json'

从上图可以看出,使用naive缓存无法避免同步过期问题,蓝色标识符表示重启服务并使用带10%抖动的advanced缓存,可以看到降低了峰值,且整体服务更加稳定。
缓存错误
当构建值失败,最简单的方式就是将错误返回给调用者即可,但这种方式可能会导致严重的问题。
例如,当服务正常工作时可以借助缓存处理10K的RPS,但突然出现缓存构建失败(可能由于短时间内数据库过载、网络问题或如错误校验等逻辑错误),此时所有的10K RPS都会命中数据源(因为此时没有缓存)。
对于高负载系统,使用较短的TTL来缓存错误至关重要。
故障转移模式
有时使用过期的数据要好于直接返回错误,特别是当这些数据刚刚过期,这类数据有很大概率等于后续更新的数据。
故障转移以精确性来换取弹性,通常是分布式系统中的一种折衷方式。
缓存传输
缓存有相关的数据时效果最好。
当启动一个新的实例时,缓存是空的。由于产生有用的数据需要花费一定的时间,因此这段时间内,缓存效率会大大降低。
有一些方式可以解决"冷"缓存带来的问题。如可以通过遍历数据来预热那些可能有用的数据。
例如可以从数据库表中拉取最近使用的内容,并将其保存到缓存中。这种方式比较复杂,且并不一定能够生效。
此外还可以通过定制代码来决定使用哪些数据并在缓存中重构这些表项。但这样可能会对数据库造成一定的压力。
还可以通过共享缓存实例(如redis或memcached)来规避这种问题,但这也带来了另一种问题,通过网络读取数据要远慢于从本地缓存读取数据。此外,网络带宽也可能成为性能瓶颈,网络数据的编解码也增加了延迟和资源损耗。
最简单的办法是将缓存从活动的实例传输到新启动的实例中。
活动实例缓存的数据具有高度相关性,因为这些数据是响应真实用户请求时产生的。
传输缓存并不需要重构数据,因此不会滥用数据源。
在生产系统中,通常会并行多个应用实例。在部署过程中,这些实例会被顺序重启,因此总有一个实例是活动的,且具有高质量的缓存。
Go有一个内置的二进制系列化格式encoding/gob,它可以帮助以最小的代价来传输数据,缺点是这种方式使用了反射,且需要暴露字段。
使用缓存传输的另一个注意事项是不同版本的应用可能有不兼容的数据结构,为了解决这种问题,需要为缓存的结构添加指纹,并在不一致时停止传输。
下面是一个简单的实现:
// RecursiveTypeHash hashes type of value recursively to ensure structural match. func recursiveTypeHash(t reflect.Type, h hash.Hash64, met map[reflect.Type]bool) { for { if t.Kind() != reflect.Ptr { break } t = t.Elem() } if met[t] { return } met[t] = true switch t.Kind() { case reflect.Struct: for i := 0; i < t.NumField(); i++ { f := t.Field(i) // Skip unexported field. if f.Name != "" && (f.Name[0:1] == strings.ToLower(f.Name[0:1])) { continue } if !f.Anonymous { _, _ = h.Write([]byte(f.Name)) } recursiveTypeHash(f.Type, h, met) } case reflect.Slice, reflect.Array: recursiveTypeHash(t.Elem(), h, met) case reflect.Map: recursiveTypeHash(t.Key(), h, met) recursiveTypeHash(t.Elem(), h, met) default: _, _ = h.Write([]byte(t.String())) } }可以通过HTTP或其他合适的协议来传输缓存数据,本例中使用了HTTP,代码为/debug/transfer-cache。注意,缓存可能会包含不应该对外暴露的敏感信息。
在本例中,可以借助于单个启用了不同端口的应用程序实例来执行传输:
CACHE_TRANSFER_URL=http://127.0.0.1:8008/debug/transfer-cache HTTP_LISTEN_ADDR=127.0.0.1:8009 go run main.go
提示:
本文由神整理自网络,如有侵权请联系本站删除!
本站声明:
1、本站所有资源均来源于互联网,不保证100%完整、不提供任何技术支持;
2、本站所发布的文章以及附件仅限用于学习和研究目的;不得将用于商业或者非法用途;否则由此产生的法律后果,本站概不负责!
点击排行
本栏推荐
