
手写一个高性能TCP服务器这事我前后折腾了快十年。从最早用Java写BIO的线程池版本到后来在C语言里折腾epoll再到参考开源网关做多进程Reactor每一个阶段都觉得这次应该够了结果一上压测工具就被打回原形。如果你也想设计一个能扛住高并发连接的TCP服务器或者你正在纠结为什么我的服务一上线连接数一多就崩那这篇文章基本是你需要的。我会把高性能TCP服务器设计从底层原理到架构演进再到压测调优的完整链路都掰开揉碎讲清楚还会顺带说说那些文档里不会写、只有踩过坑才懂的细节。先说个最反直觉的结论高性能TCP服务器设计和高效的业务逻辑代码关系不大它九成是操作系统IO模型、内存布局、锁粒度和网络协议栈之间的博弈。你写的业务逻辑再快如果事件分发的模型选错了一样被几万个连接轻松击穿。1. 为什么你的服务器连几千个连接就崩IO模型的革命史1.1 阻塞IO带来的一条连接占一个线程假象早期写TCP服务器最自然的思路是来一个连接创建一个线程或者从线程池取一个线程然后在这个线程里阻塞等待客户端发数据。这个思路在并发量不大、单连接交互很多的时候完全没问题代码逻辑也简单清晰——因为阻塞这个词在编程模型里本身就是一种顺着往下写的好处。但问题随之而来。假设每个线程栈默认是8MB你开1万个线程光栈空间就需要80GB内存这还没算线程切换的代价。操作系统每秒钟能支持的线程切换是有上限的大多服务器CPU几十核线程多了之后光调度开销就能占满CPU业务几乎跑不动。所以线程池版的阻塞IO方案在几千连接同时在线时就会开始抖动到一两万连接时基本就废了。我见过不少人拿着一个测试Demo起一个线程池设置几百个线程把并发连接压到3000就一堆超时然后满世界找为什么我的服务器性能这么差。其实不是他的代码写得差是IO模型选错了——阻塞IO的并发能力受限于线程数量而线程数量受限于内存和调度开销整个模型的天花板非常明显。1.2 非阻塞IO与IO多路复用从一个线程管一个连接到一个线程管所有连接非阻塞IO的思路就完全不同了。Socket被设置为非阻塞之后读写操作立刻返回如果暂时没有数据返回的错误码告诉你再等等。但你总不能在一个线程里死循环轮询所有连接吧——一万个连接每个都要问一句你有数据了吗效率低到完全不可用。所以操作系统提供了IO多路复用机制典型的演进路线是select - poll - epoll。select和poll本质上都是把所有fd集合交给内核内核扫描一遍告诉你有哪些fd可读了。但select有最大fd数量限制通常是1024poll没有数量限制但每次调用都要把所有fd从用户态拷贝到内核态连接越多拷贝和扫描的开销越大。这就是你在1万连接时用poll会感觉CPU很忙但流量很低的根本原因。epoll的出现解决了两件事第一fd集合不再每次调用都拷进去而是通过epoll_ctl在内核中维护一棵红黑树只有新增、修改、删除时才操作第二内核通过回调机制主动把就绪的fd放进一个就绪队列用户只需要通过epoll_wait把这个就绪队列取出来。换句话说从我帮你查一下谁就绪了变成了谁就绪了我通知你。这两个改进让单线程也能撑住十万级别连接是高性能TCP服务器设计里最关键的地基。1.3 水平触发与边沿触发LT和ET的真正差异很多人一聊epoll就提ET边沿触发好像不用ET就不配叫高性能。我自己实际用下来的体会是ET确实可以减少系统调用次数但代价是代码复杂度陡然上升——因为ET模式下一次read必须循环读到返回EAGAIN为止否则会漏数据。如果在非阻塞模式下没处理好分包、粘包、缓冲区增长和内存边界很容易出现数据明明来了但读了一半这种极其难排查的问题。水平触发LT模式下只要缓冲区里还有数据epoll_wait就会持续通知你你的read循环可以一次读一部分下次再接着读逻辑上更宽容。绝大多数业务服务器包括像Redis这种以高性能著称的组件默认用的也是LTRedis有ET的开启选项但LT依然是安全和性能均衡下的主流选择。所以如果你不是对系统调用次数极其敏感或者已经明确压测出LT是瓶颈我建议从LT入手先把架构跑稳再考虑是否切换到ET做极致优化。2. 事件循环与协议解析高性能服务器的代码血肉2.1 从epoll_wait到业务回调一个最小可用的事件循环长什么样有了epoll下一步就是搭建事件循环。这里给一个最简模型让你能直观感受高性能服务器的基本骨架// 最小事件循环示意C语言风格伪代码 int epfd epoll_create(1024); struct epoll_event ev, events[MAX_EVENTS]; // 创建监听socket设置非阻塞注册到epoll set_nonblocking(listen_fd); ev.events EPOLLIN; ev.data.fd listen_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, ev); while (1) { int n epoll_wait(epfd, events, MAX_EVENTS, -1); for (int i 0; i n; i) { if (events[i].data.fd listen_fd) { // 有新连接进来 int conn_fd accept(listen_fd, ...); set_nonblocking(conn_fd); // 把conn_fd也注册进epoll } else { // 某个已连接fd可读读数据并处理 handle_read(events[i].data.fd); } } }你看最核心的逻辑就这么简单。但真到了生产环境这个循环里每一步都有无数细节accept的时候要不要一次性把所有等待中的连接全部接收完用accept4批量接受避免惊群和延迟处理读取的时候要不要给每个连接维护独立的应用层读缓冲区和写缓冲区如果一个客户端发来的数据超过一个TCP段怎么处理半包粘包怎么解析——这些全都要在这个事件循环里落地。2.2 协议无状态TCP是字节流而不是消息流新手最容易犯的错是把TCP当成一条消息管道以为一次recv就对应客户端的一次send。错了TCP是字节流它不保证消息边界。客户端可能一次send完一整条消息内核拆成两半分别到达也可能连发几条消息一次recv全给你带回来。所以服务器设计必须要有独立的收包 - 拆包 - 包解析 - 业务处理 - 回包链路。打包和拆包有几种通用做法固定长度头例如4字节表示包体长度特殊的结束标志比如HTTP的\r\n或者固定长度的包体。我自己偏好的是TLV结构先是Tag类型然后是Length长度最后是Value数据。定长头的好处是解析简单可靠不需要逐字节扫描判断是不是结束了。读取这边一定要有动态增长的缓冲区。我见过有人图省事直接给每个连接分配一个固定大小比如64KB的buffer结果遇到大包就傻眼遇到大量小连接又白白浪费内存。合理的做法是给每个连接维护一个动态缓冲初始小、按需增长同时限制单个包的最大长度避免恶意客户端发一个超大长度头把内存炸掉。2.3 写数据的奥义不是send完就万事大吉读数据大家都会有意识地做非阻塞处理但写数据这个方向很多人会忽略。你的服务器如果只是简单地把业务结果塞给send然后不管当发送缓冲区满的时候send会返回一个暂时发不出去的提示。这时候如果你不管数据就丢了如果你阻塞等一个慢客户端就能卡住整个工作线程。高性能服务器的正确姿势是维护每个连接的写缓冲区。send试图立刻把数据发出去发不完的部分暂时存入写缓冲区然后通过epoll_ctl把EPOLLOUT事件注册上去等到socket可写时说明之前满的缓冲区被内核发走了一部分再把写缓冲区里的剩余数据尽量发出去发完了就摘下EPOLLOUT事件。这个机制叫写时机管理是整个事件驱动模型里最容易出错也最能体现水平差异的一块。我见过最快最稳的写法是给每个连接分配发送队列队列里放的是引用计数、引用共享内存块或者单纯的小内存拷贝。无论哪种核心原则都是绝对不要在事件循环里同步等待一个慢客户端否则整个服务器的吞吐会被拉到和它一样慢。3. 从单线程到多线程多进程Reactor模型的演进图谱3.1 单线程Reactor足够好但有天花板先说说最经典的Reactor单线程模型。所有的连接管理、读事件、业务处理、写事件都在一个线程里跑完。这里说的单线程Reactor并不等于单个进程单线程而是说所有IO事件都在同一个事件循环中被处理。它的好处非常明显没有锁没有线程切换上下文开销几乎为零一个线程把CPU打满也是极高吞吐的状态。但是单线程Reactor的短板也很致命如果有任何一步操作慢——不管是业务逻辑里打了日志、查到慢数据库、还是算了一个耗时的加密——整个进程就被卡住了。所有连接都跟着睡大觉。另外现在的服务器都是多核CPU单线程只能用一个核另外七个核空闲看着你干瞪眼。所以单线程模式一般只用于纯代理型、业务极简单的中间件比如早期的某些功能有限IM服务或者网络转发层。3.2 多线程Reactor把业务和IO拆开为了不白嫖多核常见演进是主线程只做事件分发——收到连接、读数据、写数据——业务处理交给独立线程池来做。事件循环里拿到完整请求包后把请求扔进业务队列立刻去处理下一个事件业务线程池处理完把结果写回连接对应的写缓冲区再向主线程发一个帮我触发写事件的信号。这个方案的优点是IO吞吐完全不受业务耗时影响业务逻辑还可以随意扩展线程数。但代价是引入了队列和锁业务请求队列要加锁连接对象在事件循环和业务线程之间共享时要加锁缓冲区状态也要加锁或者用无锁化设计。一旦锁竞争这个点没有处理好可能比单线程模型性能还差——因为锁等待本身就是在浪费CPU。多线程Reactor拆分后还有一个值得注意的地方同一个连接上的所有事件尽量保证按序处理。比如客户端连续发送了请求A和请求B如果A在业务线程池中被某个线程处理、B被另一个线程处理它们的完成顺序可能颠倒回包顺序就乱了。很多协议尤其是简单RPC协议都假定服务端回包严格保序。所以一般做法是按连接ID哈希到固定业务线程让同一个连接的所有请求都进同一条队列由同一个线程处理。虽然牺牲了一点负载均衡的精细度但保住顺序就是保住协议正确性。3.3 多进程Reactornginx风格的高可靠方案再进一步就是多进程模型。典型代表就是Nginx一个master进程负责管理监控多个worker进程各自调用epoll共同监听同一个accept端口。master不参与业务事件循环只负责拉起进程、发送信号、检查健康。多进程的核心问题在于连接分发。早年经典做法是多个worker都阻塞在同一个listen socket的accept上——内核会把新连接随机分给其中一个进程叫惊群thundering herd问题当有新连接到来时所有睡眠在accept上的进程都被唤醒但最终只有一个能成功accept其余白醒一趟浪费CPU开销。现代内核已经提供SO_REUSEPORT选项允许多个进程各自创建一个同端口的listen socket由内核在多个socket之间做负载均衡。这等于把连接分发直接下沉到了内核协议栈。开SO_REUSEPORT之后每个worker进程的监听队列是独立的内核按hash把连接均衡分配给各进程性能和稳定性都提升一截。而且还有一个附带好处某个worker进程崩溃后它的监听队列不会影响到其他worker整体可用性非常高。这个方案我在自己负责的某网关项目中实测过16核机器上开16个worker连接分发非常均匀完全不需要在应用层做任何额外调度。4. 压测中的真相连接数上去了吞吐为什么崩了4.1 一次完整的性能压测该关注哪些数据说完了架构来聊聊验证。我通常用专门的压测工具从客户端发连接关注四类指标一是最大并发连接数二是每秒新建连接数连接建立速率三是每秒请求处理数QPS四是延迟分布特别是P99甚至P999。很多人的误区是只看QPS认为QPS高就是性能好。实际上对于长连接型业务每秒新建连接数往往才是更考验服务器的每一条新连接都意味着一次accept、一次内存分配、一次内核协议栈处理、一次epoll事件注册。一个纯转发型的TCP服务如果老是慢吞吞地accept或者根本没把accept循环调到最快连接建立的吞吐成为瓶颈后客户端排队等待的时间就会传遍所有业务。压测工具本身的性能也容易成为瓶颈。一台客户端机器如果端口、CPU不够压出来的结果不准确。最好用多台压测客户端或者使用支持高并发模型的压测工具目标是从客户端侧排除瓶颈干扰专测服务器。4.2 真实压测里的三大瓶颈内存、锁、日志我印象很深的一次调优经历某个模拟项目X用多线程Reactor模型代码看着没毛病压测到几万连接时QPS上不去了CPU还非常忙。排查时用性能分析工具直接抓热点发现大量的CPU时间消耗在三个地方第一个是内存分配。每一条消息、每一个连接状态、每一块缓冲区复用不够导致new/delete频繁触发内存分配器和回收器成了热点甚至内存碎片把性能拖垮。优化方法是内存池化连接对象、读缓冲、写缓冲、请求上下文全部从池子里取。第二个是锁等待。请求队列和连接表都用了“保护性”的全局锁实际上并发冲突极重。把全局锁改成细粒度锁或者用分区加锁按连接ID分到不同桶再或者用无锁队列在单生产者单消费者的场景下以后瓶颈立刻消失。第三个是日志。业务线程里打了海量调试日志日志库内部本身就是全局互斥锁——所有线程都在抢同一把日志锁把自己的业务线程全堵住了。优化成异步日志、批量缓冲、保持低级别日志之后QPS直接翻倍。这三件事基本就是连接数上去了吞吐反而崩了的经典原因。4.3 基础参数调优清单那些 sysctl 背后的原因分析除了架构和代码内核网络参数也会直接影响性能。这里列一份我每次部署都会过一遍的清单以及为什么这么调参数推荐值典型调优目的net.core.somaxconn至少1024或者更大提高accept队列长度防止高并发下新连接被内核丢弃net.ipv4.tcp_max_syn_backlog8192或更大SYN半连接队列扩容保证突发连接不丢SYNnet.ipv4.ip_local_port_range大范围如1024-65535客户端侧可用的端口范围对高并发连接场景尤为关键net.ipv4.tcp_tw_reuse1客户端侧TIME_WAIT重用一般服务器接受新连接场景不受影响net.ipv4.tcp_fin_timeout30左右缩短TIME_WAIT在客户端侧滞留时间net.core.wmem_max/rmem_max适当调大允许更大TCP缓冲区提升大包吞吐但也要防缓冲过大拖慢内存这些参数单独拎出来看都很简单但最容易被忽视的是整体联动。比如你只调大了somaxconn却没有调大tcp_max_syn_backlog高并发时半连接队列先满了新连接同样进不来。每次我都建议在服务器上线前做一次参数基线测试默认内核参数压一次、优化参数再压一次用数据验收优化效果而不是凭感觉以为改了就一定好。5. 上线前必须处理的那堆烂事那些坑我替你都踩过了5.1 半关闭状态你的fd泄漏比想象中严重TCP连接关闭是一个四步握手的过程。有些客户端不按规矩来只发FIN关闭了写方向还留着读方向等待接收数据。这种情况下服务器端如果感知到对端关闭把连接相关资源清理了那很可能会误判。很多服务器在收到EOF后立刻回收连接对象、关闭fd但如果服务端还有数据没发完直接就丢了。更常见的问题是客户端异常断开比如网线拔了服务器不会立刻感知。你需要在事件循环里加心跳机制定期探测对端是否还活着。如果一段时期没有收到客户端数据主动发送探测包连续几个没回应就关闭连接并回收资源。否则这些半死不活的连接会一直占着fd和内存时间长了你的百万连接实际上是十万僵尸连接加九十万真连接。5.2 惊群问题你以为只有一个进程被唤醒实际它把所有CPU都吵醒了前面说了多进程accept惊群。这个问题的现代解法是用SO_REUSEPORT做内核级负载均衡。但如果你用的还是老式共享listen socket方式那就要小心了。其实除了accept惊群epoll_wait也有类似的边缘唤醒问题——多个worker同时阻塞在同一个epoll fd上时一个IO事件会唤醒所有worker。Linux后续内核加入EPOLLEXCLUSIVE事件标志可以做到当一个事件触发时只唤醒一个等待的worker进程。如果你在维护老项目、没法用SO_REUSEPORT的场合记得给listen fd注册这个事件标志同时不要让太多worker同时阻塞在同一个epoll实例上。5.3 慢客户端攻击一个饥饿的读循环如何拖垮所有人ET模式下的另一个经典坑就是慢客户端。设想某个客户端连上服务器之后一直发送数据但每次只发一丁点比如一个字节。你的主线程每次epoll_wait醒来都要为这个客户端做一次read、解析、业务处理。如果这个连接的请求很小业务逻辑却挺重比如每次都要查一次数据库那么一个恶意客户端就能频繁触发事件把主线程的时间片全部吃光其他正常连接全部饥饿。对策通常是设置限流和配额单连接的事件处理次数要有上限单连接未处理完的请求数要有水位超了就把该连接移出epoll或者放入一个慢速队列不让它污染主事件循环。另一个对策是不要把业务处理放在读事件的同一个线程中事件循环只负责把请求塞进队列就马上返回这样就彻底隔离了某个慢请求对事件循环的影响。5.4 优雅退出进程重启不丢连接的艺术上线时的平滑重启是生产环境最容易翻车的一环。如果服务进程被直接kill所有存量连接瞬间断开对客户端来说就是一场事故。优雅退出要分几步先停止accept新连接再给存量连接一个宽限期比如30秒让处理完正在进行的请求然后再关闭事件循环、清理资源。如果使用多进程模型可以通过master进程给worker发信号worker收到信号后先把listen socket从epoll中摘掉然后继续服务存量连接直到队列清空。如果追求更无缝的升级还有一种更高级的玩法通过socket继承或SO_REUSEPORT让新老进程短暂并存新连接全部落到新进程老进程只处理存量连接直到全部为空再退出。这套方案做得好能做到升级期间用户几乎无感知。但实现细节非常多比如共享监听队列后不能让新老进程互相抢连接否则可能新老协议版本处理方法不一致导致错误。5.5 高水位线与背压防止内存被写缓冲区撑爆最后说一个很容易被忽略的机制背压Backpressure。当一个慢客户端导致写缓冲区不断堆积时如果你的策略是写不进去就缓存缓存越来越多那么内存会无限膨胀。必须在每个连接的写缓冲区设高水位线超过阈值后对连接执行要么停止读取它的新请求不再注册EPOLLIN要么直接关闭连接并明确告诉上层该客户端太慢已断开。这里要遵循的原则是不要因为一个慢客户端让自己的服务器内存变成一颗炸弹宁可主动断掉一个慢客户端也不要让整个服务被拖垮。我线上的策略是同时限制连接上的未读请求数、写缓冲占用字节数和发往业务队列的积压量三个维度都设水位任何一个超限就触发降级措施。初期会被客户方吐槽你们怎么老断我连接但和整机内存被耗尽所有连接全挂相比这个代价非常值得。6. 两个真实场景的架构选型不比较不知道差距6.1 长连接网关多进程 领域转发 无状态业务我在做一个长连接网关模拟项目X时需求是一条TCP长连接上承载各种业务指令客户端数量在十万量级每条连接保持在线但不是时刻都在收发数据。这种场景下我选了多进程Reactor每进程里事件循环只做连接管理和协议透传具体业务由后端服务通过内部接口消化。在这种架构下一个worker进程挂掉只影响它当前管理的那些连接master检测后重新拉起worker存量连接自动重新连接整体稳定性很高。由于长连接居多实质上是大量空闲连接 少量活跃连接事件循环大部分时间都在epoll_wait上睡大觉CPU占用极低。十万连接在线CPU空转率控制在个位数百分比这是单机多线程Reactor很难达到的轻巧与稳定。关键在于每条连接的开销要压得非常低连接对象尽量紧凑空闲周期内不要保留大块buffer能延迟初始化的资源一律延迟到有请求时再分配这样可以大幅提升单机容量。6.2 短连接高并发RPC多线程Reactor 连接池组合拳另一类常见场景是短连接高并发RPC。客户端频繁发起一次请求然后断开服务器需要尽量快地接受新连接、处理请求、响应并关闭。这种场景的特点是连接建立速率和销毁速率很高CPU多花在接受和关闭流程上业务本身反而简单。这个场景我用过多线程Reactor模型主线程只用epoll处理accept和读写请求进入线程池快速执行执行完立即触发写事件回包然后主动关闭连接。关键在于把accept和close的代价压到最低。Linux下可以用accept4一次性取回多个连接批量注册进epollclose的时候要避免主动进入TIME_WAIT的延迟对服务端来说一般不影响新连接但要确认没有KeepAlive连接的误用问题。实测中这种模型扛住每秒上万次新建连接的速率没有什么压力如果再配合SO_REUSEPORT做多进程多实例单机承载能力还能继续线性增加。对比这两个场景就能看出高性能TCP服务器设计没有银弹。长连接场景最怕内存浪费和僵尸连接堆积短连接场景最怕accept效率和关闭流程拖后腿。选型时要先问自己你的连接是长是短业务是重是轻连接保持活跃的比例高不高这三个问题的答案直接决定你该选哪种架构。7. 一些可以立刻抄作业的操作细节这部分算是我多年实践下来可以立刻上手用的小抄。它们不大不小但常常能帮你避免上线后的眼中钉。把accept循环写满每轮事件循环只要listen端口有事件就循环accept直到返回EAGAIN这样能减少因为一次只accept一个连接导致的延迟。合理设置非阻塞监听socket、已连接socket、管道读写全部非阻塞贯穿始终。越早统一这个约定越不容易在某个角落忽视觉醒机制。连接对象从池子里拿预先分配一批连接结构体使用空闲链表组织避免每次新建连接都malloc每断开都free。避免在事件循环里做DNS解析、磁盘IO、数据库查询等阻塞操作这些一律异步化或者交给业务线程池否则一个慢DNS就能让所有连接卡住。抓包工具和性能分析工具是调试的两个利器遇到客户端说没收到数据服务器说发出去了这种问题先抓包看TCP层到底发生了什么——到底是内核没发出去还是发出去了在网络中被丢还是对端收到了但应用层没读到抓包一看便知。使用空闲内存指标监控进程内存C/C服务最容易踩内存地狱没有内存池和对象复用连接数一上去内存一定失控。这需要用压测环境复现用性能分析定位别等线上告警再说。还有一个老生常谈但你一定会再犯的提醒代码里不要随便用全局锁包住整段网络读写。大锁、大临界区、昂贵日志这三个是高性能TCP服务的头号杀手压测时一抓一个准。做了这么多年服务器设计我越来越觉得高性能TCP服务器的核心不在于某个神奇的API或哪一版内核而在于对数据流动全链路有没有彻底的理解从应用层缓冲区到内核发送队列从事件唤醒到锁等待从连接生命周期到背压策略每个环节松一分整体瓶颈就低一截。很多人天天追问为什么我的服务只能扛五千连接答案从来不在某一行代码里而在你如何审视整条链路。希望这篇既讲原理也讲踩坑的文章能让你在下次设计和调优TCP服务器时少走几步弯路。