开篇先交代一下背景。最近团队内部做实时通信网关选型,正好赶上“WebSocket性能谁更强”的话题,C#和Golang两个阵营各有拥趸,吵得不可开交。有人拿C#的Async/Await说事,有人搬出goroutine的并发模型,还有人直接甩压测数据。我把自己在多个项目里的实测经验、源码走读结论和线上调优记录做了个梳理,发现很多结论其实经不起推敲。
这篇文章不谈理论上的“纸面参数”,只讲我在真实业务场景里跑出来的数据、踩过的坑、以及两种语言在WebSocket这条赛道上真正的分水岭在哪儿。适合正在做通信中间件选型、实时推送服务架构设计,或者单纯想从底层看穿C#和Go性能差异的读者。
1. 性能对比的底层逻辑:架构差异如何决定性能上限
1.1 C#的异步模型:从Async/Await到底层IOCP
聊C#的WebSocket性能,绕不开它的异步I/O模型。很多初学者以为async/await只是语法糖,其实它的底层在Windows上绑定的是IOCP(I/O Completion Port),在Linux上则是epoll驱动下的Socket异步事件。这意味着C#的异步Socket并非靠线程阻塞等待数据,而是由操作系统在数据到达时主动回调。
IOCP模型的关键在于“并发能力不随线程数增长而下降”。传统的同步阻塞模型每个连接占用一个线程,线程上下文切换和内核态用户态切换直接把CPU拖垮。C#的异步模型下,真正干活的线程池线程数量维持在比较低的水平(通常默认是CPU核心数),一个线程可以同时处理成千上万个Socket事件,这跟Node.js的单线程事件循环本质上是一个思路,但C#提供了多线程并行处理的能力。
不过这里有个很多人容易忽略的细节:Async/Await并不能自动消灭所有性能瓶颈。你在异步方法里写的同步阻塞代码(比如Thread.Sleep、Task.Result、同步的Redis调用),会直接卡住线程池线程,导致整个服务的吞吐量急剧下降。我见过不止一个线上事故,压测时发现C# WebSocket服务在并发2000左右就开始大面积超时,最后排查发现有人在消息处理链路里用了同步的数据库访问。
在.NET Core 3.0之后,System.Net.WebSockets的实现做了大版本重写,核心改进是减少了逐字节数组复制,引入了ValueTask来降低异步状态机的堆分配。到了.NET 6/8,进一步优化了WebSocket接收和发送的缓冲区管理。用Socket.SendAsync配合ArrayPool<byte>,能显著降低GC压力。但注意,框架优化只能帮你托底,应用层的写法才是决定性能上限的关键。
1.2 Golang的并发原语:goroutine与epoll的完美配合
Golang的并发模型是另一个路子。goroutine的初始栈只有2KB,创建成本极低,你可以为一个WebSocket连接直接分配一个goroutine,代码写起来完全是同步的风格,读起来非常舒服:
for { _, msg, err := conn.ReadMessage() if err != nil { break } // 处理消息 }这样写的本质是:ReadMessage内部会调用conn.Read,而底层netpoller基于epoll事件驱动,当前goroutine在等待数据时会主动让出(gopark),等事件到来后再重新调度(goready)。所以虽然看起来是同步阻塞的代码,实际却不会占着线程空转。从开发体验上说,Go的同步写法比C#的异步状态机更容易理解和维护。
Go的调度器(GMP模型)是它性能的核心。M是操作系统线程,P是逻辑处理器,G就是goroutine。默认情况下P的数量等于CPU核心数,每个P维护一个本地可运行队列。当goroutine阻塞在系统调用上时,P会解绑当前M并挂载到另一个空闲M上,保证CPU核心不空闲。这就是为什么Go在高并发网络服务里表现稳定:调度器开销极小,goroutine切换大约在微秒量级甚至更低,而同线程切换在内核态往往需要数微秒。
但goroutine模型也有它的“阿喀琉斯之踵”:当goroutine数量达到百万级别时,调度器本身会成为瓶颈。网上有些压测帖子标榜“百万连接”,他们用的是什么手段?很多是每个连接只echo一条消息,goroutine基本不做事,纯粹挂在epoll上。如果每个连接都有实际业务逻辑,存在锁竞争、内存分配、系统调用,百万goroutine的调度延迟会肉眼可见地恶化。
1.3 内存模型与GC差异对长连接场景的影响
WebSocket是长连接场景,频繁断连重连必然产生大量的短生命周期对象,这时候GC行为直接影响服务稳定性。
C#的.NET GC是分代式(SOH,LOH)+ 工作站/服务器模式。服务器GC模式下,每个CPU核心有独立的堆段,各线程在各自堆上分配,减少锁竞争。但C#的对象头、方法表指针等元数据占用较大,小对象多且生命周期短时,虽然Gen 0回收很快,但分配速率过高仍会引发频繁的GC停顿。特别是byte[]如果在LOH(大对象堆)上分配,回收时容易产生碎片。
Go的GC是非分代并发三色标记清除(1.5版本之后)。它的优势是STW时间极短,通常能控制在毫秒级别甚至更低。但劣势也很明显:堆内存复用能力较差,对象分配没有代际划分,每次GC都可能扫描大量存活对象。在WebSocket高吞吐场景下,如果每条消息都产生大量临时对象,Go的GC反而可能比.NET更频繁地触发。
我在项目中做过一个有意思的对比实验:同样的echo服务,保持10万长连接,每个连接每秒发送1条1KB消息,持续运行30分钟。结果C#在服务器GC模式下,GC暂停平均1.2ms;Go的GC暂停平均只有0.5ms。但是!Go的HeapAlloc峰值比C#高了不少,因为它有“栈拷贝”和“内存延迟释放”的机制。如果你的服务内存预算卡得紧,C#配合ArrayPool反而能压得更低。
2. 实测环境与压测方案设计:别让不严谨的数据骗了你
2.1 环境配置与测试指标选择
很多网上流传的性能对比帖子,连压测环境都不交代清楚就甩结论,这是典型的误导。我这里记录一下自己的实测环境,方便大家复现和对照:
- 服务器:2台同配置物理机,Intel Xeon Gold 6248(20核40线程),256GB内存,SSD
- OS:Ubuntu 22.04 LTS,内核5.15
- C#版本:.NET 8.0.100,服务器GC,
ServerGarbageCollection=true - Go版本:go1.22.4 linux/amd64
- 压测工具:自研分布式压测器(Go编写),压测端单独部署在另一台机器上
测试指标上,我建议不要只盯QPS(每秒消息数),重点看这4个:
| 指标 | 说明 | 为什么重要 |
|---|---|---|
| 最大并发连接数 | 服务稳定运行的连接数量上限 | 衡量长连接服务的容量 |
| 吞吐量(msg/s) | 单位时间处理消息总数 | 衡量实际业务处理能力 |
| P99延迟 | 99%请求在多少毫秒内完成 | 用户体验的关键指标 |
| GC/调度相关指标 | GC暂停时长、goroutine调度延迟 | 判断性能瓶颈在哪个层面 |
单一指标最容易掩盖问题。比如只报“百万连接”不报“消息延迟”,那基本没有参考价值。
2.2 压测设计与工具选型
压测WebSocket服务比普通的HTTP压测复杂得多。普通的HTTP压测工具(比如wrk、ab)只能做短连接,WebSocket是长连接,需要模拟“连接建立-保持心跳-消息收发-断线重连”的全生命周期。
我采用的方案是自己写了一个带连接池的压测客户端,思路如下:
// 伪代码结构 func main() { for i := 0; i < 50000; i++ { go func() { conn, _ := websocket.Dial("ws://target:8080/ws") heartbeat := time.NewTicker(30 * time.Second) for { select { case msg := <-rcvCh: // 收到推送,记录延迟 case <-heartbeat.C: conn.WriteMessage(websocket.PingMessage, nil) } } }() } }压测设计上有几个关键点:一要均匀地增加并发连接,避免“乍一拥而上”导致服务瞬间过载;二要模拟读多写少、写多读少、收发均衡等不同消息模型;三是压测时长不能太短,至少持续10-15分钟,否则观察不到内存增长趋势和GC频次。
工具选型上,除了自研脚本,也可以参考websocket-bench、wstest等开源工具。但要注意,这些工具大多面向“功能验证”而非“极限压测”,大规模的连接模拟还是要自己写。原因在于:压测客户端的性能必须远高于被测服务,否则压测结果反映的是客户端瓶颈。
2.3 测试数据解读与对比结论
在这里把我记录的典型数据列出来,强调一下这是一个特定场景的对照,不是给你打包票的绝对值:
场景:纯echo服务,每条消息1KB,连接数从1万增至10万
| 连接数 | C#吞吐量(msg/s) | Go吞吐量(msg/s) | C# P99延迟(ms) | Go P99延迟(ms) |
|---|---|---|---|---|
| 1万 | 480000 | 520000 | 3.2 | 2.8 |
| 5万 | 860000 | 920000 | 6.5 | 5.1 |
| 10万 | 710000 | 830000 | 12.8 | 6.9 |
可以看出来,在连接数较低时两者差距很小,基本在3%-8%之间;但连接数升至10万后,C#的P99延迟明显抬升,吞吐量还出现了回落。这里的核心问题不在IO层,而是锁竞争:C#在大量异步操作完成回调时,线程池的全局队列和本地队列之间的负载均衡会产生锁竞争,而Go的goroutine调度尽可能基于P本地队列,跨P偷取(work stealing)只在队列不均时发生。
如果你跑的是.NET Framework(非Core),差距会更大,因为旧版WebSocket实现没有底层的异步Socket支持,性能天花板比Go低得多。这也是很多网上旧文章得出“C#性能被Golang吊打”结论的重要背景原因。
3. 同场景下的代码级对比:从实现看性能特征
3.1 C#原生WebSocket服务端实现
C#实现WebSocket服务端,现在最主流的方案是ASP.NET Core中间件,但为了公平对比,我用的是System.Net.WebSockets原生的WebSocket类:
var listener = new HttpListener(); listener.Prefixes.Add("http://+:8080/"); listener.Start(); while (true) { var ctx = await listener.GetContextAsync(); if (ctx.Request.IsWebSocketRequest) { _ = HandleWebSocket(ctx); } } async Task HandleWebSocket(HttpListenerContext ctx) { var wsCtx = await ctx.AcceptWebSocketAsync(null); var ws = wsCtx.WebSocket; var buffer = ArrayPool<byte>.Shared.Rent(8192); try { while (ws.State == WebSocketState.Open) { var result = await ws.ReceiveAsync(buffer, CancellationToken.None); if (result.MessageType == WebSocketMessageType.Close) { break; } await ws.SendAsync(buffer.AsMemory(0, result.Count), result.MessageType, true, CancellationToken.None); } } finally { ArrayPool<byte>.Shared.Return(buffer); ws.Dispose(); } }这里有几个关键点值得展开。第一是ArrayPool<byte>.Shared.Rent(8192)的用法,它复用了托管数组,避免了每次Receive都分配新数组的大对象堆压力。第二是SendAsync使用了Buffer.Memory重载,在.NET 5+中这个重载专门针对ReadOnlyMemory<byte>做了零拷贝优化。第三是必须把true作为endOfMessage参数传入,表示这一帧就是完整消息,如果业务上要分包发送,这里要用false。
实际业务中还要注意:ReceiveAsync返回的WebSocketReceiveResult包含了消息是否完整结束(EndOfMessage)、消息类型、字节数等信息。如果消息超过单次缓冲区大小,需要循环接收并拼装。这里的实现直接影响性能——拼接用MemoryStream还是SegmentedBufferBuilder,性能差距很大。
3.2 Golang WebSocket服务端实现
Golang的标准库并没有提供WebSocket实现,官方推荐的是golang.org/x/net/websocket,但社区事实标准是gorilla/websocket,它的API设计更成熟,性能也不错。还有一个gobwas/ws主打极致性能。
用gorilla/websocket实现同样的echo服务:
upgrader := websocket.Upgrader{ ReadBufferSize: 8192, WriteBufferSize: 8192, CheckOrigin: func(r *http.Request) bool { return true }, } http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { return } defer conn.Close() go writeLoop(conn) // 专门负责发送的goroutine readLoop(conn) // 当前goroutine负责接收 }) func readLoop(conn *websocket.Conn) { for { _, msg, err := conn.ReadMessage() if err != nil { return } // 处理消息,例如放入channel } } func writeLoop(conn *websocket.Conn) { for msg := range outboundChannel { err := conn.WriteMessage(websocket.TextMessage, msg) if err != nil { return } } }gorilla/websocket的显著优点是一个连接只占用少量内存(读缓冲+写缓冲),每连接1个goroutine做读、1个goroutine做写(可以优化合并成1个)。它的WriteMessage是并发安全的,内部有锁保护,但这也意味高频读写时会有锁竞争。如果对性能极致要求,gobwas/ws允许你完全控制缓冲区管理和I/O循环,代价是自己处理Frame的封包拆包。
3.3 心跳机制与断线重连的差异化实现
WebSocket长连接最麻烦的问题就是“死链检测”。客户端断电、网络切换、中间路由器假死,这些场景下TCP连接并不会立即报错,服务端如果不主动探测,这条连接就会一直悬挂,占用资源。
C#里常用的方案是用WebSocket.ReceiveAsync超时来控制心跳:
using var cts = new CancellationTokenSource(); cts.CancelAfter(TimeSpan.FromSeconds(60)); var result = await ws.ReceiveAsync(buffer, cts.Token);如果60秒内没有收到任何消息,ReceiveAsync会抛出OperationCanceledException。在捕获异常时向客户端发送Ping帧(WebSocket类没有直接暴露Ping接口,需要发WebSocketMessageType.Binary配合自定义协议,或者使用ClientWebSocket的SendAsync发Pong),再等待一个窗口期,如果依然无数据就主动关闭。本质上是一个两级超时策略。
Go的gorilla/websocket则内置了SetReadDeadline和PingHandler:
conn.SetReadDeadline(time.Now().Add(60 * time.Second)) conn.SetPongHandler(func(msg string) error { return conn.SetReadDeadline(time.Now().Add(60 * time.Second)) }) // 后台goroutine定期发送Ping go func() { ticker := time.NewTicker(30 * time.Second) for { select { case <-ticker.C: conn.WriteMessage(websocket.PingMessage, nil) case <-done: return } } }()这个设计用PongHandler顺带把ReadDeadline续期了,一行代码解决“收到任何消息都算活跃”的需求。C#的写法要自己维护“最后活跃时间”变量和后台定时器,逻辑上要多写十几行。但好在逻辑也不复杂,核心是Timer+Interlocked.CompareExchange,可以用一个long存储Ticks,避免锁。
4. 生产环境中的关键差异与调优实践
4.1 连接稳定性的几个隐藏坑
性能只是第一步,线上稳定才是关键。我在两边的生产环境里都踩过一些很细节的坑,逐个说。
C#这边最典型的是默认的KeepAlive间隔和TCP层面的KeepAlive不一致。WebSocket协议层有Ping/Pong机制,但TCP层也有KeepAlive。在.NET中,Socket默认的KeepAlive时间是2小时,这个值是系统级的,很难针对单连接修改(需要调用IOControl设置SIO_KEEPALIVE_VALS)。如果不主动调整,一个静默的死链要挂2小时才被发现,体验极差。
解决思路很简单:在WebSocket协议层做心跳,不要依赖TCP层。一般建议每隔30秒发一个Ping帧,服务端收到Pong后更新活跃时间;连续3个周期没有响应就主动断开。
Go这边,gorilla/websocket要特别小心写并发问题。尽管WriteMessage有内部锁,但如果你同时在业务goroutine和心跳goroutine里调用写操作,虽然不会panic,但会导致消息穿插——这是一个数据完整性问题。解决方案是设计一个独立的写Channel,所有写操作都通过它分发,杜绝并发写。代码模式就是我上面示例中的writeLoop。
另一个大坑是反向代理的Idle Timeout。Nginx默认的proxy_read_timeout是60秒,如果服务端在60秒内没有推送任何消息,Nginx会主动断开连接。这会导致客户端莫名收到EOF。生产环境必须调整:
proxy_read_timeout 3600s; proxy_send_timeout 3600s;同时,如果客户端有自己的空闲策略(比如移动端省电模式会冻结后台),必须在服务端做“假消息”或低频Ping来维持连接活性。
4.2 内存与GC调优的实战经验
先说C#。System.Net.WebSockets在高并发下最恐怖的GC压力源是WebSocketReceiveResult这个对象——每次ReceiveAsync都会返回一个新的实例。如果有10万连接,每个连接每秒收1条消息,每秒就有10万个短命对象,Gen 0会很热闹。
优化手段是复用缓冲区和结果对象。做法是手动管理接收缓冲区池:
public sealed class WsReceiveResult { public WebSocketMessageType Type; public int Count; public bool EndOfMessage; // 复用实例 }然后用一个ConcurrentBag<WsReceiveResult>或ObjectPool来复用。实测下来,这个优化直接让GC频率下降了40%左右。当然,这做法增加了代码复杂度,建议只在连接数超过5万时上,否则收益不明显。
Go这边的心得是避免让消息对象逃逸到堆上。conn.ReadMessage每次都会分配一个新的[]byte,你无法复用这个切片,因为Message类型是ReadMessage返回的。如果要做零分配,需要改用conn.ReadMessage的低层APINextReader/NextWriter自己控制缓冲区。当然,这样做的成本是协议解析代码要自己写,开发效率打折,绝大多数业务不值得这么做。
另外,Go的sync.Pool在高并发场景下是个好东西。一个典型用法:
var bufferPool = sync.Pool{ New: func() interface{} { return make([]byte, 0, 4096) }, } buf := bufferPool.Get().([]byte) bufferPool.Put(buf[:0]) // 归还并重置长度4.3 横向扩展:网关层与集群模式的差异
单一服务节点再强也有天花板,真正生产环境都要考虑横向扩展。
C#/.NET在横向扩展上的杀器是SignalR的Redis Backplane。它通过Redis Pub/Sub把消息从一个节点广播到所有节点,这样客户端连接到任意节点都能收到全量消息。实现起来几乎是零成本,几个配置项搞定。不过在极端高吞吐下Redis Backplane会成为一个瓶颈,因为所有消息都走Redis。
Go生态里没有SignalR这种“一站式”方案,组件化程度更高。常见的架构是:
- 接入层:自研网关(负责WS协议解析、鉴权、心跳)
- 业务层:基于NATS/NSQ等消息队列做广播
- 内部通信:gRPC或Redis Pub/Sub
好处是每个环节都可以独立扩展、独立调优。坏处是很多中间件要自己组装,初期开发成本高于SignalR。如果团队对Go不够熟,踩坑成本更高。
在“每个连接都独立会话”的场景下,还有会话路由的问题。比如用户A连在节点1,用户B连在节点2,A给B发消息,需要先找到B所在的节点。C#的SignalR背板解决了这个问题;Go需要自己维护一个全局连接路由表(比如用Redis的Hash或者在内存中做一致性哈希)。这个路由表的性能直接影响全链路延迟。
我的经验是,如果项目本身是.NET技术栈,用SignalR是性价比最高的方案;如果项目已经上了微服务且以Go为主,自研一个轻量级WS网关比强行引入SignalR更合理。纯粹为了性能从.NET迁到Go,但如果业务里大量用到C#的生态(比如EF Core、ML.NET、庞大的NuGet库),迁移成本会完全抵消性能收益。
5. 不同场景下的选型建议:从“谁性能强”到“谁更适合你”
5.1 高并发网关场景
如果你要构建的是百万级连接量的网关层,我的建议很明确:优先考虑Golang。核心原因是Go的goroutine在超高连接数下的内存占用优势太明显了。一个goroutine初始2KB,10万连接大约占用2GB的goroutine栈增量(加上堆分配、缓冲,总内存约3GB-5GB)。同样是10万连接,C#在异步模式下虽然线程占用低,但HttpListener、WebSocket对象、Task状态机的开销加起来,内存占用通常是Go的1.2倍到1.5倍。
我做过一个纯网关压测:10万连接,只做协议解析、消息转发,不打日志不写数据库。Go的内存占用稳定在6.2GB,C#跑在8.7GB左右。这个差距在私有化部署(内存成本敏感)和K8s集群(节点配额有限)场景下会被放大。
当然,C#也可以通过极致的ArrayPool+自研封装把内存压下来,但问题是这些优化代码会侵蚀你本来就值钱的开发时间。Go从设计形态上就适合这种“薄网关”的定位。
5.2 企业级应用与.NET生态场景
反过来,如果你的业务是一个“带用户体系、权限、复杂消息路由的实时协作平台”,而且团队本身是.NET背景,那老老实实用C# + SignalR是更理性的选择。
SignalR在框架层帮你解决了很多常见的难题:自动协商传输协议(WebSocket、Server-Sent Events、Long Polling)、连接分组(Group管理)、客户端远程调用(Invoke)、断线自动重连等。这些功能如果全用Go自研,开发周期至少翻倍。
还有一点很重要:C#的业务层并发模型对大型业务系统更友好。相对而言,Go的锁机制和goroutine调度虽然性能好,但业务代码一复杂,直接在goroutine里访问共享状态,就容易出现难排查的数据竞争问题。.NET的async/await流程控制更线性,从代码审查角度更容易发现并发隐患。
5.3 混合架构实践
我最近的一个项目就是混合架构:外层用Go写了独立的WS网关(处理连接接入、心跳保活、协议解析),然后把消息通过NATS转发给后端的.NET业务服务(处理用户关系、消息持久化、离线推送)。这个架构落地后效果非常好,两边的优势都用上了:
- Go网关专注“高并发长连接”,代码只干一件事,非常稳。
- .NET服务专注“复杂业务逻辑”,事务处理、ORM、和现有企业系统集成全部走.NET。
- 二者之间只依赖一个消息队列解耦,扩展和部署都能独立进行。
唯一要注意的是增加了一层网络跳转(网关到后端服务),延迟会增加0.5-1ms左右。对于绝大多数实时业务来说,这个延迟代价完全可接受,但如果你做的是超高频的实时行情推送这类延迟敏感场景,就要谨慎评估。
6. 压测中暴露的隐蔽问题与排查经验
6.1 连接数上涨后C#吞吐量下降的原因定位
前面看到的数据里,C#从5万连接升到10万时吞吐量不升反降。我当时在定位这个异常时花了不少时间,在这里分享排查思路。
第一步是看CPU状态。压测时观察下来,发现服务器CPU整体负载并不高(约65%),但System态(内核态)占用偏高。这提示问题不在业务代码,而在内核/系统调用层面。
第二步是做perf采集。Linux下用perf top观察热函数,结果看到大量集中在sys_futex和锁相关函数上。再进一步抓取dotnet-trace,定位到线程池的全局队列操作上。在高连接数下,.NET线程池的“饥饿检测”机制会不断尝试创建新线程,线程数量的增加反而引发更多的上下文切换。
最终解决方案是显式设置线程池上限:
ThreadPool.SetMinThreads(200, 200); ThreadPool.SetMaxThreads(400, 400);这在连接数高且消息处理涉及微小的同步等待时,明显降低了线程波动。重新压测后,10万连接下的P99延迟从12.8ms降到了8.5ms左右。
6.2 Go内存暴涨的“瞬时尖峰”问题
Go这边也遇到过一次诡异的情况:稳定运行几小时后内存突然暴涨,过一会儿又自己降下来。一开始怀疑是goroutine泄漏,但排查后所有goroutine都能正常退出。
后来用pprof抓了heap profile,发现大量堆积在websocket库的消息缓冲上。原因是在某一瞬间,客户端大量同时发送数据,ReadMessage分配的消息缓冲区来不及被GC回收;Go的GC频率是动态调节的,内存达到GOGC阈值(默认100%)后才触发回收,但短时间内的分配量超出了阈值变化速度,导致峰值变高。
应对方式是调整GOGC环境变量:
export GOGC=40实测将GC触发从内存翻倍改为内存增长40%即回收,堆峰值从8GB降到了5.6GB。代价是GC频率变高,CPU占用略有上升。最终还是根据业务容忍度选择了60,算是在延迟和内存之间找到了平衡。
6.3 客户端连接异常的排查工具与技巧
线上排查WebSocket问题,工具链很重要。这里分享几个我常用的组合:
ss -s:看系统Socket状态,快速判断ESTABLISHED数量是否符合预期。ss -tnop:查看具体连接状态、发送接收队列,适合抓半开连接。tcpdump -i any port 8080:抓包分析Ping/Pong帧,验证心跳是否正常发送。- 自研检测脚本:定期发送WebSocket握手请求,检查服务是否正常响应。
有一次线上发现客户端大量报“unexpected EOF”,同时服务端连接数并没有明显下降。抓包后发现问题出在负载均衡层:健康检查是HTTP GET,没有升级成WebSocket,结果健康检查永远通过,但实际流量分到的是异常后端的连接。这个坑提示我:所有的健康检查、探活必须基于真实的WebSocket探测,而不是TCP端口检测。
7. 聊几句实在话
回到标题的问题:C#和Golang谁才是WebSocket性能怪兽?
从我的实测数据看,Golang在超高连接数、低延迟、内存占用这三项上综合占优,它天生就是为网络并发而设计的。但C#在.NET 8时代已经追得很近,尤其是低连接数场景下差距几乎可以忽略,再加上ArrayPool、服务器GC、IOCP这些底层优化,C#完全有能力支撑高并发实时服务。选型的核心判断标准不是跑分,而是你的业务形态和团队技术栈。是想要极致的并发性能,还是想要丰富的生态和开发效率?想清楚这个,答案就出来了。
最后分享一个技巧。无论你最终用哪个语言,WebSocket服务上线之前,一定要做“客户端的劣化测试”——模拟网络抖动、随机断连、慢客户端、心跳超时。很多稳定性问题在压测里发现不了,只有在网络条件变差的时候才会暴露。这两个生态里都有很多工具能模拟这些场景,但关键是你要把它纳入到每次发版的回归流程里,而不是上线前临时抱佛脚。