当前位置: 首页 > 后端技术 > Java

采访者:谈谈《心跳》的设计吧?

时间:2023-04-01 16:23:32 Java

对了,最近又看到这篇文章《工商银行分布式服务 C10K 场景解决方案 》了。为什么又是?因为这篇文章刚发表的时候我就看了,觉得写的很好,全能银行(ICBC)确实让人印象深刻。但是我看到了也看到了,当时也没多想。这次看到的时候正好在下班路上,所以又仔细看了一遍。嗯,经常阅读和更新是很有收获的。于是写一篇文章,向大家报告一下自己看了一遍之后的收获。文章总结我知道很多同学可能还没有看过这篇文章,所以我先放一个链接,《工商银行分布式服务 C10K 场景解决方案 》。我先提炼一下文章的内容,但如果你有时间,也可以先仔细阅读这篇文章,感受一下宇宙的强大。文章内容大致是这样的。在Cosmos的框架下,随着业务的发展,在可预见的未来,会出现一个提供者为几千甚至几万消费者提供服务的场景。在如此高的负载下,如果服务器程序设计不够好,网络服务在处理数万个客户端连接时可能会出现低效甚至完全瘫痪的情况,这就是C10K问题。C10K的问题就不说了。上网查一下。这是一个非常著名的程序相关问题,但这个问题已经成为历史。CosmosXing的RPC框架使用的是Dubbo,所以他们的文章就是基于这样一个问题:基于Dubbo的分布式服务平台能否应对复杂的C10K场景?为此,他们搭建了一个大规模的连接环境,模拟服务调用,进行了一系列的探索和验证。首先,他们使用的Dubbo版本是2.5.9。版本确实有点低,但是银行,大家都明白,架构升级能干就干,稳定运行才是王道。在这个版本中,他们搭建了一个服务器,其逻辑是休眠100ms模拟业务调用,部署在8C16G服务器上。对应consumer配置服务超时时间为5s,然后在上百台8C16G服务器上部署consumer(不好意思,上百台8C16G服务器都是浪费钱,有钱真好),部署7000个serviceconsumer以容器化的方式。每个消费者启动后,该服务每分钟调用1次。然后他们定制了两个测试场景:先不说场景2,异常是难免的,因为只有一个provider,重启的时候consumer还在发送请求,肯定是冷的。但场景1从逻辑上讲不应该。你想,消费者配置的超时时间是5s,而提供者的业务逻辑只处理了100ms。反正时间够了。多说一句:本文只针对场景1。但是,朋友们,但是啊。调用者每分钟发送请求的频率虽然不高,但是有7000个调用者。这7000个来电就是传说中的突发流量,只不过这个“突发”是每分钟一次。所以偶尔超时也是可以理解的。毕竟服务器的处理能力是有限的,有些任务在队列中等待一段时间就会超时。可以写一个小例子来说明,是这样的:只是建立一个线程池,线程数为200。然后提交7000个任务,每个任务耗时100ms,用CountDownLatch模拟并发,耗时3.8s在我的12核机器上运行。也就是说如果在Dubbo场景下,每个请求加上一点点网络传输时间,一点点框架内部消耗,这一点点时间乘以7000,最终要执行的任务是理论上可以。可能超过5s。所以偶尔超时是可以理解的。但是,朋友们,一切都结束了。我前面说的是理论上的,但实践才是检验真理的唯一途径。看一下Cosmos的验证结果:首先我们可以看到消费者无论是发起请求还是处理响应都非常快,但是卡在服务端接收请求和处理请求之间。经过抓包分析,他们得出结论,事务超时的原因不在消费者端,而是在提供者端。这个结论其实很好理解,因为压力在服务商这边,所以堵也应该在上面。其实到这里我们基本可以确定肯定是Dubbo框架中的一些操作导致了耗时的增加。难的是定位,到底是什么操作?通过一系列的操作和仔细的分析,宇宙行得出了一个结论:密集的心跳导致nettyworker线程繁忙,导致交易时间增加。也就是结论中提到的一点:有了结论,就容易找到病灶,对症下药。前面说了,本文只关注场景一,那么我们来看看针对场景一给出的解决方案:都是围绕心跳优化处理,处理后的效果如下:最显着的操作是“心跳旁路序列化”。消费者和提供者之间的平均处理时间差从27ms减少到3m,增加了89%。前99%的事务从191毫秒下降到133毫秒,提高了30%。嗯,写到这里几乎就是复述当时在那篇文章中看到的一些东西,没有太多营养。只是我还记得第一次看到这篇文章的时候,我是这样的:我觉得很牛逼,小小的心跳在C10K场景下变成了性能隐患。我必须研究它。对了,宇宙给出的解决方案中最重要的是“心跳旁路序列化”。我还要研究一下Dubbo是如何实现这个功能的。经过研究,我明白了,这东西是我的。不过……我忘了当时为什么没看,不过没关系,我现在想起来了,马上就开始研究。心跳如何绕过序列化?我是如何着手研究它的?是直接看源码吗?没错,就是冲入源码。不过赶??之前先去Dubb的github看了一会:github.com/apache/dubb...然后在Pullrequest中搜索“Heartbeat”,在这个搜索中发现了很多好东西:我当我看到这两个PR,我眼前一亮。好家伙,本来只是想随便看看,没想到直接定位到了自己要研究的东西。只需要看这两个PR就知道如何实现“心跳旁路序列化”,直接让我少走很多弯路。首先看这个:github.com/apache/dubb...从这个描述可以看出,我找到了正确的地方。而从他的描述中,我们知道“heartbeatskipsserialization”,也就是将序列化的过程换成null。同时,这个pr也说明了自己的改造思路:那我就把这次提交的代码给大家看看。你怎么认为?在git上可以看到本次提交对应的文件:在源码中找到对应的地方即可,这也是一种查找源码的方式。我对Dubbo框架比较熟悉,不看这篇pr大概也知道在哪里可以找到对应的代码。但是,如果我切换到另一个我不熟悉的框架怎么办?从它的git入手其实是一个很好的角度。一个浏览源码的小技巧,送给你。不知道Dubbo框架没关系,我们只关注“心跳如何跳过序列化”。至于心跳由谁发起,如何发起,何时发起,本节暂且不谈。接下来我们从这个类入手:org.apache.dubbo.rpc.protocol.dubbo.DubboCodec从提交记录可以看出主要有两处改动,并且两处改动的代码完全一样,都是它们位于decodeBody方法中。一个在if分支,一个在else分支:这段代码做了什么?如果想到一个RPC调用,肯定涉及到消息的编码(encoding)和解码(decoding),所以这里主要是对请求和响应消息进行解码。一个心跳,一个来回,一个请求,一个响应,所以有两个变化。所以我就带大家看看请求包的处理:大家可以看到代码修改后,对心跳包进行了特殊的判断。心跳事件的特殊处理涉及到两个方法,都是本次投稿中新增的方法。第一种方法如下:org.apache.dubbo.remoting.transport.CodecSupport#getPayload是将InputStream流转换为字节数组,然后将这个字节数组作为输入参数传递给第二种方法。第二种方法如下:org.apache.dubbo.remoting.transport.CodecSupport#isHeartBeat从方法名我们知道这是判断请求是否是心跳包。如何判断是心跳包?首先看发起心跳的地方:org.apache.dubbo.remoting.exchange.support.header.HeartbeatTimerTask#doTask从发起心跳的地方我们可以知道它发出的是null。所以在接受包的地方判断它的内容是否为null,如果是就说明是心跳包。通过这两个简单的方法,完成了heartbeatskip序列化操作,提高了性能。上面两个方法都在这个类里面,所以核心的变化还是在这个类里面,但是变化不是太多:org.apache.dubbo.remoting.transport.CodecSupport这个类里面有两个小细节,我可以拿大家再看看吧。首先是这里:这个map里面缓存的是不同序列化方式对应的null,代码干的就是作者这里说的:另外一个细节就是看这个类的提交记录:还有一个优化的提交,而这次提交的内容是这样的。首先定义了一个ThreadLocal,初始化的时候是1024字节:那么这个ThreadLocal用在什么地方呢?读取InputStream时,需要开辟一个字节数组。为了避免频繁创建和销毁这个字节数据,创建了一个ThreadLocal:有同学看到这个会问:为什么这个ThreadLocal不调用remove方法?那么,不会有内存泄漏吗?不是,小伙伴们,Dubbo里面执行这个东西的是NIO线程,这个线程是可以复用的,而且里面只有一个1024字节的数组,不会有脏数据,所以不用去掉,直接重用。正是因为可以复用,才提高了性能。这就是细节,细节决定成败。这个细节就是前面提到的另一个pr:github.com/apache/dubb...看到这里,我们也知道了universeline是如何让心跳跳过序列化操作的。其实并没有什么复杂的代码,几十行代码就搞定了。但是,我的朋友们。写到这里,突然觉得不对劲。因为之前写过这篇文章,Dubbo协议有点乱。这篇文章中有这么一张图:这是当时从官网截下来的。在协议中,事件标识字段前只有0和1。但现在不一样了。从代码来看,扩大了1的范围。不一定是心跳,因为里面有if-else。于是,我就去看了官网对协议的说明。.dubbo.apache.org/zh/docs/v3.…果然有变化:不是说1是心跳包,而是改成:1可能是心跳包。严谨,这就是严谨。所以,代码改了之后开源项目并没有完成,还要考虑一些周边资料的维护。heartbeat的各种设计方案我在研究dubboheartbeat的时候,也发现了这样一个pr。github.com/apache/dubb...题目是这样的:翻译是建议使用IdleStateHandler而不是使用Timer来发送心跳。我仔细一看,看到了一个好机会。这不是老相识的95后老徐吗。看看老徐是怎么说的。他的建议是:几位Dubbo大佬在这个pr中交流了很多想法,仔细阅读后受益匪浅。大家也可以点进去看看,我在这里给大家汇报一下我的收获。首先,是几位老兄弟在实时心跳上的较量。总之,大家都知道Dubbo的心跳检测有一定的延迟,因为它是基于时间轮的,相当于一个定时任务,触发器的时效性并不能保证实时触发。这个东西类似于你有一个定时任务,每60秒执行一次。0秒开始任务,1秒准备好一条数据,但需要等待下一个任务触发。被处理。因此,处理数据的最大延迟应为60秒。每个人都应该能够理解这一点。另外,上面讨论的结果是“目前有1/4心跳延迟”,但是我去看了最新的master分支的源码,怎么感觉是1/3延迟:从源码来看,你可以看到现在,HEARTBEAT_CHECK_TICK参数在计算时间的时候是3。所以我的理解是1/3延迟。不过这不重要,不重要,反正你就是知道有延迟。而kexianjun兄认为如果是基于netty的IdleStateHandler来做的话,每次检测超时都会重新计算下一次检测时间,这样可以比较及时的检测超时。这是实时优化。而老徐觉得,IdleStateHandler除了实时性的考虑外,其实是心跳的优雅设计。但是因为是基于Netty的,当通信框架没有使用Netty的时候就会无能为力,所以可以保留Timer的设计来应对这种情况。很快,carryxyh大哥给出了非常有建设性的意见:因为Dubbo支持多种通信框架。这里说的“多”,其实不提都忘了。除了Netty,它还支持Girzzly和Mina两种底层通信框架,也支持定制化。但我想现在是2021年了,还有人在用Girzzly和Mina吗?从源码中我们也能找到他们的影子:org.apache.dubbo.remoting.transport.AbstractEndpointGirzzly,Mina和Netty各有自己的Server和Client。其中,Netty有两个版本,因为Netty4已经迈出一大步,很难兼容之前的版本,所以还是直接多实现一个比较好。但是不管怎么变,它还是叫Netty。好吧,回到之前的建设性意见。如果心跳使用IdleStateHandler方法,其他通信框架保持Timer模式,那么难免会有类似这样的代码:iftransport==netty{don'tstartheartbeattimer}这不应该出现,因为它增加了代码的复杂性。所以他的建议是,心跳检测最好使用同样的方法,即使用Timer模式。就在觉得这哥们说的有道理的时候,看了老徐的回答,突然觉得他说的也很有道理:上面我觉得不用解释了,大家看看就好。再看看carryxyh兄的观点:这时候就出现了相反的情况。在老徐看来,heartbeat肯定是必须的,但是他认为不同通信框架的实现方式不需要一致(现在都是基于Timer时间轮),他认为Timer不应该抽象成一个统一的概念。实现连接保活是一个优雅的设计。在Dubbo中,我们主要使用Netty,而Netty的IdleStateHandler机制自然是用来做心跳的。所以我个人认为是他首先认为使用IdleStateHandler是一种更优雅的实现方式,其次是时效性的提升。但是carryxyh师兄认为Timer抽象出来的定时器是一个非常好的设计。因为它的存在,我们不关心底层是netty还是mina,只需要关心具体实现即可。至于IdleStateHandler的方案,他认为还是在时效性上有优势。但我个人认为他的想法是,如果真的有优势,我们可以参考它的实现方法,给其他通信框架赋能一个“Idle”的功能,这样就可以实现大一统了。看到这里,我觉得这兄弟二人的战斗要点就是这样了。第一个前提是,它们都是围绕“心跳”这个功能展开的。一想到使用Netty有更好的“心跳”实现,而Netty是Dubbo的主要通信框架,所以只改Netty的实现应该是可以的。一种认为应该统一“心跳”的实现方案。如果Netty的IdleStateHandler方案是个好方案,我们应该把这个方案拿过来。觉得有道理,一时间不知道投给谁。但最终让我选择投老徐的是看了他写的这篇文章:《一种心跳,两种设计》。在这篇文章中,他详细的写了Dubbo心跳的演进,其中也涉及到部分源码。最后他给出了这样一张图,心跳设计方案对比:然后,就有了这么一段话:老徐在阿里搞中间件,结果搞中间件的人天天想这些东西。有趣的。看一下代码让大家看一下代码,但是不会做详细的分析,相当于指路。如果想了解更多,可以自己去看源码。首先是这里:org.apache.dubbo.remoting.exchange.support.header.HeaderExchangeClient可以看到在HeaderExchangeClient的构造函数中调用了startHeartBeatTask方法来启动心跳。同时,里面还有一个HashedWheelTimer。我对这件事很熟悉。时间轮之前分析过。然后我们重点关注startHeartBeatTask这个方法:这里就是构建一个心跳任务,然后丢进时间轮运行,没有什么复杂的逻辑。这个实现是Dubbo默认对心跳的处理。不过需要注意的是,整个方法都被if判断包裹起来了,这有很大的背景。名字叫canHandleIdle,即是否可以处理空闲操作。默认为false:所以,前面if判断的结果为真。那么什么情况下canHandleIdle为真呢?使用Netty4时为真。也就是说,Netty4并没有使用默认的心跳实现。那么它是怎样工作的?既然服务端和客户端的思路是一样的,那我们就直接看客户端的代码吧。注意它的doOpen方法:org.apache.dubbo.remoting.transport.netty4.NettyClient#doOpen在pipeline中添加了我们前面提到的IdleStateHandler事件。这个事件就是如果heartbeatInterval毫秒内没有读或者写事件,那么就会触发一个方法,相当于一个回调。heartbeatInterval默认为6000,即60s。然后添加了nettyClientHandler,它有什么作用呢?看一下它的方法:org.apache.dubbo.remoting.transport.netty4.NettyClientHandler#userEventTriggered这个方法是发送心跳事件。也就是说,如果这样写,意思就是60s内,客户端没有读或者写的时间,那么Netty会帮我们触发userEventTriggered方法。在这个方法中,我们可以发送一个心跳来查看服务器是否正常。从目前的代码来看,Dubbo最终还是采纳了老徐的建议,但是默认的实现并没有改变,只是在Netty4中采用了IdleStateHandler机制。这样的话,我倒是觉得更奇怪了。同样是Netty,一个使用时间轮,一个使用IdleStateHandler。同时我也明白了,台阶不能太大,容易把鸡蛋撕破。不过在翻源码的过程中,发现了代码中的一个小问题。org.apache.dubbo.remoting.exchange.codec.ExchangeCodec#decode(org.apache.dubbo.remoting.Channel,org.apache.dubbo.remoting.buffer.ChannelBuffer,int,byte[])在上面的方法中,有有两行代码是这样的:不用管他们做了什么,我会告诉你他们的逻辑是怎样的:你可以看到这两个方法都实现了这个逻辑:intpayload=getPayload(channel);booleanoverPayload=isOverPayload(有效载荷,大小);如果finishRespWhenOverPayload返回notnull,那没什么好说的,返回return,不会执行checkPayload方法。如果finishRespWhenOverPayload返回null,将执行checkPayload方法。这时,会再次进行检查数据包大小的操作。这不是重复了吗?所以,我觉得这行代码是多余的,可以直接删除。你明白我的意思吗?又一次为Dubbo贡献源码的机会,对你来说,你可以一炮而红。最后再给大家一些参考资料。第一个是了解SOFA-RPC的心跳机制。SOFA-PRC也是阿里巴巴开发的开源框架。心跳的实现完全基于IdleStateHandler。可以看看这两篇官方文章:www.sofastack.tech/search/?pag...第二篇是GeekTime《从0开始学微服务》。17讲老师分享了一点关于heartbeat的内容,并提出Thisisaprotectionmechanism,我以前没有想到:反正我觉得如果你仔细阅读我文章中提到的所有链接,那么对于heartbeat,也是掌握了七八个,够用了。好的,我们开始吧。