ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

223端口UDP服务端实战:协议设计、收包循环与可靠性补丁

223端口UDP服务端实战:协议设计、收包循环与可靠性补丁

223UDP服务端,简单说就是一个监听223端口、用UDP协议对外收发数据的服务端程序。它没有三次握手,客户端丢一个包过来,服务端收包、校验、解析、处理,再决定回不回包,整个交互在毫秒级完成。这类服务端最适合海量小报文、允许偶尔丢包、对延迟敏感的场景,比如终端心跳上报、设备状态采集、实时位置推送、轻量日志汇聚。如果你是后端开发、嵌入式网络方向,或者刚接触Socket编程想找一个能落地的练手项目,这篇内容可以直接拿去参考。我会把端口选择的坑、报文协议怎么定、收包循环怎么写、可靠性怎么补、压测怎么压、出问题怎么查,按我实际做下来的顺序讲一遍。

1. 端口223加UDP,这个组合要先算清楚账

1.1 端口223落在特权区间,这是第一个要迈的坎

很多人看到223第一反应是“随便挑的一个端口”,但Linux的端口划分里,0到1023属于特权端口,普通用户身份的进程直接bind会返回permission denied。我第一次在测试机上用普通账号启动服务端,日志里就一行bind: permission denied,排查了半天才反应过来是端口号的问题。这个坑不复杂,但足够让刚上手的人卡住半小时。

解决办法有三条路,按推荐程度排:第一条是用systemd托管,在unit文件里加AmbientCapabilities=CAP_NET_BIND_SERVICE,让非root的服务账号也能绑定低端口,配置大概是这样:

[Service] User=udp223 Group=udp223 AmbientCapabilities=CAP_NET_BIND_SERVICE CapabilityBoundingSet=CAP_NET_BIND_SERVICE ExecStart=/opt/udp223/server Restart=always

第二条是给二进制文件单独授权,用setcap 'cap_net_bind_service=+ep' /opt/udp223/server,然后getcap确认一下。这里有个很容易忽略的细节:每次重新编译、覆盖二进制之后,文件能力会被清掉,服务重启就会失败。我在CI里就吃过这个亏,后来把setcap写进了部署脚本的固定步骤。第三条是容器场景,docker run -p 223:223/udp只是做了端口映射,容器内进程如果是非root用户,依然需要文件能力或者以root运行,别以为映射了就万事大吉。

部署前先确认端口没被占用,ss -lunp | grep ':223 '看一眼,UDP和TCP是两套独立的端口空间,TCP占了223不影响UDP,但两个UDP服务抢同一个端口就会有一个起不来。防火墙也要按UDP放行,firewall-cmd --add-port=223/udp --permanent或者ufw allow 223/udp,云上的安全组同样要单独加UDP规则,默认往往只开了TCP。

1.2 UDP和TCP的真实取舍:别为了“可靠”把延迟搭进去

选UDP不是图新鲜,是业务形态决定的。我手上这个服务端最早是给一批小型终端做状态采集的,终端每5秒发一次几十字节的状态包,丢一两个包完全不影响业务,下一轮就补上了。这种场景如果用TCP,每个终端都要维护连接、走握手、处理重传和拥塞控制,服务端要扛的连接数上去了,内存和文件描述符都是成本。UDP没有连接状态,服务端收到包就处理,处理完就忘,天然适合这种“无状态小报文”的模型。

把两者的差异摊开看更清楚:

维度UDPTCP
连接建立无,直接发包三次握手,至少1个RTT
头部开销8字节20字节起,带选项更多
消息边界保留,一个包一个边界字节流,要自己拆包
丢包处理应用层决定内核重传
队头阻塞不存在存在
服务端状态可做到无状态每连接一份状态
适用场景高频小包、可容忍丢包文件、指令、强顺序

延迟上的差别在小包场景特别明显。TCP握手加慢启动,第一个有效数据包往往要等一到两个RTT;UDP第一个包发出去就到了。我之前做过一个粗略对比,同一台内网机器上,UDP从发包到服务端应用层收到,P99在0.3毫秒左右;TCP算上连接建立,P99接近1.5毫秒。这个差距在单次请求里不算什么,但放到每秒几万包的量级上,服务端的处理节奏完全不一样。

还有一个常被忽略的点是消息边界。TCP是字节流,客户端发两次,服务端可能一次读到,也可能分三次读到,必须自己定长度字段或者分隔符。UDP一个数据报就是一个完整消息,收包缓冲区里拿到的n就是这条消息的长度,解析逻辑简单很多。当然前提是包不能太大,超过MTU会触发IP分片,这是后话。

1.3 什么情况下不该用UDP:三种我劝退过的需求

UDP不是万能药,有三类需求我会直接建议换方案。第一类是传文件或者传固件,几百KB到几MB的数据,用UDP就得自己实现分片、确认、重传、流控,等于把TCP重写一遍,还未必有人家写得好。第二类是要求严格有序的指令流,比如一条配置分三条下发,第二条不能先到,这种用UDP要么加序号缓存重排,要么就老老实实走TCP。第三类是跨公网、经过多级NAT的复杂链路,UDP映射的存活时间不固定,有些网络设备对UDP很不友好,连通率和稳定性都不如TCP。

我的判断标准很朴素:这个包丢了,业务能不能自己恢复?能,UDP;不能,TCP。状态上报、心跳、指标采集属于前者;支付通知、配置下发、文件同步属于后者。把这个标准先定下来,后面协议设计才有方向。

2. 报文协议怎么定:让无连接也能对上话

2.1 固定26字节包头,字段一个都不能省

UDP本身不提供任何语义,协议全靠自己定。我采用的是固定长度包头加可变载荷的结构,包头26字节,字段如下:

字段长度说明
magic2字节固定值0x2230,用于快速过滤杂包
version1字节协议版本,便于灰度升级
type1字节消息类型:1心跳、2数据、3确认、4重传请求
flags1字节保留标志位,比如是否压缩、是否加密
reserved1字节对齐用,先占位
seq4字节发送序号,单调递增
ack4字节累计确认号
timestamp8字节发送端毫秒时间戳,用于RTT计算
payloadLen2字节载荷长度,不含包头
crc162字节包头加载荷的校验值

所有多字节字段统一用网络字节序大端,抓包的时候一眼能看懂。magic放在最前面是有意的,收包线程拿到数据先看前两个字节,不匹配直接丢,连后面的解析都省了,这在被扫描或者误发包打到的时候能挡掉大量无效计算。version字段看着多余,真到协议升级的时候就知道值钱了,新旧版本可以并行一段时间,服务端按version分流处理,不用一刀切。

校验用CRC16而不是CRC32,是权衡过的:26字节包头加1000字节载荷,CRC16的碰撞概率在可接受范围,计算量还小。如果业务对完整性要求高,换成CRC32或者加HMAC都行,包头里预留的flags可以标识校验算法。timestamp用发送端的毫秒时间戳,服务端收到后可以算单向延迟,但要注意两端时钟不一定同步,我一般只用来做RTT估算,不拿它做业务判断。

2.2 序号、确认和去重窗口,三件套缺一不可

UDP会丢包、会乱序、会重复,这是物理事实,不是配置问题。序号解决乱序识别,确认解决丢包发现,去重窗口解决重复处理。seq用uint32,按每秒1000包算,2的32次方大概49.7天回绕一次,回绕比较不能用简单的>,要用有符号差值:

func seqAfter(a, b uint32) bool { return int32(a-b) > 0 }

这个写法在回绕点附近依然正确,是处理序列号的通用做法。去重窗口我用的是1024位的位图,128字节,每个会话一份。收到一个包,先算seq % 1024落在位图的哪一位,再判断这个序号是否在当前窗口内、对应位是否已经置位。窗口左边界随着确认号推进,推进时把移出窗口的位清掉。窗口大小1024意味着最多能容忍1023个包的乱序,实际网络里几十个包的乱序都少见,这个容量绰绰有余。

确认机制我做得简单:每收到一个数据包,回一个type=3的确认包,ack字段填当前已连续收到的最大序号。不做累积确认的延迟回复,因为小包场景下延迟回复带来的收益有限,反而增加复杂度。确认包本身也可能丢,所以发送端不能只靠确认判断,还要有超时重传兜底。这套组合下来,实测在1%丢包的网络里,业务层感知到的完整率能到99.9%以上。

2.3 载荷边界和MTU,别让一个包变成两个包

以太网MTU是1500字节,减去IP头20字节、UDP头8字节,理论上单个UDP载荷最多1472字节。但这只是理论值,实际链路里可能有PPPoE(减8字节)、VLAN标签(减4字节)、各种隧道封装(减得更多)。一旦UDP包超过路径MTU,IP层会分片,接收端要等所有分片到齐才能重组,任何一个分片丢失,整个包就废了。

这个账很好算:假设单个分片的丢失率是1%,一个包被分成两片,端到端成功率是0.99乘以0.99,约98.01%,等效丢包率从1%涨到接近2%;分成三片就是0.99的三次方,约97.03%,丢包率接近3%。分片数量越多,放大越明显。所以我把单包载荷上限压在1200字节,包头26字节,整包不超过1226字节,给各种封装留足余量。超过1200字节的业务数据,在应用层自己切块,每块带独立的seq,丢一块只重传那一块,不影响其他块。

收包缓冲区要开得比最大包大,我一般给64KB,虽然实际包不会超过1226字节,但缓冲区小了遇到异常大包会截断,ReadFromUDP返回的n会等于缓冲区长度,这时候要靠payloadLen字段判断是否截断。这个细节在排查“为什么偶尔解析出错”的时候很有用。

3. 服务端骨架:收包循环和并发模型

3.1 单收包线程加工作池,收包线程绝对不能阻塞

UDP服务端的性能瓶颈往往不在业务逻辑,在收包环节。内核的UDP接收缓冲区是有限的,收包线程如果因为业务处理慢而卡住,缓冲区很快填满,后续的包直接被内核丢掉,而且这种丢包在应用层完全无感,只能从系统计数器里看到。我最初图省事,在收包循环里直接做业务处理,压测到每秒两万包的时候丢了将近三成,查了半天才发现是收包线程被数据库写入拖住了。

后来改成收包线程只做三件事:读包、校验magic和长度、把包丢进带缓冲的channel,剩下的解析、会话查找、业务处理全部交给工作池。channel容量给1024,满了就丢弃并计数,不阻塞收包线程。这个设计的关键认知是:UDP本来就不保证送达,应用层主动丢弃比内核悄悄丢弃更可控,至少你能在监控里看到丢了多少、为什么丢。

工作池大小按CPU核数配,业务里有IO等待可以适当放大,但别无限放大,goroutine多了调度开销也上来了。会话表用sync.Map或者分片锁的map,key用ip:port。这里有个坑:NAT环境下客户端的源端口可能变化,如果严格按ip:port找会话,端口一变就找不到,会误判成新客户端。我的做法是会话主键用ip,端口只作为最近一次通信地址记录,回包时用记录的地址,同时允许同一IP下有多个端口的活动会话,用二级map管理。

3.2 Go版本的核心代码

下面这段是收包主循环的骨架,去掉了业务细节,保留关键结构:

package main import ( "encoding/binary" "log" "net" "sync/atomic" ) const ( listenPort = 223 headerSize = 26 maxDatagram = 1200 magic = 0x2230 ) type packet struct { data []byte addr *net.UDPAddr } func main() { addr := &net.UDPAddr{IP: net.IPv4zero, Port: listenPort} conn, err := net.ListenUDP("udp", addr) if err != nil { log.Fatalf("listen udp %d: %v", listenPort, err) } defer conn.Close() if err := conn.SetReadBuffer(4 * 1024 * 1024); err != nil { log.Printf("set read buffer: %v", err) } jobs := make(chan packet, 1024) var dropped uint64 var received uint64 for i := 0; i < 8; i++ { go func() { for p := range jobs { handlePacket(p) } }() } buf := make([]byte, 64*1024) for { n, remote, err := conn.ReadFromUDP(buf) if err != nil { continue } if n < headerSize || n > maxDatagram { continue } if binary.BigEndian.Uint16(buf[0:2]) != magic { continue } pkt := packet{data: make([]byte, n), addr: remote} copy(pkt.data, buf[:n]) select { case jobs <- pkt: atomic.AddUint64(&received, 1) default: atomic.AddUint64(&dropped, 1) } } } func handlePacket(p packet) { // 校验、解析、会话查找、业务处理 }

这里有两个细节值得说。一是make([]byte, n)加copy,不能直接把buf切片传出去,因为下一轮读会覆盖同一块内存,这是新手最容易犯的错,表现是偶尔解析出乱七八糟的数据。二是select加default的非阻塞投递,channel满了立刻走default,保证收包循环永远不被业务拖住。

3.3 Python asyncio的等价写法

如果团队技术栈是Python,asyncio的DatagramProtocol也能做到类似效果,代码量更小:

import asyncio import struct MAGIC = 0x2230 HEADER_SIZE = 26 class ServerProtocol(asyncio.DatagramProtocol): def connection_made(self, transport): self.transport = transport def datagram_received(self, data, addr): if len(data) < HEADER_SIZE or len(data) > 1200: return magic, = struct.unpack("!H", data[0:2]) if magic != MAGIC: return # 交给队列或线程池处理 asyncio.create_task(self.handle(data, addr)) async def handle(self, data, addr): pass async def main(): loop = asyncio.get_running_loop() transport, _ = await loop.create_datagram_endpoint( ServerProtocol, local_addr=("0.0.0.0", 223) ) try: await asyncio.sleep(3600 * 24) finally: transport.close() asyncio.run(main())

Python版本要注意GIL的影响,datagram_received里不要做重CPU计算,要么用run_in_executor丢到线程池,要么把解析逻辑尽量做轻。asyncio的单线程事件循环在每秒几千包的量级上没问题,上万包就要考虑多进程加SO_REUSEPORT了。

3.4 优雅退出和资源回收

服务端总要有重启的时候,直接kill会导致正在处理的数据丢失、会话状态来不及落盘。我在信号处理里做三件事:关闭UDP socket让收包循环退出、关闭jobs channel让工作池处理完存量、给一个10秒的超时兜底。顺序不能反,先关channel再关socket的话,收包线程还在往里写,直接panic。Go里用signal.Notify监听SIGTERM和SIGINT,收到后执行conn.Close(),ReadFromUDP会返回错误,循环里判断net.ErrClosed就退出,然后close(jobs),工作池自然结束。

4. 可靠性补丁:在UDP上补出够用的可靠

4.1 超时重传和RTO估算

确认包丢了怎么办?发送端等不到确认就重传。重传的核心是超时时间怎么定,定太短会大量误重传,定太长业务延迟下不来。我用的是TCP那套经典算法,维护平滑往返时间SRTT和往返时间偏差RTTVAR:

第一次测量:SRTT = RTT,RTTVAR = RTT / 2 后续测量:RTTVAR = 0.75 * RTTVAR + 0.25 * |SRTT - RTT| SRTT = 0.875 * SRTT + 0.125 * RTT RTO = SRTT + 4 * RTTVAR

初值给1秒,下限200毫秒,上限3秒。每次重传后RTO翻倍,最多重传5次,还不行就标记会话异常,交给业务层决定是断开还是继续尝试。这里要注意用单调时钟计算RTT,别用系统墙上时间,NTP校时跳一下能把RTO算到天上去。重传队列用map存seq到包和发送时间的映射,一个定时器每100毫秒扫一遍,发现超时的就重发,扫完把已确认的清掉。队列要有上限,比如每个会话最多1000个待确认包,超了直接丢弃最老的并计数,防止内存被异常客户端拖爆。

4.2 乱序、重复和过期包的处理

乱序包到了先不急着交给业务,放进重排缓冲,按seq排好,等前面的包补齐再一起往上送。重排缓冲的容量和去重窗口对齐,也是1024个包。如果前面的包迟迟不到,不能无限等,设一个200毫秒的等待上限,超时就把现有的按序提交,缺的标记为丢失,让业务层知道这里有空洞。重复包靠位图挡掉,位图里已经置位的seq直接丢弃,不回复也不处理,避免重复扣款、重复计数这类问题。

过期包是另一类麻烦。客户端网络卡了,几十秒前的包突然涌进来,这些包的业务价值已经没了。我在包头里带了timestamp,服务端收到后和本地时间比对,偏差超过30秒的直接丢弃并计数。这个阈值不能太小,两端时钟本来就有偏差,30秒是个比较宽松的线,既能挡住明显过期的包,又不会误伤正常抖动。

4.3 心跳和NAT保活

UDP没有连接,服务端怎么知道客户端还在?靠心跳。客户端每15秒发一个type=1的心跳包,服务端收到后更新会话的最后活跃时间,45秒没收到就判定离线,清理会话并通知业务层。15秒这个间隔是权衡过的:太短浪费带宽和CPU,太长NAT映射可能已经过期。常见的NAT映射存活时间在30秒到120秒之间,15秒的心跳能稳定把映射续上,同时给三次容错,丢一两个心跳不会误判。

心跳包要做得足够小,包头26字节就够,不带载荷,服务端回复也只用包头。这里有个细节:心跳回复不能比请求大,否则容易被拿去做反射放大,后面防护那节会细说。会话清理用惰性加定时结合,定时器每分钟扫一遍会话表,把超时的摘出来,同时限制单IP的最大会话数,防止伪造源地址把会话表撑爆。

5. 压测和调优:数据比感觉可靠

5.1 内核缓冲区,先把这个参数调对

UDP丢包第一大嫌疑就是接收缓冲区不够。Linux的net.core.rmem_default默认值在不少发行版上是212992字节,按每个包1200字节算,只能缓冲170多个包,稍微来个突发就满了。应用层调SetReadBuffer是一方面,系统上限net.core.rmem_max也得放开,不然调不上去:

sysctl -w net.core.rmem_max=16777216 sysctl -w net.core.rmem_default=4194304 sysctl -w net.core.netdev_max_backlog=5000

改完用sysctl -p生效,然后服务端启动时调SetReadBuffer(4 * 1024 * 1024),实际生效值可以用ss -lunm看,Recv-Q那列的缓冲区大小对不对得上。这几个参数不是越大越好,缓冲区大了突发时延会增加,而且占内存,4MB到8MB对小包服务端够用了。

判断有没有因为缓冲区满丢包,看netstat -su里的receive buffer errors,或者/proc/net/snmp里Udp行的InErrors。这个数持续增长,基本就是收包跟不上,要么加缓冲区,要么加收包线程,要么查业务是不是阻塞了收包循环。

5.2 压测怎么做才有参考价值

压测工具我用两种。快速验证用iperf3的UDP模式,iperf3 -c <服务端IP> -p 223 -u -b 50M -t 60 -l 200,200字节的包,50Mbps带宽,跑60秒,它会给出发送量、接收量和丢包率。这个只能测网络路径和内核收包能力,测不到业务逻辑,但用来确认“包到底有没有到机器”很好用。

真正有参考价值的是自己写发包器,用和服务端一样的协议格式,模拟真实客户端行为。发包器要能控制速率、包大小分布、并发源IP数(单机可以用多个源端口模拟),并且统计发送数和收到回复数。我一般跑四组:固定速率找拐点、阶梯加压看丢包曲线、长时间跑稳定性、异常包比例测试(比如10%的包故意校验错)。阶梯加压那组最有意思,从每秒1万包开始,每30秒加5000,记录每个档位的丢包率和P99处理延迟,拐点通常出现在收包线程CPU打满或者channel开始大量丢弃的地方。

压测时服务端和客户端不要在同一台机器上,否则CPU互相抢,数据没法看。看CPU要分清楚是用户态还是软中断,top里si这一列高说明网络收包软中断是瓶颈,可以考虑开RPS或者多队列。单线程收包在每秒十万包以内一般够用,再往上就要考虑SO_REUSEPORT多开几个socket并行收。

5.3 我常看的几个指标

指标采集方式关注点
收包速率应用计数器和发送端对比,差值就是丢失
校验失败数应用计数器突增说明有异常流量或版本不匹配
channel丢弃数应用计数器大于0说明工作池扛不住
会话数会话表长度异常增长可能是伪造源地址
处理延迟P99埋点统计超过50毫秒要查业务逻辑
内核缓冲区错误netstat -su持续增长说明收包跟不上
重传次数应用计数器比例高说明链路质量差

这些指标我全部打到Prometheus,配上告警:channel丢弃持续5分钟大于0、缓冲区错误一分钟增长超过100、会话数一小时增长超过50%,都会触发。指标名字统一加前缀,比如udp223_received_total,后面查起来方便。

6. 常见问题和排查实录

6.1 排查速查表

现象优先排查常见原因
启动报permission denied端口权限223是特权端口,没加capability
启动报address already in use端口占用已有UDP服务占用,ss -lunp确认
客户端发了服务端没收到链路和防火墙安全组只开了TCP、本机防火墙拦UDP
服务端收到但不回包回包路径多网卡源地址选错、路由不对称
客户端收不到回复NAT和会话源端口变化导致会话找不到
大包偶发失败MTU超过路径MTU触发分片,丢一片全丢
高负载大量丢包缓冲区和工作池rmem太小、channel满、业务阻塞
容器内正常宿主机不通端口映射映射写成了tcp,漏了/udp

6.2 几个印象深的坑

第一个是回包源地址选错。服务端有多块网卡,绑定的是0.0.0.0,收包没问题,回包时内核按路由表选源地址,结果从另一块网卡出去了,客户端那边看着就是“发了没回”。抓包能看到包确实发出去了,但源IP不对,对端直接丢。解决办法是用IP_PKTINFO拿到收包时的目的地址,回包时显式指定源地址,Go里可以用golang.org/x/net/ipv4的PacketConn来做。这个问题在单网卡机器上永远复现不了,一上生产多网卡就暴露。

第二个是NAT端口变化导致会话丢失。客户端在NAT后面,映射端口隔一段时间变一次,服务端如果严格按ip:port找会话,端口一变就当成新客户端,之前的重传队列、去重窗口全丢了,业务表现是“偶发卡顿几秒”。后来改成按IP找主会话,端口只记录最新值,问题消失。代价是同一IP下多个客户端会共用会话,需要业务层用客户端ID再区分一层。

第三个是时间跳变。有台机器没配好时间同步,NTP隔几小时跳一次,RTO计算用了墙上时间,跳变后算出来的RTO要么是负数要么是几个小时,重传逻辑直接乱掉。改成time.Since用单调时钟之后彻底解决。这个坑的教训是:凡是算时间间隔的地方,一律用单调时钟,墙上时间只用来打日志。

第四个是容器端口映射。docker run -p 223:223默认映射的是TCP,UDP必须写-p 223:223/udp,两个都要就写两遍。这个错误很低级,但我在两个项目里都见过,表现是容器里服务正常,外面死活连不上,排查方向很容易跑偏到应用层。

7. 部署防护:UDP服务端暴露出去之前要想清楚

7.1 防反射放大,响应绝不能比请求大

UDP最容易被利用的地方是源地址伪造。攻击者伪造受害者的IP向服务端发包,服务端如果回一个更大的包给“源地址”,就形成了放大攻击。防护原则很明确:响应体积不超过请求体积,最好还小一点。心跳包请求26字节,回复也控制在26字节;数据包的确认回复不带载荷,只有包头。对于那些必须返回较大数据的业务,先做源地址验证,客户端先发一个带随机nonce的探测包,服务端回一个HMAC,客户端带着这个HMAC再来请求,验证通过才给大响应。这套cookie机制能挡住绝大多数伪造。

限速也必须有,按源IP做令牌桶,每秒200包、突发500包,超了就丢。全局再设一个总速率上限,防止单点被打满。公网暴露能免则免,这类服务端我的默认建议是放在内网,通过网关或者专线接入,安全组只放行已知网段。真要对公网开放,至少加上协议层的token校验,token定期轮换,别用固定密钥。

7.2 监控和日志的边界

日志里不要打完整载荷,尤其是业务数据里可能带设备标识、位置信息这类内容。我一般只记包头字段:类型、序号、来源IP、长度、校验结果,载荷只在调试开关打开时按比例采样打印,采样率默认千分之一。错误日志要带足够的上下文,比如“校验失败”要能看出来是magic不对、长度不对还是CRC不对,这三种原因对应的排查方向完全不同。

监控除了应用指标,系统层也要看。ss -s看socket统计,ethtool -S <网卡>看网卡层的drop和fifo错误,/proc/net/snmp看UDP层的InErrors和RcvbufErrors。应用层指标和系统层指标对不上,往往说明问题出在内核或者驱动层,比如网卡队列满了、软中断分不到CPU。这种时候光看应用日志是查不出来的。

7.3 上线顺序和回滚预案

新服务端上线我一般分三步。第一步灰度一个网段,服务端双端口并行,223新版本、224旧版本,客户端按配置切换,观察24小时的成功率和延迟。第二步扩大到一半流量,重点看会话数、重传率、channel丢弃数有没有异常。第三步全量,旧端口保留一周再下线。回滚预案要提前写好:客户端配置能一键切回旧端口,服务端旧版本二进制留着,数据库或者状态存储的格式变更要做向前兼容,别新版本写进去旧版本读不出来。

发布窗口避开业务高峰,UDP服务端重启会丢一批在途的包,虽然业务能恢复,但监控上会出现一个明显的成功率凹陷,挑个低峰期能少解释很多次。

我在实际使用中的体会是,UDP服务端的复杂度不在收发包本身,在“丢了怎么办”和“出问题怎么看见”这两件事上。协议设计时多花半天把序号、确认、去重想清楚,比上线后半夜起来查丢包划算得多;监控指标在上线前就埋好,比出事后再加埋点快得多。还有一个很小的技巧:本地开发时用tcpdump -i any udp port 223 -nn -vv -X抓包,把十六进制打出来对着协议表看,比在代码里加一堆打印直观,新协议调试阶段我基本全程开着这个命令。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进