TCP/IP(Transmission-Control-Protocol/Internet-Protocol),中文译名为传输控制协议/因特网互联协议,又名网络通讯协议,是Internet最基本的协议、也是Internet国际互联网络的基础。
我上大学的时候就是一门必修课。工作后我还专门重新看了一遍,觉得比上学时理解的多了些。但是书本上东西毕竟不贴合工作。本文结合工作中常用的方面以及现实中出现过的线上问题来讲解说明。
TCP/IP协议栈为什么是“栈”?
大学的时候课本上讲过:开放系统互联参考模型OSI/RM分7层,从低到高分别是物理层,数据链路层,网络层,传输层,会话层,表示层和应用层。
先从7层模型入手,是因为下面讲的TCP/IP四层模型里缺少咱们平时工作中常用的RPC远程过程调用协议,它工作在会话层,基于下面图中的socket来实现。
实际上互联网通信使用的是TCP/IP协议栈。这里举例来说明为什么叫“栈”。
栈是一种先进后出的数据结构。拿一个HTTP报文来说,HTTP报文属于应用层协议的报文,我们输入网址,首先会调用到DNS协议(域名协议)。HTTP报文在传输层用的是TCP协议,我们把TCP压入栈中,再将IP层也压入栈中。链路层的话,就用最常见的以太网。
好了,现在我们的栈里面从头至尾依次是:
HTTP协议-TCP协议-IP协议-以太帧头
然后我们先忽略最底层的物理层,假设这个封装好的栈一样的报文漂洋过海,来到了它的目的地。对端收到这个报文以后,也就是我们封装好的这个栈一样的东西以后该怎么办呢?会不会也是先拿HTTP呢?因为这个报文是我们构造的一个栈,所以说它的顺序肯定也是栈。
因此拿取的顺序就是:
以太帧头-IP协议-TCP协议-HTTP协议
在《接下来一段时间会对大家进行网络通信的魔鬼训练-理解socket》中我讲过:传输层操作是在内核空间完成的,就是说不是靠咱们平时的应用编码可以直接介入的。咱们平时直接用的就是应用层协议。想通过应用层操作传输层怎么办呢?这就用到了socket编程。因为HTTP协议的内容被封装进入了socket。这里称为套接字层。总体示意图如下:
发现没,最先被封装入的HTTP报文(套接字层)是最后才被拿走的。
TCP三次握手和四次挥手
三次握手
在《懂得三境界-使用dubbo时请求超过问题》这篇文章中,我讲过三次握手。这里用打比方的方式再讲一遍:
我在《两地书--K8s基础知识》里讲过司马相如和卓文君的故事。其实在巜史记》和《汉书》里有记载说:他们的爱情是一场精心策划的阴谋。
司马相如从一开始就打算找个富婆。所以和当地县令窜通,县令经常登门拜访司马相如,司马相如却避而不见。但县令还是天天来。很有三顾茅庐的意思。当地都在传这个人得县令如此垂青一定不简单。
卓文君的爹是当地首富,听说之后就请司马相如去坐客。司马相如在宴会上的目标是和卓文君建立通信联系。他精心计算过:卓文君夫婿刚刚去世,被接回娘家。不像未出阁的姑娘那么挑剔。自己又仪表堂堂,文采不凡。应该搞得定。
但是想傍富婆的何只他一人?他精心准备了一曲《凤求凰》,向卓文君发出第一次建立通信的请求。
第一次握手,客户端司马相如发出请求。服务端卓文君只有一个进程专门处理所有的连接请求,处理能力有限。所以这个才子、那个才子的建立联系请求会先放入队列,服务端会按照顺序来处理申请。
服务端卓文君收到客户端司马相如的申请,会验证收到的信息是否有误(实际TCP中检验的是通信数据是否有错误)。无误则对这个客户端的状态变成listen状态,并向司马相如发出一个回执:“你的琴弹的不错,可以建立联系。”这就是第二次握手。
客户端司马相如收到服务端卓文君的回执,认为自己的目的已达到,把自己标记为established状态,并进行第三次握手:“好的,仰慕已久。”这时候客户端已经一厢情愿的认为自己可以自由与服务端通信了。
但是服务端卓文君那边呢,目前还处在与客户端司马相如建立连接的过程中,请求在全连接队列中排队呢。毕竟要应付的才子多啊。客户端司马相如的回执还在排队,卓文君还没处理呢。所以这时候,客户端司马相如如果发了一封书信(传输的数据)。卓文君没还没跟自己丫鬟说跟客户端司马相如已经建立联系了呢。所以书信根本到不了卓文君手上。这封信发送就失败了。
等服务端卓文君处理到客户端司马相如的请求,真正建立连接。司马相如的书信才真正到达卓文君手上。达到了司马相如的第一步。
后来司马相如让卓文君和他私奔。他们回到司马相如的家乡,卓文君才发现司马相如家一贫如洗。卓文君问娘家要钱,娘家不给。司马相如就带着卓文君回到了卓文君家乡,并让卓文君抛头露面当庐卖酒。为的就是套路文君爸,让他爸觉得脸上无光,主动给钱。
司马相如计划得逞,文君爸给了他们万贯家财,让他们回司马相如老家过日子。但是钱大手大脚花,也很快花完了。司马相如就进京找钱去了。
四次挥手
这次司马相如果然得到了汉武帝赏识。司马相如发达了就想休妻再娶。终于某日,客户端司马相如给服务端卓文君送出了一封十三字的分手信:“一二三四五六七八九十百千万。” 无亿,就是说我就你已经无意啦。这就是第一次挥手。实际上第一次挥手会告诉服务端如果你还有数据没有发送完成,则不必急着关闭Socket,可以继续发送数据。
服务端卓文君收到这个消息,非常震惊。她还没有准备好。于是,先回复了一个消息让客户端司马相如等一等。这就是第二次挥手。(这个时候客户端就进入FIN_WAIT状态,继续等待服务端的FIN报文。)
在这时候,他们还是夫妻关系。服务端卓文君可以继续给客户端司马相如发送消息。服务端卓文君先回复了一首《怨郎诗》:
一别之后, 二地相思, 只道是三四月, 又谁知五六年。 七弦琴无心弹, 八行书无可传, 九曲连环从中折断, 十里长亭望眼欲穿。 百思想, 千系念, 万般无奈把君怨。 万语千言说不完, 百无聊赖十倚栏。 重九登高看孤雁, 八月中秋月圆人不圆。 七月半,秉烛烧香问苍天。 六月伏天人人摇扇我心寒。 五月石榴似火红,偏遭阵阵冷雨浇花端。 四月枇杷未黄,我欲对镜心意乱。 急匆匆,三月桃花随水转; 飘零零,二月风筝线儿断。 噫,郎呀郎,恨不得下一世,你为女来我做男。
等服务端卓文君把自己想说的话说完了,就给客户端司马相如回复说:“闻君有两意,故来相决绝。”(出自《白头吟》)意思是我准备好了,既然你变心啦,咱们就算了吧。这就是第三次挥手。
这时候,如果客户端司马相如是一般人,得偿所愿了。就会回复一个:“祝你幸福。”这是韩剧里标准的分手时最后话术,是通知服务端彻底断绝关系的。然后再等等确认服务端彻底没有反馈就是分手成功了。这就是第四次挥手。(实际上客户端收到FIN报文后,就知道可以关闭连接了,但是它还是不相信网络,怕服务端不知道要关闭,所以发送ACK后进入TIME_WAIT状态,如果服务端没有收到ACK则可以重传。)
但是司马相如是个艺人啊。艺人最怕脱粉啊。想想司马相如挣钱靠的主要是《长门赋》这样用来挽回感情的怨妇诗。自己这般铁石心肠,那诗还有谁会买账呢!还会得罪多少达官贵人。何况卓文君的这两篇《怨郎诗》和《白头吟》文采如此出众!聪明如司马相如马上就知道如果不继续维持这段才子佳人的佳话,那事业也没了。所以他选择了重新三次握手建立连接。
TCP长连接和短连接
TCP连接有三次握手和四次挥手的加持,被称为可靠的连接。UDP这样的数据报被称为不可靠的连接。可靠就有代价。
长连接和短连接的本质区别如上图。短连接每次通信都伴随着三次握手和四次挥手,而长连接复用了这两个过程。为了让收益最大化成本最小化,不同的应用场景会选择采用长连接或者短连接。
长连接和短连接各自的优缺点
短连接应用场景
一般网站类的web服务都是短连接。试想一个普通网站,比如查看我这篇公众号文章,总共有6000多个字。一旦打开,内容加载完之后,很长时间不用再次传输数据。那占着连接是不是很浪费?
而且服务端可承载的最大连接数是有限的,不然文件句柄不够用啊。一个网站希望用几十、几百台4核8G就可以支撑日活几百万,那最好使用短连接。
长连接应用场景
公司内的各个系统之间使用RPC。大家使用的工具不太相同,有的公司自己基于thrift协议进行开发,有的使用开源的Dubbo。但是大家都头脑清醒的使用了长连接。
因为内部场景下,上下游是固定的,接入的客户端数量也相对固定。长连接节省连接建立开销,请求量上来了也可以直接进行数据传输相对高效。
公司内部使用的中间件也大多使用长连接。例如:MQ、k8s、Redis、mysql。提到这些不得不提相关的两个技术。
keepalive保活机制
KeepAlive并不是TCP协议规范的一部分,但在几乎所有的TCP/IP协议栈(不管是Linux还是Windows)中,都实现了KeepAlive功能。
先看看使用场景:
我没看过这个电影,也不知道是哪个电影里的场景。只是知道,张艺谋在一个姑娘楼下不断的叫喊:“安红我想你” 这个镜头有段时间特别火。
客户端张艺谋发送了消息。服务端收到消息后一看,瞧给你牛的,然后没理客户端,傻狗客户端一直在等待,但是不知道是不是服务器挂掉了?
这时候TCP协议提出一个办法,当客户端端等待超过一定时间后自动给服务端发送一个空的报文,如果对方回复了这个报文证明连接还存活着,如果对方没有报文返回且进行了多次尝试都是一样,那么就认为连接已经丢失,客户端就没必要继续保持连接了。如果没有这种机制就会有很多空闲的连接占用着系统资源。原理如下图:
如何设置它?
在设置之前我们先来看看KeepAlive都支持哪些设置项
我们讲讲在Linux操作系统和使用Java如何设置。
KeepAlive默认不是开启的,如果想使用KeepAlive,需要在你的应用中设置SO_KEEPALIVE才可以生效。
查看当前的配置:
cat /proc/sys/net/ipv4/tcp_keepalive_time
cat /proc/sys/net/ipv4/tcp_keepalive_intvl
cat /proc/sys/net/ipv4/tcp_keepalive_probes
在Linux中我们可以通过修改 /etc/sysctl.conf 的全局配置:
net.ipv4.tcp_keepalive_time=7200
net.ipv4.tcp_keepalive_intvl=75
net.ipv4.tcp_keepalive_probes=9
添加上面的配置后输入 sysctl -p 使其生效,你可以使用 sysctl -a | grep keepalive 命令来查看当前的默认配置
如果应用中已经设置SO_KEEPALIVE,程序不用重启,内核直接生效
这里我们使用常用的Java网络框架Netty来设置,只需要在服务端设置即可:
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 100)
.childOption(ChannelOption.SO_KEEPALIVE, true)
.handler(new LoggingHandler(LogLevel.INFO));
// Start the server.
ChannelFuture f = b.bind(8088).sync();
// Wait until the server socket is closed.
f.channel().closeFuture().sync();
} finally {
// Shut down all event loops to terminate all threads.
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
这段代码来自经典的echo服务器,我们在childOption中开启了SO_KEEPALIVE。 Java程序只能做到设置SO_KEEPALIVE选项,其他配置项只能依赖于sysctl配置,系统进行读取。
一般我们使用KeepAlive时会修改空闲时长,避免资源浪费,系统内核会为每一个TCP连接建立一个保护记录,相对于应用层面效率更高。
常见的几种使用场景:
KeepAlive通过定时发送探测包来探测连接的对端是否存活,但通常也会许多在业务层面处理,他们之间的特点是:
像Dubbo这种通信中间件都使用到了TCP的保活机制。k8s客户端和服务端是基于http协议的长连接,用到了http的保活复用连接。
连接池技术
上面四次挥手讲了,如果服务器总是在断开连接,tcp会总是处于time wait状态。很多连接没有得到真正的释放。像数据库操作,是非常频繁的。咱们一般都不会像下图这么每次都申请和关闭连接吧。
所以就希望尽可能的复用三次握手和四次挥手的过程,让客户端和服务端投入更多的资源在数据传输上。这就要用到连接池。
连接池是一种池式结构。其他的池式结构有:线程池、协程池、内存池和对象池。它们的实现都很接近。连接池中也有很多大家听说过的场景,如:数据库连接池、MQ的连接池、Redis的连接池。
提到MQ的连接池,之前的时候,有个同事排查我们使用的MQ并发吞吐太低。因为用的是标准的Java消息服务JMS客户端,跟了代码发现里面用到了connection.close。就怀疑用的短连接。其实close方法的实现一般不是销毁连接,还是归还到连接池。
连接池咱们平时使用最多、面试最多的是其中控制伸缩性的参数:最小连接数、最大连接数。最小连接数主要是要限定池的大小,最大连接数主要是限定能打开的最大连接数。使用连接池进行通信的流程如下图所示:
关于连接池更详细的内容因为预计还要2千字以上,我会专门写一篇文章来讲。
细思极恐的Socket
在《接下来一段时间会对大家进行网络通信的魔鬼训练-理解socket》中我专门讲过socket,它功能强大但是让人细思极恐。之前发生过一个线上问题,某个时刻发生了几笔请求超时,排查发现打印Dubbo调用的来源IP时,触发了DNS反解析。网络闪断,连接不到DNS服务器,结果夯住10s直到超时。
熟悉Java语言的朋友可以了解一下当时线上问题的原因:
dubbo调用的来源IP使用的是dubbo的
RpcContext.getContext().getRemoteAddress().getHostName();
因为RpcContext.getContext()是基于上篇文章《ThreadLocal&MDC内存泄漏问题》中提到的ThreadLocal,就是保存在了线程里的一个固定值。所以也就相当于
new InetSocketAddress(固定的IP, 固定的端口).getHostName();
这个方法会进行DNS lookup!所以具体这个函数的执行时间受到网络状况的影响。建议可以直接使用IP的地方使用getAddress()代替getHostName()。
这个问题可以这么来理解:
相当于在程序里执行了一个linux命令:nslookup 某IP
为什么说让人细思极恐呢?想想看,使用Socket编程,只需一行代码,有时候就是隐式的,实际上却可能发出了一个绕地球半圈的信号,关键多数开发者还不知道。
总结
如大家所见,本篇算是《白话linux操作系统原理》的姊妹篇。和《网络通信之Session的历史血脉》、《深入理解MQ生产端的底层通信过程-理解channel》、《接下来一段时间会对大家进行网络通信的魔鬼训练-理解socket》、《网络字节序列-大端序和小端序》、《https引起的跨域问题-COE&casestudy》、《懂得三境界-使用dubbo时请求超过问题》、《一个http请求进来都经过了什么(2021版)》、《架构师之路-https底层原理》是一个系列。
在之前的文章中说过,我尽量想办法让大家能把这些系列文章有兴趣看下去。如果大家坚持下去,会对以后的工作有很大帮助。《白话linux操作系统原理》这篇文章发出之后,反馈特别好。我就在想是不是这里存在着可以让大家坚持下去的点。可是,我自己都没搞清楚这个点在哪里。
只要是用心写了,对自己的知识能力也有自信,知道所写的对大家知识能力是很有帮助的。那就坚持下去。相信可以帮助到大家,也为祖国的科技发展尽一份力。