Thrift(Java版)到网络编程(一)
写在前面
在面试和平时交流过程中,发现很多同学对于网络编程这块关注度较低(做业务开发的同学居多)。作者认为当今互联网界做工程,是不可能饶过这一块的。当然本人对这块理解也很浅。这也是写本文的动机和初衷。本文试图通过基于Thrift的一些实际案例,把网络编程过程中遇到的常见的问题和知识点总结一下。文中一些链接,同学们也可以多去点一点,大部分都是写得不错的。
选择Thrift作为主要的参考学习对象,一是Thrift自身已经很成熟了,对基本的网络通信模型支持都不错;二是thrift相对来说是一个非常轻量级的框架,相信对于入门级同学有较大的学习价值。
希望本文对于以下同学有些参考价值:1)网络编程入门玩家;2)Thrift入门玩家(Java党最好)
。
一、 Thrift简介
本文不是《Thrift 30min入门到精通》之类的。所以不会对Thrift做过多的介绍。大家可以到官网或者参考Apache THRIFT: A Much Needed Tutorial(推荐)。
个人觉得Thrift最大的优势有:高性能(跟Protocol
Buffer近似),可压缩(相对纯文本协议),多语言支持(相对Dubbo),配套RPC框架(不仅仅是IDL支持)等。当然从个人喜好角度,更喜欢其轻量化设计和强大的扩展能力。Thrift目前在我厂各种微服务框架中得到了较为广泛的应用。
有些基本概念还是要简单讲一下(本文源码是基于V0.9.3)。首先看一下Thrift整体架构图(协议栈)。如图1所示,带颜色的部分是Thrift框架范围。结合图2网络分层模型我们可以确定:Thrift是一个应用层协议。
图1. Thrift整体架构图
图2. 网络分层模型
你会发现分层的思想无处不在。不仅是网络,如组织架构,建筑,软件架构等都离不开这个基本思想。所以该分层时候千万不要排斥。分层的好处是每一层各执其职,术有专攻。当然对下层服务能力和上层需求要有足够的了解,才能把本职工作做好。本文更多是在应用层徘徊,socket往下基本不涉及。如果有机会作者也希望能够往下再探一探。
下面大致讲一下Thrift一些基本概念。
TTransport
TTransport是对下层传输的一个抽象。只提供open/close,read/write, flush等少数接口。目前实现了Socket、HTTP、Zlib等多重传输方式。
图3. TTransport
在server端Thrift同时提供了TServerTransport接口。主要提供了Listen, accept, close等接口。实现有TServerSocket和TNonblockingServerSocket。一个用于阻塞通信,一个用于非阻塞通信。
图4. TServerTransport
说到这里,已经很难回避一个东西了,那就是socket。相信绝大部分同学对这个概念已经非常熟悉了。当然想把这块东西都说明白,估计又得再写一篇长博文了。还好之前有人已经努力过了,这里贴个参考链接,感兴趣的同学可以去看看。
Socket是一种技术规范,最早用于Unix系统。Socket
API是由操作系统提供的编程接口。Socket位于传输层和应用层之间。Java提供了对Socket JDK层面的支持。具体可以参考java.net.Socket和java.net.ServerSocket。面向连接的Socket(TCP)通信主要流程请参考图5(来源),这里就不再展开了。
图5. TCP Socket通信过程
大家可以看看TSocket和TServerSocket源码。他们内部就是封装了Socket和ServerSocket,同时实现了TTransport和TServerSocket接口。
TProtocol
TProtocol主要负责序列化和反序列化工作。估计这个就不用过多的罗嗦了。目前Thrfit主要支持binary,compact,JSON等方式,如图6。其中MultiplexedXX是为了支持一个进程支持多个TServer而引入的。
图6. TProtocol接口
细心的同学会发现图中没有列出接口。不是不想,而是太多了。TProtocol提供了对于thrift支持的所有数据结构的R/W操作。那有哪些数据结构呢。可以参考TType定义。其中最特殊的是STOP,这其实是个占位符。传输过程中一般都是用‘0’表示的,表示分段结束。
图7. TType
Protocol其实就是业务层的一个协议,用于通信双方有效的交换信息。从TLV,PB,JSON都是在做这个事情。我们自然也可以自己约定个协议。只要C/S两端能识别就没问题。Thrift在这方面扩展性也是很强大的。必要的时候大家可以尝试下。
TProcessor
TProcessor负责从一个input
protocol读取一份数据,将数据转发给相应的handler,然后把结果写入到output
protocol中。一个Service对应的Processor是通过thrift gen自动生成的。而这个handler就是我们需要实现的部分。
TProcessor接口简单的不能再简单了(图8)。其实现可以参考TBaseProcessor.process()。因为Thrift是一个RPC框架,所以真正process过程都是交给具体接口来完成的。具体可以参考ProcessFunction.process().
图8. TProcessor接口
TServer
TServer负责把上述processor,protocol,transport等等组合在一起,协同实现一个Thrift服务器。主要实现类如图9所示。因为这个是后续讨论重点。也希望有时间能把这些Server一一都过一下。这里先不展开。
图9. TServer
Thrift IDL
IDL(Interface Description Language)顾名思义,主要是用于描述thrift接口的。根据IDL文件,我们可以通过thrift --gen生成不同语言的版本的Processor等。下面是本文会用到的IDL文件:
//hello world service
namespace java com.itegel
service Hello {
string sayHello(1:string name, 3:i32 age);
}
根据此文件生成的类如图10。其中 Processor和AsyncProcessor,就是上面说的TProcessor的实现,一个用于同步模式,一个用于异步模式。同步异步这个话题后面会单独再讨论。Client是对客户端逻辑的一个封装。Clinet继承自TServiceClient,实现了IFace接口。具体代码还请各位同学自行看一下,不多。本文不想把代码一行一行都讲一遍。但是每个例子里都希望能把一些核心代码拿出来share一下。
图10. 自动生成Processor
二、 TSimpleServer及单线程同步模式
写了这么久,终于可以切入主题了(此处小兴奋)。
TSimpleServer是一个单线程的server。所以线上很少能用得到。但是作为教学Server再好不过了。
服务端代码
Hello.Iface handler;
Hello.Processor processor;
handler = new
HelloHandler();
processor = new
Hello.Processor<>(handler);
TServerSocket.ServerSocketTransportArgs transportArgs
= new TServerSocket.ServerSocketTransportArgs()
.backlog(backlog)
.port(port)
.clientTimeout(timeout);
TServerTransport serverTransport = new TServerSocket(transportArgs);
TSimpleServer.Args args = new TSimpleServer.Args(serverTransport)
.processor(helloProcessor)
.protocolFactory(new
TBinaryProtocol.Factory());
TServer tServer = new TSimpleServer(args);
tServer.serve();
其中Processor是自动生成的。
Handler是我们实现的代码。本文只是简单返回个字符串:
public class HelloHandler implements
Hello.Iface {
@Override
public String
sayHello(String name, int age)
{
System.out.print(new
Date() + ": " + name + ",
" + age + "\n");
try {
if (name.equals("Itegel"))
{
Thread.sleep(300000);
}
} catch (InterruptedException
e) {
e.printStackTrace();
}
return "Hello
" + name + ", you are " +
age + ".";
}
}
这里sleep部分先忽略,那是为了演示用的 。
服务端transport用了TServerSocket,protocol用了TBinaryProtocol。然后把这两个逐层传递给了TSimpleServer。
我们首先看一下TServerSocket都干了啥。看下构造器:
public TServerSocket(ServerSocketTransportArgs
args) throws TTransportException {
clientTimeout_ =
args.clientTimeout;
if (args.serverSocket
!= null) {
this.serverSocket_
= args.serverSocket;
return;
}
try {
// Make server socket
serverSocket_ = new
ServerSocket();
// Prevent 2MSL delay
problem on server restarts
serverSocket_.setReuseAddress(true);
// Bind to listening port
serverSocket_.bind(args.bindAddr, args.backlog);
} catch
(IOException ioe) {
close();
throw new TTransportException("Could
not create ServerSocket on address " + args.bindAddr.toString()
+ ".", ioe);
}
}
因为参数里,我们没带下来serverSocket,所以会new
ServerSocket()。好吧,这事儿基本就这样了,毫无花哨的东东。这里有两个参数非常重要。
1.timeout:
这里只有一个值,但实际上socket编程里有两个timeout。先看看最终调用的setTimeout方法吧。
public void setTimeout(int
timeout) {
this.setConnectTimeout(timeout);
this.setSocketTimeout(timeout);
}
所以一个叫connectTimeout,一个叫socketTimeout。
ConnectTimeout顾名思义是客户端过来建立连接过程的耗时。也就是三次握手开始到成功建立连接的时间。如果超过这个时间还是未能建立连接,服务端会主动rst当前半连接。
SocketTimeout这里实际上是socket里的readtimeout。注意一点,read timeout是针对读取单个包的timeout。数据分包场景下不等于整个结果获取的时间。
Timeout为啥会这么重要呢?我们经常提雪崩,大部分都是因为下游某个服务故障加上上游不断重试,导致本服务资源耗尽所致。这里一个很重要的资源是连接。如果超时设置不合理,就会导致当下游故障时,不能及时释放连接资源,导致不断建立新连接。故障服务相关连接把整个连接池都占用了,其他可以正常响应的请求就无法建立连接。
这里还提到了重试次数设置。这个也很重要。一般建议是如果上游有重试机制,中间服务要谨慎加重试或直接不重试,以便有效的保护下游。
本服务加些必要的限流措施也是很有必要的。这样在一些极端情况下不至于所有服务都挂掉。
2.backlog:
Linux内核中有两个队列,一个叫syns
queue, 一个叫accept queue。服务端收到客户端syn消息后,会把相应的请求放到syns(SYN_SENT?)队列(半连接状态)。而连接established的连接会放到accept queue中等待accept()消费。Backlog长度就是syns队列的长度。当该队列满之后,客户端在想连接就会直接拒绝。
Thrift里backlog被默认设置为50。那到底设置多少合适呢?太小了,那么对于一些尖峰流量就没有容忍度了。太大会有啥问题?关键要看处理能力。举个例子,上游超时设置为1s,本服务处理能力是100QPS。那么把backlog设置为大于100会有个问题:当发生挤压,处理队尾连接是很可能已经被客户端断开了。FIFO顺序执行可能会有更悲剧的事情发生。当然还有一些资源占用方面的考虑。参考下小节。
public static abstract class AbstractServerTransportArgs
AbstractServerTransportArgs>
{
int backlog
= 0; // A value of 0 means the
default value will be used (currently set at 50)
int clientTimeout
= 0;
…
}
3.连接数:
我们一般是不用特别设置这个值的,那他受哪些限制呢?第一个就是上面说到的backlog会限制半连接的个数。一般工作线程大小限制established状态的连接个数。更大的限制是系统内存(包含JVM配置)和系统句柄数限制。Linux下:用户级别限制可以通过ulimit
–n查看;系统级别限制通过以下命令查看:sysctl -a|grep file-max.
关于protocol,这里先不占开,后面抓包章节再看。
客户端代码
客户端代码更简单:
TSocket tSocket = new TSocket(hostname, port);
tSocket.setTimeout(timeout);
TTransport tTransport = tSocket;
TProtocol tProtocol = new TBinaryProtocol(tTransport);
Hello.Client client = new Hello.Client(tProtocol);
tTransport.open();
System.out.println("RESPONSE: " + client.sayHello(name, age));
tTransport.close();
这里client代码是自动生成的。Transport用的是socket,protocol用的是TBinaryProtocol(需要跟服务端一致)。看一下transport.open:
public void open() throws TTransportException {
……
if (socket_ == null) {
initSocket();
}
try {
socket_.connect(new InetSocketAddress(host_, port_), connectTimeout_);
inputStream_ = new BufferedInputStream(socket_.getInputStream(), 1024);
outputStream_ = new BufferedOutputStream(socket_.getOutputStream(), 1024);
} catch (IOException iox) {
close();
throw new TTransportException(TTransportException.NOT_OPEN, iox);
}
}
可以看到,底层用的是http://java.net的socket.connect()方法。除了host和port,还有个timeout参数,这里就不再展开了。
作者贴这两段代码,不是想给大家一个完整的tutorial。官网和google,有一堆tutorial。以上小sample主要是为了下面讨论做铺垫。当然完全没接触过thrift的同学相信看完之后也能大概知道在做什么。
抓个包
当年烧单板时候养成一个习惯:所有涉及网络的事情,首先都希望抓个包看看。在RPC环境下,即使不抓包,把输入输出打出来看看还是必要的。
抓包当然是需要工具的。这里推荐两个工具。第一个必然是tcpdump了。估计也不用多介绍。另一个神器是WireShark。Tcpdump大家自行谷哥吧。实际应用中,可以通过tcpdump抓包,然后拿用wireshark分析。本文中因为作者只是在自己pc上做个demo,所以直接用wireshark抓的包。
针对以上sample,我们抓个正常交互的报文如下:
图11. SimpleServer + BinaryProtocol 抓包
作者惊喜的发现,wireshark竟然支持thrift协议(仅限于binary protocol,其他暂时没看到相应插件等支持)。
一次完整的RPC过程,总共发了10条消息。前三条就是传说中的三次握手。最后三条对应关闭连接的四次握手消息(第二条把ACK和FIN在同一条消息里发送)。这部分大家请参考下节。
这里核心是蓝色部分,客户端发送一个消息,服务端返回ACK。服务端响应了一条消息,客户端也发送了相应的ACK。
在CALL sayHello时候到底发送了什么呢?请看下面截图:
图12. sayHello RPC报文
从报文结构能够看到二层以太网协议包着ip协议->tcp->thrift,很清晰。因为篇幅没法一个一个点开给各位客官看了。有兴趣可以自行抓包看看。以太网已下,我就看不懂了...
回到Thrift协议,我们通过抓包去,可以帮助我们了解一下TProtocol是怎么work的。
首先客户端的rpc调用,实际调用是Client下的send_sayHello方法。如下:
public void send_sayHello(String
name, int age) throws org.apache.thrift.TException
{
sayHello_args args = new
sayHello_args();
args.setName(name);
args.setAge(age);
sendBase("sayHello", args);
}
而sendBase实际上是调用protocol的相应方法,组装报文:
private void sendBase(String
methodName, TBase,?>
args, byte type) throws TException
{
oprot_.writeMessageBegin(new
TMessage(methodName, type, ++seqid_));
args.write(oprot_);
oprot_.writeMessageEnd();
oprot_.getTransport().flush();
}
DATA部分,我们可以参考下
sayHello_argsStandardScheme.write()方法实现:
public void write(org.apache.thrift.protocol.TProtocol
oprot, sayHello_args struct) throws
org.apache.thrift.TException {
struct.validate();
oprot.writeStructBegin(STRUCT_DESC);
if (struct.name
!= null) {
oprot.writeFieldBegin(NAME_FIELD_DESC);
oprot.writeString(struct.name);
oprot.writeFieldEnd();
}
oprot.writeFieldBegin(AGE_FIELD_DESC);
oprot.writeI32(struct.age);
oprot.writeFieldEnd();
oprot.writeFieldStop();
oprot.writeStructEnd();
}
这里的oprot就是out protocol的实例。我们可以看出,TProtocol就是在负责怎么把具体的字段(包括自身协议头和协议body)转换成实际传输的二进制数据。一般我们会把这个过程叫做序列化。
而Read的过程是反过来的(反序列化)。当然Thrift支持in和out的protocol可以设置成不同的协议。
有兴趣的同学结合上面抓包结果和thrift代码对比多看看。相信很快你就会有自行定义个协议的冲动。当然多种数据类型的支持是这里的关键点。这个过程非常繁杂。这可能也是为什么thrift没有支持嵌套的struct和enum等的原因吧。
TCP连接
上面提到了很多连接相关东西。本节简单说一下。首先看一TCP连接状态图,参考博客:
图13.1 TCP连接状态图
图13.2 TCP连接状态图 VS建立和关闭连接
图13.1大家应该很熟悉了,本科计算机网络课本上应该就有。个人觉得图13.2更容易记忆。详细的讨论请大家点上面“来源”连接。作者写得比较细。
通过netstat –nat|grep ‘port’可以看到该端口相关连接状态。 这里就不再罗嗦了。
有几个状态,大家可能要多注意。
1.服务端大量CLOSE_WAIT:
这个可能是一个比较常见的情况。从图13可以看出,当客户端主动关闭,但是服务端没有发送FIN,那么就会处在一个CLOSE_WAIT状态。而这个是怎么产生的呢?实际中,可能原因很多。一种常见的情况是,因为服务端处理速度跟不上,导致客户端timeout,主动close了。这时候因为服务器还在处理业务逻辑,没法及时响应close请求,从而连接处于CLOSE_WAIT状态。我们可以简单模拟,比如client设置timeout为200ms,服务端处理线程sleep个1s,就会出现服务端CLOSE_WAIT。此时客户端一般处于FIN_WAIT_2:
jstack看下,服务端刚启动:
服务线程进入sleep:
如果出现CLOSE_WAIT,可以先排查有没有长耗时的业务,有没有业务死循环,死锁等。当然很多时候事情并没有如此简单,具体问题具体分析吧。Jstack总是要会看的。当然线上一般不会给你这个机会。大家有问题时候首先想到的就是先重启恢复服务。所以我们一般会定时把jstack信息打到日志文件里,已备后续分析。当然线上有问题,留个环境这种意识最好也有。比如有几十台机器,留一台有问题的机器,一般也不会影响业务的。
2.客户端大量TIME_WAIT:
TIME_WAIT实际上并不可怕,这个是TCP协议本身决定的。四次握手时,主动关闭方,发送ACK后,自己进入TIME_WAIT状态,保持2MSL(Maximum Segment Lifetime)时间。目的是当被动关闭方发送FIN后因超时,再次发起FIN请求。这样主动发起方,还有机会再发送一遍ACK。
但是实际中如果出现大量TIME_WAIT,很可能是网络或者服务端原因造成的。可以考虑通过setReuseAddress和setSoLinger设置来达到忽略或缩短这个时间的目的。具体情况还需要具体分析,更多内容可以继续谷哥。
线程状态
细心的朋友会发现,jstack里可以看到有个线程状态。现在还不想发散,后面有机会再看看。附上一幅图(TIMED_WAITING就是下图sleeping):
图14. Java线程状态图
感觉这张图还是很清晰了,就不展开了。多线程问题,作者希望下一篇中能够更多的展开些。大家可以先参考
我们用jstack定位问题时候,通常可能需要多执行几次,看有没有线程夯住等情况。
这里有个各种常用工具组合介绍,很全,给原作者点赞!
写在后面
写了不少了,发现这块如果真想说清楚(当然还要先搞清楚),还是需要很大篇幅的。最近懒惰综合征又犯了,所以第一篇先写到这里。
下一篇希望能把多线程、各种pool、阻塞非阻塞这些能尽量说明白。至于NIO这些估计很可能得下下篇了。
P.S.微信"搜一搜","看一看"亮瞎眼。