想起当年刚学网络原理的时候,总觉得计算机网络离编程还是有点远,认为实际敲代码不需要太多的理论支撑,现在回想起来,还真是有些无知!后来才渐渐明白理论的重要性,不管是造航母,还是拧螺丝,都像是造一架飞机,如果没有理论经验支撑,只是天马行空,那造出来的东西真的可靠吗,真的经得住考验吗?
(本文较长,本文目录)
- OSI七层模型&TCP/IP四层模型
- 报文简介
- TCP连接的建立过程
- TCP的代码抽象
- TCP流量控制
- TCP拥塞控制
- TCP多路复用
- TCP多路分解
- TCP心跳机制
- TCP长连接/短连接
- TCP编程实现
- TCP抓包
- TCP粘包
- 实际线上运维/问题定位
提问:TCP/IP离业务远吗?
有些小伙伴可能会认为研发只要精通业务即可,剩下的自然可以交给运维处理,所以他们一般不会去思考业务以外的其他问题,也不会去尝试新鲜事物,如K8S/Docker/Nginx等。但据我看来这恰恰是一种自我设限的思维,往往也困住了自己的脚步。回到问题,TCP/IP真的离业务很远吗?我们不烦来看看拧螺丝中是否也有遇到下面提到的类似问题。
所以,你是否也有即使焦头烂额也排查不出问题所在的时候?
不少写业务的小伙伴可能会碰到类似于connection reset by peer
,too many open files
,EOF
等异常,如果是api服务,还会出现http 50x
,很多时候这些异常是偶发性的,比如在QPS较高的时候才会出现。这种偶发性事件往往很难保存现场,导致有种摸不着头脑的情况。可能是代码的bug,也可能是容器的资源有限,又或者是上下游网络的问题。但任何一件事情的发生,一定是会有前因后果,只是你基础原理没有吃透,才会出现这种雾水。
所以:网络到底是什么样的呢?
OSI七层模型&TCP/IP四层模型
TCP管理了端对端的可靠的数据传输,在互联网中随处可见。
应用层:为上层应用程序提供网络服务
表示层:提供数据加密/解密服务,常见的远程SSH登录等需要要求数据严格加密
会话层:管理会话的建立/断开/重连等,常见的如各种session
传输层:管理端对端连接的建立/断开/重连等
网络层:用于IP寻址和路由选择
数据链路层:承上启下,将数据由报文数据转化为二进制比特数据
物理层:真正网络间的数据传播
也许小伙伴你已经知道了TCP的连接与断开连接的原理,为了方便后续的论证,这里稍微也回顾一下TCP的握手和挥手原理
TCP报文简介
TCP连接是一个有状态的四元组
源IP: 源端口 <---> 目标IP:目标端口
有且只有四元组唯一确定,才能建立一个完整可靠的TCP连接。
在网络中,TCP也是通过数据报的形式传输的,一个TCP的报文格式如下:
源端口和目标端口各占据16位,这也是为什么我们见到的端口号都是1~65535的原因
我们说TCP报文是四元组,为什么没有看见报文中对源IP和目标IP的定义呢?原因是IP信息由IP报文组装,不需要TCP自己组装。
Seq最大32位,说明序列号最大的长度只能是0~2^32-1, 在校验序列号的时候都会判断序列号是否递增,那如果序列号递增超过了这个最大值该如何处理呢?答案就是序列号回环,我们想象0~2^32-1头尾相连,当超过最大之后又开始重新算起
Ack最大32位,与Seq同理,同样遵守回环
这里有很多标志位,我们常说的SYN
报文,ACK
报文,即该标志位为on
的报文,它传递了一些标志位信息。其他的标志位如:
Flag | Description |
---|---|
ACK | 确认标志,1表示已确认 |
SYN | 请求建立连接标志,1表示请求建立,同时携带校验序列号ISN |
FIN | 请求断开连接标志,1表示请求断开,同时携带校验序列号ISN |
RST | 请求重新建立连接标志,1表示请求重连,同时携带校验序列号ISN |
TCP连接的建立过程
思考: 数据是如何在网络之间传递的呢?
思考:一个TCP连接是怎么建立的呢?
当client想要建立连接:
(第一次握手)client发送SYN报文给server,发送完成后client进入
SYN_SENT
状态(第一次握手)server接收到SYN报文并校验数据正常后,发送ACK应答报文的同时也发送SYN报文给client,目的是告诉client说服务端已经准备好连接,此时server进入
SYN_RCVD
状态(第二次握手)同样client在接收到服务端的ACK报文后,状态进入
ESTABLISHED
,此时为半连接状态,因为服务端还未建立连接,同时client也向服务端发送ACK应答报文(第三次握手)server接收到客户端的ACK报文后,进入
ESTABLISHED
状态。
此时两端之间就可以通过上层协议进行来往交互了
当client想要断开连接:
client发送FIN报文给server,发送完成后client进去
FIN-WAIT-1
状态(第一次挥手)server接收到FIN报文后,发送ACK应答报文给client,客户端接收到后进去
FIN-WAIT-2
状态(第二次挥手)server发送完ACK信号后,等上层应用处理完正在处理的请求后,向client发送FIN报文,此时server端进去
CLOSE-WAIT
状态(第三次挥手)client接收到FIN报文后,发送ACK应答报文给server,此时client进去
CLOSE-WAIT
状态(第四次挥手)server接收到ACK报文后,主动关闭连接,此时server进入
CLOSED
状态client继续等待2MSL,确定server再也没有报文发送过来后主动关闭连接,此时client进入
CLOSED
状态
思考,为什么握手是三次,而挥手一定要是4次呢?
仔细对比握手和挥手的各个步骤,其实都是一样的,一来一回,发送方是一定要等接收方回应ACK信号的,毕竟TCP是可靠的,既然可靠,就一定要保证自己发送出去的内容是可以被接收到的。之所以握手只用了三次,是因为在连接还未建立之前,server
端在接收到client
端的SYN
·信号后将接收方自己的ACK
和SYN
信号放在同一次交互中了,那为什么挥手不也一样合并处理呢?这是因为在断开连接的时候,不是由网络层决定的,是否要断开连接,需要由上层应用来决定,比方说,接收方在接收到发送方的FIN
信号后,立即回复了ACK
应答,旨在通知客户端说服务端已经收到信息了,也知道客户端不会在发送过来其他数据了,客户端可以单方面等待关掉连接了,但这时候服务端可能还在处理之前没处理完的报文数据,此时若直接通知客户端关闭连接,就很可能造成上层应用的数据处理出现问题,所以在关闭连接时这两个步骤是分开的。当服务端已经处理完成,此时再告知客户端说服务端也已经不会再向发起方发送数据了,这时候服务端直接关闭连接,进入CLOSED
状态。而客户端接收到了服务端的FIN信号之后,也进入了TIMEWAIT
状态,如果实在已经没有来自服务端的数据了,那客户端也就进去CLOSED
状态,至此,整个TCP连接完全被释放。
思考:TCP的握手&挥手安全吗?如何攻防?
即然理解明白了TCP的建立过程,那黑客是否可以自己组装报文,大量伪造源IP和端口,从而实现目标攻击呢?答案是可以的,只是随着互联网的发展,各种解决方案也逐渐被提出来。
ISN随机序列号算法的提出,大大防止了黑客伪造合法请求的概率。
TCP报文的伪造,可以用来攻击目标服务器,我们称之为
SYN Flood
。其原理是伪造TCP报文,向目标服务器发送大量请求连接的SYN
报文。因为每次应答,都是需要耗费服务器一个Socketfile的。为了解决上面的问题,
SYN Cache
和SYN Cookie
被提出,目的就是在本地加一个缓存,校验数据合法后才分配TCP资源。
TCP的代码抽象
想象一下,建立TCP连接的是怎么体现在我们每日都在敲写的代码上的呢?其实,它离我们的日常工作非常之近!
服务启动:服务端绑定端口
bind()
并启动服务listen()
发起请求:客户端调用
connect()
方法接收请求:服务端掉用
receive()
方法并阻塞发送数据:客户端调用
write()
往连接中写数据处理数据:服务端掉用
read()
方法从连接中读数据
TCP流量控制
在网络中,为了便捷,传输往往是分块传输,这样既避免了因网络问题而导致的大量重传的浪费,还能更方便地实现流量控制。TCP的流量控制,是指在TCP报文的头部标记好TCP的body长度,我们称这个标记为窗口大小,这样接收方通过读取报文头部中的窗口大小就能清晰地知道这个数据报都多大,从而申请适当的内存空间来接收数据。在TCP报文格式中,窗口大小是16bit的标记为,这里也可以直接看出每个TCP数据报最多能传递2^16-1个字节(65535Byte)。每个TCP接收方接收到连续的数据时,会先把数据写入缓存区,应用程序再从缓存中读取数据,这个过程是异步的。而应用程序通过系统内核拷贝缓冲区中的数据这个过程,我们称之为系统调用。
TCP拥塞控制
在实际服务场景中,要衡量一个服务是否稳定健壮,就必须时长关注服务QPS/延时/带宽/流量/QOS等量化指标。其中带宽的高低就体现了网络的吞吐量。如果网络质量变差,比如丢包,这时我们就称之为网络拥塞。
TCP会根据网络的拥塞状态做出动态的调整,当网络拥塞比较严重时,就减少每个TCP报文的长度,当网络比较顺畅时,就自动适当增大每个TCP报文的长度。咋一看,TCP真是智能!这是怎么实现的呢?
TCP的这种根据网络质量自动伸缩TCP报文长度的功能(设定CWND),我们称之为TCP的拥塞控制。TCP的拥塞控制有多种策略/算法
第一种是慢开始,CWND的值变化为1 --> 2 --> 4 --> 8 --> ...
第二种是避免拥塞,CWND的值变化为 1 --> 2 --> 3 --> 4 --> ...
第三种为快速重传,快重传和CWND关系不是很大,客户端发送数据报1的同时可以继续发送数据报2,而不用等接收到数据报1的正确应答ACK后再顺序传输,旨在当数据报丢失时由接收方通知后立马重传丢失的数据报,而不需要等待2MSL判断数据是否丢失后才进行重传
TCP多路复用
注意,TCP的多路复用和网络IO的多路复用不是一回事,不要混淆。一个TCP连接是有一个四元组组成的,多路复用的意思是多个TCP连接共用一个网络层和物理链路,比方说,多个应用程序同时建立Socket连接,当数据向下传输,从网络层开始,都是共用的,唯一的区别是报文中的四元组信息不同。
[图片上传失败...(image-b88dcc-1614249862438)]
TCP多路分解
与TCP多路复用相对,多路分解是指多个报文从网络层向上传递时,根据不同的套接字,不同的Socket连接会被分发到不同的应用层应用。
TCP心跳机制
TCP具备自己的保活机制,即我们常说的Keep-alive,客户端在心跳周期到了之后,往服务端发送一个keep-alive包(本质上是一个ACK包),如果服务端接收到了,则回复一个应答包(本质上也是一个ACK包),若服务端此时没有回应,则客户端会往服务端发送一个RST包,通知服务端连接重置。
其实,在应用层上的很多应用/协议也会使用自己的心跳包,如在HTTP之上封装的WS,就可以通过相互发送ping/pong的方式来检测对方是否正常。
TCP长连接/短连接
常常听说长连接/短链接/HTTP1.1长连接/HTTP1.0短链接等等,所以什么是长连接和短链接呢?
短链接是指每次交互都进行三次握手,四次挥手。无疑这种方式需要频繁地建立和断开连接,相应耗费的资源也较高
长连接是相对于短链接而言,在建立连接后,进行多次交互,从而避免频繁地创建和断开连接,此时通过TCP心跳机制来决定连接是需要断开重置还是继续使用。这种方式提升了资源利用率。
TCP编程实现
golang的net
包已经有TCP相关的方法,使用go
编写一个TCP服务也很方便。一个简单的例子:
tcpAddr, err := net.ResolveTCPAddr("tcp4", fmt.Sprintf("%s:%s", s.host, s.port))
if err != nil {
panic(err)
}
listener, err := net.ListenTCP("tcp", tcpAddr)
if err != nil {
panic(err)
}
for {
connection, err := listener.AcceptTCP()
if err != nil {
continue
}
// TODO: read from connection or write to connection
}
如果要实现一个TCP服务框架,就必须为每个连接创建一个协程来处理连接信息。包括http
服务,也是一样的道理。
TCP抓包
即然TCP是以报文的形式出现,就可以通过代理抓包工具来分析TCP报文。常用的工具比如WireShark:
TCP粘包
粘包,从字面上理解是指两个TCP数据包粘在了一起,服务端接收时同时读取到了两个包。打个比方,客户端调用了多次send()
,而服务端在一次recv()
中就全部读出来了。其实从字面上来理解粘包的概念并不是很合理。
比较标准地来说,TCP数据包的发送和接收是通过字节数来算的,如以下代码:
connection, err := listener.AcceptTCP()
// TODO: check err
for {
buf := make([]byte, 512)
_, err := c.Read(buf)
}
这里就是每次读取512个字节,有可能这512个字节中包含多次客户端写入的数据。归根结底,就是TCP是面向数据流的,而不会以用户的发送操作作为数据边界。所以可能出现粘包的原因是:
发送端需要等缓冲区满了之后才会发出(Nagle算法)
接收端未及时接收而导致缓存区中有多个数据
解决方式:理论上应该结合实际业务接收框架的设计来定,粗略地说主要可以通过以下方式解决
可以采用固定长度的包发送方式
可以为每个数据添加消息边界
实际线上运维/问题定位
线上常见问题
当一方由于某些原因已经关闭了端口,而另一方尝试新建连接时出现
Connecton refuse
当一方由于某些原因已经关闭了端口,而另一方还在读数据时出现
Connection Reset
当一方由于某些原因已经关闭了端口,而另一方还在写数据时出现
Connection reset by peer
-
当一方设置了超时,而另一方在超时时间段内没有收发数据,就会出现
EOF
思考:在生产环境中,往往会出现各种各样的网络问题。那如何一步一步排查现场,直至定位问题呢?
- 使用
ss
命令
- 使用
// 查询
~ ss -ntr | more
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
ESTAB 0 0 andersonu20:48050 andersonu20:18002
ESTAB 0 0 andersonu20:56622 andersonu20:18003
ESTAB 0 0 andersonu20:18006 andersonu20:46432
// 统计
➜ ~ ss -ntr | awk '{print $1}' | sort | uniq -c
67 ESTAB
- 使用
netstat
命令
// 查询
netstat -t | grep tcp | more
tcp 0 0 andersonu20:48050 andersonu20:18002 ESTABLISHED
tcp 0 0 andersonu20:56622 andersonu20:18003 ESTABLISHED
tcp 0 0 andersonu20:18006 andersonu20:46432 ESTABLISHED
tcp 0 0 andersonu20:18003 andersonu20:56622 ESTABLISHED
// 统计
➜ ~ netstat -t | grep tcp | awk '{print $6}' | sort | uniq -c
67 ESTABLISHED
- 查询服务端端口是否通畅
telnet
➜ ~ telnet www.baidu.com 80
Trying 183.232.231.174...
Connected to www.a.shifen.com.
➜ ~ telnet x.ssj 801
Trying 120.240.95.35...
telnet: connect to address 120.240.95.35: Connection refused
telnet: Unable to connect to remote host
如果本文对你有帮助,也可以关注我的个人公众号~ 微信搜索: Int64
有疑问加站长微信联系(非本文作者)