GRPC丢包之路
GRPC是Google开源的一个高性能、跨语言的RPC框架,基于HTTP2协议,基于protobuf-3.x,基于Netty-4.x。
最近开发一个公司的游戏项目,采用分布式的服务器架构,内部服务之间均采用GRPC进行通信。但是在测试过程中发现一个重大的bug,偶现丢包。
前置信息:ServerA(作为grpc的client端)通过futureStub异步调用ServerB(作为server端)。
查看日志发现,client中有调用发送请求的日志,但server端却没有任何收到请求的日志。于是怀疑server在接收请求的过程中吃掉了异常,对整个过程加上try-catch,然而并没有任何发现,于是怀疑根本没有收到请求。通过tcpdump抓包后发现确实是client没发包到网络层,由此确定了是client端问题。
怀疑是grpc的futureStub有bug,于是在finally中加入future.get()打印出调用结果。结果一天下来一直没有发现任何丢包,”完美“。懵逼了,去掉finally中的future get以后又出现丢包。这个时候,直觉判断,由于调用futue.get()会block当前线程,所以不会出现丢包,而不block就会丢包。有种接近真相的感觉。检查代码后发现client端创建stub的时候没有指定线程池,grpc会默认使用当前线程,结合偶现的表现,觉得非常像是线程在异步发送的过程中被销毁导致。
然而,指定线程池以后,依旧发现丢包。无奈之下只有开始查看grpc源码,并打印出grpc的日志。在日志中发现了如下错误:
1 | ----------------OUTBOUND-------------------- |
看到errorCode=8,并结合grpc源码中对io.grpc.Status的定义:
1 | /** |
推断是由于系统文件句柄资源耗尽导致,查看服务器系统文件句柄限制为65535,而进程打开的限制远不止这么多。于是找sa将文件句柄限制改为100w,然后信心满满的重启了全部服务,重新进行测试。结果很快又发现了丢包,内心崩溃!
由于当future.get()方法阻塞线程时,就不会出现丢包,无法重现。于是想到把futureStub改为stub,在保持异步的同时,通过observer回调来观察调用结果,这次在回调的onError方法中发现了如下异常:
1 | CANCELLED, |
终于发现原来是这个call被grpc在底层cancel掉了。查代码发现原来上面的errorCode=8是指http2的code定义:
1 | /** |
知道了是由于被cancel才导致的丢包,那么就要查找为什么会被cancel,由于grpc的stub调用是通过异步的线性执行器执行io.grpc.ClientCall。所以堆栈中无法看到整个调用过程,只能通过源码来看。不管是哪种stub的调用,最后都会走到ClientCalls.startCall()方法中,然后进ClientCallImpl.start()中,在这个方法中,可以看到与cancel有关的代码:
1 | if (context.isCancelled()) { |
可以看出来cancel的原因是由于context.isCancelled()的结果为true
1 | public boolean isCancelled() { |
可以看出,如果parent==null,则不可能为true,所以必然是由于parent.isCancelled()为true导致的。由此我们基本可以猜测出来,当前的ClientCall被cancel的原因是由于它的parent context canceled导致。
回到ClientCallImpl中,可以看到this.context = Context.current();表明每个ClientCall被创建的时候,context都会被赋值为Context.current()
1 | public static Context current() { |
如果当前没有context,则用ROOT作为context
1 | /** |
在注释中可以看出,ROOT是不可以被cancel的,所以可以判断,当前必然有一个grpc的context存在。
最后联想到由于业务代码中,ClientCall是在ServerA处理grpc请求的过程中执行的,那么这个Context必然是由GrpcServer处理请求的时候生成的,并赋值给了ClientCall。而由于ClientCall是线性执行器中异步执行,当真正执行ContextRunnable的时候,GrpcServer的context可能已经被cancel,导致当前ClientCall被cancel。
解决办法:可以对当前context进行fork,或者把每个call都封装为Runnable,通过Context提供的run静态方法执行。
至此,终于弄清楚了grpc丢包的原因。虽然,这个问题通过业务层代码可以避免掉,但是我觉得grpc在底层默默的把这件事做了,并且没有往上层抛出任何信息,这对开发者是非常不友好的。同时,这个cancel逻辑的处理上,我觉得也有待商榷。毕竟在GrpcServer中异步的去执行GrpcCall是一个非常普遍合理的模型。也许以后Google会改吧。