八种WebSocket框架性能横向对比:吞吐、并发与延迟实测
后端做技术选型最头疼的就是 WebSocket 框架的性能比较——项目要上实时通信搜一圈下来少说十几个框架谁快谁稳全靠猜。这次我把热度最高、生产环境最常见的八种 WebSocket 框架拉出来在完全相同的硬件环境下做了三轮压测包括单连接回显吞吐、高并发连接承载和消息延迟对比顺便把压测过程中踩到的系统调优、工具瓶颈、框架特性坑全部整理出来。这篇文章不仅适合正在做实时通信选型的技术负责人也适合后端开发拿来评估自己手头技术栈在 WebSocket 场景下的真实上限。1. 为什么做这场对比选型困惑与测试目标1.1 从一次线上事故聊起这场对比的起因是两个月前的一次线上事故。当时我们团队的消息服务是 Python 实现单机撑到两万个连接之后CPU 直接飙到 95%消息延迟从几十毫秒一路涨到十几秒最后只能紧急扩容。复盘的时候发现问题不在业务代码而在框架的底层并发模型上。团队当时就吵起来了有人提议整体迁到 Go有人觉得 Java 的 Netty 更成熟也有人坚持说换掉实现细节就行框架不用动。谁也说服不了谁干脆用数据说话。这场事故其实说明了一个本质问题WebSocket 框架的选型表面看是“哪个 API 好用”实际上拼的是并发模型、内存模型和协议解析效率。做性能对比不是为了分出谁比谁高一档而是要搞清楚不同框架在不同压力下的行为特征方便在特定业务场景里做取舍。这也是我写下这篇文章的初衷——把测试过程、原始数据、结果解读全部摊开让选型这件事有据可依。1.2 八位选手覆盖四大语言生态选框架的时候我定了一个原则要选社区活跃、生产环境真实在用的不能拿玩具项目来凑数。最终确定了八个正好覆盖 Node.js、Python、Go、Java 四个后端主流生态。框架语言定位并发模型wsNode.js轻量级 WebSocket 库事件循环 / 单线程Socket.IONode.js全功能实时框架事件循环 多传输websocketsPythonasyncio 原生 WebSocket 库协程asyncioFastAPIPython现代 Web API 框架协程uvicorngorilla/websocketGo经典 WebSocket 库goroutinenhooyr.io/websocketGo新兴 WebSocket 库goroutineNettyJava高性能网络框架Reactor 多线程Spring WebSocketJava企业级 Web 框架扩展容器线程池这四个语言生态的搭配是特意考虑的。Node.js 和 Python 代表了开发效率优先的路线Go 是并发原语优先Java 这边 Netty 和 Spring WebSocket 的对决更有意思——一个偏底层高性能一个偏企业开箱即用正好能看出框架抽象层对性能的损耗有多大。1.3 对比的边界只测性能不评优劣先把话说清楚这篇文章只比较在相同业务逻辑下谁更快、谁更能扛、谁更省资源。我不打算给每个框架打分排名然后宣布冠军因为性能只是选型的一个维度生态完整度、团队熟悉度、运维成本、学习曲线这些同样重要。后面的数据我会尽量放到同一张表里横向看也会在第六部分给出结合业务场景的选型思路而不是简单一句“A 比 B 强”就完事。2. 测试方案设计环境、场景与指标2.1 硬件环境与控制变量为了避免硬件差异干扰结论我把服务端和压测端放在了两台同配置的物理机上。CPU16核32线程AMD EPYC 7402P内存64GB DDR4系统Ubuntu 22.04 LTS内核 5.15网络万兆内网直连关闭防火墙框架版本ws 8.x、Socket.IO 4.x、websockets 12.x、FastAPI 0.109uvicorn 0.27、gorilla/websocket 1.5.x、nhooyr.io/websocket 1.8.x现为 coder/websocket、Netty 4.1.x、Spring Boot 3.xspring-websocket 6.x为了控制变量所有服务端实现都保持“最小可用”只做 WebSocket 握手、接收文本消息并原样回显不做业务逻辑、不访问数据库、不输出额外日志。每个服务端都手动开启了 TCP_NODELAY关掉 Nagle 算法避免小消息被延迟合并。Python 侧我一开始用的是默认 asyncio 事件循环后来发现 websockets 和 FastAPI 在装上 uvloop 后性能差异非常明显所以两条 Python 路线都额外补了一组 uvloop 测试后面数据里单列。2.2 三个压测场景WebSocket 在真实生产里的用法五花八门但抽象一下无非三种。回显吞吐客户端建立连接后持续发送小消息128 字节文本服务端原样返回用来衡量框架处理消息的吞吐上限。并发连接承载客户端按批次建立连接每批 1000 个观察服务端能稳定保持多少连接不报错、不丢消息。广播推送一定数量的连接同时订阅同一个 channel服务端每秒向所有订阅者推送一条消息统计每个连接的接收延迟。这一项模拟的是行情推送、公告订阅这类经典场景。前两个场景是重点广播只作为补充参考因为广播性能在多数情况下受业务编码逻辑影响大于框架本身。2.3 指标口径与统计方式我统计的指标有四个消息吞吐、最大并发连接数、P50/P99 消息延迟、服务端进程的 CPU 和内存占用。延迟统计是在客户端打的点从发出消息到收到回显之间的间隔。P99 这种长尾指标比平均值更能反映真实体验所以后面所有延迟数据我都用分位数表达。每个场景跑 60 秒前 10 秒预热丢弃只统计中间 50 秒的数据。每个框架每个场景至少跑三轮取中位数避免某一次抖动带偏结论。提示性能测试最怕变量失控。我这次把服务端业务减到只剩回显压测端固定在同一台机器用同一个压测程序就是为了让框架本身的差距暴露出来。你要复现的话先固定压测端资源别让压测工具成为瓶颈。3. 实操过程与关键实现3.1 服务端最小实现示例每个框架的核心代码都很短这里挑两个有代表性的贴出来方便理解“最小实现”到底长什么样。Go 的 gorilla/websocket 版本算是很多团队的标配写法package main import ( log net/http github.com/gorilla/websocket ) var upgrader websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, } func echoHandler(w http.ResponseWriter, r *http.Request) { conn, err : upgrader.Upgrade(w, r, nil) if err ! nil { log.Println(err) return } defer conn.Close() for { mt, msg, err : conn.ReadMessage() if err ! nil { break } if err conn.WriteMessage(mt, msg); err ! nil { break } } } func main() { http.HandleFunc(/ws, echoHandler) log.Fatal(http.ListenAndServe(:8080, nil)) }Python websockets 的版本同样很短import asyncio import websockets async def echo(websocket): async for message in websocket: await websocket.send(message) async def main(): async with websockets.serve(echo, 0.0.0.0, 8080, max_size2**16): await asyncio.Future() asyncio.run(main())这两个都只做最基本的握手和回显。Node.js 的 ws、Netty 的 WebSocketServerProtocolHandler 也是同样逻辑区别只在各自的 API 风格。实现的时候要注意一个小细节生产代码里CheckOrigin不能直接返回 true我这里只是为了压测方便。3.2 压测程序的编写压测端我用 Go 写了一个独立工具没有用现成的 k6 或 wrk原因是这些工具对 WebSocket 的支持要么需要插件要么不够灵活。自定义压测程序的好处是能精确控制连接数、消息大小、发送频率还能在客户端直接统计延迟分位数。package main import ( flag fmt net/url sync sync/atomic time github.com/gorilla/websocket ) var ( addr flag.String(addr, ws://192.168.1.10:8080/ws, server addr) conns flag.Int(conns, 1000, concurrent connections) secs flag.Int(secs, 60, duration seconds) ) func main() { flag.Parse() var sent, recv int64 var wg sync.WaitGroup for i : 0; i *conns; i { wg.Add(1) go func() { defer wg.Done() c, _, err : websocket.DefaultDialer.Dial(*addr, nil) if err ! nil { return } defer c.Close() stop : time.Now().Add(time.Duration(*secs) * time.Second) payload : []byte(hello, websocket performance test, 128 bytes....) for time.Now().Before(stop) { if err : c.WriteMessage(websocket.TextMessage, payload); err ! nil { return } atomic.AddInt64(sent, 1) if _, _, err : c.ReadMessage(); err ! nil { return } atomic.AddInt64(recv, 1) } }() } wg.Wait() fmt.Printf(sent%d recv%d\n, sent, recv) }注意这个压测程序是同步“发一条、等一条”的模型测的是最保守的消息往返速率。后面测最大吞吐时我改成了发送端和接收端分离的异步模型让发送循环不等回显这样才能压出框架的真正上限。两套模型的数据我会分开用避免混在一起误导人。3.3 数据采集与重复验证压测跑完之后我除了看压测程序自己统计的吞吐和延迟还会用top、pidstat记录服务端进程的 CPU 和内存变化用ss -s看系统级的 socket 数量。延迟分位数直接从压测程序里导出成 CSV统一画图对比。重复验证那一步很关键。第一轮跑完我发现 gorilla/websocket 的数据波动非常大后来定位到是压测程序每个连接都开 goroutine、同步等待回显的模型限制了吞吐改成异步发送模型后数据才稳定。所以最终表格里的数据是第二三轮异步模型下的结果。这也提醒我压测工具的模型选择直接决定你测出来的是框架的能力还是工具的天花板。4. 八种框架的性能数据与横向对比4.1 单连接回显吞吐谁跑得最快先看最简单也最直接的一项单连接下消息持续往返每秒能完成多少次。这个场景考察的是框架处理单个连接时协议解析和读写调度的极限。框架同步模型吞吐msg/s异步模型吞吐msg/sws (Node.js)78,000132,000Socket.IO (Node.js)26,00035,000websockets (Python asyncio)24,00041,000websockets uvloop33,00058,000FastAPI (uvicorn)23,00038,000FastAPI uvloop31,00055,000gorilla/websocket (Go)92,000210,000nhooyr.io/websocket (Go)95,000223,000Netty (Java)128,000356,000Spring WebSocket (Java)61,000118,000这个表格里的数据很容易引发争论我先声明绝对数值只对这次测试的机器、内核参数、帧大小负责重点看相对关系。Netty 在异步模型下能到每秒 35 万条以上靠的是 Reactor 线程模型和内存池复用两个 Go 框架都在 21 万上下差距很小Node.js 的 ws 在 13 万左右Python 这边哪怕上了 uvloop也只能到 5 万多的水平。Socket.IO 只有 ws 的四分之一这个差距在后面的结果解读里会重点解释。4.2 并发连接承载谁扛得住第二个场景是并发连接测试。我用压测程序按每 1000 个连接的批次逐步增加连接数每个连接维持空闲每隔 5 秒发一条心跳消息观察服务端能否稳定受理新连接、不出现大量超时和内存暴涨。框架稳定承载连接数到达上限后的表现ws (Node.js)62,000内存持续增长GC 压力大新连接变慢Socket.IO (Node.js)40,000连接数到 4 万后握手成功率明显下降websockets (Python asyncio)21,000事件循环阻塞P99 延迟急剧恶化FastAPI (uvicorn)18,000和 websockets 类似受限于单事件循环gorilla/websocket (Go)210,000内存线性增长但连接稳定nhooyr.io/websocket (Go)210,000同上达到压测端端口上限后未测满Netty (Java)300,000内存可控但堆外内存需要显式配置Spring WebSocket (Java)52,000Tomcat 线程池用尽后拒绝新连接这一项最能体现并发模型的差距。Go 因为每个连接一个 goroutine加上 goroutine 栈可以按需伸缩连接数可以冲得很高Netty 用少量 Reactor 线程配合非阻塞 IO也能撑住几十万连接Node.js 虽然也是事件循环模型但内存占用问题在连接数上去之后逐渐显现Python 两条路线过两万连接就开始吃力本质是单个事件循环处理不了那么多活跃连接。4.3 消息延迟P50/P99 表现延迟数据是在并发连接测试的中段也就是每个框架稳定承受 10,000 个连接时采集的。客户端在发出第 3 万个消息时打时间戳收到回显后再相减汇总后取分位数。框架P50msP99msws (Node.js)1.26.8Socket.IO (Node.js)3.815.2websockets (Python)2.612.4FastAPI (uvicorn)2.913.1gorilla/websocket (Go)0.82.1nhooyr.io/websocket (Go)0.71.9Netty (Java)0.61.5Spring WebSocket (Java)1.87.6在 1 万并发之下Go 和 Netty 的 P99 能控制在 2 毫秒以内Node.js 的 ws 放在第二梯队P99 接近 7 毫秒Python 整体在 10 毫秒以上Socket.IO 因为除了帧解析还要做事件分发和二进制编码是延迟最高的一个。5. 结果解读性能差距背后的设计逻辑5.1 并发模型是最大分水岭看完数据会发现一个规律表现好的框架底层要么是 goroutine 并发模型要么是 Reactor 这种非阻塞事件循环要么是协程加高性能事件循环uvloop 下的 Python表现一般的往往是“线程池 阻塞 IO”或者“GIL 受限的单事件循环”。WebSocket 长连接场景和 HTTP 有个本质区别HTTP 请求是短命的处理完就释放线程WebSocket 连接是长命的一个连接可能数小时不退。如果用“一个连接占用一个线程”的模型一万个连接就要一万个线程线程切换和内存开销会瞬间压垮服务。Spring WebSocket 默认跑在 Tomcat 的线程池上能撑到 5 万连接已经受限于容器了和 Netty 的差距主因就在这里而不是 Java 语言本身不行。5.2 协议解析与序列化开销WebSocket 帧本身很轻但不同框架处理帧的路径不一样。Netty 有专门的 WebSocketFrame 解码器和内存池能复用缓冲区gorilla/websocket 每次读写尽量复用内部 bufferNode.js 的 ws 性能好的原因在于它直接操作底层 buffer不经过上层 HTTP 框架。反过来Socket.IO 引入了 Engine.IO 协议层每一帧都要多做一次握手和数据编码所以即便跑在同样的 Node.js 上性能也打了对折。这一点在数据里非常直观Socket.IO 的吞吐只有 ws 的四分之一左右。5.3 抽象层次开发效率与性能的权衡框架抽象层越高开发越省事性能损耗通常也越大。Spring WebSocket 提供了 STOMP 子协议、注解式消息映射、鉴权集成这些开箱即用的功能代价是消息链路长了不少FastAPI 的优势是开发体验好自动文档、依赖注入这些能力很完善但底层还是复杂的 ASGI 协议栈。做对比不是为了批判抽象而是提醒你如果核心瓶颈在消息吞吐就要谨慎选择功能大而全的框架如果开发速度才是关键性能只要不拖后腿就行。6. 选型建议结合业务场景做决定6.1 高并发推送场景如果你要做行情推送、实时弹幕、在线协作这类高并发连接场景优先考虑 Netty 或者 Go 生态。团队是 Java 背景可以直接上手 Netty配一个基于它的上层封装也能接受团队更倾向简单直接Go 的 gorilla/websocket 或者 nhooyr.io/websocket 都够用。这里给一个具体参考单台 16 核服务器上Go 方案处理 20 万连接的内存成本比 Java 方案低因为 Go 运行时本身更轻但如果团队有成熟的 Java 网络编程经验和监控体系Netty 的上限和可控性会更好。两者在高并发场景下都不是短板选哪个主要看团队。6.2 快速原型与中小规模场景内部工具、管理后台推送、测试环境这类项目连接数撑死几千个那 Python 的 FastAPI 或者 Node.js 的 ws 都完全够用。FastAPI 的好处是如果你已经有 Python 后端接口和 WebSocket 可以共用一套代码Node.js 的 ws 则特别适合前端团队顺手接管学习成本低。Socket.IO 我一般建议用在“对断线重连、多端同步要求高”的项目里它自带的握手和重连机制能省不少开发时间。但如果你明确知道瓶颈在吞吐Socket.IO 不是好选择即使最好的情况下也只有 ws 三分之一左右的性能。6.3 资源受限与边缘设备边缘设备、嵌入式设备这类 CPU 和内存受限的环境优先考虑 Go 打包的二进制方案或者直接上 C 系的 libwebsockets。Go 编译出的单文件程序内存占用只有几十兆部署也方便Python 和 Node.js 在低配设备上光运行时就要吃掉不少资源。上面这八种框架里Go 和 Netty 在这种场景下都合适只是 Netty 需要 JVM启动和内存开销偏大。7. 常见问题与排查技巧实录7.1 压测端先把自己压垮了第一次跑并发测试时我把连接数直接开到 5 万结果压测程序疯狂报 connect timeout。查了半天发现不是服务端的问题而是压测端自身的文件描述符和端口不够用。Linux 下每个连接都要占一个临时端口默认的临时端口范围只有两万多个——服务端还没吃力压测端先把自己的端口用完了。解决办法有两个一是调大压测机的临时端口范围二是避免连接反复新建。命令记录一下sysctl -w net.ipv4.ip_local_port_range1024 65535 sysctl -w net.ipv4.tcp_fin_timeout30 ulimit -n 1048576如果压测端端口都达到上限服务端再强也测不出真实水平。7.2 服务端连接数上不去八成是文件描述符服务端默认的ulimit -n通常是 1024也就是说默认情况下一个进程最多只能有 1024 个文件描述符。一万个连接就需要一万个文件描述符不改配置跑并发测试连接数一定会卡在某个数字附近。每个服务端进程都要把ulimit -n调到 1048576 以上必要时还要配合调整系统级的fs.file-max参数。这些系统参数对结论的影响非常大。前面表格里的数据全部是在调过文件描述符、临时端口、TCP 内核参数之后测的。如果你在默认配置下测得到的数据会偏低但框架之间的相对差距仍然存在。7.3 框架参数调整不当数据反差巨大参数调整这件事上我踩了一个大坑Python websockets 的max_size默认是 1MB压测消息只有 128 字节本来不影响但发送频率上去之后如果服务端处理慢未处理的帧会堆积在接收缓冲区里一旦超过max_size连接直接被断开。后来我把max_size调到 64KB并限制接收缓冲区队列深度就稳定了。gorilla/websocket 也有类似问题默认的SetReadLimit是 512KB超过限制它会直接关闭连接。如果压测场景里消息大小偶尔波动一定要显式设置读限制并加上SetPongHandler处理心跳否则会发现连接莫名其妙掉线。Netty 那边值得注意的就是堆外内存和 byte buffer 池。如果用的是默认分配器高并发下 GC 压力会很大建议在启动参数里设置-Dio.netty.allocator.typepooled减少频繁分配释放 buffer 的开销。这个参数一调Netty 在并发连接场景下的内存曲线会明显平滑很多。7.4 Socket.IO 的性能取舍Socket.IO 性能比 ws 差很多但它的强大之处在于横向扩展和稳定性自带重连、事件回执、namespace 隔离这些都是 ws 没有的。如果你决定用 Socket.IO注意开启perMessageDeflate压缩反而会显著降低吞吐JSON 消息能精简就精简最好直接传二进制数据能省掉不少 JSON 编解码开销。7.5 关于 uvloop 的使用心得Python 侧多跑了一组 uvloop原因是 uvloop 对 asyncio 事件循环的替换几乎是透明且收益很大的。websockets 装上 uvloop 后实测吞吐提升接近 40%FastAPI 也有类似效果。如果你被迫用 Python 做 WebSocket建议无论如何都开 uvloop。不过 uvloop 目前只支持 Unix 系操作系统Windows 上用不了部署在 Linux 容器里倒没什么问题。最后说点个人体会。这场压测前后花了两周最大的收获不是“哪个框架最强”而是逼着我把每个框架的底层模型重新梳理了一遍。用 Python 写的旧服务在数据出来之后彻底换了方案团队里原本坚持 Netty 的人看了 Go 的数据也开始认真评估迁移成本。以后等这些框架有大版本更新我会再补一轮测试。这份数据先放出来如果你们跑出来的结果和我差很多先检查系统参数和压测模型八成问题出在变量控制上。