Day24 Gin with Cache

前言

我们在并发HTTP Server的时候,经常有对接口内容做缓存的需求。例如:某些热点内容,我们希望在1分钟内做缓存,避免短时间内不会对相同的内容进行重复性读取与运算,同时也降低系统的整体负载。

有时我们需要把缓存逻辑放在Server内部,而非网观测如Nginx等。是因为这样我们可以根据需求便捷地清除缓存,或者利用Redis等其他储存空间做为缓存後端。

也因此需要快取的资料有两种特徵:

  • 经常被调用
  • 资料不常变动

那快取的种类也可依不同类型分为三种:

  • Client Cache
  • Server Cache
  • Network Cache

Client Cache

Client Cache指的是服务器与浏览器之间的快取机制,Server Side透过服务器的快取将不常变动的资讯储存在浏览器快取之中,避免重复下载浪费效能。

Client Cache的设定方式是透过HTTP Request的Header去带params,当浏览器接收到特定params就会采取相对应的快取处理。

如果对Client Cache有兴趣的人可以参考下方连结,胡立大写的很好

https://blog.techbridge.cc/2017/06/17/cache-introduction/

Server Cache

Server Cache主要指的是在Backend 与 Database间资料的Cache。当大量的Query Request进来时,会导致Database的I/O操作过多,因而造成Session堵塞、效能低落等问题,即使进行读写分离在不同的丛集当中,也只能解决部份问题,因此我们会将经常被查询的资料储存在像是Redis之类的key-value资料库,并以LRU等Strategy来进行快取资料的变更,以分担资料库I/O压力。而我们这章节主要也是在讲Gin在Server Cache的实作!

有兴趣者也可以参考Cloudflare的官方资讯,他们解说的挺详细的

https://www.cloudflare.com/zh-tw/cdn/

Network Cache

最後则是Network Cache,他的理念即是User会从离他最近的Server去取资料,用以节省Response Time,也就是CDN(Content Delivery Network)的概念!

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

Gin with Cache

这边我们选用的是yahui大神所重新封装的gin-cache package,因其可以依据自身要求定义cache key, 且在性能方面也较官方的gin-contrib/cache优秀,因此选用它。

Installation

go get -u [github.com/chenyahui/gin-cache](http://github.com/chenyahui/gin-cache%E3%80%82)

Router with Cache

app/config/router.go

package config

import (
	cache "github.com/chenyahui/gin-cache"
	"github.com/chenyahui/gin-cache/persist"
	"github.com/gin-gonic/gin"
	"ironman-2021/app/controller"
	"ironman-2021/app/middleware"
	_ "ironman-2021/docs"
	"time"
)

func RouteUsers(r *gin.Engine, m *persist.RedisStore) {
	posts := r.Group("/v1/users")
	{
		posts.POST("/", controller.NewUsersController().CreateUser)
		posts.GET("/:id", middleware.JWTAuthMiddleware(), cache.CacheByRequestURI(m, 2*time.Hour),
			controller.QueryUsersController().GetUser)
		posts.POST("/login", controller.LoginUserController().AuthHandler)
	}
}
  • 我们让GET /v1/users/:id 这个endpoint吃得到快取,然後快取时间设定为2小时

main.go

package main

import (
	"github.com/chenyahui/gin-cache/persist"
	"github.com/gin-gonic/gin"
	"github.com/go-redis/redis/v8"
	"github.com/joho/godotenv"
	ginSwagger "github.com/swaggo/gin-swagger"
	"github.com/swaggo/gin-swagger/swaggerFiles"
	"ironman-2021/app/config"
	"ironman-2021/app/dao"
	"ironman-2021/app/middleware"
	"ironman-2021/app/model"
	"os"
)

// @title Gin swagger
// @version 1.0
// @description Gin swagger

// @contact.name Flynn Sun

// @license.name Apache 2.0
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html

// @host localhost:8080
// schemes http
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
func main() {
	envErr := godotenv.Load()
	if envErr != nil {
		panic(envErr)
	}

	port := os.Getenv("PORT")
	dbConfig := os.Getenv("DB_CONFIG")
	db, ormErr := dao.Initialize(dbConfig)
	if ormErr != nil {
		panic(ormErr)
	}
	migrateErr := db.AutoMigrate(&model.User{})
	if migrateErr != nil {
		return
	}

	server := gin.Default()
	server.Use(middleware.CORSMiddleware())
	server.GET("/hc", func(c *gin.Context) {
		c.JSON(200, gin.H{
			"message": "health check",
		})
	})
	redisStore := persist.NewRedisStore(redis.NewClient(&redis.Options{
		Network: "tcp",
		Addr:    "redis:6379",
		DB: 0,
	}))
	config.RouteUsers(server, redisStore)
	url := ginSwagger.URL("http://localhost:8080/swagger/doc.json")
	server.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler, url))
	err := server.Run(":" + port)
	if err != nil {
		panic(err)
	}
}

Performance

我们可以看到下图在第一次GET /v1/users/:id与之後几次的Response Time都差距相当的多,因为第二次之後都是从Cache拿取资料

https://ithelp.ithome.com.tw/upload/images/20211009/20129737Y50NIzIM2C.png

此外我们也可以进去redis的container,并观察到当第一次GET /v1/users/:id

後,会多一个/v1/users/1的Key,我们就以Key-Value的资料去读取快取

docker exec -it redis bash

root@e2c9049d9ac3:/data# redis-cli -n 0
127.0.0.1:6379> KEYS *
(empty array)
127.0.0.1:6379> KEYS *
1) "/v1/users/1"
127.0.0.1:6379>

题外话,能够如此容易的使用各项服务与监测效能也是我们用dockerdocker-compose的好处之一。

gin-contrib/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只提供CachePage、CachePageWithoutQuery等函数,用户可以根据url作为缓存的key。但该组件并不支持自定义cache key。

性能方面

  1. 该组件写入缓存的方式是,重载了ResponseWriter的写入函数。每次在gin中调用写入函数时,触发一次性能的获取和追加操作。比较差的。
  2. 最糟糕的是关於并发安全的实现。因为该组件写缓存之前需要先得到原始内容进行生成,这个过程不是原子的。加了一个互斥锁来保证不会写冲突,接口代码如下。
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)
	}
}

缓存击穿问题

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

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

benchmark

使用Linux CPU 8核,16G内存系统配置下,Cyhone大使用wrk对gin-contrib/cachegin-cache做benchmark压力测试,这边我则撷取他的实验结果来解说,有兴趣者可以去Reference的连结转连。

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

https://ithelp.ithome.com.tw/upload/images/20211009/201297374A8KSZV4bJ.png
https://ithelp.ithome.com.tw/upload/images/20211009/201297372ZL1YLaDLU.png

  • 对於MemoryCache 进程内缓存这个场景,gin-cache 提升了23%。
  • 对於Redis做缓存这个场景,gin-cache相比QPS更提升了30倍左右。当然也使用gin-cache使用的redis客户端库的性能更好。
  • 从口的设计对比来看,在当处理者请求接手,gin-cache 的优势将更加明显。

Reference

https://blog.techbridge.cc/2017/06/17/cache-introduction/

https://www.cloudflare.com/zh-tw/cdn/

https://www.cyhone.com/articles/gin-cache/


<<:  Day24 X Web Rendering Architectures

>>:  成员 26 人:企业留才之迷因

Choosing the correct postcard size for print

Whenever a perfect ERP installation is expected, o...

第二十七日-MYSQL的「如果」:IF、CASE基本用法

IF、CASE翻成中文就是「如果」,根据条件来决定要执行的事情, 在各个程序语法理面都会有类似的语法...

不只懂 Vue 语法:以 Vue 和 Nuxt 为例,说明 SPA 和 SSR 的概念?

问题回答 Vue 是 SPA 框架,而 Nuxt 是 Vue 生态系里的一个能同时实现 SPA 和 ...

Day 25 Compose UI Theme

今年的疫情蛮严重的,希望大家都过得安好,希望疫情快点过去,能回到一些线下技术聚会的时光~ 今天目标:...

求救 各位linux大神! 关於mediapipe build 成apk的问题

在 build an Android example app 的时候:输入: bazel build...