如何缓存Go语言API响应以实现高性能
Go语言能够轻松构建开箱即用的快速API。但随着使用量的增长,仅靠语言级别的速度是不够的。如果每个请求都持续访问数据库、重复处理相同数据或反复序列化相同的JSON,延迟就会增加,吞吐量也会受到影响。缓存是通过存储已完成的工作来保持高性能的工具,使未来的请求能够立即重用这些工作。让我们看看在Go中缓存API的四种实用方法,每种方法都通过类比和可适应的简单代码进行解释。
目录
- 使用本地和Redis存储进行响应缓存
- 数据库查询结果缓存
- 使用ETag和Cache-Control进行HTTP缓存
- 使用后台刷新的Stale-While-Revalidate技术
- 总结
使用本地和Redis存储进行响应缓存
当生成API响应的过程变得昂贵时,最快的解决方案是存储整个响应。想象一下早晨高峰时段的咖啡店。如果每个顾客都点同样的拿铁,咖啡师可以为每个订单研磨豆子和蒸牛奶,但队伍移动会很慢。更聪明的做法是煮一壶咖啡,然后反复倒出。为了同时处理速度和规模,商店在柜台放一个小壶用于即时倒出,在后面放一个大壶用于补充。在软件术语中,柜台上的壶是本地内存缓存,如Ristretto或BigCache,而大壶是Redis,允许多个API服务器共享相同的缓存响应。
在Go中,这种两层设置通常遵循缓存旁路模式:首先在本地内存中查找,如果需要则回退到Redis,只有当两个层都未命中时才计算结果。一旦计算完成,该值将保存在Redis中供所有人使用,并在内存中保存以供下次调用立即重用。
|
|
在上面的代码中,首先尝试从本地缓存检索响应,如果键或数据存在则立即返回。如果未找到,则查询Redis作为第二层。如果Redis也没有返回任何内容,则运行昂贵的计算,并将其结果存储在Redis中,过期时间为60秒,以便其他服务可以访问它,然后放置在本地缓存中供立即重用。之后,响应作为JSON写回客户端。
这为您提供了两全其美的效果:重复调用的闪电般快速响应,以及所有API服务器之间的一致缓存。
数据库查询结果缓存
有时API本身很简单,但真正的成本隐藏在数据库中。想象一个新闻编辑室等待选举结果。如果每个编辑都不断打电话给计票办公室询问相同的数字,电话线路可能会堵塞。相反,一名记者打一次电话,将结果写在板上,每个编辑都从那里复制。板就是缓存,它既节省了时间,又减轻了办公室的压力。
在Go中,您可以通过缓存查询结果应用相同的原则。不是为每个相同的请求访问数据库,而是将结果存储在Redis中,使用一个代表查询意图的键。当下一个请求进入时,您从Redis拉取,跳过数据库,并更快地响应。
|
|
在这里,我们构建一个缓存键,使用用户ID唯一标识查询,然后尝试从Redis获取序列化结果。如果键存在,它将字节反序列化回User结构,并立即返回而不触及数据库。在缓存未命中时,它通过存储库执行实际的数据库查询,将User对象序列化为JSON,将其存储在Redis中,过期时间为两分钟,并返回结果。
这种模式显著减少了读取密集型API的数据库负载和响应时间,但您必须记住在数据更改时清除或刷新条目,或设置较短的生存时间值以保持结果合理新鲜。
使用ETag和Cache-Control进行HTTP缓存
并非所有缓存都必须在服务器内部发生。HTTP标准已经提供了让客户端或CDN重用响应的工具。通过设置像ETag和Cache-Control这样的头部,您可以告诉客户端响应是否已更改。如果没有新内容,客户端保留自己的副本,服务器只发送轻量级的304响应。
这类似于经理在办公室公告板上张贴通知。每张纸都带有一个小印章。员工将印章与已有的印章进行比较。如果匹配,他们知道自己的副本仍然有效,并跳过获取新副本。只有当印章更改时,他们才替换它。
在Go中,这很简单。从响应体计算ETag,将其与客户端发送的内容进行比较,并决定是返回完整负载还是仅返回304。
|
|
上面的代码生成一个ETag,它是响应内容的指纹或哈希,然后检查客户端是否发送了带有来自先前请求的匹配ETag的If-None-Match头部。如果ETag匹配,则内容未更改,因此服务器响应304 Not Modified状态且不发送正文,节省带宽。当ETag不匹配或客户端没有缓存版本时,服务器附加新的ETag和一个允许公共缓存60秒的Cache-Control头部,然后发送完整响应。
这种方法减少了带宽,降低了CPU使用率,并且与可以缓存和直接提供响应的CDN配合良好。
使用后台刷新的Stale-While-Revalidate技术
在某些情况下,如果服务稍微旧的数据可以保持API快速,这是可以接受的。股票仪表板、分析摘要或提要端点通常适合这种模型。不是让用户在每次请求时等待新数据,您可以立即提供缓存值,并在后台悄悄刷新它。这种技术称为Stale-While-Revalidate。
想象一下大厅里的股票行情屏幕。数字可能落后几秒钟,但对任何瞥见屏幕的人仍然有用。同时,后台进程获取最新数字并更新行情。读者永远不会盯着空白屏幕,系统即使在高峰期间也保持响应。
在Go中,可以通过存储不仅仅是缓存数据,还有定义数据新鲜的时间戳、仍然可以作为陈旧数据服务的时间戳以及必须重新计算的时间戳来构建这一点。singleflight包有助于确保只有一个goroutine执行刷新工作,防止更新堆积。
|
|
在这里,代码检索一个缓存条目,包含数据以及标记新鲜度和陈旧度边界的两个时间戳。如果当前时间在新鲜阈值之前,数据被认为是完全新鲜的并立即返回。如果时间已过新鲜阈值但仍在陈旧窗口内,代码立即返回稍微过时的数据,同时启动一个后台goroutine异步刷新它,确保下一个请求获得更新的信息。一旦时间甚至超过陈旧边界,数据太旧无法服务,因此代码阻塞并执行同步刷新后再返回。
这保持了低延迟,同时仍确保缓存定期更新,是新鲜度和性能之间的平衡。
总结
缓存不是单一策略,而是一套适应不同需求的策略。全响应缓存在顶层消除了重复工作。查询结果缓存保护数据库免受重复负载。HTTP缓存利用协议减少数据传输。Stale-While-Revalidate达成了一种妥协,倾向于速度而不让数据陈旧太久。
在实践中,这些方法通常是分层的。Go API可能使用本地内存和Redis进行响应,对热表应用查询级缓存,并设置ETag以便客户端避免不必要的下载。通过正确的组合,您可以将延迟降低几个数量级,处理更多的流量,并节省计算和数据库资源。