持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第10天,点击查看活动详情
请求放大的问题同一请求链路中对下游的请求放大是现代微服务体系中经常遇到的痛点。
举个例子:某个业务流程中,需要获取用户的积分余额,从而进行后续判断。但这个【请求余额】的行为,不仅仅在某个场景需要使用,而是在整个请求的生命周期,多处逻辑都可能需要,甚至负责开发的都不是同一个人。这个时候就很容易出问题了。小 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 的时候是逆序的。
func WithValue(parent Context, key, val interface{}) Context {
if key == nil {
panic("nil key")
}
if !reflectlite.TypeOf(key).Comparable() {
panic("key is not comparable")
}
return &valueCtx{parent, key, val}
}
复制代码
我们可以利用这个能力,把请求结果 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
}
复制代码
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)
})
}
复制代码
Do*cacheItem.doOnce
好了,现在有了 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
// 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
}
复制代码
使用方法
WithCallCacheLoadFromCtxCache
myloader
cacheCtx := WithCallCache(ctx)
func loadData(ctx context.Context, param string) (int, error) {
key := "loadData" + param
r, e := LoadFromCtxCache(ctx, key, func(ctx context.Context) (interface{}, error) {
return myloader(ctx, param)
})
return r.(int), e
}
复制代码
使用起来其实非常简单,只需要大家封装一下自己的数据加载逻辑即可。
源码仓库:go-ctxcache,感兴趣的同学可以试一下,整体代码量很小,实用性很强。