我的编程空间,编程开发者的网络收藏夹
学习永远不晚

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

短信预约 -IT技能 免费直播动态提醒
省份

北京

  • 北京
  • 上海
  • 天津
  • 重庆
  • 河北
  • 山东
  • 辽宁
  • 黑龙江
  • 吉林
  • 甘肃
  • 青海
  • 河南
  • 江苏
  • 湖北
  • 湖南
  • 江西
  • 浙江
  • 广东
  • 云南
  • 福建
  • 海南
  • 山西
  • 四川
  • 陕西
  • 贵州
  • 安徽
  • 广西
  • 内蒙
  • 西藏
  • 新疆
  • 宁夏
  • 兵团
手机号立即预约

请填写图片验证码后获取短信验证码

看不清楚,换张图片

免费获取短信验证码

基于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 缓存的资料请关注编程网其它相关文章!

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

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

下载Word文档到电脑,方便收藏和打印~

下载Word文档

猜你喜欢

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

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

在Go语言中如何解决并发网络请求的请求缓存和缓存更新问题

在Go语言中,可以使用sync.Map来实现请求缓存和缓存更新的问题。首先,我们可以创建一个全局的sync.Map来作为缓存,用于存储已经请求过的URL和对应的响应数据。sync.Map是Go语言提供的并发安全的map。```govar c
2023-10-09

在Go语言中如何解决并发网络请求的请求缓存和缓存更新问题?

标题:Go语言中的并发网络请求的请求缓存和缓存更新问题解决方案引言:在现代程序开发中,网络请求是非常常见的操作,而并发请求更是提高程序性能和响应速度的关键。然而,在并发网络请求中,往往会面临请求重复发送、数据不一致等问题。本文将介绍如何在G
2023-10-22

基于Redis缓存数据常见的问题如何解决

这篇文章主要介绍“基于Redis缓存数据常见的问题如何解决”,在日常操作中,相信很多人在基于Redis缓存数据常见的问题如何解决问题上存在疑惑,小编查阅了各式资料,整理出简单好用的操作方法,希望对大家解答”基于Redis缓存数据常见的问题如
2023-07-02

基于Redis缓存数据常见的三种问题及解决

目录1.缓存穿透1.1 问题描述1.2 解决方法2.缓存击穿2.1 问题描述2.2 解决方法3.缓存雪崩3.1 问题描述3.2 解决方法1.缓存穿透1.1 问题描述缓存穿透是在客户端/浏览器端请求一个不存在的key,这个key在redi
2022-06-16

编程热搜

  • Python 学习之路 - Python
    一、安装Python34Windows在Python官网(https://www.python.org/downloads/)下载安装包并安装。Python的默认安装路径是:C:\Python34配置环境变量:【右键计算机】--》【属性】-
    Python 学习之路 - Python
  • chatgpt的中文全称是什么
    chatgpt的中文全称是生成型预训练变换模型。ChatGPT是什么ChatGPT是美国人工智能研究实验室OpenAI开发的一种全新聊天机器人模型,它能够通过学习和理解人类的语言来进行对话,还能根据聊天的上下文进行互动,并协助人类完成一系列
    chatgpt的中文全称是什么
  • C/C++中extern函数使用详解
  • C/C++可变参数的使用
    可变参数的使用方法远远不止以下几种,不过在C,C++中使用可变参数时要小心,在使用printf()等函数时传入的参数个数一定不能比前面的格式化字符串中的’%’符号个数少,否则会产生访问越界,运气不好的话还会导致程序崩溃
    C/C++可变参数的使用
  • css样式文件该放在哪里
  • php中数组下标必须是连续的吗
  • Python 3 教程
    Python 3 教程 Python 的 3.0 版本,常被称为 Python 3000,或简称 Py3k。相对于 Python 的早期版本,这是一个较大的升级。为了不带入过多的累赘,Python 3.0 在设计的时候没有考虑向下兼容。 Python
    Python 3 教程
  • Python pip包管理
    一、前言    在Python中, 安装第三方模块是通过 setuptools 这个工具完成的。 Python有两个封装了 setuptools的包管理工具: easy_install  和  pip , 目前官方推荐使用 pip。    
    Python pip包管理
  • ubuntu如何重新编译内核
  • 改善Java代码之慎用java动态编译

目录