
如果你刚跑通console.log打印一个“12”下一步大概率会碰到真正的 Node.js 网络编程。Node.js 里一提起写通信程序大家第一反应都是net模块TCP但很多实时音视频、局域网设备发现、日志上报、游戏帧同步场景选 UDP 反而更合理。这时候就要用到 dgram 模块这是 Node.js 内置的 UDP 数据报通信实现不需要额外装任何依赖创建套接字、发包、收包、组播、广播都靠它。这篇就是围绕 dgram 模块展开的完整实践笔记。我会先讲清楚 UDP 和 TCP 的区别再拆解 dgram 的每个核心 API最后给出一套能直接跑起来的回显服务、广播和组播示例以及我踩过的几个坑。内容更适合已经熟悉 JavaScript 基础语法、想开始写网络程序的开发者也适合那些用 Node.js 写工具脚本但一直没搞懂 UDP 为什么“不可靠”的人。1. dgram 模块能干什么先理解 UDP 的使用边界1.1 UDP 和 TCP 的本质差异很多人刚开始学网络编程脑子里只有 TCP建立连接、可靠传输、有序到达觉得这才是“正常”的网络通信。但 UDP 走了完全相反的路子它不建立连接、不保证送达、不保证顺序甚至不保证数据报的完整性。这就是 dgram 模块的基础哲学你调用一次send系统就尽力把这个数据报发出去能不能到、什么时候到、到了之后顺序对不对UDP 协议本身一概不管。那为什么还要用它因为省掉了确认应答、连接维护、拥塞控制这一整套开销。TCP 每个包都要等 ACK要维护滑动窗口网络延迟高、吞吐抖动大的场景里TCP 像是一个做事反复确认的同事UDP 则像扔纸条写了就扔效率极高。dgram 模块给你提供的正是这种“最小可用”的通信能力没有连接对象、没有流的概念只有一个数据报接一个数据报。1.2 dgram 适合哪些场景不适合哪些场景用 dgram 之前先判断业务能不能接受丢包和乱序。最适合的场景是实时性要求高、偶发丢包可容忍的领域比如在线游戏的移动和状态同步丢了上一帧就不补发了直接用最新的。音视频通话、直播推流画面卡一下可以等重传就完蛋了。局域网内的设备发现比如手机 App 搜智能音箱广播一条“谁在”能回应就行。日志和监控指标上报偶尔丢几条不影响整体统计。DNS 查询、NTP 时间同步这类“一问一答”式短消息本质就是 UDP。物联网传感器上报数据量小、频率高不需要为每一条建立清爽的可靠连接。反过来需要传输文件、需要保证每笔交易不丢、需要按顺序处理消息的场景就不要硬用 dgram 了。文件上传下载、订单提交、聊天消息推送这些老老实实走 TCP 或者在此基础上加 WebSocket。你可以在应用层给 UDP 加上重传和排序但那是自己实现一个 TCP复杂度和成本都高得多。选型判断就一句话你想“快而糙”还是“稳而慢”。2. 核心 API 逐个拆createSocket、bind、send、message2.1 createSocket 和 bind创建套接字并绑定端口dgram 模块所有操作的起点是dgram.createSocket(type[, callback])type二选一udp4对应 IPv4udp6对应 IPv6。绝大部分局域网和内网场景用udp4如果要在 IPv6 环境中通信再使用udp6两者创建的套接字不通用地址格式也不一样。const dgram require(dgram); const socket dgram.createSocket(udp4);创建出来的套接字一开始并没有监听任何端口。你要接收消息就必须调用bind(port[, address][, callback])指定服务端监听哪个端口。address可以省略默认绑定到0.0.0.0也就是本机所有网卡如果只需要本机回环测试也可以显式写127.0.0.1。socket.bind(41234, () { const address socket.address(); console.log(UDP 服务端已监听 ${address.address}:${address.port}); });这一点和 TCP 监听很像但有个关键区别TCP 的listen之后还有个“等连接进来”的过程UDP 没有。bind一旦完成只要数据报到达这个端口就会触发message事件不管对方之前有没有和你有过交互这就是无连接特性在 API 层面的直接体现。callback在绑定完成后触发适合在这里打印监听地址。但不建议在这里做“开启业务逻辑”这样的事因为消息随时可能进来更稳妥的做法是提前注册好message监听再调用bind。2.2 send 才是主角数据报怎么发出去所有收包逻辑都围绕message事件展开而发包逻辑紧紧围绕send方法。dgram 的send有两种常用形式完整的签名是这样的socket.send(msg, offset, length, port, address[, callback]);其中msg是Buffer、Uint8Array或字符串。offset和length表示从msg的第几个字节开始发、发多长。为什么要拆这么细因为实际开发里经常是先拼一个大 Buffer再截取其中一段作为消息内容如果每次都要复制出一块新 Buffer在高频发送场景下会白白增加内存拷贝。新手最容易搞错的是把port和address的顺序弄反。先写端口再写 IP 地址这个顺序是固定的和 TCP 连接时的host:port写法刚好相反。如果写反了程序不会直接报错而是数据报发到了一个错误的目标排查起来很费劲。const message Buffer.from(hello udp); socket.send(message, 0, message.length, 41234, 127.0.0.1, (err) { if (err) console.error(发送失败, err); else console.log(发送成功); });新版 Node.js 还支持更简洁的调用方式把 offset 和 length 省掉默认发送整个 Buffersocket.send(message, 41234, 127.0.0.1);我自己写工具脚本时倾向用这种简短形式但在要求精确控制包内容的项目里我会用完整版本。原因很简单UDP 数据报有最大长度限制通常不建议超过 MTU 大小手动指定offset和length可以方便你在协议层把消息切成多个分片发出去。还有一个细节容易被忽略客户端调用send之前如果没有显式bind系统会自动临时分配一个随机端口。这个随机端口对主动发送没问题但如果服务端需要主动给客户端发送消息客户端必须提前bind一个固定端口否则服务端拿不到稳定的回调地址。2.3 几个容易被忽略的辅助方法send和message是核心但有几个辅助方法在复杂场景里非常有用。socket.address()返回当前绑定的地址信息对象包含{ address, family, port }在message事件回调里配合rinfo使用可以区分“这是本机的地址”和“对方是谁发来的”。socket.setBroadcast(flag)控制是否允许发送广播包。默认情况下发送到255.255.255.255会报错EACCES必须设置socket.setBroadcast(true)才能把数据报发到广播地址。这里容易踩坑的是flag参数是布尔值而且一旦设置为true就作用于整个套接字不区分目标地址所以广播套接字最好不要同时承载需要保密的数据。socket.setMulticastTTL(ttl)设置组播包的存活时间决定这个包能在网络里传播多少跳。局域网内组播一般设置128足够不需要改太大。socket.setMulticastLoopback(flag)决定组播数据包是否回环回发给自己。同一台机器上即发即收的测试场景需要把 loopback 设为true否则接收端收不到自己进程发送的组播消息。socket.setRecvBufferSize(size)和socket.setSendBufferSize(size)调整系统层面收发缓冲区大小。当消息量大、内核缓冲区溢出时加大这个值能明显减少丢包但注意它设置的是内核缓冲区不是应用层队列不要设到远超物理内存的水平。socket.close()关闭套接字并释放端口。长期运行的服务端一般不需要频繁调用但在脚本里跑完测试必须关闭否则 Node.js 进程不会退出终端一直挂着。3. 从零写一个 UDP 回显服务服务端 客户端完整代码3.1 服务端监听 message 事件原样回发写一个最简单的 UDP 回显服务客户端发什么服务端就原样返回什么。这和 TCP 的 echo 服务思路一致但代码量少很多因为没有连接管理和流处理。const dgram require(dgram); const server dgram.createSocket(udp4); server.on(message, (msg, rinfo) { console.log(收到来自 ${rinfo.address}:${rinfo.port} 数据: ${msg.toString()}); server.send(msg, rinfo.port, rinfo.address, (err) { if (err) { console.error(回发失败, err); } }); }); server.on(listening, () { const address server.address(); console.log(UDP 服务端已启动 ${address.address}:${address.port}); }); server.on(error, (err) { console.error(服务端发生错误, err); server.close(); }); server.bind(41234, 0.0.0.0);每个message回调都会收到两个参数第一个msg是 Buffer也就是对方发来的原始数据第二个rinfo是发送方信息包括address、port、family和size字段。回发时必须用rinfo.port和rinfo.address因为 UDP 没有连接对象你不能像 TCP 那样直接往某个已建立的 socket 上写数据必须每次携带目标地址。这里注意msg的类型是 Buffer不是字符串。如果业务数据是文本记得调用msg.toString()如果是二进制协议数据比如结构体、序列化后的对象就不要做字符串转换直接用 Buffer 操作。3.2 客户端send message 组合拳客户端这边既要发送数据也要监听服务端回包所以它也必须是一个 dgram 套接字哪怕不主动bind固定端口。const dgram require(dgram); const client dgram.createSocket(udp4); const message Buffer.from(你好UDP!); client.send(message, 41234, 127.0.0.1, (err) { if (err) { console.error(发送失败, err); client.close(); } else { console.log(消息已发送等待响应...); } }); client.on(message, (msg, rinfo) { console.log(收到服务端响应: ${msg.toString()} (来自 ${rinfo.address}:${rinfo.port})); client.close(); }); client.on(error, (err) { console.error(客户端错误, err); client.close(); });这个逻辑里有个细节send的回调和message事件是分开的。如果发送失败在send回调里能拿到err并做处理如果发送成功后续服务端回包会通过message事件回到客户端。也就是说一次 UDP 请求-应答流程由“一个发送动作 一个事件监听”组合完成这和 TCP 的“连接建立后流式读写”体验差异很大刚开始容易摸不着头脑。还有一个实际的坑如果服务端没启动或者地址写错了客户端send并不会立刻报错。UDP 是无连接的数据报发出去了系统就认为发送成功至于有没有人接收协议层不关心。所以调试 UDP 程序第一件事永远是先确认服务端已经bind并监听端口再用ss -unlp | grep 41234或者netstat查看端口状态。3.3 跑通了之后再做一步“伪连接”封装基本回显跑通后你会发现每次发送都要传port和address写多了很啰嗦。dgram 也想到了这一点提供了一个connect(port, address[, callback])方法但它不是 TCP 那种真正的连接只是给套接字设置一个“默认发送地址”。之后再用socket.send(msg)发送时就不需要再指定端口和 IP 了。const dgram require(dgram); const client dgram.createSocket(udp4); client.connect(41234, 127.0.0.1, () { client.send(连上了这就是伪连接, (err) { if (err) { console.error(发送失败, err); } }); });connect之后远程地址被记录在套接字内部send会默认发到那里。但这个“连接”只是本地状态不通知服务端也没有握手服务端依然是一视同仁地处理所有到达端口的数据报。这个特性适合客户端长期固定和一个服务端通信的场景可以减少重复参数还能通过监听error事件更快发现目标不可达。不过要记住伪连接不会改变 UDP 的不可靠本质别指望连接之后就有了拥塞控制和重传机制。3.4 中途踩过的字节序和 Buffer 坑说一个我最初写 dgram 程序时翻车的经历。当时想把一个整数统计值随消息发出去直接用了Buffer.from([255, 0])结果接收端解出来的数字完全不对。原因很简单整数在二进制协议里有高字节和低字节的顺序问题也就是大端序和小端序的差别。TCP 也一样有这个问题但 UDP 的开发更底层很多人会直接在 Buffer 上操作数字这个坑就更容易踩中。我的习惯是在应用层协议里统一使用大端序也就是网络字节序。Node.js 的 Buffer 提供了现成方法const buf Buffer.alloc(4); buf.writeUInt32BE(1024, 0);对方收到后用buf.readUInt32BE(0)还原就能拿到正确的 1024。消息中如果有多个字段建议先用Buffer.alloc分配合适的空间再用writeUInt16BE、writeUInt32BE或writeFloatBE逐个写入最后通过socket.send(buf, ...)发出去。整个过程不要依赖隐式的字符串拼接字节序出错的 bug 很难排查现场日志看到的是一堆莫名其妙的数字。4. 广播与组播dgram 实现一对多通信4.1 局域网广播setBroadcast 之后发到 255.255.255.255UDP 最强大的特性之一就是一对多通信。最简单的实现是广播把数据报发送到当前子网的所有设备。IPv4 的广播地址是255.255.255.255发送到这个地址的包路由器不会转发但同一局域网内所有主机的 UDP 端口如果监听在目标端口上都能收到。在 Node.js 里发送广播必须设置setBroadcast(true)否则发送255.255.255.255会直接报错。const dgram require(dgram); const sender dgram.createSocket(udp4); sender.bind(() { sender.setBroadcast(true); const msg Buffer.from(局域网广播有人吗); sender.send(msg, 5000, 255.255.255.255, (err) { if (err) { console.error(广播发送失败, err); } else { console.log(广播已发送); } setTimeout(() sender.close(), 2000); }); });接收端和平时的 UDP 服务端没有区别绑定对应端口监听message事件即可。需要注意广播能到达的范围由子网掩码决定。如果两台机器不在同一个广播域比如中间隔了路由器广播就无法跨网段传播这是广播方式的天然限制。我自己做局域网内服务发现时早期就是用广播实现的。手机 App 发一条广播同网段所有安装了对应服务的电脑都会响应然后把 IP 和端口汇报上来。这个方案实现简单但在较大的网络里广播会产生大量无差别流量影响整个子网。所以在规模稍微大一点的场景我建议用组播代替广播。4.2 组播addMembership setMulticastTTL 的组合组播multicast更像“订阅者模式”。发送方把数据发到一个特定的组播地址比如239.0.0.100只有加入了该组的接收方才能收到。相比广播组播不会打扰无关设备而且可以跨越支持组播的路由器传播。接收方要加入组播组核心是addMembership方法const dgram require(dgram); const receiver dgram.createSocket(udp4); const MULTICAST_ADDR 239.0.0.100; const PORT 6000; receiver.on(message, (msg) { console.log(收到组播消息: ${msg.toString()}); }); receiver.bind(PORT, 0.0.0.0, () { receiver.addMembership(MULTICAST_ADDR); console.log(已加入组播组 ${MULTICAST_ADDR}:${PORT}); });addMembership告诉底层协议栈这个套接字要接收发送到该组播地址的数据报。如果不调用这个方法即使端口对上也收不到组播包。发送方不需要加入组播组只需要把数据发到组播地址const dgram require(dgram); const sender dgram.createSocket(udp4); const MULTICAST_ADDR 239.0.0.100; const PORT 6000; sender.bind(() { sender.setMulticastTTL(128); sender.setMulticastLoopback(true); const msg Buffer.from(这是一条组播消息); sender.send(msg, 0, msg.length, PORT, MULTICAST_ADDR, (err) { if (err) { console.error(组播发送失败, err); } else { console.log(组播消息已发送); } setTimeout(() sender.close(), 2000); }); });这里setMulticastLoopback(true)很关键。如果接收方和发送方在同一台机器上调试只有打开 loopback本机进程才能收到自己发出去的组播包。如果 loopback 是关闭的在远程机器上可能一切正常本地却怎么都收不到非常容易误判为代码问题。组播地址的选取有讲究。224.0.0.0到239.255.255.255都是 IPv4 组播地址段但其中一部分被协议保留不能用在自己的业务里。一般我会用239.0.0.0/8这是本地管理范围可以随便分。不要在224.0.0.x里选那些大多保留给路由协议。4.3 多网卡环境怎么指定出口笔记本、服务器经常有多个网络接口一个 Wi-Fi、一个网线、一个虚拟机的虚拟网卡。这种情况下dgram 的默认行为是把数据从“系统选择的默认网卡”发出去但这不是我们总想要的。如果当前默认网卡是虚拟机网卡广播和组播消息就发不到真实局域网。addMembership支持第二个参数用来指定加入组播时使用的本机网卡 IPreceiver.addMembership(MULTICAST_ADDR, 192.168.1.100);发送端也可以把绑定地址指定到具体网卡sender.bind(0, 192.168.1.100, () { sender.setMulticastInterface(192.168.1.100); });setMulticastInterface用于设置组播数据发送时使用的网络接口这个方法设一次之后会覆盖默认行为。真机调试前先用ip addrLinux或者ipconfigWindows看下网卡 IP确认你要发的数据该走哪个网卡再把它传给 dgram可以少踩很多环境坑。5. 高负载下的 UDP丢包、乱序、粘包问题排查记录5.1 数据报边界为什么 UDP 不用处理粘包接触过 TCP 的人都知道粘包问题TCP 是字节流消息之间没有天然边界应用层要设计分隔符或固定长度去拆包。UDP 则完全相反它是数据报协议每次send调用对应一个完整数据报接收端的message事件也是一次回调对应一个数据报。也就是说发送方调用两次send接收方就会收到两个message事件不会出现两个消息粘在一起。但边界清晰不代表没有新问题。如果发送速度太快、接收缓冲区太小数据报会直接在内核层被丢弃。这种丢弃不是“消息残缺”——单个 UDP 数据报要么完整到达、要么整个丢弃不会出现半截消息。所以 UDP 编程里你不需要拆包但需要面对丢包和乱序。这是把双刃剑省去了粘包处理的复杂度却把可靠性的责任转移到了应用层。5.2 应用层协议怎么设计加序列号和长度字段既然 UDP 不保证可靠生产环境里应用层协议就要自己兜底。我的做法是在每个数据报的最前面加一个固定头部至少包含两个字段消息序号seq用UInt32BE表示连续发送的消息序号递增接收方通过序号发现丢包和乱序。消息长度length用UInt16BE表示标示后面业务数据的总长度。发送端拼包const payload Buffer.from(业务数据); const header Buffer.alloc(6); header.writeUInt32BE(seq, 0); header.writeUInt16BE(payload.length, 4); const packet Buffer.concat([header, payload]); socket.send(packet, PORT, ADDR);接收端拆包socket.on(message, (msg) { if (msg.length 6) { console.warn(数据报长度异常丢弃); return; } const seq msg.readUInt32BE(0); const len msg.readUInt16BE(4); const payload msg.subarray(6, 6 len); console.log(收到消息 #${seq}长度 ${len}); });有了序号接收方可以做乱序重排如果检测到某个序号缺失可以决定是立即请求重传还是直接跳过等待最新数据。业务上还要做去重因为应用层重传后同一个序号可能到达两次。这套机制写起来不复杂却是 UDP 生产化的基础。纯 UDP 裸奔在真实业务里基本撑不过一天。5.3 端口被占用、跨网段收不到、收发不匹配把 dgram 用起来的路上有三类问题比较典型我分开说。第一类是端口被占用。错误码是EADDRINUSE和 TCP 一样。Linux 下用lsof -i :41234查占用进程Windows 用netstat -ano | findstr 41234。Node 服务重启时如果上一个进程没有正常退出端口会处于 TIME_WAIT 或暂未释放状态稍等几秒再启动或者直接在代码里监听error事件做重试策略。第二类是跨网段收不到。局域网内两台机器能互相 ping 通但 UDP 消息就是到不了。这是广播和组播场景最容易遇到的现象原因是路由器默认不转发广播包。跨网段通信要么部署组播路由器支持 IGMP要么干脆改用 TCP 或上层代理。我建议不要纠结直接改单播简单可靠很多 IoT 场景的头疼问题其实都是选型不当造成的。第三类是收发不匹配。客户端和服务端明明都在跑但收不到响应先用本机回环测试127.0.0.1如果回环能通、局域网不同重点查防火墙。很多系统的防火墙会拦截入站 UDP 包需要在系统防火墙里放行对应的 UDP 端口。还有一个隐蔽情况是服务器绑定的是127.0.0.1只能本机访问外部自然收不到响应。bind时用0.0.0.0才可以监听所有网卡。5.4 常见问题速查表现象可能原因排查思路EADDRINUSE启动失败端口已被其他进程占用lsof/netstat查端口占用换端口或等进程退出发送到广播地址报EACCES没有调用setBroadcast(true)在send前设置广播开关同一台机器收不到组播setMulticastLoopback(false)设置为true让本机进程可以回环接收组播跨网段收不到路由器未开启组播转发改用单播或确认网络设备支持 IGMP 并配置发送成功但对方没反应UDP 不保证送达先确认对方端口和 IP再在接收端监听日志收到的数字不对字节序不一致统一使用大端序writeUInt32BE/readUInt32BE高频发送大量丢包接收端的recvBufferSize太小setRecvBufferSize加大缓冲区或降低发送频率客户端临时端口无法被服务端回包客户端未bind固定端口客户端主动bind(0)或固定端口再把地址传给对端5.5 小技巧用 tcpdump 和 Wireshark 观察 UDP 流量调试 UDP 程序光靠 console.log 不够因为很多问题出在“数据报到底有没有被发出 / 有没有真正到达”这一层。我的习惯是先在本机跑 tcpdump 看包sudo tcpdump -i any udp port 41234 -X如果看到包发出去了但接收端没有日志问题在接收端的套接字配置如果包根本没出现问题在发送端的地址、广播开关或网卡绑定。这个思路比盲改代码高效太多。Wireshark 更适合看数据中心负载和协议细节。打开捕获后过滤条件填udp.port 41234就能看到每一条数据报的时间戳、源地址、目标地址和载荷内容。我在分析乱序和数据报分片问题时都是靠 Wireshark 的图形化排序功能一眼就能看出哪个序号早到了、哪个包被切成多个 IP 分片了。有了这些排查手段再用 dgram 做真实项目会稳妥很多。UDP 的坑大多不是 Node.js 特有的而是网络协议本身的特性理解了底层原理再回头看 dgram 的 API 设计就会觉得一切都顺理成章了。