做后端和运维的,几乎每天都在跟 Socket 网络编程打交道,但 Socket 这件事,很多人是“用而不知”。你写一个 API 调用,框架已经把连接建好了;你调一次数据库,驱动已经帮你把 Socket 封装完了。可一旦出问题——连接池里的连接突然全断、服务报 Socket read timed out、IDE 启动时抛出一句 Failed to create server shutdown socket on address [localhost] and port [802]——如果你不懂 Socket,就只能靠重启大法。这篇是网络编程详解系列的第二篇,上一篇我们把 TCP 的分层和握手机制聊透了,这一篇集中讲 Socket 编程本身:它是谁、能干什么、服务端客户端各自应该注意什么,从 Python 到 Spring Boot 再到 VBA 的落地方案,外加一张我整理过的报错排查地图。刚入门的朋友可以在这里找到从零跑通一个 TCP 服务的完整路径,被线上诡异问题折磨过的老手也能直接跳到第五部分查报错。
1. Socket 到底是什么:从“进程间通信”说到“跨主机通信”
服务器上的进程之间交换数据,最简单的办法是共享内存、管道、消息队列,成本低、速度快。可一旦两个进程不在同一台机器上,这些手段全部失效,必须把数据交给网卡,经过网络到达另一台机器。网络传输本身是 TCP/IP 协议栈负责的,但应用代码不能直接操作协议栈,操作系统于是给应用开发者提供了一组 API,这组 API 就是 Socket。它是操作系统暴露出来的一道大门,你的程序把要发送的数据从这道门丢进去,协议栈负责把它打包、寻址、重传,最终送到对端进程的 Socket 门口。
1.1 Socket 是接口,不是协议
经常有人问,Socket 和 TCP 是什么关系?一个常见误区是把 Socket 理解为一种协议。实际上 TCP 是传输层协议,它规定了数据怎么拆分、怎么确认、怎么重传;Socket 是传输层之上的编程接口,它自己不定义任何数据传输规则,只是把 TCP/UDP 的能力包装成一组函数调用,让你能 bind、listen、connect、send、recv。可以打个比方:TCP 是邮政系统的运输规则,Socket 是你家门口的信箱,寄信取信都通过这个信箱完成,但信怎么走、走哪条路,是邮政系统的事。协议栈在你的操作系统内核里,Socket 接口则是内核给你的一张操作凭据。
这个区分特别重要。很多排错思路都会被“Socket 是不是一种协议”这种误解带偏。比如有人问“Socket 怎么设置编码”,这是个伪命题,编码是应用层的事,Socket 只负责搬运字节;真正要设置的是字符集、序列化方式。理解了接口和协议的区别,你就不会再去折腾内核里不存在的东西。
1.2 文件描述符:一切皆文件的哲学
Unix 世界里有一条设计原则叫“一切皆文件”,网络连接也遵循这个原则。当你调用 socket() 创建一个套接字时,内核会返回一个整数,这个整数叫文件描述符(fd)。之后你对这个整数执行读写操作,内核就知道它对应的是哪一条网络连接。比如 read(fd, buffer, len) 就是从连接里读数据,write(fd, buffer, len) 就是把数据发给对端。文件描述符的好处是统一了设备、文件和网络的 IO 模型,坏处是它本身是无状态数字,一旦被误写就会串到完全不相关的文件或连接上,这也是很多诡异的“脏数据”问题的源头。Java 里的 Socket 类只是对这个整数的一个面向对象包装,底层操作还是那些系统调用。
我看到不少刚入门的朋友在 Windows 环境下写代码,连报错信息带有的 WSA 前缀都要查半天。Windows 的 Winsock 为每个 Socket 维护了一个独立的错误码表,函数名也多了个 WSA 前缀,比如 WSAStartup、WSACleanup。用 Python、Java 这类高级语言时感觉不明显,但你一旦用 VBA 或者 C 直接调 API,就必须先做 WSAStartup 初始化,否则拿不到正确的错误码。这些都是同一个 Socket 接口在不同平台上的外观差异,核心逻辑完全一致。
1.3 四元组:唯一确定一条 TCP 连接
一条 TCP 连接由四个要素确定:源 IP、源端口、目标 IP、目标端口,也就是大家常说的“四元组”。服务端监听 9000 端口时,这个监听 Socket 只用了 IP+端口来标识;当它 accept 之后,内核会为每个新连接生成一个全新的连接 Socket,这个连接 Socket 的四元组各不相同,所以同一个监听端口可以应对成千上万的并发连接。理解四元组是排查一切网络问题的基础:当你觉得“连接被占用”“端口被占”时,首先要分清是哪一层出了问题——是监听端口冲突,还是高并发连接把文件描述符耗尽了,还是 TIME_WAIT 状态堆积导致新连接无法建立。
用 netstat 查看连接时,你会看到 LISTEN、ESTABLISHED、TIME_WAIT、CLOSE_WAIT 这些状态。很多新手会把它们混在一起看,其实它们各自代表不同的生命周期阶段。ESTABLISHED 是正常通信状态,TIME_WAIT 是主动关闭方在等待残留报文消失,CLOSE_WAIT 是被动关闭方还没调用 close 的状态。如果 CLOSE_WAIT 大量堆积,基本可以断定是应用代码漏调了关闭方法,不是网络的问题。
2. 编程模型演进:BIO、NIO、AIO 到底解决了什么问题
Socket 编程的难点从来不在“怎么收发数据”,而在“如何高效地管理大量连接”。最简单的模型是来一个连接就开一个线程专门处理,这就是 BIO。听起来合理,但线程是稀缺资源,一个线程默认栈 1MB,500 个连接就是 500MB 内存,而且大部分线程在等待数据到达时都在睡觉。后来引入了 NIO,用少数几个线程去轮询成千上万个连接,哪个连接有数据就处理哪个,相当于把“每连接一线程”改成了“多连接共享线程”。AIO 则更进一步,把“等待数据”这个动作也交给内核,数据到了内核才通知你。
2.1 服务端的三步启动:bind、listen、accept
服务端 Socket 程序的固定套路是三步走。第一步 bind,把 Socket 绑定到指定 IP 和端口,相当于给自家门口挂上门牌号;第二步 listen,告诉内核开始接受别人发来的连接请求,listen 第二个参数是 backlog,表示允许排队的连接数量;第三步 accept,这个过程在阻塞模型里会一直沉睡,直到有客户端完成了三次握手,内核把这条连接放进一个叫“已完成连接队列”的地方,accept 才从队列里取出一个连接交给应用。这里有个很多人忽略的细节:三次握手发生在内核层面,accept 只是把已经握好手的连接取走,不是 accept 的时候才握手。
客户端那边其实也有一堆隐藏动作。connect 调用会触发 SYN 包发送,如果网络不通,connect 会阻塞一小段时间后超时,这个超时时间通常由系统参数控制,应用层很难干预。很多初学者误以为 connect 失败是代码问题,其实多半是网络不可达、对端没监听、防火墙丢弃了 SYN。遇到 connect 超时,优先检查的不是代码,而是链路。
2.2 主动关闭与 TIME_WAIT 的代价
断开连接是一套更讲究的流程。TCP 四次挥手任何一端都能发起,主动关闭的这一端在发完最后一个 ACK 后会进入 TIME_WAIT 状态,要等两倍 MSL(报文最大生存时间)才彻底销毁连接。TIME_WAIT 不是为了浪费时间,是因为网络中可能还残留着之前的数据包,如果连接马上被复用,残留包可能被当作新连接的数据。设计者宁可让主动关闭方等一会儿,也要保证数据不串门。所以服务端重启时,如果大量连接处于 TIME_WAIT 状态,再 bind 同一个端口就会报 Address already in use,这种情况可以设置 SO_REUSEADDR 让内核允许在这种状态下复用端口。
生产环境里有种常见误区:看到大量 TIME_WAIT 就紧张,急着调短 TIME_WAIT 或开 tcp_tw_reuse。TIME_WAIT 本身是 TCP 正常机制,短连接模型下数量多很正常。真正应该考虑的是:为什么连接频繁建、频繁断?能不能改成连接池复用长连接?这往往比内核参数更治本。盲目开 tcp_tw_reuse 在某些场景会引入数据错乱风险,不是万灵丹。
2.3 NIO 三件套:Channel、Selector、Buffer
NIO 与 BIO 最大的区别在于三个新概念:Channel、Selector、Buffer。Channel 是连接的通道,读写都基于它;Buffer 是数据缓冲区,读写必须在这个缓冲区上操作;Selector 是事件多路复用器,它同时监听很多 Channel 的可读、可写、可连接事件,线程只要阻塞在 Selector 上,就能响应所有连接的事件。这套机制完美对应了“一个线程服务千万连接”的目标。
不过 NIO 的代码复杂度比 BIO 高一个数量级,所以大多数业务系统选择的是 Netty 这样的框架,把 Channel、Selector、Buffer 的细节封装掉。不要因为自己用 Netty 就忽略底层原理,遇到“连接卡死”“线程迟迟不退”这类问题,最后定位时仍然要回到 Socket 本身的机制上。
3. 实操第一站:用 Python 跑通一个完整的 TCP 服务
概念讲得再多,不如跑一个真实程序。Python 的 socket 模块是对 C Socket API 的直接翻译,最接近底层,适合用来建立直觉。下面演示一个最简单的回声服务:客户端发什么,服务端原样返回。
# server.py 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(f'connected: {addr}') while True: data = conn.recv(1024) if not data: break print('received:', data) conn.sendall(b'echo: ' + data) conn.close()# client.py import socket client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) client.connect(('127.0.0.1', 9000)) client.sendall(b'hello socket') data = client.recv(1024) print('response:', data) client.close()3.1 服务端和客户端代码逐行拆解
服务端第 3 行把套接字绑定到 0.0.0.0,意思是接收本机所有网卡上的连接请求,如果只绑 127.0.0.1,那就只有本机能访问;第 4 行设置 SO_REUSEADDR,是为了防止上次运行残留的 TIME_WAIT 状态导致端口无法绑定,调试阶段几乎必备;第 6 行 listen(5) 的 5 就是 backlog,代表内核里能排队的待处理连接数。主循环里 accept 返回两个值:conn 是新连接的 Socket,addr 是客户端地址,然后在 conn 上做循环读写。
客户端这边,connect 触发三次握手,成功后就能 sendall。这里有个小坑:send 和 sendall 不一样,send 不一定把数据一次性发完,可能只发一部分;sendall 会循环发送直到全部发出去,业务代码里尽量用 sendall。recv(1024) 表示最多读取 1024 字节,但实际读到的可能少于 1024,也可能多次 recv 后才能凑齐完整消息,这是 TCP 流式特性的直接体现。
我建议你亲手把这段代码跑起来,然后做一个小实验:在客户端连续 sendall 三次“hello”,看服务端一次 recv 能收到多少。实验结果会让你立刻理解粘包是怎么回事。
3.2 粘包与“补随机数”的真相
TCP 是流协议,没有消息边界。你 send 两次,对端可能一次 recv 就全部读到,也可能一个 send 的数据要分好几次 recv 才能读完。这就是粘包和拆包问题。解决方案不是去 Socket 层面设置什么开关,而是要在应用层自定义消息格式:常见方案有两种,一种是每个消息末尾加特殊分隔符,比如字符串消息里约定 \r\n;另一种更通用,就是每个消息前增加 4 字节的长度前缀,接收端先读长度再读正文。
关于“为什么 Socket 接收到奇数字节后面会补一个随机数”这个问题,网上问的人很多。我见过最典型的现场,其实是 C 语言里发送结构体造成的。比如定义一个结构体,里面有一个长度为 3 的字符数组和一个 int 字段,编译器为了让 int 地址对齐,会在 3 个字节后面填充几个字节,sizeof 结果会比你预想的大。直接把这个结构体 send 出去,发送缓冲区里就带了填充字节,接收端按自以为的长度去读,多出来的填充字节在下一次 recv 时出现,看起来就像系统随机补了一个字节。这根本不是 Socket 的问题,是结构体内存布局和协议定义没对齐。Java 里如果用了 DataOutputStream.writeUTF,也要注意它会在内容前面写入两字节长度,如果解析协议时没把这个长度也算进去,同样会多出“看不懂的字节”。
3.3 自定义协议:从半包到完整消息
解决粘包和拆包,实战中最可靠的方式是“长度前缀法”。假设每条消息格式是:4 字节大端整数表示消息长度,后面跟着消息体。发送端依次写入长度和内容;接收端先读 4 字节,解析出 N,再继续读取 N 字节才算一条完整消息。
Python 里用 struct 模块打包很容易实现:
import socket import struct def send_msg(sock, data: bytes): header = struct.pack('!I', len(data)) sock.sendall(header + data) def recv_exact(sock, n: int): buf = b'' while len(buf) < n: chunk = sock.recv(n - len(buf)) if not chunk: raise ConnectionError('connection closed') buf += chunk return buf def recv_msg(sock): header = recv_exact(sock, 4) length = struct.unpack('!I', header)[0] return recv_exact(sock, length)这段代码里 recv_exact 必须循环接收,直到收满指定字节数为止。很多粘包问题之所以出现,就是因为你只调了一次 recv,没把数据收完就去做业务处理了。写网络通信代码时,把“读完整一个消息”封装成独立函数,是避免这类问题最有效的手段。
4. Spring Boot 集成 WebSocket:连接参数到底怎么配
很多人搜 Spring Boot 集成 WebSocket 的 YML 配置,结果翻半天找不到 spring.websocket 相关的配置键,于是开始怀疑是不是自己姿势不对。我可以直接告诉你:Spring Boot 没有给 WebSocket 提供一组专门的全局配置键,端点的注册是在 Java 配置类里完成的。你真正需要配的是承载连接的 Web 容器参数。
4.1 注册 WebSocket 端点的标准姿势
WebSocket 在 Spring Boot 里的标准做法是实现 WebSocketConfigurer 接口,注册一个自定义 Handler:
@Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(new MyHandler(), "/ws") .setAllowedOrigins("*"); } }Handler 则继承 TextWebSocketHandler,核心方法是 afterConnectionEstablished、handleTextMessage、handleTransportError。WebSocket 连接在服务端最终落地为一个底层 TCP 连接,所以它依然受 Socket 层的超时、心跳、线程池影响。不要以为用了 WebSocket 协议就能绕开网络的物理规律,长连接被防火墙空闲断开、连接堆积拖垮线程,这些问题一样会出现。
4.2 YAML 里真正值得调整的连接参数
既然 YML 里没有 spring.websocket 专用配置,那真正值得调的是什么?如果你用的是内嵌 Tomcat,那就是 server.tomcat 下的几个参数:
server: port: 8080 tomcat: threads: max: 200 accept-count: 100 max-connections: 8192threads.max 是处理请求的最大工作线程数,accept-count 是当工作线程用满时,内核等待队列里还能排多少请求,max-connections 是容器能同时维持的最大连接数。对 WebSocket 这类长连接服务,连接会长时间占住一个线程(Tomcat 默认使用 BIO 模型处理 WebSocket),所以在线人数越多,线程数越要相应调大。如果并发量很高,我建议换 Undertow 或 Jetty,或者直接把 WebSocket 服务独立部署,避免和普通 HTTP 请求互相挤压线程资源。
4.3 长连接的心跳、超时与线程模型
WebSocket 默认存在空闲超时,客户端和服务端之间如果长时间没有数据往来,网络设备或服务端自己都可能把连接断掉。工程上通用的做法是应用层心跳:客户端定时发 Ping 帧(也就是 ping/pong),服务端必须在规定时间内响应,否则判定连接失效。这比 TCP KeepAlive 更可控,因为 TCP KeepAlive 的默认探测周期很长,往往发现连接死掉时已经过去一两个小时。
Java 侧的超时控制也要注意。TCP Socket 层有一个 SO_TIMEOUT 参数,当连接假死时如果没设超时,读操作会一直阻塞,线程白白挂着。在 WebSocket 场景下,还要配合空闲关闭策略。很多人线上连接数缓慢上涨、内存缓慢增长,最后定位到的问题就是心跳没做、超时没设、连接没关干净。
5. 线上最常见的五类 Socket 报错排查实录
这部分是我最想讲的。以下报错都是我实际遇到过、反复定位过的问题。每个都给出现象、原因和排查链路,你可以直接保存下来当速查手册用。
5.1 java.sql.SQLException: Socket read timed out!
现象很典型:数据库连接长时间执行无响应,然后抛 Socket read timed out,尤其在 Oracle 场景下高频出现。这个报错发生在 JDBC 驱动从数据库读数据时,等待时间超过了驱动层面的 socketTimeout。原因可能是数据库 SQL 性能差、数据库繁忙、网络不稳定、驱动超时时间设置过短。
我的排查顺序是:第一,看数据库侧 active session 和等待事件,先确认是不是 SQL 把数据库拖垮了;第二,检查连接池配置,HikariCP 和 Druid 里的连接超时、验证超时要调到一个合理值;第三,用 tcping 直接测数据库端口,ICMP 的 ping 通不代表 TCP 通;第四,如果业务允许,把驱动 socketTimeout 调大,连接池开探活。注意 Druid 的 socketTimeout 和 MySQL Connector/J 的 socketTimeout 是不同参数,配置前先看你用的是哪个版本。
5.2 no more data to read from socket
这条报错常见于旧版 Cassandra 驱动、Thrift 类客户端以及配置中心客户端。它的字面意思是,对端已经把连接关闭了,客户端还继续在这个已经关闭的 Socket 上读数据,读到最后什么都没有。最常见的触发场景是:客户端连接池里复用了长时间空闲的连接,但服务端因为 idle timeout 或滚动重启把连接关了,客户端不知道,下一个请求一来就报错。
处理手段有三个:客户端开心跳,让连接一直有流量;连接池做好连接有效性检测,发现坏连接立即重建;对偶发的这种异常做一次重试。另外,如果 Cassandra 集群节点经常滚动重启,要注意把驱动连接池的空闲清理时间和重启窗口错开。
5.3 failed to create server shutdown socket on address [localhost] and port [802]
这个报错最容易出现在 IDE(尤其是 IntelliJ IDEA)里同时启动多个 Spring Boot 实例的时候。IDE 为了让“停止应用”按钮能优雅关闭程序,会尝试在本机绑定一个专用的 shutdown 端口,默认往往是 802。这个端口被其他实例占用,或者上一个应用的端口还没释放,新实例启动时就报这个错。
排查方法很简单:netstat -ano 看 802 端口被谁占用,把那个进程关掉。如果多个实例同时要跑,就在 IDE 的 Spring Boot 运行配置里把 Shutdown port 改成不同端口,或者直接设成 0。纯命令行用 java -jar 启动应用时基本不会碰到这个报错,所以遇到它先想开发环境,别去线上找原因。
5.4 Connection reset / Broken pipe:谁先关谁被动
Connection reset 和 Broken pipe 往往一起出现。本质是:服务端已经关闭了整个连接,客户端还在往这条连接上写数据,内核收到数据后发现没有对端在监听,直接回一个 RST 包,后续写操作就报错。也可能是客户端读操作收到 RST,直接抛 Connection reset。
排查这类问题,一定要先看服务端日志里有没有异常退出、有没有主动 close。我见过一个案例:服务端程序在处理完一次请求后误关了连接,但客户端是长连接模式,下次复用这条连接立刻 reset。最后用 tcpdump 抓包,看到 reset 包都是从服务端 IP 发出来的,问题瞬间定位。所以抓包永远是解决 RST 问题的终极武器。
5.5 Address already in use:TIME_WAIT 与 SO_REUSEADDR
服务端反复重启时报 Address already in use,是最常见的开发期问题。原因是大量连接处于 TIME_WAIT 状态,端口还没释放。代码里加一句 setsockopt(SO_REUSEADDR) 能解决大部分场景。但生产环境如果频繁出现绑不上端口,同时连接数又高,要检查是不是短连接太多导致的 TIME_WAIT 堆积,优先考虑连接池复用,而不是反复重启。
5.6 Socket 报错速查表
| 报错信息 | 常见场景 | 优先排查点 | 快捷处理 |
|---|---|---|---|
| Socket read timed out | JDBC、HTTP 客户端 | 数据库 SQL、网络链路、超时配置 | 调大读取超时,连接池探活 |
| no more data to read from socket | Cassandra/Thrift/配置中心长连接 | 服务端空闲断开、重启、防火墙 | 心跳、坏连接检测、一次重试 |
| Failed to create server shutdown socket on address [localhost] and port [802] | IDE 启动多个 Spring Boot 实例 | 802 端口占用 | 改 Shutdown port,或关掉占用进程 |
| Connection reset / Broken pipe | 长连接被对端关闭后继续读写 | 谁先 close、抓包看 RST 方向 | 服务端保活,客户端重连 |
| Address already in use | 端口未释放 | 连接处于 TIME_WAIT、监听端口冲突 | SO_REUSEADDR,改用长连接 |
| SocketException: Connection refused | 对端没监听或防火墙拦截 | 端口监听状态、防火墙规则 | 确认服务启动,检查安全组 |
6. 冷门但应急好用:用 VBA 调一次 Telnet
现在正经项目里用 VBA 写网络程序的很少,但如果你在维护老旧的运维工具、Excel 报表系统,偶尔会遇到“用 VBA 连一个 Telnet 服务”的需求。Windows 下 VBA 调 Socket 主要就是两条路:一条是用系统自带的 MSWinsock 控件,另一条是直接声明 ws2_32.dll 里的 WinSock API。
6.1 基于 MSWinsock 控件的 Telnet 示例
MSWinsock 控件是 Windows 系统提供的一个 ActiveX 控件,在 VBA 开发环境里通过“工具-附加控件”勾选 Microsoft Winsock Control 6.0 就能使用。它把 Socket 的 connect、send、recv 封装成了方法和事件,理解起来非常友好。
一个简单 Telnet 客户端大致长这样:
Private Sub UserForm_Initialize() Winsock1.RemoteHost = "192.168.1.10" Winsock1.RemotePort = 23 Winsock1.Connect End Sub Private Sub Winsock1_DataArrival(ByVal bytesTotal As Long) Dim strData As String Winsock1.GetData strData, vbString Debug.Print strData End Sub Private Sub CommandSend_Click() Winsock1.SendData "dir" & vbCrLf End SubTelnet 是明文协议,端口 23,发送指令时一定要带换行符,服务端才会执行。VBA 里的字符串是 Unicode,有些 Telnet 服务端只认 ASCII,发送前建议用 StrConv 把字符串转成 ANSI 或 ASCII 字节数组,否则中文和特殊字符会乱码。另外 MSWinsock 控件在新版 Office 里可能找不到,这取决于 Office 位数和系统控件注册状态,遇到这种情况就只能走 API 路线。
6.2 直接调 ws2_32.dll 的 WinSock API
第二种方式更底层,直接在 VBA 里 Declare 外部函数,完全绕开控件依赖。核心函数无非是 socket、connect、send、recv、closesocket,还要定义一个 SOCKADDR_IN 结构体来承载 IP 和端口。
Private Type SOCKADDR_IN sin_family As Integer sin_port As Integer sin_addr As Long sin_zero(0 To 7) As Byte End Type Private Declare Function socket Lib "ws2_32.dll" (ByVal af As Long, ByVal sType As Long, ByVal protocol As Long) As Long Private Declare Function connect Lib "ws2_32.dll" (ByVal s As Long, ByRef name As SOCKADDR_IN, ByVal namelen As Long) As Long Private Declare Function send Lib "ws2_32.dll" (ByVal s As Long, ByRef buf As Any, ByVal len As Long, ByVal flags As Long) As Long Private Declare Function recv Lib "ws2_32.dll" (ByVal s As Long, ByRef buf As Any, ByVal len As Long, ByVal flags As Long) As Long Private Declare Function closesocket Lib "ws2_32.dll" (ByVal s As Long) As Long端口号在 SOCKADDR_IN 里必须转成网络字节序,也就是端口的高字节和低字节要交换。VBA 没有现成的 htons 函数,得自己写一个字节交换函数。connect 时把结构体传进去,socket 的函数返回一个句柄,后续 send、recv 都靠这个句柄操作。这套写法的优点是可控性强,缺点是参数类型一旦写错,轻则死循环,重则让 Excel 直接崩溃。声明时在 64 位 Office 下要加 PtrSafe 关键字,这是最容易踩的兼容性坑。
6.3 VBA 网络编程的坑
结合我自己的实践,VBA 调 Socket 有三个坑值得单独提一下。第一,recv 收到的数据要用 Byte 数组接收,直接声明 String 去收容易因为 Unicode/ASCII 问题产生乱码;第二,send 和 recv 同样可能只处理一部分数据,循环发送、循环接收的写法在 VBA 里一样不能省;第三,连接本身是异步的,控件方式的 Connect 方法返回后不代表连接成功,必须在 Connect 事件里再发起业务请求。
如果你只是定期往某个 Telnet 服务器发指令,我更推荐用 MSWinsock 控件方式,开发速度快,代码也容易维护。如果是要做性能测试或者完全不想依赖控件,再考虑 API 方式。
把上面这些坑基本踩过一遍之后,我最大的体会是:Socket 编程真正难的地方不在 API 本身,而在“对端到底想表达什么”。同样一个 recv 返回,可能是对方关闭,可能是半包,可能是缓冲不够,你必须把协议设计得足够明确,把每个返回值都当成一种语义去处理,而不是指望框架帮你兜底。如果你看完这篇能自己动手把 Python 回声服务改成带长度前缀的协议,或者去把线上那个 read timed out 的连接检查一遍,那这篇就没白写。几个报错如果暂时没遇到,建议保存下来,等它在凌晨两点突然出现的时候,至少能知道从哪里下手。