做了七八年网络编程我见过太多人上来就写Socket、调接口结果遇到个连接失败就抓瞎。其实网络编程的地基就那些东西分层模型、IP端口、TCP/UDP、HTTP、DNS。你把这几个概念吃透后面调接口、写服务端、看日志基本都能自己搞定。这篇是“网络编程-001”系列的第一篇专门聊聊网络基础适合刚接触网络编程的开发者也适合那些已经写过不少代码但对底层概念仍模糊的人。我会把实际踩坑的经验直接摊开说哪些东西必须理解哪些地方特别容易出错一次讲清楚。很多人在学习网络编程时第一个问题不是“怎么调API”而是“数据到底怎么过去的”。你写的代码只是在操作系统提供的那层接口上发力底下发生了三次握手、重传、路由转发你根本看不见。不把这条路走通一遍后面排查问题就是靠猜。我更愿意先把底层逻辑讲透再回到代码层面这样你看到的每个API背后都有真实的画面而不是一堆黑魔法。1. 网络编程为什么必须先打地基1.1 很多人把网络编程等同于调API我见过不少同事项目里天天写HTTP请求代码用起框架来很熟练但某天线上服务突然大量超时他们第一反应是“重启大法”。后来定位到是TCP连接没有正常关闭文件描述符被耗尽。问题根源不是业务逻辑而是压根没理解TCP连接状态机。所以说网络编程这个行当底子不牢出了问题你连排查方向都没有。“调API”只是网络编程的最后一公里。真正影响你判断的往往是基础概念连接是谁发起的、数据是流式的还是消息式的、对端断开时你会收到什么信号、重试是不是幂等的。这些问题没有网络基础支撑你只会靠猜。等到线上故障发生再回去翻三年前的笔记就晚了。这也是为什么我把“网络基础”放在整个系列的第一篇。1.2 数据从一端到另一端发生了什么讲一个最通俗的例子你给朋友寄快递。你把装好的盒子交给快递站快递站贴上面单写着源地址和目的地址然后运输车把包裹送到目的城市的转运中心再派送给朋友。朋友收到后拆箱看到你放的东西。这个过程里你写的应用数据就是盒子里的东西快递面单上的地址就是网络层加的IP头运输车走的路就是把数据帧从一段链路送到下一段链路转运中心就是路由器。一份应用数据从进程里发出来先经过传输层TCP/UDP加上端口号相当于在盒子里附一封信写“请转交给某某房间的人”再到网络层加上IP地址变成可路由的包裹最后链路层加上MAC地址和帧格式真实地在网线上传输。对方收到后一层层拆掉包装最后拿到数据。这个过程叫封装和解封装。理解了这条链路后面看抓包结果时你就能分清哪一行是IP头、哪一行是TCP头、哪一行是应用数据调试效率完全不一样。1.3 需要掌握的核心知识点清单在你正式动手写第一个Socket之前下面六项内容就是后续所有文章反复用到的主干。你不需要现在就把每一项背下来但至少要有印象最好能把它们当成一把尺子遇到任何网络问题先量一量是出在哪一层再决定往哪个方向使劲。分层模型知道数据在每一层的形态和职责。IP地址与子网知道机器之间如何定位。端口知道同一台机器上如何区分不同进程。TCP/UDP知道可靠传输和实时传输的取舍。HTTP/HTTPS知道应用层最常见的协议长什么样。Socket知道系统API如何把这套理论露给你用。这条清单就是“网络编程-001”的骨架也是后面几篇准备展开的内容。别贪多先把这六块串起来再谈高并发、多路复用、自定义协议都会顺很多。2. 网络分层模型一回事情分四层说2.1 OSI七层模型和TCP/IP四层模型如何对应教科书喜欢从OSI七层开始讲但实际互联网跑的是TCP/IP四层模型。先记四层再回看七层会容易很多。四层分别是应用层、传输层、网络层、网络接口层。应用层的范围大致对应OSI的会话层、表示层和应用层传输层对应OSI的传输层网络层对应OSI的网络层网络接口层对应OSI的数据链路层和物理层。TCP/IP四层典型协议对应OSI部分应用层HTTP、DNS、SSH第五到七层会话、表示、应用传输层TCP、UDP第四层传输网络层IP、ICMP第三层网络网络接口层以太网、Wi-Fi第一、二层物理、数据链路OSI里的会话层、表示层其实已经融进了现代应用层的设计里TLS加密属于表示层功能Cookie和Session管理属于会话层功能但应用层协议自己直接实现了。所以学习时不用死磕七层按照四层模型理解已经足够应付绝大多数开发场景。2.2 每一层干的事和实际协议应用层负责“说什么”你请求一个网页HTTP报文里写了方法、URL、头部和正文你做域名解析向DNS服务器发出查询。应用层不关心数据包怎么路由只关心业务语义。比如你用JSON还是XML用GET还是POST这些事都发生在应用层写代码时接触最多的就是这一层。传输层负责“给哪个进程”TCP和UDP在数据上加了源端口和目的端口。TCP还额外负责可靠性、顺序、流量控制UDP则只做简单封装不保证送达。传输层是你控制“连接还是无连接”的关键层级也是网络编程面试最爱问的部分。网络层负责“给哪台机器”IP协议为每一台接入网络的设备分配地址路由器根据IP地址转发数据包。这一层不做可靠保证包丢了就让上层去处理。你平时听到的路由、子网、NAT都发生在这一层。网络接口层负责“怎么从线缆上走”把IP包封装成以太网帧通过MAC地址在局域网内传输处理物理信号。这一层对大多数应用开发者是透明的但抓包时你会看到帧头。很多人学网络只知道TCP和HTTP忽略了网络层和链路层但在排查“时通时不通”的问题时往往就是这两层出了状况只看应用层代码完全找不到原因。2.3 为什么分层这么设计分层的好处用一个词概括解耦。应用层不需要知道自己跑的是TCP还是UDP它只需要调用传输层提供的接口传输层不需要知道底下是光纤还是无线只要从网络层拿到IP包就能处理。每一层只给自己的上层提供服务又复用下层的功能这种设计让协议栈可以单独演进。从IPv4换到IPv6上层应用几乎不用改就是因为网络层和传输层的接口保持稳定。从实际操作看分层还给了你一套排错框架。当连接出问题时你从最底层往上一层一层确认链路通不通、IP通不通、端口通不通、应用协议有没有响应。这套方法我后面会专门展开。记住它比背协议细节更值钱因为大部分线上故障最后都能通过“按层剥洋葱”定位到具体的某一层。3. IP地址、端口和DNS三个绕不开的基础物件3.1 IP地址IPv4和IPv6、公网和私网、子网掩码一台设备在网络里的定位靠IP地址。IPv4是32位二进制数常写成四段十进制比如10.0.0.1。由于只有约43亿个地址不够用就有了NAT技术把私有地址在出口转换成公网地址。私有网段你肯定见过10.0.0.0/8、172.16.0.0/12、192.168.0.0/16。你在家里的设备基本都是私有地址路由器对外的公网地址才是全球唯一的。写网络程序时出现“内网能通、外网不通”的情况八成和公网私网地址转换有关。IPv6是128位写成八组十六进制比如fe80::1。它主要是解决地址枯竭问题也去掉了NAT的必要性。但对应用开发者来说你写的服务端可能同时监听IPv4和IPv6如果代码里只绑定了IPv4的地址在双栈环境里就会出现“检查端口已监听但请求就是进不来”的诡异现象。编写监听逻辑时最好显式处理地址族或者直接监听通配地址让系统帮你做双栈适配。子网掩码很多人一看到就头疼。其实它就是用来划分“网络号和主机号”的。拿192.168.1.10/24来说/24表示前24位是网络号网络地址是192.168.1.0可用主机范围是192.168.1.1到192.168.1.254。判断两个IP是否在同一个局域网就是把IP地址和掩码做与运算结果相同就在同一子网。写局域网发现、服务注册这类功能时这个判断很常见不搞清楚很容易把跨网段的机器也当成局域网内机器。3.2 端口是逻辑编号不是物理接口端口是传输层用来识别进程的16位整数范围0到65535。常见端口包括HTTP的80、HTTPS的443、SSH的22、MySQL的3306。0到1023是知名端口通常需要管理员权限才能绑定。1024到49151是注册端口49152到65535是动态端口常用作客户端临时端口。你写客户端时不需要指定本地端口系统会从动态端口范围内自动分配一个连接建立后对端就是从TCP头里的源端口字段识别你的。有个经典误解端口不是网卡上的某个孔。同一台机器上两个进程不能监听同一IP和同一端口组合但不同IP或不同协议TCP vs UDP下端口可以复用。比如你可以同时监听0.0.0.0:80和127.0.0.1:80在Linux上是允许的这涉及地址通配符和绑定顺序的问题。实战中踩过的坑是服务重启时端口还处于TIME_WAIT状态直接bind会报Address already in use。解决办法通常是设置SO_REUSEADDR选项后面写Socket时我会演示。3.3 DNS解析在做什么浏览器输入一个域名你并不能直接拿域名去建立TCP连接必须先查出对应的IP。DNS就是做这个的。完整流程大概是本机缓存先查没有就找配置的递归DNS服务器比如路由器给的下发DNS它再代表你向根服务器、顶级域服务器、权威服务器逐级查询拿到记录后返回并缓存一段时间。TTLTime To Live决定了一条DNS记录能在缓存里存活多久。排查域名解析异常时先用nslookup或dig查一下是否有解析结果再ping IP能快速区分是DNS问题还是网络问题。我见过一个现象把业务依赖的域名解析结果错误地硬编码在配置里结果对方换了IP业务直接瘫痪。所以DNS查询建议走标准解析流程不要硬编码。另外一个常见坑是缓存污染本机hosts文件里写了旧记录导致解析结果和线上不一致改完hosts记得清DNS缓存。4. TCP和UDP两个传输层老兵怎么选4.1 面向连接与无连接的差异TCP给我的感觉像打电话先拨号对方接听后你才能一句话一句话地说而且能确认对方听清了挂断时双方都知道通话结束。UDP像寄明信片你写好丢进邮筒它可能到可能丢也可能乱序但你不用等对方“接听”再开始。开发时选哪个取决于你在意的是可靠性还是实时性。从技术上说TCP是面向字节流的可靠协议UDP是面向报文的不可靠协议。TCP有序列号、确认号、重传、滑动窗口和拥塞控制UDP的头只有8字节没有这些机制所以延迟更低、开销更小。如果你的应用需要文件传输、数据库连接、支付交易那必须用TCP或基于TCP封装的上层协议如果是实时音视频、游戏同步、监控上报UDP往往更合适因为偶尔丢一个包比等一个迟到的包好得多。4.2 TCP三次握手与四次挥手三次握手是建立连接的过程。用序列号表示如下客户端发送SYNseqx。服务端回复SYNACKseqyackx1。客户端发送ACKseqx1acky1。第三次握手是为了防止已经失效的连接请求突然又到达服务端导致服务端建立空连接浪费资源。你可以在抓包工具里看到这三步过滤SYN标志的位置就能看到客户端先发SYN服务端回SYNACK客户端再回ACK然后两边才开始传数据。很多人问为什么不能只有两次握手原因就在于服务端在第二步就已经认为自己连接建立成功了但客户端可能还没准备好所以需要第三次ACK让服务端确认客户端收到自己的回应。四次挥手是断开连接的过程主动方发送FIN被动方回ACK然后被动方发送FIN主动方回ACK。总共四步是因为TCP是全双工的每一方向都要单独关闭。主动关闭方会进入TIME_WAIT状态等待2MSL最大报文段生存时间。很多人不理解为什么要等其实是为了防止最后一次ACK丢失时对端重发FIN同时让旧报文从网络中消失避免污染新连接。代码里如果短连接特别多你会看到大量TIME_WAIT这不是Bug但可以通过复用连接、调整TCP参数来减少影响。4.3 TCP可靠传输机制重传、窗口、拥塞控制TCP的可靠性不是一句口号。发送方给每个字节编号接收方每收到数据就回复ACK。如果发送方超过一定时间没收到ACK就重传。确认号表示“这个序号之前的字节我都收到了”序列号表示“这段数据从哪个字节开始”。理解重传的关键就是要知道这两个数字不是一回事。滑动窗口解决的是效率问题不是发一个等一个而是一口气发一批窗口大小表示接收方还能接收多少数据。这样网络利用率高同时又不会把对端缓冲区打爆。窗口大小会在TCP头里动态变化抓包时你经常能看到Window字段如果它变成0表示接收方缓冲区满了发送方得停止发送。拥塞控制是为了不让网络过载。慢启动、拥塞避免、快速重传、快速恢复这些算法让发送速率适应网络状态。实战中你很少直接改这些参数但遇到“高带宽低延迟的机房内网延迟却很高”时就该想到可能是拥塞窗口或初始窗口设置的问题。丢包率高的时候TCP会自动降低发送速率表现为吞吐量上不去但CPU和网络带宽都看着正常。4.4 UDP的特点和适用场景UDP不需要三次握手也没有连接状态所以延迟低、内存占用少还能多播。适合视频会议、语音通话、游戏实时同步、DNS查询这类多少丢点包也不致命但延迟高了体验会崩的场景。不过UDP没有可靠保障应用层要自己处理丢包、乱序和重复。比如实时通话里你宁可丢掉一个旧包也不愿意停下来等重传因为等来的已经是迟到的数据。如果你要设计自己的UDP上层协议至少要考虑是否需要序列号、是否需要确认和重传、是否需要心跳检测对端存活。不要以为UDP看起来简单就能偷懒很多看起来用UDP实现的功能最后会做成半个TCP。这类协议最大的坑是NAT穿透和中间线路的MTU限制数据报超过MTU会被分片分片丢失又会导致整个包丢弃所以UDP包体最好控制在合理范围内。5. HTTP/HTTPS应用层你避不开的协议5.1 HTTP报文长什么样HTTP报文就是应用层的“语言表达”。一个请求报文由请求行、请求头、空行、请求体组成。一个响应报文由状态行、响应头、空行、响应体组成。你可以在终端里用curl -v直接看到全过程它会打印出TCP连接信息、发送的请求头和收到的响应头。第一次看的时候会很不习惯因为里面全是看似冗余的头部字段但它们每一个都有明确用途。别看报文简单很多问题是隐藏在细节里的。比如Connection: keep-alive表示复用连接Content-Length表示响应体长度Transfer-Encoding: chunked表示分块传输。如果你自己写HTTP解析器处理Content-Length和chunked是必须过的两个坎。内容长度写错接收端就会一直等不完数据分块传输没处理好解析出来的body就是残缺的。5.2 方法、状态码和头字段方法用途GET查询资源POST创建资源或提交数据PUT全量更新PATCH部分更新DELETE删除资源状态码分五类1xx是信息2xx成功3xx重定向4xx客户端错误5xx服务端错误。常用的有200、301、302、304、400、401、403、404、500、502、503。学到什么程度算够至少看到状态码能第一时间定位是谁的问题4xx先查请求5xx先查服务端。304表示资源未修改可以继续走本地缓存这对性能优化很关键但前提是你发送了正确的条件请求头。头字段里比较关键的有Host、User-Agent、Content-Type、Authorization、Cache-Control等。开发中常见的坑是没设置Content-Type导致接收端解析异常或者没校验Content-Length导致请求体解析错位。跨域场景还要处理Origin、Access-Control-Allow-Origin这些字段写接口的人经常被它们搞得头疼其实也都是应用层而已。5.3 HTTP/1.1、HTTP/2、HTTP/3的演进HTTP/1.1主要靠长连接减少了重复握手但一个连接同一时刻只能处理一个请求队头阻塞严重。HTTP/2引入了多路复用可以在一个TCP连接上同时发送多个请求和响应通过二进制分帧解决连接层面的队头阻塞但TCP本身还是可能因为丢包而阻塞。HTTP/3直接换成基于UDP的QUIC从根本上解决了TCP队头阻塞握手更快连接迁移也不用重新握手。对做应用开发的人来说建议至少了解HTTP/2的多路复用和HTTP/3的0-RTT握手。这些特性直接影响你写客户端时的超时策略、并发控制和丢包重试设计。比如在HTTP/2下你并发多个请求实际上只建立了一条TCP连接如果客户端代码里还按老思路为每个请求创建一个连接池就会造成资源浪费甚至被服务端限流。5.4 HTTPS加密和证书验证流程HTTPS就是HTTP跑在TLS上。TLS做的事情是身份验证、加密传输、完整性校验。核心流程包括客户端发送ClientHello服务端返回ServerHello、证书和公钥客户端验证证书链是否由可信CA签发、域名是否匹配、证书是否过期然后双方协商出会话密钥。用公钥加密、私钥解密是非对称加密大量数据用对称加密比如AES加密对称密钥则通过非对称加密安全地交换。这个过程就是TLS握手。如果你在客户端遇到证书错误很多情况下是证书链不完整或者系统时间不对。在测试环境特别常见排错时可以先用openssl s_client查看证书链。我自己踩过的坑是服务端配置的证书缺少中间证书浏览器走完整链路验证时报错但很多脚本语言却不一定严格校验结果就是同一个服务浏览器访问失败、程序访问成功两个端表现不一致。6. Socket编程把理论落成能跑的代码6.1 Socket的本质Socket不是协议是操作系统给应用层的编程接口。它把IP和端口封装成一个文件描述符让你可以用read/write的思路来收发数据。创建一个TCP Socket本质上就是创建了一个通信端点然后调用connect、listen、accept完成连接管理。不同语言的网络库底层最终都会走到这套系统调用上。很多人一上来就学select、epoll我建议先把阻塞式单连接的流程走通再谈高并发。网络编程和普通文件读写最大的区别是数据什么时候到、来多少、连接什么时候断开你都无法预知只能通过系统中的事件通知机制去处理。阻塞模式下一切都很直观适合理解模型非阻塞和事件循环只是把等待变成回调底层原理没有变。6.2 最小可用的TCP Echo服务端和客户端用Python写一个最简单的Echo服务数据发过来原样返回。服务端代码import socket server socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((0.0.0.0, 9000)) server.listen(5) print(listening on 9000) while True: conn, addr server.accept() print(connected from, addr) while True: data conn.recv(1024) if not data: break conn.sendall(data) conn.close()关键点SO_REUSEADDR是解决端口复用的listen(5)是等待队列长度recv(1024)表示最多读1024字节但实际读到的可能更少。sendall确保全部发送普通send只发一次可能没发完。这个例子里的嵌套while是阻塞式读取如果客户端不关闭连接且不发数据服务端就会一直卡在recv上。客户端代码import socket client socket.socket(socket.AF_INET, socket.SOCK_STREAM) client.connect((127.0.0.1, 9000)) client.sendall(bhello network) resp client.recv(1024) print(resp) client.close()运行后你会看到服务端收到连接客户端打印出hello network。就这么简单但你实际上已经完成了三次握手、数据传输、四次挥手整个流程。建议你在抓包工具里抓一次这个交互看看TCP报文里的SYN、ACK、FIN标志比看十篇文章都有效。6.3 代码里常见的坑粘包、阻塞、超时、连接重置粘包是TCP字节流最容易遇到的问题。TCP没有消息边界你send两次接收方可能一次recv就全收到也可能分多次收到。解决办法是应用层自己定义消息边界常见做法有固定长度、长度字段加消息体、分隔符等。用UDP则天然有报文边界但又有丢包问题。设计协议时我习惯在消息头里放魔数、版本号、长度、序列号和消息类型这样解析器能快速校验并确定一条完整消息。阻塞是另一个大头。默认Socket是阻塞的connect、recv都可能一直卡住。生产代码一定要设置超时比如timeout3否则对端不响应你的线程就挂了。connect超时尤其重要因为对端IP不可达时系统可能要等很久才能返回错误。设置超时之后你还需要处理超时异常决定是重试还是放弃不能让它一直堵着。连接重置也经常遇到当你往一个已经关闭的连接写数据进程可能收到SIGPIPE很多服务莫名退出就是这个原因。程序里需要屏蔽SIGPIPE信号或者在对端关闭时主动处理错误。另一个表现是“Connection reset by peer”通常是服务端收到了不完整请求后直接关闭或者两端处理超时不一致导致一方把连接断掉。7. 网络问题的排查思路从基础到实用7.1 用好抓包工具和命令行工具排查网络问题先备好这几件工具ping用来测主机是否可达看延迟和丢包率telnet或nc用来测端口是否开放比如telnet 10.0.0.1 3306能连上说明端口开着curl用来测HTTP接口加-v能看完整交互netstat或ss用来查端口监听和连接状态tcpdump或Wireshark用来抓包分析协议细节dig或nslookup用来查DNS解析。每件工具的适用场景不同但组合起来可以覆盖从底层到应用层的全过程。有人会问Wireshark是不是只给运维用的不是。做网络编程你自己写客户端和服务端遇到收发数据对不上抓包是最直接的证据。你抓一次包就能看到是代码没发出数据还是对端没回还是数据在中间丢了。只要证据摆在眼前问题就解决了一大半。很多“诡异”问题比如收到的数据多几个字节、连接到了但响应慢抓包后一眼就能看出是协议解析边界不对。7.2 常见网络故障的排查顺序一次从客户端到服务端的请求排查顺序应该按分层走排查点工具常见问题DNS解析dig/nslookup域名无法解析、解析到旧IP主机可达ping网络不通、丢包严重端口连通nc/telnet网络策略拦截、服务未启动应用协议curl/tcpdump请求头错误、响应超时数据与性能Wireshark/ss重传多、TIME_WAIT多、长连接泄漏我见过不少同事一上来就查代码绕了一圈发现是网络策略把端口封了。如果按这个顺序走十分钟就能定位。先确认域名能不能解析成IP再确认IP能不能通再确认端口有没有监听最后才看应用协议交互。前面任何一步不通后面都不用查。7.3 我踩过的几个坑和总结先说一个最典型的线上服务出现大量Connection reset by peer。一开始怀疑是客户端问题后来抓包发现是服务端因为处理时间过长超过了对端的读取超时对端直接断开连接。我们把处理流程缩短后问题就消失了。从这个事情我学到两点第一抓包要抓两端不能只看一端第二应用层超时设计要和服务端处理时间匹配否则你服务端还在处理客户端早就没耐心了。第二个坑是用NAT环境做局域网联调A机器能ping通B但B程序监听在0.0.0.0上却不响应A的请求。抓包发现请求到了但程序没有按预期处理。后来才发现是端口绑定错了监听了一个没用的端口。建议机器上多个IP时绑定地址一定要写清楚不要想当然。尤其是容器化环境容器内看到的主机名和IP可能和外部完全不一样排查时尤其要注意。第三个坑是长连接不发送心跳服务端或者中间网络设备把空闲连接断了客户端还傻傻以为连接可用下一笔请求直接失败。解决办法是用心跳包或者设置TCP KeepAlive应用层最好也有自己的心跳检测。心跳间隔要考虑网络设备和服务端空闲超时太频繁浪费资源太慢又不够灵敏。一般设置为比服务端空闲超时时间的一半略小一点比较稳妥。这三个坑本质上都在考验网络基础扎不扎实。只要分层思想清晰排查方向就错不了。你以为的“魔法”拆开看其实都是协议字段在起作用。最后再送一个建议学网络编程别急着上框架。先用原生的Socket写一遍用抓包确认你写的每个字节在网络里长什么样再去看那些封装好的库你会觉得它们一点都不神奇。第一次能把三次握手、四次挥手用抓包可视化地说出来这个底子就算打牢了。后面再学高并发、多路复用、HTTP库源码都会快很多。