Go语言API响应缓存高性能实战指南

本文详细介绍了四种Go语言API缓存技术:本地与Redis存储响应缓存、数据库查询结果缓存、ETag和Cache-Control的HTTP缓存,以及后台刷新的Stale-While-Revalidate模式,帮助提升API性能。

如何缓存Go语言API响应以实现高性能

Go语言能够轻松构建开箱即用的快速API。但随着使用量增长,仅靠语言层面的速度是不够的。如果每个请求都持续访问数据库、重复处理相同数据或反复序列化相同JSON,延迟将会增加,吞吐量也会受到影响。缓存通过存储已完成的工作来保持高性能,使得未来请求能够即时复用。让我们看看Go中缓存API的四种实用方法,每种方法都通过类比解释并附有可适配的简单代码。

目录

  • 本地和Redis存储的响应缓存
  • 数据库查询结果缓存
  • 使用ETag和Cache-Control的HTTP缓存
  • 后台刷新的Stale-While-Revalidate模式
  • 总结

本地和Redis存储的响应缓存

当生成API响应的过程变得昂贵时,最快的解决方案是存储整个响应。想象早晨高峰期的咖啡店。如果每位顾客都点同样的拿铁,咖啡师可以为每个订单研磨豆子和蒸牛奶,但队伍移动会很慢。更聪明的做法是煮一壶咖啡并反复倒出。为了兼顾速度和规模,店铺在柜台放一个小壶用于即时倒出,在后面放一个大壶用于补充。在软件术语中,柜台壶是本地内存缓存(如Ristretto或BigCache),大壶是Redis,允许多个API服务器共享相同的缓存响应。

在Go中,这种双层设置通常遵循缓存旁路模式:首先在本地内存中查找,如果需要则回退到Redis,仅当两个层都未命中时才计算结果。一旦计算完成,值将保存在Redis中供所有人使用,并在内存中保存以供下次调用立即重用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
val, ok := local.Get(key)
if !ok {
    val, err = rdb.Get(ctx, key).Result()
    if err == redis.Nil {
        val = computeResponse() // 昂贵的数据库或逻辑操作
        _ = rdb.Set(ctx, key, val, 60*time.Second).Err()
    }
    local.Set(key, val, 1)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(val))

在上面的代码中,首先尝试从本地缓存检索响应,如果键或数据存在则立即返回。如果未找到,则查询Redis作为第二层。如果Redis也未返回任何内容,则运行昂贵的计算,并将其结果存储在Redis中,过期时间为60秒,以便其他服务可以访问,然后放置在本地缓存中供立即重用。之后,响应作为JSON写回客户端。

这为您提供了两全其美的效果:重复调用的闪电般快速响应,以及所有API服务器之间的一致缓存。

数据库查询结果缓存

有时API本身很简单,但真正的成本隐藏在数据库中。想象一个等待选举结果的新闻编辑室。如果每位编辑都不断致电计票办公室获取相同数字,电话线路可能会堵塞。相反,一名记者致电一次,将结果写在板上,每位编辑都从那里复制。板就是缓存,它既节省时间又减轻办公室压力。

在Go中,您可以通过缓存查询结果应用相同原则。不是为每个相同请求访问数据库,而是将结果存储在Redis中,使用代表查询意图的键。当下一个请求进入时,从Redis拉取,跳过数据库,并更快响应。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
key := fmt.Sprintf("q:UserByID:%d", id)
if b, err := rdb.Get(ctx, key).Bytes(); err == nil {
    var u User
    _ = json.Unmarshal(b, &u)
    return u
}

u, _ := repo.GetUser(ctx, id) // 真实数据库调用
bb, _ := json.Marshal(u)
_ = rdb.Set(ctx, key, bb, 2*time.Minute).Err()
return u

这里,我们构建一个使用用户ID唯一标识查询的缓存键,然后尝试从Redis获取序列化结果。如果键存在,它将字节反序列化回User结构体并立即返回,无需接触数据库。在缓存未命中时,它通过存储库执行实际数据库查询,将User对象序列化为JSON,以两分钟过期时间存储在Redis中,并返回结果。

这种模式显著减少了读取密集型API的数据库负载和响应时间,但您必须记住在数据更改时清除或刷新条目,或设置较短的生存时间值以保持结果合理新鲜。

使用ETag和Cache-Control的HTTP缓存

并非所有缓存都必须在服务器内部发生。HTTP标准已经提供了让客户端或CDN重用响应的工具。通过设置ETag和Cache-Control等头部,您可以告诉客户端响应是否已更改。如果没有新内容,客户端保留自己的副本,服务器仅发送轻量级304响应。

这类似于经理在办公室板上张贴通知。每张纸带有一个小印章。员工将印章与已有的进行比较。如果匹配,他们知道自己的副本仍然有效并跳过拿新副本。仅当印章更改时他们才替换它。

在Go中这很简单。从响应体计算ETag,与客户端发送的内容比较,并决定是返回完整负载还是仅返回304。

1
2
3
4
5
6
7
8
9
etag := computeETag(responseBytes)
if match := r.Header.Get("If-None-Match"); match == etag {
    w.WriteHeader(http.StatusNotModified)
    return
}

w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "public, max-age=60")
w.Write(responseBytes)

上面的代码生成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执行刷新工作,防止更新雪崩。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
entry := getEntry(key) // {data, freshUntil, staleUntil}
switch {
case time.Now().Before(entry.freshUntil):
    return entry.data
case time.Now().Before(entry.staleUntil):
    go refreshSingleflight(key) // 后台刷新
    return entry.data
default:
    return refreshSingleflight(key) // 必须立即刷新
}

这里,代码检索包含数据以及标记新鲜度和陈旧度边界的两个时间戳的缓存条目。如果当前时间在新鲜阈值之前,数据被视为完全新鲜并立即返回。如果时间已过新鲜阈值但仍在陈旧窗口内,代码立即返回略微过时的数据,同时启动后台goroutine异步刷新,确保下一个请求获得更新信息。一旦时间甚至超过陈旧边界,数据太旧无法提供,因此代码阻塞并执行同步刷新后返回。

这保持低延迟,同时确保缓存定期更新,是新鲜度和性能之间的平衡。

总结

缓存不是单一策略,而是一套适应不同需求的策略集合。完整响应缓存在顶层消除重复工作。查询结果缓存保护数据库免受重复负载。HTTP缓存利用协议减少数据传输。Stale-While-Revalidate达成妥协,偏向速度而不让数据陈旧太久。

在实践中,这些方法通常是分层的。Go API可能使用本地内存和Redis进行响应,对热表应用查询级缓存,并设置ETag以便客户端避免不必要下载。通过正确组合,您可以将延迟降低几个数量级,处理更多流量,并节省计算和数据库资源。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计