基于context.Context的Golang loader缓存请求放大问题解决

2023-05-20 06:05:56 缓存 请求 放大

请求放大的问题

同一请求链路中对下游的请求放大是现代微服务体系中经常遇到的痛点。

举个例子:某个业务流程中,需要获取用户的积分余额,从而进行后续判断。但这个【请求余额】的行为,不仅仅在某个场景需要使用,而是在整个请求的生命周期,多处逻辑都可能需要,甚至负责开发的都不是同一个人。这个时候就很容易出问题了。小 A 在入口处就请求了余额,但只放在了自己的业务结构中。随后小 B 也需要,又请求了一次余额。这就出现了请求放大。

为什么需要考虑这个问题?

  • 放大可不一定只有 2 倍,事实上,复杂的业务链路如果不仔细思考,调整,最终出现 4 - 5 次请求放大都是很常见的;

  • 下游的服务的负载是需要考量的,明明一次请求就可以拿到的数据,你请求了多次,下游可能会被打挂,哪怕可以承受,也额外付出了更多的 CPU,通信成本;

  • 通常出现放大时,各个业务的处理逻辑是独立的,也就意味着,一旦微服务不稳定,后续请求网络超时,你可能会因为一个明明此前已经拿到的数据,而导致整个链路返回了失败。

所以,我们需要严肃地看待这件事。目标其实很明确:

  • 只拿需要的数据;
  • 不重复拿同一份数据(如果数据可能会变,可以考虑放大,这不是绝对的);
  • 处理好强弱依赖,不因为一个明明可以接受,降级的失败请求,导致整个处理流程中断。

那我们怎么才能保证一个请求处理过程中,不去重复请求下游呢?我只是其中一环,怎么知道此前流程里是不是已经拿过数据了呢?就算知道,人家都放到了自己业务的结构体里,我怎么用?

中间件能解决么?

这里常见的思路是使用【接口中间件】,即:把一些通用的 loader 放到 middleware 中,比如请求用户信息,租户信息,鉴权等。我们这里举的例子也可以这么处理。

接口中间件里我就把余额拿到,随后作为一个公共的结构体,一路透传。类似这样:

type BizContext struct {
	Ctx context.Context
	UserInfo
	TenantInfo
	UserBalance
}
func ExecuteLogic(bc *BizContext, param interface{}) error {
	// TODO:业务逻辑
}

这样,大家通过 BizContext 就能获取到这些公共数据了。不需要重复请求。Problem solved!

但这个思路存在一个致命伤(并不是 struct 内嵌 context.Context,你段位到了就可以这么用,背景参照我们此前的文章golang context.Context 原理,实战用法,问题 )。

问题在于,所有放到中间件里的 loader 逻辑,都是对整个接口的请求消耗。的确,我们可能在场景 A,D,F 要用到这个 UserBalance,但场景 B,C 呢?人家是不是白白的承担了这种性能消耗,又没有任何收益?

所有中间件里的逻辑一定是通用的,高性能的,具有普适性的。注定没法覆盖到所有业务场景。

一定不要滥用中间件,塞入大量个别场景需要的逻辑。中间件越重,接口性能就越不可控。

基于 context.Context 的解决方案

我们知道,context.Context 提供了 WithValue 函数,支持将一些常见的上下文信息通过这个函数写入 ctx。本质是用 valueCtx 基于 parent Context 派生出来一个 child Context,形成了一条链。获取 value 的时候是逆序的。

type BizContext struct {
	Ctx context.Context
	UserInfo
	TenantInfo
	UserBalance
}
func ExecuteLogic(bc *BizContext, param interface{}) error {
	// TODO:业务逻辑
}

我们可以利用这个能力,把请求结果 cache 到 context.Context 中,这样就可以随后复用了。但这样本质上和此前 BizContext 是一样的,都是需要一个链路上都能获取到的结构体。

loader 是一个数据加载器,下游可能是某个存储,或是微服务。每个业务场景可能包含自己对应的 loader。

我们希望这个 loader cache 要具备下面的能力:

  • 适配任何数据加载器,和具体业务的架构不强绑定;
  • 按需加载,业务可以自行指定是否需要启用 cache 能力,默认直接走 loader;
  • 高性能,不要带来过高的性能消耗。

loader 定义

鉴于要实现一个通用的数据 loader,我们不希望和特定结构绑定,所以势必要返回 interface{},同时入参交给业务自行判断,通用定义里我们不做要求:

type loadFunc func(context.Context) (interface{}, error)

存储结构

我们希望往 Context 里面放什么数据,这一点很关键。鉴于我们希望支持多个业务场景,势必会需要一个 map 结构,key 对应场景,value 是缓存的值。

同时,鉴于 Context 本身是支持并发的,而且整个 loader cache 会作为基础的能力提供出来,我们希望这里的 map 也能在高并发下正常读写,所以回到了经典的选型:

  • map + Mutex
  • map + RWMutex
  • sync.Map

选项一的粒度比较粗,性能上会差一些。而 sync.Map 的 LoadOrStore 方法参数会逃逸到heap上,所以我们选择 map + RWMutex,手动来控制读写锁。

type callCache struct {
	m    map[string]*cacheItem
	lock sync.RWMutex
}

callCache 本身是外层的结构。我们从 Value(key interface{}) interface{} 接口就可以读到。

这里 cacheItem 里面放什么,很关键!

  • 是不是直接就一个 interface{} 就可以了?

非也!如果我们完全不感知 cacheItem 的结构,会导致我们无法感知到这里到底是否已经调用过 loader 拉取数据。即便可以置为 nil,但实际上 loader 也可能加载后发现没有数据,这一点不可行。

要实现只有一次调用 loader,后续调用都能复用结构。cacheItem 需要包含一个 sync.Once。

  • 错误如何感知?

我们对于每个场景,唯一能感知到的就是 cacheItem,所以除了正常的业务数据,这里还需要有错误信息。否则 loader 调用出错了都没法给上游返回错误。

综上两点,一个可能的结构如下:

type cacheItem struct {
	ret  interface{}
	err  error
	once sync.Once
}

这样我们就可以利用 sync.Once 的能力来控制,调用 loader 拿到结果和 error

func (ci *cacheItem) doOnce(ctx context.Context, loader loadFunc) {
	ci.once.Do(func() {
		ci.ret, ci.err = loader(ctx)
	})
}

sync.Once 保证了某个 Goroutine 进入 Do 方法后,其他协程会阻塞等待。所以,我们可以假设,在 *cacheItem.doOnce 结束后,如果访问 *cacheItem 是能够拿到 ret 和 err 的最新值的。

好了,现在有了 cacheItem 的定义和 doOnce 能力,我们回到 callCache,完成调度逻辑:

type callCache struct {
	m    map[string]*cacheItem // sync.Map的LoadOrStore方法的参数会逃逸到heap上,这里用map+rwmutex
	lock sync.RWMutex
}

我们从 Context 直接获取的结构是 callCache,那么当某个场景的 key 首次请求的时候,势必需要对 cacheItem 进行初始化。

这个函数: func (cache *callCache) getOrCreateCacheItem(key string) *cacheItem,如何实现,这里很关键!

  • 既然用了 RWMutex,我们希望把读写粒度拆开,所以一上来应该判断读锁,如果有值,直接返回;
  • 如果在读锁里没获取到,说明需要初始化,开始加写锁;
  • 在写锁中,完成初始化,写入 callCache,并返回,defer 解掉写锁。
func (cache *callCache) getOrCreateCacheItem(key string) *cacheItem {
	cache.lock.RLock()
	cr, ok := cache.m[key]
	cache.lock.RUnlock()
	if ok {
		return cr
	}
	cache.lock.Lock()
	defer cache.lock.Unlock()
	if cache.m == nil {
		cache.m = make(map[string]*cacheItem)
	} else {
		cr, ok = cache.m[key]
	}
	if !ok {
		cr = &cacheItem{}
		cache.m[key] = cr
	}
	return cr
}

SDK 接口

好了,现在我们已经具备底层能力了,思考一下我们希望开发者怎么用这个 lib。

WithCallCache

首先,ctx cache 不应该是默认启用的,有可能业务就是需要有一些放大,这里需要开发者通过 SDK 接口显式声明。

此外,既然要往 Context 里面放,一定需要一个自己的 key,这里我们采用空结构体,用来与其他类型区分开。这也是经典的操作。

type keyType struct{}
var callCacheKey keyType
// WithCallCache 返回支持调用缓存的context
func WithCallCache(parent context.Context) context.Context {
	if parent.Value(callCacheKey) != nil {
		return parent
	}
	return context.WithValue(parent, callCacheKey, new(callCache))
}

LoadFromCtxCache

这里是最核心的接口。我们需要支持开发者传进来:1.业务场景;2.业务对应的 loader。

如果此前通过 WithCallCache 启用了 ctx cache,我们就看看业务的 loader 此前有没有执行过,如果有,直接返回 ctx 中缓存的结果。如果从未执行过,调用此前的 cacheItem.doOnce 来执行。

// LoadFromCtxCache 从ctx中尝试获取key的缓存结果
// 如果不存在,调用loader;如果没有开启缓存,直接调用loader
func LoadFromCtxCache(ctx context.Context, key string, loader loadFunc) (interface{}, error) {
	var cacheItem *cacheItem
	v := ctx.Value(callCacheKey)
	if v == nil {
		cacheItem = nil
	} else {
		cacheItem = v.(*callCache).getOrCreateCacheItem(key)
	}
	// cache not enabled
	if cacheItem == nil {
		return loader(ctx)
	}
	// now that all routines hold references to the same cacheItem
	cacheItem.doOnce(ctx, loader)
	return cacheItem.ret, cacheItem.err
}

使用方法

  • 使用 WithCallCache 针对当前的 ctx 启用 loader cache;
  • 改造数据加载逻辑,抽出来 loader,外层用 LoadFromCtxCache 来调用,以达到上游无感。

假设我们的 loader 是 myloader,接受一个 string,返回 int 和 error,下面看一下示例:

使用起来其实非常简单,只需要大家封装一下自己的数据加载逻辑即可。

源码仓库:go-ctxcache,感兴趣的同学可以试一下,整体代码量很小,实用性很强。

以上就是 context.Context 的 Golang loader 缓存请求放大问题解决的详细内容,更多关于Golang loader 缓存的资料请关注其它相关文章!

相关文章