- 前言
- 浅谈 OSI
- TCP
- 三次握手
- 数据传输
- 四次挥手
- Socket 服务端/客户端通信测试
- 服务端代码
- 客户端代码
- tcpdump 命令监控
- 命令总结
- FAQ
- 怎么确认数据包的大小?
- TCP 拥塞如何避免?
- 如何理解 TCP keep-alive 原理?
- 总结
前言
在网络知识体系,TCP 这块的三次握手、四次挥手是必备的,在面试中,也是老生常谈了,以下通过抓包工具抓取整个握手、挥手交互的过程进行分析.
浅谈 OSI
OSI(Open System Interconnect)缩写,意为开放式地系统互联,七层参考模型,利用了软件工程学的概念,是为了分层解耦
- 应用层:与用户打交道的那一层,通过 interface 接口交互的,比如浏览器、Tomcat,应用层出现的协议有 HTTP、FTP、SSH等等
- 表示层:协议、语义,是否符合协议规定由表示层来处理
- 会话层:服务器验证用户登录,session->保持会话的动作由会话层来完成
- 传输控制层:如何建立连接、如何传输,是成功还是失败的一个控制,传输层出现的协议有 TCP/UDP,TCP 是面向连接、可靠的传输协议,UDP 恰恰相反
- 网络层:由网络层来处理设备之间是如何路由,是如何找到的
- 链路层:点与点之间的通信 、点与点之间是什么样的通信协议,具体能发出什么
- 物理层:WIFI、光纤等设备
TCP
在这里主要介绍在 TCP 协议中服务端与客户端三次握手、四次挥手是如何完成的一个过程,TCP 是在传输控制层中进行交互的
服务端/客户端它们都会各自去维护自己的序列号:seq
三次握手
- 客户端调用 connect() 指令,发送 SYN-建立连接标识、seq 起始序列号给到服务端
- 服务端会有 listent() 指令监听来自客户端的链接,收到 SYN 标识后,发送服务端的 seq 起始序列号、ACK 确认号「客户端起始序列号+1」给到客户端,客户端应答后即可建立
- 客户端建立了连接以后,回复 ack 确认号「服务端起始序列号+1」给服务端,服务端也建立好了
以上是握手的交互流程,同时也说明会产生一些资源的消耗/开辟:线程、对象、文件描述符、调用等等操作
到这里,就会问两次握手可以去完成握手吗?
不可能是两次,若为两次的话;客户端先发起,服务端回复了,客户端后续就不管了做其他的事情去了,而服务端还在傻傻的等着回应,同时资源也还在占用,这及其不安全也消耗资源的
数据传输
在数据传输交互中过程中
- 客户端通过 write 指令去写入数据,客户端会携带:序列号+1 值、ack 确认号到服务端
- 服务端通过 read 指令读取数据,将数据进行处理后,会发送 ack 确认号到 客户端
- 这样,数据传输就能在客户端/服务端之间完成
四次挥手
- 客户端调用 close() 指令,携带 FIN-断开连接标识、序列号+2「之前 +1 的值用过了」、ack 确认号,发起断开连接请求
- 服务端确认后,回复确认号序列号 +3
- 服务端调用 close() 指令,携带 FIN-断开连接标识、系列号+1,发起断开连接请求
- 客户端确认后,回复确认号系列号 +2
到这里,就会问为什么要发生四次挥手,两次不可以吗?
因为 TCP 连接是双向的,因此在四次挥手中前两次是用于断开一方的连接,而后两次是用于断开另外一方的连接;同时,考虑到 socket 问题,端口号数量是有限的
{socket:套接字通信,端口数量最多 65535 个}
,用完了必须要记得回收、关闭资源,保证端口释放以便给其他的服务进行使用;
Socket 服务端/客户端通信测试
通过 Socket 通信,采用 TCP 协议来进行服务端、客户端之间的交互演示
服务端代码
import java.io.*;
import java.net.*;
public class SocketIoServer {
// server socket listen property:
private static final int RECEIVE_BUFFER = 10;
private static final int SO_TIMEOUT = 0;
private static final boolean REUSE_ADDR = false;
// 备胎可以有两个:后台最多可以有多少个待处理的连接
private static final int BACK_LOG = 2;
// client socket listen property on server endpoint:服务端客户端之间维护着心跳,互相确认自己还活着
private static final boolean CLI_KEEPALIVE = false;
private static final boolean CLI_OOB = false;
private static final int CLI_REC_BUF = 20;
private static final boolean CLI_REUSE_ADDR = false;
private static final int CLI_SEND_BUF = 20;
private static final boolean CLI_LINGER = true;
private static final int CLI_LINGER_N = 0;
private static final int CLI_TIMEOUT = 0;
// 关闭 Nagle 算法:不组合小分组的数据,而是每次都立即发送出去
// 开启 Nagle 算法:组合小分组的数据,以一个分组的方式发送出去,降低了吞吐量
private static final boolean CLI_NO_DELAY = false;
/*
StandardSocketOptions.TCP_NODELAY
StandardSocketOptions.SO_KEEPALIVE
StandardSocketOptions.SO_LINGER
StandardSocketOptions.SO_RCVBUF
StandardSocketOptions.SO_SNDBUF
StandardSocketOptions.SO_REUSEADDR
*/
public static void main(String[] args) {
ServerSocket server = null;
try {
server = new ServerSocket();
server.bind(new InetSocketAddress(9090), BACK_LOG);
server.setReceiveBufferSize(RECEIVE_BUFFER);
server.setReuseAddress(REUSE_ADDR);
server.setSoTimeout(SO_TIMEOUT);
System.out.println("server up use 9090!");
while (true) {
// System.in.read(); //分水岭:
Socket client = server.accept(); // 阻塞的,一直卡着不动,内核指令:accept(4,
System.out.println("client port: " + client.getPort());
client.setKeepAlive(CLI_KEEPALIVE);
client.setOOBInline(CLI_OOB);
client.setReceiveBufferSize(CLI_REC_BUF);
client.setReuseAddress(CLI_REUSE_ADDR);
client.setSendBufferSize(CLI_SEND_BUF);
client.setSoLinger(CLI_LINGER, CLI_LINGER_N);
client.setSoTimeout(CLI_TIMEOUT);
client.setTcpNoDelay(CLI_NO_DELAY);
Thread thread = new Thread(
() -> {
try {
InputStream in = client.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(in));
char[] data = new char[1024];
while (true) {
int num = reader.read(data);
if (num > 0) {
System.out.println("client read some data is :" + num + " val :" + new String(data, 0, num));
} else if (num == 0) {
System.out.println("client readed nothing!");
continue;
} else {
System.out.println("client readed -1...");
System.in.read();
client.close();
break;
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
);
thread.start();
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
server.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
客户端代码
import java.io.*;
import java.net.Socket;
public class SocketClient {
public static void main(String[] args) {
try {
// 172.16.249.12:虚拟机中 ifconfig 网卡中的外网 IP
Socket client = new Socket("172.16.249.12",9090);
client.setSendBufferSize(20);
client.setTcpNoDelay(true);
OutputStream out = client.getOutputStream();
InputStream in = System.in;
BufferedReader reader = new BufferedReader(new InputStreamReader(in));
while(true){
String line = reader.readLine();
if(line != null ){
byte[] bb = line.getBytes();
for (byte b : bb) {
out.write(b);
}
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
tcpdump 命令监控
在这里会介绍一些常用的 Linux 命令以及如何在 Linux 使用 tcpdump
通过 ifconfig 查看虚拟机中的网卡,ens160 基于外网交互的,lo 基于虚拟机内网交互的,由于服务端、客户端代码都会在这台虚拟机节点上进行编译,所以这里 会使用 lo 这个网卡去 dump 它们之间的 TCP 交互过程,使用 ens160 网卡去 dump 在控制台上是不会输出内容的!
1、启动 socket 服务端,编译后运行
[root@172 ~]# javac SocketIoServer.java && java SocketIoServer
server up use 9090!
2、开启一个新的窗口,查看 socket/tcp 网络信息:netstat -natp,会发生多了下面这条 listen 条目信息
[root@172 ~]# netstat -natp
Active Internet connections (servers and established)
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp6 0 0 :::9090 :::* LISTEN 3236/java
3、查询服务端进程下文件描述符信息:lsof -op pid,此时 offset 偏移量为 0
[root@172 ~]# lsof -op 3236
COMMAND PID USER FD TYPE DEVICE OFFSET NODE NAME
java 3236 root 5u IPv6 28833 0t0 TCP *:websm (LISTEN)
4、新开启一个窗口,打开 tcp 抓包的程序:tcpdump -i lo -nn port 9090
- i:网络配置的接口,通过 ifconfig 命令拿到的
- nn:显示 IP、端口号
- v:显示更多详细信息
5、新开启一个窗口,客户端连接到服务端,按回车键
[root@172 ~]# javac SocketClient.java && java SocketClient
此时,tcpdump 窗口就会显示出很多的信息出来,如下:
16:14:35.969838 IP 172.16.249.12.48028 > 172.16.249.12.9090: Flags [S], seq 823509243, win 65495, options [mss 65495,sackOK,TS val 1014857448 ecr 0,nop,wscale 7], length 0
48028 客户端发送 Flags:SYN,序列号:823509243,给到 9090 服务端
16:14:35.969852 IP 172.16.249.12.9090 > 172.16.249.12.48028: Flags [S.], seq 3254927829, ack 823509244, win 1152, options [mss 65495,sackOK,TS val 1014857448 ecr 1014857448,nop,wscale 0], length 0
9090 服务端发送 Flags:SYN,序列号:3254927829,ack=客户端序列号+1值,给到 48028 客户端
16:14:35.969859 IP 172.16.249.12.48028 > 172.16.249.12.9090: Flags [.], ack 1, win 512, options [nop,nop,TS val 1014857448 ecr 1014857448], length 0
48028 客户端发送 ack=1,双方已互相确认,此次连接建立完成
6、客户端发送数据:111,在服务端配置时,为客户端设置了参数 CLI_NO_DELAY{不组合小分组的数据},会有多条信息出来,如下所示:
16:21:29.986816 IP 172.16.249.12.48028 > 172.16.249.12.9090: Flags [P.], seq 1:2, ack 1, win 512, options [nop,nop,TS val 1015271464 ecr 1014857448], length 1
客户端 48028 发送 Flags:PSH{数据传输标识}、序列号:
1:2,代表的意思就是发出去的是 1,期望收到的是 2,给到 9090 服务端
16:21:29.987257 IP 172.16.249.12.9090 > 172.16.249.12.48028: Flags [.], ack 2, win 1151, options [nop,nop,TS val 1015271465 ecr 1015271464], length 0
服务端 9090 发送 ack=客户端序列号+1,也就是客户端期望收到的值
16:21:29.987428 IP 172.16.249.12.48028 > 172.16.249.12.9090: Flags [P.], seq 2:3, ack 1, win 512, options [nop,nop,TS val 1015271465 ecr 1015271465], length 1
16:21:29.987555 IP 172.16.249.12.48028 > 172.16.249.12.9090: Flags [P.], seq 3:4, ack 1, win 512, options [nop,nop,TS val 1015271465 ecr 1015271465], length 1
客户端 48028 发送 Flags:PSH{数据传输标识}、序列号:
2:3、3:4,组合两条数据一起发出去「受到了客户端 SEND_BUFFER_SIZE 参数值」,给到 9090 服务端
16:21:29.989499 IP 172.16.249.12.9090 > 172.16.249.12.48028: Flags [.], ack 4, win 1151, options [nop,nop,TS val 1015271467 ecr 1015271465], length 0
服务端 9090 发送 ack=客户端最新的序列号+1,也就是客户端期望收到的值
6、到这里,TCP 三次握手、数据传输的过程通过 dump 就搞定了,还剩下四次挥手的过程!
由于在虚拟机内网 lo 网卡中监测不到挥手是四次的过程,所以采用外网网卡 ens160 这里也不懂这是什么原因导致的?
所以在这里我模拟请求百度首页:www.baidu.com,tcpdump 80 端口,来演示四次挥手的过程
- 在一个窗口命令:tcpdump -i ens160 -nn port 80,监控
- 另外一个窗口操作命令:curl www.baidu.com 80,等这个请求处理完成以后,当前窗口强制退出断开,在第一个窗口尾部就会出现以下的信息
16:42:30.946631 IP 172.16.249.12.44270 > 163.177.151.109.80: Flags [F.], seq 447954833, ack 1899774463, win 62780, length 0
客户端 44270 发送 Flags:FIN(关闭连接标识) 、序列号、ack 确认号请求,给到百度服务端
16:42:30.948011 IP 163.177.151.109.80 > 172.16.249.12.44270: Flags [.], ack 1, win 64239, length 0
百度服务端确认,发送确认号给客户端 44270
16:42:30.962906 IP 163.177.151.109.80 > 172.16.249.12.44270: Flags [FP.], seq 1, ack 1, win 64239, length 0
百度服务端 发送 Flags:FIN(关闭连接标识) |PSH(数据传输)、序列号、确认号给客户端 44270
16:42:30.963000 IP 172.16.249.12.44270 > 163.177.151.109.80: Flags [.], ack 2, win 62780, length 0
客户端 44270 确认,发送确认号给百度服务端,此次通信完毕
命令总结
- yum install -y tcpdump:安装 tcpdump,⼀个命令⾏的⽹络流量分析⼯具,功能⾮常强⼤,⼀般我们⽤来抓 TCP 包
- lsof -p:进程中的文件描述符信息,比如:偏移量、文件类型、状态
- netstat -natp:socket 网络的信息,listener 条目信息
- tcpdump -i ens160 -nn port 9090:监听某端口下的 tcp 交互
TCP 序列号详解:在同一个 TCP 链接过程窗口中,seq 序列号值是持续递增的;SYN 建立链接、FIN 关闭链接,都会消耗 seq,凡是涉及到对端确认的,一定需要消耗 TCP 报文的序列号
FAQ
怎么确认数据包的大小?
最大传输单元: MTU,通过 ifconfig 查看,此时的大小是携带上了 ip、port 的
ens160: flags=4163<UP,BROADCAST,RUNNING,MULTICAST> mtu 1500
通过 tcpdump 观察数据传输
过程的报文会有如下信息:
options [mss 1460,sackOK,TS val 2603013609 ecr 0,nop,wscale 7]
MSS:最大报文段长度-1460,由 MTU=1500,得出 ip、port 占有 40 字节
TCP 拥塞如何避免?
在网络交互过程中,当服务端的窗体没有容量了,在与客户端握手的过程,通过确认包会告知服务端没有余量了,客户端因此会将自己阻塞住,不再发送数据给到服务端了,等服务端空余了,再补充一个包告知客户端可以继续发了
如何理解 TCP keep-alive 原理?
在一个 TCP 连接上,若通信双方都不再向对方发送数据,那么 TCP 连接就不会有任何数据交换了;假设应用程序是一个 WEB 服务器,客户端发出三次握手以后故障宕机或被剔除网线,对于 WEB 服务器而言,下一个数据包将永远无法到来,但它却一无所知
TCP 协议设计者,考虑到了这种检测长时间死连接的需求,于是乎设计了 keep-alive 机制,它的作用就是探测对端的连接有没有失效,通过定时发送探测包来探测连接的对端是否存活,不过默认情况下需要 7200 s 没有数据包交互才会发送这个探测包,往往这个时间太久了,我们熟知的很多组件都没开启 keep-alive 特性,而是选择在应用层做心跳机制
通过 sysctl -a | grep keepalive
命令可查看 keep-alive 内核参数配置
# 探测包检测频率,多长一次,默认 75 s
net.ipv4.tcp_keepalive_intvl = 75
# 探测包次数,默认 9 次
net.ipv4.tcp_keepalive_probes = 9
# 没有数据交互后多长时间去探测
net.ipv4.tcp_keepalive_time = 7200
总结
该文章浅谈了 TCP 三次握手、数据传输、四次挥手的交互过程,通过 tcpdump 抓包工具对这三个流程进行了信息的输出以及介绍,文末整理了一些操作 TCP 常用的命令以及一些常见的问题,当然,对于整个 TCP 可靠性协议,这些只是皮毛一角了,后续会细究出更多内容进行输出!!
点一波关注不迷路,你的支持是对我最大的鼓励