一个 Gin 缓存中间件的设计与实现

我们在开发 HTTP Server 的时候,经常有对接口内容做缓存的需求。例如,对于某些热点内容,我们希望做 1 分钟内的缓存。短期内缓存相同内容不会对业务造成实质影响,同时也会降低系统的整体负载。

有时我们需要把缓存逻辑放在 Server 内部,而非网关侧如 Nginx 等,是因为这样我们可以根据需要便捷地清除缓存,或者可以使用 Redis 等其他存储介质作为缓存后端。

这样的缓存场景无非是有缓存时从缓存取,无缓存时从下游服务取,并将数据放入缓存中。这其实是个非常通用的逻辑,应该可以将其抽象出来。从而缓存逻辑无需侵入进业务代码。

cache

我常用的 HTTP 框架是 golang 的 gin。gin 官方就有一个 cache 组件:github.com/gin-contrib/cache,但这个 cache 组件无论在性能还是接口设计上,都有一些不足之处。

因此,我重新设计了一套 cache 中间件: gin-cache。 从压测结果来看,其性能相比于 gin-contrib/cache 明显提升。

gin-contrib/cache 的问题分析

gin-contrib/cache 是 gin 官方提供的一个 cache 组件,但这个组件在性能还是接口设计上,都并不令人满意。如下:

接口设计

  1. gin-contrib/cache 对外提供的使用方式是 wrap handler 的方式,而非更加优雅和通用的 middleware。
    如:
cache.CachePage(store, time.Minute, func(c *gin.Context) {
		c.String(200, "pong"+fmt.Sprint(time.Now().Unix()))
})
  1. 用户无法根据请求自定义地生成 cache key。gin-contrib/cache 只提供了 CachePageCachePageWithoutQuery 等函数,用户可以根据 url 作为缓存的 key。但该组件并不支持自定义 cache key。对于一些特殊场景,将无法满足需求。

性能方面

  1. 该组件写入 cache 的方式是,重载了 ResponseWriterWrite 函数。每次在 gin 中调用 Write 函数时,都会触发一次缓存的 get 和 append 操作。这种边写边 cache 的过程,其性能显然是比较糟糕的。
  2. 最糟糕的是关于并发安全的实现。由于该组件写缓存之前需要先 get 原始内容进行拼接,这个过程并非是原子的。为了保证在最 HTTP Server 基本的并发安全性,该组件在对外提供的 CachePageAtomic 接口,加了一把互斥锁来保证缓存不会写冲突, 代码如下。这把互斥锁会使得在并发越大的情况下,反而接口性能会越差。
func CachePageAtomic(store persistence.CacheStore, expire time.Duration, handle gin.HandlerFunc) gin.HandlerFunc {
	var m sync.Mutex
	p := CachePage(store, expire, handle)
	return func(c *gin.Context) {
		m.Lock()
		defer m.Unlock()
		p(c)
	}
}

关于性能的这两个方面,让我着实踩了一些坑。针对于性能方面的第一项,我也对 gin-contrib/cache 提了一个 pull request。但是其他方面, 尤其是接口设计方面,让我觉得这个库或许不是最终的答案。

在踩了这个库的坑后,我决定不如实现一个新的库,可以满足我这些需求。

新的方案

在踩了 gin-contrib/cache 的坑后,gin-cache 就随之诞生。其具体实现可以看 github.com/chenyahui/gin-cache

app.GET("/hello",
    cache.CacheByPath(cache.Options{
        CacheDuration: 5 * time.Second,
        CacheStore:    persist.NewMemoryStore(1 * time.Minute),
    }),
    func(c *gin.Context) {
        c.String(200, "hello world")
    },
)
  1. 对外提供的形式是 Middleware 的方式
  2. 用户可以根据自己需要自定义 cachekey 的生成方式。例如,可以根据 Header 内容或者 body 内容进行 Cache 。​自定义方式如下:
Cache(
    func(c *gin.Context) (string, bool) {
        return c.Request.RequestURI, true
    },
    options,
)

当然,我也提供了一些快捷方法:CacheByURICacheByPath,分别以 url 为 key 以及忽略 url 中的 query 参数为 key 进行 cache,这样可以满足大部分的需求。

在性能方面,相比于 gin-contrib/cache 边写边 cache 的方式,gin-cache 只会在整个 handler 结束后,cache 最终的 response 内容。整个过程只会涉及一次写 cache 的操作。

除此之外,gin-cache 也有其他方面的性能优化:

  1. sync.Pool 来优化高频对象的创建和释放。
  2. 使用 singleflight 解决缓存击穿问题.

缓存击穿问题

在缓存设计中,会遇到一个常见的问题: 缓存击穿 。缓存击穿指的是:当某个热点 key 在其缓存过期的一瞬间,大量的请求将访问不到这个 key 对应的缓存,这时请求将直接打到下游存储或服务中。一瞬间的大量请求,可能会对下游服务造成极大压力。

关于此问题,golang 官方有一个 singleflight 库: golang.org/x/sync/singleflight,可以有效的解决缓存击穿问题。其原理非常简单,有兴趣的可以直接在 Github 搜源码看就可以了,本文不再展开讨论。

benchmark

使用 Linux CPU 8 核,16G 内存的系统配置下,我使用 wrk 对 gin-contrib/cache 和 gin-cache 做了 benchmark 压力测试。

我们使用如下命令进行压测:

wrk -c 500 -d 1m -t 5 http://127.0.0.1:8080/hello

我们分别对MemoryCache和RedisCache两种存储后端进行了压测。从下图来看,最终的压测结果非常惊人。

memory_cache qps
redis_cache qps

  • 对于MemoryCache进程内缓存这个场景,gin-cache提升了23%。
  • 对于Redis做缓存后端这个场景,gin-cache相比来说QPS更是提升了30倍左右。当然这也得益于gin-cache使用的redis client库的性能更好。

而且,从二者的设计对比来看,在当 handler 请求耗时越大,gin-cache 的优势将更加明显。