Linux下基于IO多路复用的大规模可靠UDP服务器设计与实现
1. 为什么要在Linux上折腾可靠UDP服务器做网络编程有些年头的人多半都经历过这样一个阶段TCP用得好好的突然某个需求场景把你逼到了UDP这边。比如实时对战游戏、音视频传输、金融行情推送这些场景对延迟的敏感程度远超对可靠性的要求TCP的重传机制、拥塞控制、队头阻塞反而成了拖累。但UDP本身又不保证可靠丢包、乱序、重复都得自己处理。于是问题就变成了怎么在Linux上用IO多路复用做一个能扛住大规模并发、同时又能保证可靠性的UDP服务器。这个标题里的关键词拆开来看信息量其实很大。Linux系统意味着我们要跟epoll打交道而不是select或者pollIO多路复用是核心手段决定了整个服务器的并发模型大规模意味着单机要处理成千上万的并发连接不能一个连接一个线程可靠UDP说明要在应用层实现ACK、重传、序号、超时这些机制服务器则意味着整体架构要偏向服务端设计考虑资源管理、连接生命周期、性能瓶颈。我写这个系列的第一篇不打算一上来就贴几千行代码。更想先把设计思路、技术选型、核心难点讲清楚让后面动手写的时候每一步都知道自己在干什么。这篇文章适合已经写过基本socket编程、了解TCP/UDP区别、但没做过大规模可靠UDP服务器的朋友。如果你正在做游戏后端、实时通信、或者任何需要自定义传输协议的项目这里的内容应该能直接拿来参考。2. 整体架构设计与技术选型思路2.1 为什么是IO多路复用而不是多线程先说说最根本的问题为什么大规模UDP服务器要用IO多路复用而不是一个连接开一个线程假设你要支撑一万个并发客户端。如果用线程模型每个线程默认栈大小8MB一万个线程就是80GB虚拟内存光是线程切换的开销就能把CPU吃满。而且UDP是无连接的内核根本不知道哪些包属于哪个“连接”你得自己在应用层维护会话状态。线程模型下每个线程阻塞在recvfrom上内核收到包之后唤醒哪个线程这本身就是个问题。IO多路复用的思路完全不同。整个服务器只有一个或少数几个线程通过epoll同时监听所有活跃的socket。UDP服务器通常只需要一个监听socket所有客户端的数据都从这个socket进来。epoll告诉你“有数据可读了”你就recvfrom把数据取出来根据来源地址找到对应的会话处理完再sendto回去。整个过程没有线程切换没有锁竞争CPU缓存友好。注意UDP的IO多路复用和TCP有个本质区别。TCP里每个连接对应一个fdepoll能精确告诉你哪个连接有数据。UDP只有一个fdepoll只能告诉你“这个fd可读”但具体是哪个客户端的数据得你自己recvfrom之后从地址里解析。这意味着UDP服务器的会话管理完全在应用层内核帮不了你。2.2 epoll还是select/pollLinux下IO多路复用有三个选择select、poll、epoll。做大规模服务器epoll是唯一合理的选择。select的fd_set有1024的上限而且每次调用都要把整个集合从用户态拷贝到内核态返回后还要遍历所有fd找哪个就绪了时间复杂度O(n)。poll用链表解决了数量限制但拷贝和遍历的问题还在。epoll则用红黑树管理fd就绪事件放在一个双向链表里epoll_wait直接返回就绪的fd时间复杂度O(1)。对于一万个并发这个量级select和poll的差距是数量级的。epoll还有两种触发模式水平触发LT和边缘触发ET。LT模式下只要fd可读每次epoll_wait都会返回它ET模式下只有状态变化时才通知一次。对于UDP服务器我建议用LT模式。原因很简单UDP是数据报每个recvfrom取一个包如果一次没取完比如缓冲区里有多个包LT会继续通知你ET则要求你必须循环取到EAGAIN为止。LT写起来更不容易出错性能差距在UDP场景下也不明显。2.3 可靠UDP到底要可靠到什么程度“可靠UDP”这个词其实很模糊。TCP的可靠包括不丢、不重、不乱序、有流量控制、有拥塞控制。但实际项目里你往往不需要全部。比如实时游戏丢一两个旧的位置更新包完全可以接受因为下一个包马上就到了重传反而增加延迟。但关键指令比如释放技能丢了就必须重传。所以可靠UDP的设计第一步是分级哪些数据需要可靠传输哪些允许丢失。我一般把数据分成三类可靠有序必须到达且顺序不能乱。比如登录认证、交易指令。这类数据走类似TCP的机制序号、ACK、超时重传、接收窗口。可靠无序必须到达但顺序无所谓。比如资源加载完成通知。只需要ACK和重传不需要排序。不可靠允许丢失追求最低延迟。比如实时位置同步。直接发不重传。这个分类决定了后面协议头的设计、发送队列的管理、接收缓冲区的组织。一开始就想清楚比后面改要省事得多。2.4 单线程Reactor还是多线程Reactor虽然前面说了IO多路复用避免多线程但完全不使用多线程也不现实。现代服务器都是多核的单线程只能用一个核。常见的做法是Reactor模式一个主线程负责epoll_wait和网络IO工作线程池负责业务逻辑处理。但UDP有个特点收包和发包都在同一个fd上如果业务处理耗时会阻塞后续收包。所以更实际的做法是网络线程专门负责recvfrom和sendto以及协议解析、ACK生成这些轻量操作。工作线程池负责业务逻辑处理完把响应包交给网络线程发送。线程之间用无锁队列通信避免锁竞争。这个架构在单机上支撑几万并发没问题。3. 核心细节解析与实操要点3.1 UDP socket的缓冲区设置这是最容易被忽视但影响巨大的一个点。Linux下UDP socket有两个缓冲区接收缓冲区和发送缓冲区。默认值通常很小比如208KB在高并发下会大量丢包。查看和设置方法# 查看系统默认和最大值 sysctl net.core.rmem_default sysctl net.core.rmem_max sysctl net.core.wmem_default sysctl net.core.wmem_max// 在代码中设置 int rcvbuf 4 * 1024 * 1024; // 4MB int sndbuf 4 * 1024 * 1024; setsockopt(fd, SOL_SOCKET, SO_RCVBUF, rcvbuf, sizeof(rcvbuf)); setsockopt(fd, SOL_SOCKET, SO_SNDBUF, sndbuf, sizeof(sndbuf));提示SO_RCVBUF设置的值内核实际会翻倍因为要预留元数据空间。另外如果设置的值超过rmem_max会被截断到最大值。生产环境建议把rmem_max调到16MB以上。缓冲区多大合适有个粗略的计算假设你每秒处理10万个包每个包平均200字节那么每秒数据量是20MB。如果网络线程每10ms轮询一次那么两次轮询之间最多积累200KB数据。考虑到突发流量缓冲区至少要是这个值的10倍也就是2MB起步。实际我一般设4MB到8MB。3.2 会话管理的数据结构选择UDP服务器需要根据客户端地址找到对应的会话。这个查找操作在每个包到达时都要执行性能至关重要。最直接的想法是用哈希表key是客户端地址IP端口。但这里有个坑如果直接用std::unordered_map每次查找都要算哈希、比较而且并发环境下还要加锁。我的做法是用开放寻址的哈希表自己实现或者用一些高性能库。关键优化点哈希函数要快。对于IPv4地址直接把32位IP和16位端口拼成一个64位整数用乘法哈希。冲突处理用线性探测缓存友好。每个会话结构体尽量小把热数据最近活跃时间、序号、状态放在前面冷数据统计信息放后面。会话数量大时还要考虑淘汰策略。长时间不活跃的会话要清理否则内存会爆。我一般用时间轮或者简单的LRU链表每秒钟扫描一次把超时的会话回收。3.3 协议头设计可靠UDP的协议头是整个系统的骨架。设计得好后面实现顺风顺水设计得差处处别扭。我的协议头一般包含这些字段字段长度说明魔数2字节固定值用于快速过滤非法包版本1字节协议版本便于升级类型1字节数据/ACK/心跳/握手会话ID4字节服务端分配的会话标识序号4字节发送序号用于去重和排序ACK序号4字节确认收到的序号时间戳4字节用于RTT计算载荷长度2字节后面数据的长度校验和2字节头部校验总共24字节。这个大小在大多数场景下可以接受。如果对带宽极度敏感可以压缩到12字节左右但会牺牲一些功能。序号的设计有个细节用循环序号还是单调递增循环序号比如模65536节省空间但处理回绕比较麻烦。单调递增用4字节可以撑很久实现简单。我倾向用单调递增4字节在每秒10万包的速率下能用11个小时才回绕实际会话不会持续那么久。3.4 重传超时RTO的计算TCP的RTO计算用了Jacobson/Karels算法考虑平滑RTT和RTT方差。可靠UDP也可以借鉴但要根据场景调整。基本公式SRTT (1 - α) * SRTT α * RTT RTTVAR (1 - β) * RTTVAR β * |SRTT - RTT| RTO SRTT 4 * RTTVARα通常取1/8β取1/4。初始RTO一般设1秒第一次测量后更新。但在实时场景下RTO不能太大否则重传没意义。我一般设一个上限比如200ms。超过这个时间还没ACK要么是网络真的差要么是对端挂了重传也是浪费。还有个优化快速重传。如果收到三个重复ACK不等超时就重传。这个在TCP里很有效UDP也可以借鉴但要注意UDP的ACK是累积的还是选择性的。我一般用选择性ACKSACK每个ACK携带一个接收位图这样发送方知道具体哪些包丢了。4. 实操过程与核心环节实现4.1 环境准备与基础框架搭建先确认系统环境uname -a # Linux xxx 5.15.0-xx-generic #xx-Ubuntu SMP ...内核版本建议4.0以上epoll的很多优化都是新内核才有的。基础框架我一般分成几个模块net_epoll封装epoll的创建、添加、删除、等待。udp_socket封装socket的创建、绑定、缓冲区设置。session_mgr会话管理包括查找、创建、销毁、超时清理。protocol协议头的序列化和反序列化。reliable可靠传输逻辑包括发送队列、重传、ACK处理。先写一个最简单的echo服务器把epoll跑通int fd socket(AF_INET, SOCK_DGRAM, 0); struct sockaddr_in addr; addr.sin_family AF_INET; addr.sin_port htons(8888); addr.sin_addr.s_addr INADDR_ANY; bind(fd, (struct sockaddr*)addr, sizeof(addr)); int epfd epoll_create1(0); struct epoll_event ev; ev.events EPOLLIN; ev.data.fd fd; epoll_ctl(epfd, EPOLL_CTL_ADD, fd, ev); struct epoll_event events[1024]; while (1) { int n epoll_wait(epfd, events, 1024, -1); for (int i 0; i n; i) { char buf[65536]; struct sockaddr_in client; socklen_t len sizeof(client); int r recvfrom(fd, buf, sizeof(buf), 0, (struct sockaddr*)client, len); if (r 0) { sendto(fd, buf, r, 0, (struct sockaddr*)client, len); } } }这个框架跑通之后再往上加可靠逻辑。4.2 会话创建与握手流程UDP没有连接的概念所以需要一个应用层的握手来建立会话。我的握手流程是这样的客户端发送HELLO包携带一个随机生成的客户端ID。服务端收到后检查是否已有该客户端的会话。如果没有创建一个新会话分配会话ID返回HELLO_ACK。客户端收到HELLO_ACK记录会话ID握手完成。后续所有包都携带会话ID服务端直接根据ID查找会话不再需要地址匹配。这个设计的好处是会话ID是4字节整数比IP端口6字节小而且查找更快。但有个问题如果客户端换了网络比如从WiFi切到4GIP变了会话ID还在服务端怎么处理我的做法是会话里同时记录ID和地址。如果收到包的地址和会话记录的地址不一致但ID匹配就更新地址前提是包通过了校验。这样支持客户端漫游。注意握手包本身是不可靠的可能丢失。所以客户端要重发HELLO直到收到HELLO_ACK。服务端对重复的HELLO要幂等处理不能重复创建会话。4.3 发送队列与重传机制可靠数据的发送不是直接sendto就完事而是要放进发送队列等ACK回来才删除。发送队列的结构struct send_packet { uint32_t seq; uint64_t send_time; // 发送时间微秒 uint32_t retrans_count; uint16_t len; uint8_t data[MAX_PACKET_SIZE]; struct send_packet* next; };每个会话维护一个发送队列按序号排序。发送时把包放进队列记录发送时间然后sendto。收到ACK后从队列里删除对应的包。重传定时器怎么实现有两种方式每个包一个定时器用timerfd或者时间轮。实现复杂但精度高。定期扫描每10ms扫描一次所有会话的发送队列检查哪些包超时了。实现简单但精度受扫描间隔限制。我一般用第二种因为10ms的精度对大多数场景足够了。扫描的时候只检查队列头部的包因为序号最小的包最可能超时如果头部包没超时后面的肯定也没超时。void check_retransmit(session_t* s, uint64_t now) { while (s-send_queue now - s-send_queue-send_time s-rto) { if (s-send_queue-retrans_count MAX_RETRANS) { // 重传次数过多认为连接断开 destroy_session(s); return; } sendto(s-fd, s-send_queue-data, s-send_queue-len, 0, (struct sockaddr*)s-addr, s-addr_len); s-send_queue-send_time now; s-send_queue-retrans_count; // 不删除等ACK break; // 只重传头部避免一次重传太多 } }4.4 接收端的去重与排序接收端要处理乱序和重复。每个会话维护一个接收窗口struct recv_window { uint32_t base; // 窗口起始序号 uint32_t bitmap[64]; // 位图标记哪些序号已收到 uint8_t buffer[WINDOW_SIZE][MAX_PACKET_SIZE]; uint16_t len[WINDOW_SIZE]; };收到包时如果序号小于base说明是旧包直接丢弃但可以发ACK。如果序号在窗口内检查位图。如果已收到丢弃否则存入buffer设置位图。如果序号等于base且位图连续向上滑动窗口把连续的数据交给上层。ACK的生成每次收到包都发送一个ACK携带当前base和位图。这样发送方知道哪些包到了哪些没到。提示ACK本身也可能丢失。如果ACK丢了发送方会重传数据接收方收到重复数据后再发一次ACK。所以ACK不需要可靠传输丢了就丢了下次数据包会触发新的ACK。4.5 性能测试与调优写完基本功能后一定要做压力测试。我一般用自己写的测试客户端模拟大量并发连接每个连接以固定速率发包。测试指标吞吐量每秒处理多少包多少字节。延迟P50、P99、P999延迟。丢包率应用层统计的丢包和重传次数。CPU和内存占用。调优的几个方向减少系统调用用recvmmsg和sendmmsg一次处理多个包。Linux 3.0以上支持。CPU亲和性把网络线程绑定到固定核心减少缓存失效。内存池预分配会话和包缓冲区避免频繁malloc/free。批量处理epoll_wait返回多个事件时批量处理减少循环开销。// recvmmsg示例 struct mmsghdr msgs[32]; struct iovec iovecs[32]; char bufs[32][2048]; for (int i 0; i 32; i) { iovecs[i].iov_base bufs[i]; iovecs[i].iov_len sizeof(bufs[i]); msgs[i].msg_hdr.msg_iov iovecs[i]; msgs[i].msg_hdr.msg_iovlen 1; } int n recvmmsg(fd, msgs, 32, MSG_DONTWAIT, NULL);这个调用一次能收32个包系统调用开销摊薄到每个包上就很小了。5. 常见问题与排查技巧实录5.1 收包丢包严重怎么排查这是最常见的问题。现象是客户端发了包服务端没收到或者收到了但处理不过来。排查步骤先看系统统计netstat -su看packet receive errors和receive buffer errors。如果这两个数字在涨说明缓冲区不够。看网卡统计ethtool -S eth0看rx_dropped和rx_fifo_errors。如果涨说明网卡缓冲区不够要调rx_ring大小。看CPUtop -H看网络线程是不是跑满了。如果跑满说明处理能力到瓶颈了要优化代码或者加线程。看应用层统计在recvfrom前后打点统计实际收到的包数和期望的包数。我遇到过一个案例缓冲区设了8MB但还是丢包。最后发现是net.core.netdev_max_backlog太小默认1000高并发下网卡软中断队列满了。调到10000后问题解决。5.2 重传风暴怎么避免如果网络突然抖动大量包超时同时重传会把带宽打满反而加剧拥塞。避免方法限制重传速率每个会话每秒最多重传N个包。指数退避每次重传后RTO翻倍直到上限。拥塞控制借鉴TCP的慢启动和拥塞避免维护一个拥塞窗口控制发送速率。我一般用简单的令牌桶每个会话每秒有100个重传令牌重传消耗令牌令牌用完就等下一秒。5.3 会话泄漏怎么发现会话泄漏是指客户端已经断开但服务端还保留着会话内存慢慢涨。发现方法定期打印会话数量观察是否持续增长。每个会话记录最后活跃时间超过阈值比如60秒就清理。用valgrind或者heaptrack检查内存泄漏。清理策略要小心不能太激进否则客户端网络抖动一下就被踢了。我一般设两个阈值30秒不活跃标记为“可疑”60秒不活跃才真正删除。删除前可以发一个心跳探测没响应再删。5.4 常见问题速查表问题可能原因解决方法收包丢包缓冲区太小调大SO_RCVBUF和rmem_max收包丢包CPU瓶颈优化代码用recvmmsg加线程重传过多RTO太小调大初始RTO用平滑RTT重传过多网络真的差限制重传速率指数退避会话泄漏清理不及时加超时清理定期扫描内存增长发送队列不释放检查ACK处理确保删除已确认包延迟高处理逻辑阻塞网络线程和业务线程分离吞吐低系统调用太多用recvmmsg/sendmmsg批量处理5.5 一些踩过的坑坑一epoll的LT模式在UDP下可能重复通知。如果一次recvfrom没把缓冲区读空epoll会一直通知。所以要么循环读到EAGAIN要么确保每次只处理一个包。我一般循环读但设一个上限比如一次最多读100个包避免饿死其他fd。坑二sendto可能返回EAGAIN。如果发送缓冲区满了sendto会失败。这时候不能丢包要把包放回队列等缓冲区可写再发。但UDP的epoll不支持EPOLLOUT因为UDP没有真正的发送阻塞所以只能定期重试。坑三多线程下会话的并发访问。网络线程和工作线程都可能访问会话必须加锁。但锁的粒度要小我一般每个会话一把自旋锁只保护关键字段。坑四时间戳精度。用gettimeofday精度是微秒但开销大。用clock_gettime(CLOCK_MONOTONIC)更好单调递增不受系统时间调整影响。这个系列的第一篇到这里就差不多了。整体架构、核心细节、实操要点、常见问题都过了一遍。下一篇我会具体讲协议头的序列化、发送队列的实现、以及怎么用C把这些模块组织起来。如果你在实现过程中遇到什么问题或者有更好的方案欢迎交流。