十分钟深入浅出TCP/IP

一只老辣鸡 · · 196 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

想起当年刚学网络原理的时候,总觉得计算机网络离编程还是有点远,认为实际敲代码不需要太多的理论支撑,现在回想起来,还真是有些无知!后来才渐渐明白理论的重要性,不管是造航母,还是拧螺丝,都像是造一架飞机,如果没有理论经验支撑,只是天马行空,那造出来的东西真的可靠吗,真的经得住考验吗?

(本文较长,本文目录)

  1. OSI七层模型&TCP/IP四层模型
  2. 报文简介
  3. TCP连接的建立过程
  4. TCP的代码抽象
  5. TCP流量控制
  6. TCP拥塞控制
  7. TCP多路复用
  8. TCP多路分解
  9. TCP心跳机制
  10. TCP长连接/短连接
  11. TCP编程实现
  12. TCP抓包
  13. TCP粘包
  14. 实际线上运维/问题定位

提问:TCP/IP离业务远吗?

有些小伙伴可能会认为研发只要精通业务即可,剩下的自然可以交给运维处理,所以他们一般不会去思考业务以外的其他问题,也不会去尝试新鲜事物,如K8S/Docker/Nginx等。但据我看来这恰恰是一种自我设限的思维,往往也困住了自己的脚步。回到问题,TCP/IP真的离业务很远吗?我们不烦来看看拧螺丝中是否也有遇到下面提到的类似问题。

所以,你是否也有即使焦头烂额也排查不出问题所在的时候?

不少写业务的小伙伴可能会碰到类似于connection reset by peertoo many open filesEOF等异常,如果是api服务,还会出现http 50x,很多时候这些异常是偶发性的,比如在QPS较高的时候才会出现。这种偶发性事件往往很难保存现场,导致有种摸不着头脑的情况。可能是代码的bug,也可能是容器的资源有限,又或者是上下游网络的问题。但任何一件事情的发生,一定是会有前因后果,只是你基础原理没有吃透,才会出现这种雾水。

所以:网络到底是什么样的呢?

OSI七层模型&TCP/IP四层模型

TCP管理了端对端的可靠的数据传输,在互联网中随处可见。

  • 应用层:为上层应用程序提供网络服务

  • 表示层:提供数据加密/解密服务,常见的远程SSH登录等需要要求数据严格加密

  • 会话层:管理会话的建立/断开/重连等,常见的如各种session

  • 传输层:管理端对端连接的建立/断开/重连等

  • 网络层:用于IP寻址和路由选择

  • 数据链路层:承上启下,将数据由报文数据转化为二进制比特数据

  • 物理层:真正网络间的数据传播

也许小伙伴你已经知道了TCP的连接与断开连接的原理,为了方便后续的论证,这里稍微也回顾一下TCP的握手和挥手原理

TCP报文简介

TCP连接是一个有状态的四元组

源IP: 源端口 <---> 目标IP:目标端口

有且只有四元组唯一确定,才能建立一个完整可靠的TCP连接。

在网络中,TCP也是通过数据报的形式传输的,一个TCP的报文格式如下:

20210223113940.png
  • 源端口和目标端口各占据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连接的建立过程

思考: 数据是如何在网络之间传递的呢?

20210223120212.png

思考:一个TCP连接是怎么建立的呢?

Untitled-2021-02-23-2016.png

当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·信号后将接收方自己的ACKSYN信号放在同一次交互中了,那为什么挥手不也一样合并处理呢?这是因为在断开连接的时候,不是由网络层决定的,是否要断开连接,需要由上层应用来决定,比方说,接收方在接收到发送方的FIN信号后,立即回复了ACK应答,旨在通知客户端说服务端已经收到信息了,也知道客户端不会在发送过来其他数据了,客户端可以单方面等待关掉连接了,但这时候服务端可能还在处理之前没处理完的报文数据,此时若直接通知客户端关闭连接,就很可能造成上层应用的数据处理出现问题,所以在关闭连接时这两个步骤是分开的。当服务端已经处理完成,此时再告知客户端说服务端也已经不会再向发起方发送数据了,这时候服务端直接关闭连接,进入CLOSED状态。而客户端接收到了服务端的FIN信号之后,也进入了TIMEWAIT状态,如果实在已经没有来自服务端的数据了,那客户端也就进去CLOSED状态,至此,整个TCP连接完全被释放。

思考:TCP的握手&挥手安全吗?如何攻防?

即然理解明白了TCP的建立过程,那黑客是否可以自己组装报文,大量伪造源IP和端口,从而实现目标攻击呢?答案是可以的,只是随着互联网的发展,各种解决方案也逐渐被提出来。

  • ISN随机序列号算法的提出,大大防止了黑客伪造合法请求的概率。

  • TCP报文的伪造,可以用来攻击目标服务器,我们称之为SYN Flood。其原理是伪造TCP报文,向目标服务器发送大量请求连接的SYN报文。因为每次应答,都是需要耗费服务器一个Socketfile的。

  • 为了解决上面的问题,SYN CacheSYN Cookie被提出,目的就是在本地加一个缓存,校验数据合法后才分配TCP资源。

TCP的代码抽象

想象一下,建立TCP连接的是怎么体现在我们每日都在敲写的代码上的呢?其实,它离我们的日常工作非常之近!

sss.png
  • 服务启动:服务端绑定端口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:

unnamed.jpg

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


有疑问加站长微信联系(非本文作者)

本文来自:简书

感谢作者:一只老辣鸡

查看原文:十分钟深入浅出TCP/IP

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:701969077

196 次点击  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传