这才是中间件client API设计!

图片

图片

👉目录

1 中间件 client API 概述

2 外网问题两例

3 中间件 client API 设计要点

4 结语

后台中间件类服务的 client API,往往都要做网络、异步、缓存等逻辑,通常功能较为复杂。最近遇到两个外网问题,一个是Tcaplus client API(TcaplusDB 是腾讯云专为游戏设计的分布式 NoSQL 数据存储服务) 另一个则是 pulsar go client API 使用不当所致,本文是笔者结合排查经过,加上自己的思考做的一点探讨分享。

关注腾讯云开发者,一手技术干货提前解锁👇

鹅厂程序员面对面直播继续,每周将邀请鹅厂明星技术大咖讲解 AI 时代下的“程序员护城河”。更有蛇年公仔等精美周边等你来拿,记得提前预约直播~👇

01

中间件 client API 概述

消息队列、zookeeper、etcd、redis ... 等服务的 client API,功能复杂,可以称得上是 rich API。它们都需要和远端服务打交道,而为了和一个远端服务打交道:

  1. 首先得要能和远端建立网络链接,所以必然要处理网络链接管理,网络异常了,要能重建链接。

  2. 如果服务侧不提供路由反向代理,且 client 需要感知 master、slave 情况的,则还得做主备选择相关的逻辑。

  3. 要考虑异步处理(基于线程、协程、回调等),作为后台服务的 API,需要设计异步机制来确保应用的吞吐不受影响。

  4. 有时还需要做数据的缓存,例如对于消息队列而言,push 的数据可能需要先缓存到本地队列里,远端 ack 之后再删除。

以上希望能给大家建立一点基本的印象,有了基本的印象之后,笔者接下来分享下遇到的两个问题。

02

外网问题两例

   2.1 问题一:tcaplus API 回调 coredump

tcaplus API(TcaplusDB 是腾讯云专为游戏设计的分布式 NoSQL 数据存储服务) 在收到回包之后,回调业务注册的回调函数,发生 coredump。

对应的堆栈上下文:

上图中的指针解引用后的 map 相关访问是非法访问,导致了 coredump。指针非法访问这类问题无非就两种可能:

  1. 指针是错乱的,被冲毁了。关于这点,通常我们会查看指针附近的变量情况,因为冲毁往往都是成片的,很少恰好仅仅冲毁了指针,在本例中指针附近变量并未冲毁。

  2. 指针没错乱,但是它失效了,也就是它成了野指针。关于这点,分析指针赋值上下文,最后发现它指向一个栈变量,所以如果对应栈已经销毁,那这个指针就成了一个野指针。

因为这里是异步的 API,业务的异步上下文如果没有做好相关 callback 情况的标记或清理,显然容易会造成野指针问题。

让我们来看一下流程示例:

———— 某业务协程- 业务逻辑- 初始化一个 tcaplus callback 对象,对象里有个指针指向了一个栈变量- 请求 tcaplus 查询数据- 切协程栈等回包- 因为超时等原因,唤醒协程栈- 业务协程结束,释放相关协程栈,此时 callback 对象里的指针成了野指针———— tcaplus 回包- tcaplus 回包姗姗来迟,找回了尚未销毁的 callback 对象- 执行到了上面截图的回调代码,访问到了野指针,coredump

知道了原因后,其实就很容易修复,我们可以在业务协程结束时,清理掉不需要的 callback 对象,或者将 callback 对象中的指针置空,再或者做一个标记,表示本 callback 对象已经无效了,这些做法都可以解决上述 coredump 问题。

   2.2 问题二:pulsar go API 死锁

我们采用 sidecar 方式来写入消息队列,业务进程通过本机 rpc 发给 sidecar,再由 sidecar 通过 pulsar go client api 写入消息队列。有一天傍晚出现了大量的写入失败错误,通过线上分析 goroutine 的堆栈情况,发现 API 内出现了死锁。

注:采用 sidecar 方式可以简化消息队列 API 接入的工作,只需要接一种语言的 API 即可。

下图是死锁时 API 内【eventsloop goroutine】的堆栈:

下图是死锁时 API 内【超时清理 goroutine】堆栈:

结合代码可以清晰地看到 Lock 这里导致了死锁,加锁的代码如下:

其中的 pp.Close 对应以下代码:

producer Close 加锁成功后,会调用 partitionProducer 的 Close 接口,这里会等收到 doneCh 通道的数据才返回,而 doneCh 是 【eventsloop goroutine】写入的。

综合以上信息,具体来讲,死锁是因果链条是:

  1. 首先是 API 内部感知到有较多的消息队列写入超时。

  2. 【超时清理 goroutine】会清理已经超时蛮久的消息,并回调生产事件的 callback 告知给业务调用方,在业务的 callback 里识别到异常,会去做一个关闭行为,而关闭是先向 cmdChan 通道发一个命令给【eventsloop goroutine】,然后会通过 doneCh 等待关闭完成的通知(参见上文的代码截图)。

  3. 【eventsloop goroutine】收到 cmdChan 通道过来的关闭命令,它会做一些清理工作,将 pending 的一些消息清空掉,并且在清空的时候也会调用 callback 告知给业务调用方(这个也说得通,需要告诉业务相关消息其实没被投递),这些都处理完毕之后才会写入 doneCh。

  4. 前述两点叠加到一起,便会产生互等死锁:

    - 【超时清理 goroutine】识别到超时,在做超时消息清理时,调用业务 callback,加锁成功,发出关闭命令,等 doneCh。

    - 【eventsloop goroutine】收到关闭命令,清理 pending 消息,也要调用业务 callback,加锁,但因为已经被锁了需要等锁,因此也不会执行到写入 doneCh 的逻辑。

callback 代码是业务上层调用提供的,用于 API 通知业务具体消息的推送结果,可能是成功也可能是失败。在失败的时候,callback 里面做了关闭生产者的处理。正是由于这个行为,结合 API 自身的机制,导致了最终 API 死锁,不同 goroutine 之间互等。解决方案也很简单,我们只要不在 callback 里直接关闭生产者,就可以避免上述问题。

ok,至此两个问题都分享完了,管中窥豹,中间件的 client API 显然是比较复杂的。

接下来让我们稍微打开一点话题,换个视角,假设我们自己是设计者,发散聊聊一个中间件复杂 API 设计需要注意的点。笔者这里并不想做面面俱到的列举,所以就权且“以偏概全”地想到哪谈到哪,如果读者朋友发现其他想讨论的点,欢迎在评论区留言。

03

中间件 client API 设计要点

   3.1 清晰易用的接口

最好是只需要少数接口即可,使用起来非常简单,例如主要就初始化接口、读写接口、清理关闭接口,然后再搭配一些方便查问题的日志回调、debuginfo 之类的接口。

   3.2 运转驱动机制

合理规划线程结构,设计 API 的逻辑驱动机制,和业务逻辑之间做好衔接。一般可以分两大类:

  1. 一类是完全由业务来驱动,API 自己不启动任何线程,这种一般需要业务自己调用 API 的 Tick() 之类的接口来驱动运转。

  2. 另一类则是 API 底层自己有个 runtime,它会自己启动一些独立的执行流程,跑自己的  eventloop,它也完全可以有自己的网络管理线程。(问题二中的 API 就是这类)。

当然有时候也会是两者兼有的,既要业务自己去调用驱动接口,同时它自己内部也做了一些独立线程,tcaplus 的 API 就是如此。

   3.3 异步回调机制

后台场景下考虑到吞吐,一般中间件 client API 都需要提供异步接口。而对于常态的通信上下文恢复,一般也有两种做法:

  1. 业务主动调用 API 的接口来进行查询,类似轮询。采用轮询未必是低效的,没数据的时候可以立即返回。

  2. API 通过回调的方式,直接调用业务提供的回调函数。(问题一和问题二中的 API 都使用了回调机制)。

这里注意回调函数内的业务代码,非必要就别再直接调用 API 的接口,否则可能形成堆栈交叉的情况(API -> callback -> API -> callback...),上文的问题二其实就是这种情况。从设计者的角度,也可以适当考虑到这类情况,做一定的容错。

   3.4 网络并发

API 在和远端通信时,考虑到性能,会在上一个请求回包之前就发出下一个请求,甚至可以设计一定程度的 batch,例如消息队列的推送,就可以做一个切片时间的 batch,批量提交。

由于请求是批量多个的,应答也会相应地持续不断抵达的,我们需要明确应答包和哪个请求上下文对应,所以一般会设计一个异步会话 seqID,通过这个 seqID 来找到原来的上下文对象。请求的包里会带上这个 seqID,然后中间件服务端处理后可以回传,这样就可以做上下文关联。

如果是有保序要求的业务场景,网络之上的逻辑层自己也可以有一个 ID,例如消息队列的有序消息 ID,远端在应答的时候只要告诉请求端已经推送成功的最大消息 ID,API 侧就知道哪些消息都推送成功了。

   3.5 接口重入

API 的接口要结合使用环境来对重入进行考虑,如果允许多线程环境调用,API 内部要谨慎使用锁。锁范围必须尽量小,而且最好是加锁之后不阻塞等待其他流程,否则就容易产生死锁类问题(参考上文问题二)。

   3.6 容错设计

网络容错

因为要访问远端服务,依赖了网络通信,所以首先网络错误就在所难免。API 需要定义好各种统一的错误码和错误描述,方便应用层快速定位问题。API 还应该做自愈,如果网络链接异常了,需要重建网络链接。

在网络链接重建好之前,业务层的请求也可以考虑做缓存,当然这取决于业务对实时性的要求情况。如果是 req - rsp 等待型的,可能不合适缓存,而是直接返回失败。如果是消息队列投递类业务,对时效要求不高,其实可以先缓存着,等网络好了再重试投递。

重试机制

设计合适的重试机制,也可以支持时效,如果是太久之前的请求仍然可以丢弃。

上文问题二里提到的 pulsar go client API,它里面就设计了 pendingQueue,推送的时候会先放到队列里,等远端明确 ACK 了才从队列里删除,这样可以提高投递的可靠性。

重试也是与具体业务类型有关的,能做到幂等的才适合无脑重试。做不到幂等的,就需要明确感知到请求包并未投递到网络上,并且满足时效要求,才可以重试,如果只是发包超时没回包之类的,不能贸然直接重试。

restart-safe

支持优雅停止,例如对于消息队列的 API,理想情况是得都投递成功后再退出,如果太久了才强行结束。

crash-safe

现在很多业务都倾向于无状态化,重启后就漂移了,所以如果要做 crash-safe,得使用云磁盘。通常 crash-safe 都会和 WAL 技术相关,但因为实现起来细节较多相对复杂,业务 crash 概率又低,所以实际上大部分情况下并不会考虑这点。

   3.7 方便维护

无损配置刷新

支持部分字段的配置化更新,这里涉及到平滑切换的一些逻辑,例如原来 API 内最大 pending 队列长度为 10000,但是改为 1000 了,然而队列本身里面已经有 2000 条数据了,此时就需要合理考虑 pending 队列内的已有数据如何处理。

提供日志接口

可以支持注册日志接口给 API,这样 API 输出的日志能整合到业务日志之中,方便做问题排查。

提供内部信息接口

对 API 运作情况进行统计,暴露接口支持输出 API 当前的内部状态,可以方便调用者了解 API 运行的情况。

04

结语

除了中间件的 client API 较为复杂外,其实还有很多代码库也是相对复杂的,例如 tcmalloc、libcurl 等。在我们实际的工作中,未必会让大家去设计一个复杂的中间件 client API,但是我们自己业务本身其实也会面临相似的问题,这些问题背后的思考是相通的,我们都会希望代码的实现:

  1. 易用性好,接口简单明了。

  2. 高并发性能,稳定性好,充分考虑性能和容错。

  3. 易维护,适当配置化,输出清晰的日志,统计上报关键信息。

全文完,感谢阅读 ~

-End-

原创作者|吴连火

感谢你读到这里,不如关注一下?👇

图片

📢📢来领开发者专属福利!点击下方图片直达👇

图片

图片

你认为中间件 client API 设计中最重要的一点是什么?欢迎评论留言补充。我们将选取1则优质的评论,送出腾讯云定制文件袋套装1个(见下图)。6月24日中午12点开奖。

图片

图片

图片

图片

图片

图片

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值