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

 更新时间:2023年05月12日 11:09:11   作者:ag9920  
这篇文章主要为大家介绍了基于context.Context的Golang loader缓存请求放大解决方案,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪

请求放大的问题

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

举个例子:某个业务流程中,需要获取用户的积分余额,从而进行后续判断。但这个【请求余额】的行为,不仅仅在某个场景需要使用,而是在整个请求的生命周期,多处逻辑都可能需要,甚至负责开发的都不是同一个人。这个时候就很容易出问题了。小 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 缓存的资料请关注脚本之家其它相关文章!

相关文章

  • Go 语言json.Unmarshal 遇到的小问题(推荐)

    Go 语言json.Unmarshal 遇到的小问题(推荐)

    这篇文章主要介绍了 Go 语言json.Unmarshal 遇到的小问题,本文给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下
    2020-07-07
  • 浅析Go语言中内存泄漏的原因与解决方法

    浅析Go语言中内存泄漏的原因与解决方法

    这篇文章主要来和大家聊一聊Go语言中内存泄漏的那些事,例如内存泄漏的原因与解决方法,文中的示例代码讲解详细,需要的小伙伴可以参考下
    2024-02-02
  • 一文详解golang中的gmp模型

    一文详解golang中的gmp模型

    这篇文章主要介绍了golang中的gmp模型的诞生、概念及调度讲解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2023-11-11
  • Go语言--切片(Slice)详解

    Go语言--切片(Slice)详解

    这篇文章主要介绍了Go语言--切片(Slice),Go 语言切片是对数组的抽象,下面文章小编将为大家详细介绍该内容,需要的朋友可以参考下,希望对你有所帮助
    2021-10-10
  • Go接口的用法详解

    Go接口的用法详解

    本文主要介绍了Go接口的用法详解,包括定义接口、实现接口、使用接口、空接口等,通过接口,可以实现多态性,即一个对象可以实现多个接口,从而实现不同接口的行为,感兴趣的可以了解一下
    2023-11-11
  • Go语言字符串操作指南:简单易懂的实战技巧

    Go语言字符串操作指南:简单易懂的实战技巧

    本文将介绍Go语言中字符串的实战操作,通过本文的学习,读者将掌握Go语言中字符串的常用操作,为实际开发提供帮助,需要的朋友可以参考下
    2023-10-10
  • go install和go get的区别实例详解

    go install和go get的区别实例详解

    go install是Golang用来编译和安装自定义package的工具,下面这篇文章主要给大家介绍了关于go install和go get区别的相关资料,文中通过实例代码介绍的非常详细,需要的朋友可以参考下
    2023-01-01
  • go引入自建包名报错:package XXX is not in std解决办法

    go引入自建包名报错:package XXX is not in std解决办法

    这篇文章主要给大家介绍了go引入自建包名报错:package XXX is not in std的解决办法,这是在写测试引入包名的时候遇到的错误提示,文中将解决办法介绍的非常详细,需要的朋友可以参考下
    2023-12-12
  • 总结Go语言中defer的使用和注意要点

    总结Go语言中defer的使用和注意要点

    Go语言中的defer关键字实现比较特殊的功能,这篇文章给大家总结了关于Go语言中defer的使用和注意要点,有需要的朋友们可以参考借鉴,下面来一起看看吧。
    2016-09-09
  • Go设计模式之中介者模式讲解和代码示例

    Go设计模式之中介者模式讲解和代码示例

    中介者是一种行为设计模式,让程序组件通过特殊的中介者对象进行间接沟通,达到减少组件之间依赖关系的目的,因此本文就给大家详细介绍一下Go中介者模式,需要的朋友可以参考下
    2023-06-06

最新评论