👉目录
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。它们都需要和远端服务打交道,而为了和一个远端服务打交道:
首先得要能和远端建立网络链接,所以必然要处理网络链接管理,网络异常了,要能重建链接。
如果服务侧不提供路由反向代理,且 client 需要感知 master、slave 情况的,则还得做主备选择相关的逻辑。
要考虑异步处理(基于线程、协程、回调等),作为后台服务的 API,需要设计异步机制来确保应用的吞吐不受影响。
有时还需要做数据的缓存,例如对于消息队列而言,push 的数据可能需要先缓存到本地队列里,远端 ack 之后再删除。
以上希望能给大家建立一点基本的印象,有了基本的印象之后,笔者接下来分享下遇到的两个问题。
02
外网问题两例
2.1 问题一:tcaplus API 回调 coredump
tcaplus API(TcaplusDB 是腾讯云专为游戏设计的分布式 NoSQL 数据存储服务) 在收到回包之后,回调业务注册的回调函数,发生 coredump。
对应的堆栈上下文:
上图中的指针解引用后的 map 相关访问是非法访问,导致了 coredump。指针非法访问这类问题无非就两种可能:
指针是错乱的,被冲毁了。关于这点,通常我们会查看指针附近的变量情况,因为冲毁往往都是成片的,很少恰好仅仅冲毁了指针,在本例中指针附近变量并未冲毁。
指针没错乱,但是它失效了,也就是它成了野指针。关于这点,分析指针赋值上下文,最后发现它指向一个栈变量,所以如果对应栈已经销毁,那这个指针就成了一个野指针。
因为这里是异步的 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】写入的。
综合以上信息,具体来讲,死锁是因果链条是:
首先是 API 内部感知到有较多的消息队列写入超时。
【超时清理 goroutine】会清理已经超时蛮久的消息,并回调生产事件的 callback 告知给业务调用方,在业务的 callback 里识别到异常,会去做一个关闭行为,而关闭是先向 cmdChan 通道发一个命令给【eventsloop goroutine】,然后会通过 doneCh 等待关闭完成的通知(参见上文的代码截图)。
【eventsloop goroutine】收到 cmdChan 通道过来的关闭命令,它会做一些清理工作,将 pending 的一些消息清空掉,并且在清空的时候也会调用 callback 告知给业务调用方(这个也说得通,需要告诉业务相关消息其实没被投递),这些都处理完毕之后才会写入 doneCh。
前述两点叠加到一起,便会产生互等死锁:
- 【超时清理 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 的逻辑驱动机制,和业务逻辑之间做好衔接。一般可以分两大类:
一类是完全由业务来驱动,API 自己不启动任何线程,这种一般需要业务自己调用 API 的 Tick() 之类的接口来驱动运转。
另一类则是 API 底层自己有个 runtime,它会自己启动一些独立的执行流程,跑自己的 eventloop,它也完全可以有自己的网络管理线程。(问题二中的 API 就是这类)。
当然有时候也会是两者兼有的,既要业务自己去调用驱动接口,同时它自己内部也做了一些独立线程,tcaplus 的 API 就是如此。
3.3 异步回调机制
后台场景下考虑到吞吐,一般中间件 client API 都需要提供异步接口。而对于常态的通信上下文恢复,一般也有两种做法:
业务主动调用 API 的接口来进行查询,类似轮询。采用轮询未必是低效的,没数据的时候可以立即返回。
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,但是我们自己业务本身其实也会面临相似的问题,这些问题背后的思考是相通的,我们都会希望代码的实现:
易用性好,接口简单明了。
高并发性能,稳定性好,充分考虑性能和容错。
易维护,适当配置化,输出清晰的日志,统计上报关键信息。
全文完,感谢阅读 ~
-End-
原创作者|吴连火
感谢你读到这里,不如关注一下?👇
📢📢来领开发者专属福利!点击下方图片直达👇
你认为中间件 client API 设计中最重要的一点是什么?欢迎评论留言补充。我们将选取1则优质的评论,送出腾讯云定制文件袋套装1个(见下图)。6月24日中午12点开奖。