从需求层面讲,“epoll+reactor实现百万并发”是很多后端工程师跨不过去的一道坎。它不是一个简单的demo,而是对系统、内核、内存、调度和业务设计的综合大考。这篇文章会把我在设计这类高并发接入层时积累的完整思路和踩坑记录写出来,从epoll的底层行为到reactor线程模型,再到压测前的内核参数和内存规划,全部按实操顺序展开,适合想挑战百万长连接、高性能网关或IM服务器的同学参考。
1. 百万并发到底意味着什么:先从需求和误区说起
1.1 百万连接和百万QPS是两个维度
很多第一次接触这个目标的人,会把“百万并发”理解成“每秒处理百万请求”,这是典型的误区。百万并发连接指的是同时保持建立状态的连接数量,也就是TCP连接数达到百万级别,而每秒钟实际产生的请求量可能并不高。举个具体例子:一个物联网平台有100万台设备在线,每5秒上报一次心跳,每秒实际吞吐只有20万心跳包,但服务器必须时刻维持100万个socket的连接状态。你的epoll关注集合里会挂上100万个fd,但这100万个fd并不都是活跃的。
真正考验系统的,是“少量活跃事件分散在巨量空闲连接中”时,如何仍然保持低延迟和低资源占用。如果按QPS来设计,你会在单核CPU上死磕事件处理效率;但按百万连接来设计,你首先要解决的是内存布局、fd管理、定时器扫描和锁竞争这些问题。这两个目标需要分开对待,否则后面所有架构选型都会走偏。
1.2 为什么传统IO模型扛不住
传统多线程阻塞式模型里,一个线程负责一个连接,线程数跟着连接数走。到一万个连接就得开一万个线程,光线程栈空间(默认8MB虚拟内存)和上下文切换开销就能把服务器压垮。线程多了,CPU时间全耗在切换上,业务逻辑反而得不到执行。这时的系统瓶颈不在网卡,不在CPU,而在调度器和操作系统对线程资源的管理上限。
select和poll模型在fd数量上来之后也会失效。select有FD_SETSIZE的固定上限,通常1024,你得重新编译内核才能改大;poll没有数量限制,但每次调用都要把完整fd列表从用户态拷贝到内核态,然后再从内核态拷贝回来。百万fd意味着每次poll要拷贝百万个结构体,哪怕只有几个事件发生,整体开销也是O(n)。这种O(n)扫描机制天然不适合海量连接场景。
epoll把复杂度降到了O(1)级别,因为它只关心“有事件发生的fd”,不在每次调用时全量扫描。它靠的是内核维护的一个事件就绪链表,用户态通过mmap和内核共享部分状态,加上epoll_wait直接返回就绪事件集合。这套机制才是百万连接真正可行的地基。但地基只是第一步,你的用户态程序怎么组织事件处理,是决定你能否在百万fd下依然流畅的另一个关键。
2. 前置基础:epoll的核心机制与选型理由
2.1 从select/poll到epoll,差在哪里
epoll的三个关键API是epoll_create、epoll_ctl和epoll_wait。epoll_create在内核里创建一个epoll实例,这个实例内部维护两个重要结构:一个红黑树用于存放你注册的所有fd及其事件类型,一个就绪链表用于存放触发了事件的fd。epoll_ctl负责增删改红黑树中的节点,epoll_wait只返回已就绪的事件,不需要你逐个检查。
这套设计带来的直接收益:注册一百万fd,红黑树查找和插入是O(log n),比poll的全量拷贝O(n)高了一个量级;等待事件时,内核直接遍历就绪链表返回,没用的事件根本不会出现在用户态。更妙的是就绪链表里每个节点在fd触发事件时会被挂进去,epoll_wait只是把这些节点摘出来返回,事件拿到后你再通过回调去处理业务。
select和poll都是“无状态”的,每次调用都要重新向内核传递完整的监视列表,而epoll利用内核中的红黑树“记住”了这批fd,之后每次wait都只传一个超时时间。理解这个差异,你就能明白为什么百万fd下只有epoll(或类似的事件通知机制)能活下来。
2.2 水平触发与边缘触发:选错会踩坑
epoll支持两种触发模式,这是新手最容易踩的第一个大坑。水平触发模式下,只要fd的缓冲区里还有数据可读,或者写缓冲区还有空间可写,epoll_wait就会一直上报该fd,哪怕你上次没处理完,下次依然会再通知一次。边缘触发模式下,内核只在状态发生“变化”的那一次通知你,比如缓冲区从空变成有数据,只会通知一次,如果你没把数据读完,那后续再也收不到通知,数据就会被卡死在缓冲区里。
我见过不少项目直接用默认的水平触发,代码简单不容易丢数据,但活跃fd频繁上报会造成大量重复系统调用。边缘触发要求你必须一次性把数据读完,通常搭配非阻塞IO和循环read,直到返回EAGAIN。优点是事件通知次数少,处理效率高,但编程复杂度明显上升。
以我个人的实践建议:没有十足把握不要一上来就上边缘触发。先用水平触发把业务逻辑跑通,再通过压测对比两种模式下的CPU占用和事件唤醒频率。对于百万连接这类场景,我最终选择了边缘触发加主动read到EAGAIN的方式,前提是read循环里有严格的单次读取上限,避免饿死其他连接的处理。
2.3 epoll的常用API与工作流程
写一个最简事件循环,核心代码并不长:
int epfd = epoll_create(1); struct epoll_event ev; ev.events = EPOLLIN; ev.data.fd = listen_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev); struct epoll_event events[1024]; while (1) { int n = epoll_wait(epfd, events, 1024, -1); for (int i = 0; i < n; i++) { handle_event(&events[i]); } }就这么几行,背后已经涵盖了epoll的主要流程。这里有两个容易忽略的细节:epoll_wait的maxevents参数,也就是数组大小,决定了单次最多返回多少个就绪事件,压测时如果这个值太小,内核里堆积的就绪事件要多次wait才能取完,会放大延迟;另一个是timeout为-1表示无限阻塞,在纯事件驱动模型里很常见,但如果你还需要处理定时任务,最好把timeout设置成最近一个定时器的到期时间。
有人问过我把fd注册进epoll之后,是不是就不需要管这个fd了。答案不是,你得负责fd的关闭和从epoll中摘除。如果直接close(fd),内核会自动把它从epoll实例中移除,但更安全的做法是在连接关闭前先EPOLL_CTL_DEL,再close,避免因为fd被复用而出现脏事件。高并发的fd复用速度很快,脏事件会导致连接串号,这个问题在压测高连接数时特别容易爆出来。
3. Reactor模式:如何组织海量连接的事件循环
3.1 单线程Reactor的经典骨架
Reactor模式的核心思想是把“等待事件”和“处理事件”解耦。一个事件循环线程负责集中调用epoll_wait,拿到就绪事件后根据事件类型分发(dispatch)给对应的处理器函数。业务处理逻辑不关心自己是哪个连接的fd,只关心事件来了怎么处理。
最经典的骨架是:
- 主循环调用epoll_wait阻塞等待就绪事件;
- 对每个就绪fd,根据EPOLLIN、EPOLLOUT等不同事件调用独立的处理函数;
- 处理完当前事件后回到主循环继续等待下一批事件。
单线程Reactor的精髓在于“所有连接的事件处理都在一个线程内顺序完成”,这意味着天然没有锁竞争、没有上下文切换开销、不需要考虑并发安全。Redis就是单线程事件循环的典型代表,它在几万连接时表现极其稳定。
但单线程的局限也很明显:如果你的业务处理包含磁盘IO、数据库访问或者复杂计算,单线程处理这些耗时操作时,epoll_wait就会停在那里,其他连接全都得不到响应。所以在百万连接场景里,单线程Reactor只适合做纯协议转发、简单的读写缓存和数据透传,一旦涉及复杂业务就必须引入线程池或改成多Reactor模型。
3.2 从单线程到多线程:主从Reactor与工作线程池
我推荐主从Reactor加工作线程池的组合,这也是Netty、libuv等主流框架采用的结构。主Reactor只负责处理新连接的建立,也就是监听listen_fd的EPOLLIN事件;新连接accept之后,把客户端fd注册到某一个从Reactor的epoll实例上。从Reactor可以有多个,每个跑一个线程,它们之间通过一定策略(比如取模或轮询)分配连接。
这样做的好处是“连接建立”和“IO事件处理”互不干扰,即使某个从Reactor的事件循环因为业务阻塞变慢,新的连接依然能被主Reactor快速accept,不会出现“连接建立不了了”的雪崩。业务处理如果比较重,从Reactor读到完整请求后,可以封装成任务丢给独立的工作线程池去执行,执行完毕再通过一个线程安全的队列把结果送回原Reactor线程,由Reactor继续write响应。
这个模型完美解决了两个问题:一是把阻塞操作从epoll线程里剥离出去,保证事件循环的响应速度;二是充分利用多核CPU,让多个从Reactor并行处理不同fd上的IO事件。很多号称百万并发的网关卡,本质就是这种主从Reactor的变体,只不过加了更深度的连接管理。
3.3 为什么Reactor适合epoll
Reactor和epoll是天然的一对,因为epoll本身就是事件通知机制,而Reactor就是基于事件分发的事件处理模型。你的epoll_wait拿到的是一个事件列表,Reactor告诉你怎么把这些事件对应到连接上下文、怎么调用用户注册的handler。两者结合的架构,连接状态机非常清晰:每个连接都有初始化态、读态、写态、关闭态,每个状态对应不同的事件处理器。
另外Reactor模式和epoll的EPOLLONESHOT配合起来也很好用。EPOLLONESHOT会保证一个fd上的事件在未重新注册前只触发一次,这样可以防止多线程同时处理同一个fd导致数据竞态。在多Reactor模型里,连接和线程的绑定关系比较稳定,基本用不到EPOLLONESHOT,但如果你采用“epoll_wait后把fd抛给线程池”的方式,就必须用EPOLLONESHOT把事件处理权绑到某个工作线程上。
4. 百万并发服务器架构设计:关键模块拆解
4.1 连接管理:连接槽位、fd复用与哈希索引
百万连接,光是每个连接的状态管理就够写一本小书。你不能再为每个连接动态创建一套复杂数据结构,必须预先规划好内存池和索引。一个标准的做法是创建固定大小的连接数组,每个连接对象独占一个槽位,槽位索引就是连接ID,fd作为数组下标或哈希键来定位。
fd和连接槽位要能互相快速查找。大多数服务器习惯把fd直接当作temporary key存入哈希表,但fd在频繁开关连接时会被内核复用,如果哈希表里残留脏项,新连接可能拿到上一个连接的残留状态。我的做法是用数组下标直接对应fd,但这个数组会非常大,fd能到百万级别,数组按最大fd预分配内存太浪费。
实际用的是一种两级索引:第一级是一个“fd到槽位”的哈希映射,只存最近活跃的fd;第二级是连接对象池本身,连接对象用高并发内存池管理。当连接建立时从连接池取出一个空闲对象,将fd和对象地址写入哈希表;连接关闭后从哈希表删除并归还对象。关键点在于fd的复用周期很短,哈希表里删旧建新的操作一定要保证原子性,多线程环境要加锁或用无锁哈希表,这一步是百万连接性能的分水岭。
4.2 读写缓冲区与内存池:避免频繁malloc
每个连接都有收包缓冲区和发送缓冲区。如果每个连接每次收发时都直接malloc、free,百万连接下内存分配器的锁竞争和碎片化会直接拖垮系统。Linux的malloc在分配和释放大量小内存时有一定的优化,但面对每秒几万次申请释放,依然会出现性能毛刺。
我的做法是给每个连接预先分配一块固定大小的读缓冲区和可伸缩的写缓冲区。读缓冲区大小根据业务最大包长设置,比如4KB或16KB,不够时用链式缓冲区扩展。写缓冲区用内存池管理,内存池按大小分级,类似mcentralcache的设计,小内存块从池里取,用完退池,不直接还给操作系统。这块内存来自预先mmap的大块区域,通过自由链表切分和回收,分配时间稳定在纳秒级。
内存池的另一个优势是连接对象本身也能放在池中,避免连接反复创建销毁带来的构造析构开销。在百万连接场景下,连接对象可能达到几十MB甚至上百MB,频繁分配和释放会带来严重的堆碎片。实际压测中发现,使用内存池后,内存碎片率从百分之十几降到了百分之二以内,长时间的连接颠簸不再导致RSS内存持续增长。
4.3 定时器与超时管理:百万定时器怎么高效
连接超时检测在百万连接场景里是最大的隐形杀手。如果每个连接一个定时事件,而且用的是普通的最小堆或链表,一秒内检测百万定时器会耗费大量CPU。有人说用时间轮,确实时间轮是海量定时器的标准答案,也是我最终采用的方案。
简单解释时间轮:它是一个环形数组,每个槽代表某个粒度的时间片,比如1毫秒。定时器根据到期时间哈希到对应的槽位,插入操作是O(1)。指针每毫秒往前走一格,检查该槽上的定时器链表是否有到期项。相比最小堆的O(log n)插入和O(log n)删除,时间轮在定时器事件频繁增删的场景下优势明显。时间也可以分成多级,比如第一级精确到毫秒,第二级精确到秒,能覆盖几分钟甚至几小时的超时需求,同时减少内存占用。
在连接建立和关闭都非常频繁的系统中,连接超时定时器会大量创建和取消。用最小堆会频繁siftup和siftdown,CPU消耗明显;时间轮的添加和取消都是链表操作。另外,不要试图把所有超时都精确到毫秒,绝大多数业务场景的读超时、写超时、保活间隔都有容忍范围,稍微粗糙一点的粒度能节省大量开销。
4.4 线程模型与锁优化:减少竞争
多线程就离不开锁,但锁的粒度决定了你的性能上限。一个常见的败笔是给全局连接管理表加个大锁,每次事件处理都要抢锁,线程一多反而比单线程还慢。好的线程模型应该让每个线程尽可能独立工作,连接数据只属于某个Reactor线程,不需要全局锁。
在实际代码里,我会这样划分:每个从Reactor线程拥有自己的一组连接对象,这些连接上的读写操作只由该线程处理,所以读缓冲区和写缓冲区本身不需要加锁。需要跨线程通信的是工作线程池的任务队列,用无锁队列(比如基于数组的MPSC队列)来传递任务和结果。全局唯一的带锁结构只有统计计数器和一些配置更新,这些可以用原子操作或读写锁。
锁竞争还来自fd哈希表。如果把fd到连接对象的映射放在全局,每个连接事件都要读这张表,竞争非常剧烈。更好的做法是每个Reactor线程维护一个独立的fd映射,因为一个fd始终由同一个Reactor线程管理,查表只在线程内部发生,根本不需要锁。主线程accept后分配fd时,决定好这个fd归属哪个Reactor,后续所有操作都在那个线程上完成,这种线程亲和消除了主要的锁冲突。
5. 核心实现解析:基于epoll+Reactor的伪代码与实案
5.1 事件循环主线程实现
下面这段伪代码展示了单Reactor主循环如何和连接管理配合。虽然实际生产里是多Reactor,但核心逻辑一致:
while (is_running) { int timeout_ms = next_timeout_in_heap(); int n = epoll_wait(epfd, events, max_events, timeout_ms); for (int i = 0; i < n; i++) { int fd = events[i].data.fd; uint32_t ev = events[i].events; if (fd == listen_fd) { handle_accept(); } else { if (ev & EPOLLERR || ev & EPOLLHUP) { close_conn(fd); } else { if (ev & EPOLLIN) do_read(fd); if (ev & EPOLLOUT && is_writable(fd)) do_write(fd); if (need_close(fd)) close_conn(fd); } } } process_timers(); }这个循环最关键的一点是epoll_wait的超时设置,不能总是-1。如果定时器堆里最近一个连接超时是100ms后就需要超时,那epoll_wait最多等100ms就要返回,让process_timers执行超时检测。这才能保证空闲连接能被及时回收,避免死连接越积越多。
另一个关键点是事件处理顺序。我习惯先处理可读事件,再处理可写事件,最后才判断连接是否应该关闭。因为一次epoll_wait可能同时返回EPOLLIN和EPOLLOUT,如果先进行写操作而读事件还没处理,可能把本该关闭的连接继续往出发数据,造成资源浪费。
5.2 连接建立与数据读取流程
accept新连接时,要做的事比想象中多:
int fd = accept(listen_fd, nullptr, nullptr); if (fd < 0) return; // 设置非阻塞和禁止Nagle算法 set_nonblock(fd); set_tcp_nodelay(fd); // 从连接池取对象 Conn *c = conn_pool_alloc(fd); c->state = ESTABLISHED; c->last_active = now; // 注册读事件到当前线程的epoll epoll_ctl(epfd, EPOLL_CTL_ADD, fd, ...);这里要注意,accept之后需要立刻把连接状态初始化和epoll注册完成,窗口期很短,否则数据已经到了内核缓冲区,而你还没有关注这个fd,可能会丢失唤醒。不过只要使用的是水平触发模式,后续epoll_wait仍会继续上报EPOLLIN,边缘触发模式下这个问题就比较致命,所以用边缘触发时accept后一定要立即注册。
read流程就比较直接了:
void do_read(int fd) { Conn *c = get_conn(fd); char *buf = c->read_buffer; while (true) { ssize_t n = read(fd, buf + c->read_len, buf_free_space); if (n > 0) { c->read_len += n; c->last_active = now; process_request(c); if (c->state == CLOSED) return; } else if (n < 0) { if (errno == EAGAIN) break; close_conn(fd); return; } else { close_conn(fd); return; } } }如果业务处理很快,直接在read循环里同步解析请求并写回响应,性能最高。如果业务涉及数据库或远程调用,就不能在这个循环里同步等待,需要把完整的请求数据拷贝出来,封装成任务丢给线程池,让主循环继续读下一个连接。
5.3 写事件管理与异步发送
写入数据比读数据更容易出错。因为TCP发送缓冲区可能短暂满,你没法保证一次write能把全部数据写完。这时候必须把剩余数据挂到连接的发缓冲区,并向epoll注册EPOLLOUT事件。当EPOLLOUT触发且发缓冲区为空时,要立刻删除EPOLLOUT注册,避免fd一直可写导致忙轮询。
这里面有个很容易忽略的问题:每次调用write返回EAGAIN后再注册EPOLLOUT,不如一开始就把数据追加到发缓冲区,让事件循环统一处理。这样避免多次write系统调用,还可以批量合并多个小数据包。很多开源框架采用“先尝试直接写,写不完再排队”的优化策略,其实对一半场景有效,另一半场景因为TCP窗口拥塞导致频繁写一半,这时候预排队更好。
我的处理模式是:
void send_data(Conn *c, const char *data, size_t len) { if (c->write_len == 0 && c->writing) { // 尝试直接write ssize_t n = write(c->fd, data, len); if (n > 0) { data += n; len -= n; } if (len == 0) return; if (errno != EAGAIN) return close_conn(c); } append_to_send_buffer(c, data, len); epoll_ctl(c->fd, EPOLL_CTL_MOD, EPOLLIN | EPOLLOUT); }这里用完EPOLL_CTL_MOD而不是ADD,因为fd已经注册过。频繁调用epoll_ctl修改事件也会消耗系统调用,所以一定要避免无意义的修改,比如已经注册了EPOLLOUT,再次追加数据时没必要重复调用。可以在Conn结构里加一个字段记录当前注册的事件掩码,只有确实需要变化时才调用EPOLL_CTL_MOD。
6. 压测与实践:验证百万并发需要做什么
6.1 压测工具对比:wrk、ab、自研客户端
做百万连接压测,你首先要明白,wrk和ab这类HTTP压测工具是设计用来打吞吐量的,它们创建的连接数通常不会太高,几十万已经极限了。想要压到百万连接,要么用很多压测机器,要么自己写一个轻量级压测客户端,利用异步IO同时建立大量连接。
我建议分两步验证:第一步用wrk打QPS,看服务器在维持多少并发连接时吞吐能达标;第二步用自研压测工具专注于连接数压测,工具内部用epoll管理所有客户端socket,分批注册、步骤化建连,避免一次性accept太多导致系统抖动。自研工具也比较简单,每个客户端连接建立后随机发送心跳包,服务器只需回正确响应。关键是测试机和被测机之间要断开网络限制,比如防火墙、端口范围限制等等。
压测机本身的资源也很关键。一个进程最多打开的fd数默认是1024,必须调大ulimit,同时压测机的端口数只有六万多,一台机器最多只能建立六万多个连接(目标地址相同的情况下)。所以百万连接压测通常需要十几台压测机联合进行,或者启用多IP、多端口来扩展四元组数量。
6.2 系统参数调优:ulimit、tcp栈、内核参数
这是最磨人的部分,很多人代码写得没问题,却倒在操作系统默认配置上。我整理了必改的内核参数:
# 最大文件描述符数 ulimit -n 1048576 sysctl -w fs.file-max=1048576 # 端口范围 sysctl -w net.ipv4.ip_local_port_range="1024 65535" # TIME_WAIT快速回收和重用(小范围环境慎重) sysctl -w net.ipv4.tcp_tw_reuse=1 sysctl -w net.ipv4.tcp_tw_recycle=0 # 内核4.12以后移除了,新版不用管 # TCP连接跟踪大小 sysctl -w net.netfilter.nf_conntrack_max=1048576 # 听队列长度 sysctl -w net.core.somaxconn=32768特别提醒,tcp_tw_reuse是给客户端的,不是给服务端的。服务端主动关闭连接时会产生TIME_WAIT,大量TIME_WAIT会占用本地端口和内存,这在高并发的短连接场景中是个大问题。如果服务协议允许,尽量让客户端先主动关闭连接,服务端用tcp_tw_reuse和tcp_timestamps配合来做优化。
此外,TCP内核缓冲区默认值可能不够。百万连接下,即使每个连接只分配几KB的内核缓冲区,合计也要好几个GB内存,所以需要限制每个socket的收发缓冲区大小,用sysctl或setsockopt设置明确上限,防止内存被内核缓冲吃光。
6.3 实测中常遇到的问题与排查技巧
问题一:连接数到达20万左右后,accept速度急剧下降,甚至不再接受新连接
排查下来通常是somaxconn不够或者accept的backlog参数设置太小。内核中全连接队列满后,syn包直接丢弃,客户端表现为连接超时。调整net.core.somaxconn和listen时的backlog参数会有立竿见影的效果。另外,如果你的accept循环里做了一些重操作,连接建立速度会被放大,要保证accept函数尽可能轻量。
问题二:内存涨到一定程度后出现抖动,触发OOM
百万连接下,每个连接如果分配1MB用户态缓冲区,那瞬间就是100GB,所以连接对象和缓冲区必须严格按需。我遇到过因为连接池回收不彻底,导致空闲连接一直占着大块写缓冲区不释放的情况。解决方法是在连接变成空闲一段时间后,通过定时器把写缓冲区降为初始大小,然后归还剩余内存给内存池。连接池的收缩策略比扩张策略更重要。
问题三:CPU占用莫名高,但业务处理量很低
首先要怀疑busy-loop,也就是事件循环一直在空转。常见原因是在EPOLLOUT事件触发时没有正确删掉写事件,或者错误设置了0超时循环。另一个可能性是epoll_wait返回很多EPOLLEXCLUSIVE误唤醒(只在多线程不配合使用时有)。可以先用perf top看一下热点函数,如果是tcp_ack或者tcp_recvmsg高,那可能是内核参数问题;如果是自己的epoll_wait处理逻辑高,那要检查是否存在大量无效的事件循环。
排查问题我的经验是先用perf和strace缩小范围。strace看系统调用频率是否异常,perf看热点在用户态还是内核态,然后再针对性地通过开启详细日志观察连接生命周期。压测时记得每一层预热,避免把冷启动效果当成稳定指标。
7. 经验总结与避坑指南
7.1 关于百万并发必须接受的现实
百万并发是“运维内核参数、架构设计、代码实现、压测验证”四者的综合产物,任何一环有短板,整个系统都会露馅。如果你没有提前为百万连接准备好内存预算,代码优化得再漂亮也会被操作系统OOM杀死;如果你的业务处理逻辑里有一个阻塞的数据库查询在Reactor线程上跑,那再好的epoll也无法让你的延迟稳定。
另外,很多方案在几十万连接时还风平浪静,到百万时出现“conn串号”“事件丢失”“定时器失效”“内存膨胀”等疑难杂症,这些几乎都与连接的创建和销毁管理有关。你必须严格执行“连接对象池化、fd归属线程、事件注册状态机、超时时间轮”这些基础设施,千万不要在百万并发的路上写一堆临时补丁。架构上该上主从Reactor就上,别用单线程Reactor硬撑,也别用一个全局锁接盘所有连接状态,这些曾经让我吃过大亏。
7.2 个人实操体会
后来把架构稳定在“主线程accept + 四核从Reactor线程 + 独立工作线程池 + 时间轮管理超时”的模型后,我在单机16G内存、8核的裸金属服务器上成功保持过105万左右的长连接数,同时稳定处理每秒3~5万的心跳消息。坦白讲,这个跳动的心跳消息量不算高,但连接数确实到百万级别了,整个过程中CPU占用在60%上下,没有出现锁竞争剧烈或者内存无限增长的问题。
那次压测让我最意外的是,真正的问题不在用户态代码,而在内核的TCP连接表占用和内存回收策略。所以我想特别强调:开始写代码前,先在目标机器上用一个小demo跑通“建连、保活、断连、重连”的完整循环,通过/proc/sys/net/ipv4/tcp_mem和free的数据估算每连接内存开销。只有心中有数,后面的百万并发之路才不会被突发的系统级问题打断节奏。如果你也正在尝试同样的目标,愿上面这些经验能让你少走几段我为期几个月的弯路。