1. 从零搭建一个能扛住真实流量的即时聊天服务
聊到即时通讯,很多人第一反应是“上个WebSocket不就完了”。真到动手的时候才发现,连接管理、心跳保活、消息可靠投递、多实例横向扩展,每一个都是坑。我用C# WebApi配合WebSocket做过几套内部沟通工具和客服系统,从最初单机跑几十个连接就飘红,到后来稳定支撑几千并发在线,中间踩的坑足够写一本小册子。这篇就把整套思路和实操细节摊开讲清楚,适合已经会写C# WebApi、想往实时通信方向深入的后端开发者,也适合正在选型即时聊天方案的技术负责人。
核心要解决的问题很明确:怎么在一个标准的ASP.NET WebApi项目里,把WebSocket这条长连接通道跑通、跑稳,并且让它具备生产环境可用的基本素质。所谓“基本素质”,至少包括连接生命周期管理、心跳检测、消息格式约定、异常断线重连、以及单机到多机的扩展路径。下面按我实际落地的顺序,一层层拆。
2. 整体架构设计与技术选型思路
2.1 为什么是WebSocket而不是轮询或SSE
即时聊天最核心的诉求是服务端能主动推消息。短轮询是客户端定时问“有没有新消息”,延迟高、无效请求多;长轮询稍微好点,但每次消息推送都要重新建立一次HTTP请求,连接开销摆在那里。SSE(Server-Sent Events)是单向的,服务端推客户端收没问题,但聊天需要双向,客户端发消息还得另开HTTP接口,架构上就割裂了。
WebSocket在TCP之上做了一次HTTP升级握手,之后就是全双工通道,双方随时可以发数据。对于聊天场景,这是最自然的模型。在ASP.NET里,WebSocket有两种接入方式:一种是直接用System.Net.WebSockets配合中间件,另一种是用SignalR。SignalR封装了连接管理、分组、重连、降级,开箱即用,但它的抽象层也意味着你对底层连接的控制力变弱,排查问题时要穿透好几层。我选的是原生WebSocket方案,原因有三:一是聊天业务的消息格式和路由逻辑我们完全自定义,不需要SignalR的Hub模型;二是原生方案对连接状态的掌控更直接,心跳、超时、缓冲区这些参数都能自己调;三是排查线上问题时,少一层抽象就少一层迷雾。
2.2 单机架构的模块划分
一个最小可用的聊天服务,我把它拆成四个模块。连接管理层负责WebSocket的Accept、生命周期回调、连接池维护;消息路由层负责解析客户端消息、判断消息类型(文本、心跳、控制指令)、分发到对应处理器;业务逻辑层处理具体的聊天语义,比如私聊、群聊、消息落库;推送层负责把消息投递给目标连接,包括在线直推和离线存储。
这四层之间通过接口解耦,连接管理层不关心消息内容,业务层不关心连接怎么建立。这样拆的好处是,后面要换存储、加消息队列、做多实例广播,改动都局限在某一层。
2.3 连接标识的设计
每个WebSocket连接需要一个唯一标识。我见过有人直接用Guid.NewGuid(),这没问题,但有个隐患:客户端重连后会拿到新ID,服务端无法识别“这是同一个人又回来了”。我的做法是双标识:连接ID用Guid,标识这一次TCP会话;用户ID由客户端在握手后的第一条认证消息里带上,服务端校验后把两者绑定。这样重连时,旧连接ID对应的会话被清理,新连接ID绑定到同一个用户ID,业务层始终按用户ID找连接。
连接池用ConcurrentDictionary<string, WebSocketSession>存,key是连接ID。同时维护一个ConcurrentDictionary<string, string>做用户ID到连接ID的映射,方便按用户推送。两个字典的增删要保证原子性,否则会出现用户映射指向一个已关闭连接的情况。
3. 核心细节解析与实操要点
3.1 WebSocket握手与中间件配置
在ASP.NET Core里启用WebSocket,第一步是在Program.cs或Startup.cs里注册WebSocket中间件。这里有个顺序坑:UseWebSockets必须放在UseRouting之后、UseEndpoints之前,否则路由匹配不到你的WebSocket端点。
var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = TimeSpan.FromSeconds(30), AllowedOrigins = { "https://your-frontend-domain" } }); app.Map("/ws", async context => { if (context.WebSockets.IsWebSocketRequest) { var webSocket = await context.WebSockets.AcceptWebSocketAsync(); await HandleConnection(context, webSocket); } else { context.Response.StatusCode = 400; } }); app.Run();KeepAliveInterval这个参数值得说。它控制的是WebSocket协议层面的Ping帧发送间隔,默认是2分钟。设成30秒是为了更早发现死连接。但注意,协议层的Ping/Pong是底层自动处理的,应用层感知不到,所以它只能保活,不能用来做业务层的心跳检测。业务心跳还得自己实现,后面细说。
AllowedOrigins是防跨站WebSocket劫持的第一道门。不设的话,任何网页都能连你的WebSocket端点。生产环境必须配成你的前端域名。
3.2 连接生命周期管理
AcceptWebSocketAsync之后,连接就进入活跃状态。整个生命周期围绕一个while循环展开,不断调用ReceiveAsync读取客户端发来的数据。
private async Task HandleConnection(HttpContext context, WebSocket webSocket) { var session = new WebSocketSession { ConnectionId = Guid.NewGuid().ToString("N"), Socket = webSocket, ConnectedAt = DateTime.UtcNow, LastHeartbeat = DateTime.UtcNow }; _connectionPool.TryAdd(session.ConnectionId, session); var buffer = new byte[1024 * 4]; var segment = new ArraySegment<byte>(buffer); try { while (webSocket.State == WebSocketState.Open) { var result = await webSocket.ReceiveAsync(segment, CancellationToken.None); if (result.MessageType == WebSocketMessageType.Close) { await webSocket.CloseAsync( WebSocketCloseStatus.NormalClosure, "Client closed", CancellationToken.None); break; } if (result.MessageType == WebSocketMessageType.Text) { var message = Encoding.UTF8.GetString(buffer, 0, result.Count); await _messageRouter.RouteAsync(session, message); } } } catch (WebSocketException ex) { _logger.LogWarning(ex, "WebSocket异常断开: {ConnectionId}", session.ConnectionId); } finally { await CleanupSession(session); } }这里有几个实操要点。缓冲区大小:4KB对聊天消息够用,但如果客户端可能发大文件或长文本,需要处理分片消息。ReceiveAsync返回的result.EndOfMessage为false时,说明消息还没收完,要继续读。我一般会在消息协议里限制单条消息最大长度,比如64KB,超过就拒绝,避免内存被撑爆。
异常处理:WebSocketException是常态,客户端网络抖动、进程被杀、手机切后台,都会导致连接异常断开。不要把它当错误日志打,用Warning级别记录就够了,否则日志会被刷爆。
清理逻辑:finally块里必须做清理,从连接池移除、解除用户映射、通知业务层用户下线。清理要幂等,因为可能被调用多次。
3.3 心跳机制的设计与实现
WebSocket协议层的Ping帧能检测TCP层面的死连接,但检测不到“TCP还活着但应用已经卡死”的情况。比如客户端进程假死,TCP连接还在,但不再处理任何消息。所以应用层心跳是必须的。
我的方案是:客户端每30秒发一条{"type":"heartbeat"},服务端收到后更新session.LastHeartbeat。服务端起一个后台定时任务,每10秒扫一遍连接池,把LastHeartbeat超过90秒没更新的连接强制关闭。
public class HeartbeatMonitor : BackgroundService { private readonly IConnectionPool _pool; private readonly ILogger<HeartbeatMonitor> _logger; private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(90); protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { var now = DateTime.UtcNow; foreach (var session in _pool.GetAll()) { if (now - session.LastHeartbeat > Timeout) { _logger.LogInformation("心跳超时,关闭连接: {ConnectionId}", session.ConnectionId); await session.Socket.CloseAsync( WebSocketCloseStatus.NormalClosure, "Heartbeat timeout", CancellationToken.None); } } await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); } } }为什么是90秒?这是三个心跳周期。客户端30秒发一次,网络抖动丢一两次很正常,给三次机会。太短会误杀,太长则死连接占用资源。这个值可以根据你的网络环境调,移动端网络差的话可以放宽到120秒。
注意:心跳消息不要走业务消息的落库和路由逻辑,在消息路由层最前面就拦截掉,直接更新
LastHeartbeat然后返回。否则每条心跳都触发一次数据库操作,几千连接就是几千次无谓写入。
3.4 消息协议设计
消息格式我推荐用JSON,可读性好,调试方便。每条消息至少包含三个字段:type标识消息类型,payload承载数据,timestamp用于排序和去重。
{ "type": "chat.private", "payload": { "toUserId": "user_002", "content": "你好", "clientMsgId": "c_123456" }, "timestamp": 1700000000000 }clientMsgId是客户端生成的唯一ID,用于消息去重和ACK确认。服务端收到消息后,先落库,再推送,推送成功后给发送方回一条{"type":"ack","payload":{"clientMsgId":"c_123456"}}。如果客户端在超时时间内没收到ACK,就重发。这样能保证消息不丢。
消息类型我一般分这几类:heartbeat心跳、auth认证、chat.private私聊、chat.group群聊、ack确认、error错误通知。类型用字符串而不是数字,日志里一眼能看懂。
3.5 消息路由与分发
消息路由层的职责是:解析JSON、校验字段、根据type分发。我用一个字典做类型到处理器的映射,避免一长串if-else。
public class MessageRouter { private readonly Dictionary<string, IMessageHandler> _handlers; public MessageRouter(IEnumerable<IMessageHandler> handlers) { _handlers = handlers.ToDictionary(h => h.MessageType); } public async Task RouteAsync(WebSocketSession session, string rawMessage) { MessageEnvelope envelope; try { envelope = JsonSerializer.Deserialize<MessageEnvelope>(rawMessage); } catch (JsonException) { await SendError(session, "消息格式错误"); return; } if (!_handlers.TryGetValue(envelope.Type, out var handler)) { await SendError(session, $"未知消息类型: {envelope.Type}"); return; } await handler.HandleAsync(session, envelope); } }每个处理器实现IMessageHandler接口,只关心自己的业务。新增消息类型时,加一个处理器类就行,不用改路由代码。这是开闭原则的典型应用。
4. 完整实操流程与关键环节实现
4.1 项目初始化与依赖配置
新建一个ASP.NET Core WebApi项目,目标框架选.NET 6或更高。不需要额外NuGet包,System.Net.WebSockets是框架自带的。如果你要用JSON序列化,System.Text.Json也是内置的,性能比Newtonsoft.Json好,尤其是高并发场景。
项目结构我习惯这样组织:
/WebSocketChat /Handlers 消息处理器 /Models 消息模型 /Services 连接池、心跳、推送 /Middlewares WebSocket中间件 Program.csProgram.cs里注册服务:
builder.Services.AddSingleton<IConnectionPool, ConnectionPool>(); builder.Services.AddSingleton<MessageRouter>(); builder.Services.AddSingleton<IPushService, PushService>(); builder.Services.AddHostedService<HeartbeatMonitor>(); builder.Services.AddScoped<AuthHandler>(); builder.Services.AddScoped<ChatPrivateHandler>();连接池和路由必须是单例,因为它们要跨连接共享状态。处理器可以是Scoped,但如果处理器里注入了单例服务,用Singleton也没问题。
4.2 认证握手流程
WebSocket握手阶段可以带Token,但我不建议在URL里带,因为URL会被记录到访问日志里,有泄露风险。我的做法是:连接建立后,客户端在5秒内发送第一条auth消息,带上Token。服务端校验通过后,把连接标记为已认证,并绑定用户ID。5秒内没认证的连接直接关闭。
public class AuthHandler : IMessageHandler { public string MessageType => "auth"; public async Task HandleAsync(WebSocketSession session, MessageEnvelope envelope) { var token = envelope.Payload.GetProperty("token").GetString(); var userId = ValidateToken(token); if (userId == null) { await SendError(session, "认证失败"); await session.Socket.CloseAsync( WebSocketCloseStatus.PolicyViolation, "Auth failed", CancellationToken.None); return; } session.UserId = userId; session.IsAuthenticated = true; _connectionPool.BindUser(userId, session.ConnectionId); await SendAsync(session, new { type = "auth.ok", payload = new { userId } }); } }未认证的连接不能发聊天消息,路由层要检查session.IsAuthenticated,未认证的直接拒绝。这是安全底线。
4.3 私聊消息的完整链路
一条私聊消息从发送到接收,经过这些环节:客户端A发消息 → 服务端路由到ChatPrivateHandler→ 校验参数 → 消息落库 → 查B是否在线 → 在线则推送,离线则存离线消息 → 给A回ACK。
public class ChatPrivateHandler : IMessageHandler { public string MessageType => "chat.private"; public async Task HandleAsync(WebSocketSession session, MessageEnvelope envelope) { if (!session.IsAuthenticated) { await SendError(session, "未认证"); return; } var toUserId = envelope.Payload.GetProperty("toUserId").GetString(); var content = envelope.Payload.GetProperty("content").GetString(); var clientMsgId = envelope.Payload.GetProperty("clientMsgId").GetString(); if (string.IsNullOrWhiteSpace(content) || content.Length > 5000) { await SendError(session, "消息内容不合法"); return; } var message = new ChatMessage { Id = Guid.NewGuid().ToString("N"), FromUserId = session.UserId, ToUserId = toUserId, Content = content, SentAt = DateTime.UtcNow, ClientMsgId = clientMsgId }; await _messageRepository.SaveAsync(message); var targetConnectionId = _connectionPool.GetConnectionIdByUser(toUserId); if (targetConnectionId != null) { var targetSession = _connectionPool.Get(targetConnectionId); if (targetSession != null && targetSession.Socket.State == WebSocketState.Open) { await _pushService.PushAsync(targetSession, new { type = "chat.private", payload = new { messageId = message.Id, fromUserId = session.UserId, content = content, sentAt = message.SentAt } }); } } else { await _offlineMessageStore.SaveAsync(toUserId, message); } await SendAsync(session, new { type = "ack", payload = new { clientMsgId, messageId = message.Id } }); } }这里有个细节:推送和ACK的顺序。先推送再ACK,还是先ACK再推送?我选先推送再ACK。因为如果先ACK,客户端以为消息已送达,但实际上推送可能失败。先推送,推送成功后再ACK,语义更准确。但推送失败也不应该阻塞ACK,否则发送方会一直重发。我的做法是推送失败记日志,ACK照发,离线消息兜底。
4.4 群聊消息的扇出策略
群聊比私聊复杂在扇出。一个500人的群,一条消息要推给500个连接。如果串行推送,500次网络IO,延迟会很高。我用Task.WhenAll并发推送,但加一个并发上限,比如同时最多50个推送任务,避免线程池被占满。
public async Task PushToGroupAsync(string groupId, object message) { var memberIds = await _groupService.GetMemberIdsAsync(groupId); var onlineSessions = memberIds .Select(uid => _connectionPool.GetConnectionIdByUser(uid)) .Where(cid => cid != null) .Select(cid => _connectionPool.Get(cid)) .Where(s => s != null && s.Socket.State == WebSocketState.Open) .ToList(); var semaphore = new SemaphoreSlim(50); var tasks = onlineSessions.Select(async session => { await semaphore.WaitAsync(); try { await _pushService.PushAsync(session, message); } finally { semaphore.Release(); } }); await Task.WhenAll(tasks); }群成员列表要缓存,不能每次发消息都查数据库。我用内存缓存加5分钟过期,群成员变动时主动失效缓存。500人的群,缓存命中时扇出耗时大概几十毫秒,可以接受。
4.5 多实例部署的广播方案
单机连接池只能管本机的连接。部署两个实例时,A实例上的用户给B实例上的用户发消息,B实例的连接池里找不到目标连接。解决方案是引入一个消息中间件做广播。
我的做法是:每个实例订阅一个广播频道。当A实例要推送消息给某个用户时,先查本机连接池,找到就直接推;找不到就把消息发到广播频道,所有实例收到后各自查自己的连接池,谁有目标连接谁推。这样不需要维护全局连接路由表,实现简单。
广播消息要带一个sourceInstanceId,实例收到广播后先判断是不是自己发的,是就忽略,避免重复处理。消息中间件可以用Redis的Pub/Sub,轻量够用。如果对可靠性要求高,用RabbitMQ或Kafka,但复杂度也上去了。内部聊天工具用Redis Pub/Sub足够了。
5. 常见问题与排查技巧实录
5.1 连接建立失败排查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 握手返回400 | 请求不是WebSocket升级请求 | 检查客户端是否用了ws://或wss://,请求头是否有Upgrade: websocket |
| 握手返回403 | 跨域被拒 | 检查AllowedOrigins配置,确认前端域名在白名单里 |
| 握手返回404 | 路由没匹配上 | 检查Map("/ws")路径和客户端连接路径是否一致 |
| 连接建立后立即断开 | 认证超时 | 检查客户端是否在5秒内发了auth消息 |
| 连接建立后立即断开 | 中间件顺序错误 | 确认UseWebSockets在UseRouting之后 |
5.2 消息收不到的五种情况
第一种,连接已关闭但客户端不知道。TCP半开连接很常见,客户端以为还连着,实际服务端已经关了。解决办法就是心跳,客户端超过一定时间没收到任何消息就主动重连。
第二种,消息被路由到了错误的处理器。检查type字段拼写,大小写敏感。我踩过这个坑,客户端发的是chat.Private,服务端注册的是chat.private,结果消息被当成未知类型丢弃。
第三种,推送时目标连接状态不是Open。WebSocketState有Open、Closing、Closed、Aborted几种。只有Open能发数据。推送前必须检查状态,否则会抛异常。
第四种,并发写同一个WebSocket。WebSocket不允许并发写,两个线程同时调SendAsync会抛InvalidOperationException。解决办法是给每个session加一个SemaphoreSlim(1,1),发送前先Wait。
public async Task PushAsync(WebSocketSession session, object message) { await session.SendLock.WaitAsync(); try { if (session.Socket.State != WebSocketState.Open) return; var json = JsonSerializer.Serialize(message); var bytes = Encoding.UTF8.GetBytes(json); await session.Socket.SendAsync( new ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, CancellationToken.None); } finally { session.SendLock.Release(); } }第五种,消息太大超过缓冲区。默认缓冲区4KB,超过会分片。如果客户端没处理分片,就会解析失败。要么限制消息大小,要么在服务端做分片重组。
5.3 内存泄漏的隐蔽来源
连接池是内存泄漏的重灾区。常见问题:连接关闭后没从字典移除,字典越来越大。解决办法是在finally块里强制移除,并且加一个定时清理任务,扫描状态为Closed或Aborted的连接,从池里剔除。
另一个泄漏点是事件订阅。如果给WebSocket的Closed事件挂了处理器,连接关闭后没取消订阅,处理器会一直持有session引用。我的建议是尽量不用事件,用while循环加try-finally,逻辑更可控。
5.4 性能调优的几个参数
KeepAliveInterval设30秒,ReceiveAsync的缓冲区设4KB到16KB,心跳超时设90秒,群聊扇出并发设50。这些值不是拍脑袋定的,是根据实际压测调的。压测工具可以用websocat或者自己写个C#客户端,模拟1000个连接同时在线,每个连接每秒发一条消息,观察CPU、内存、GC频率。
如果GC频繁,检查是不是每条消息都创建了大量临时对象。JSON序列化用System.Text.Json的Utf8JsonWriter直接写字节,比先序列化成字符串再转字节少一次内存分配。
6. 从单机到生产的扩展路径
单机跑通之后,下一步就是考虑怎么扩展。垂直扩展简单,加CPU加内存,但WebSocket是长连接,单机连接数受限于文件描述符和内存,一般几千到一万就到顶了。水平扩展是必然选择。
水平扩展的核心是连接路由。用户A连在实例1,用户B连在实例2,A给B发消息,实例1必须知道B在实例2上。方案有两种:一是维护全局路由表,用Redis存userId -> instanceId的映射,发消息前查一下;二是广播,消息发到所有实例,谁有目标连接谁推。路由表方案精准但多一次Redis查询,广播方案简单但消息量乘以实例数。我选广播,因为聊天消息量不大,广播的额外开销可以接受,而且省去了路由表一致性的维护成本。
再往上走,消息量大了,广播会成为瓶颈。这时候可以引入一致性哈希,把用户按ID哈希到固定实例,消息直接路由到目标实例。但这套复杂度高,除非真的到了那个量级,否则没必要。
实操心得:多实例部署时,客户端的重连策略要配合。客户端断线后不要立即重连,加一个指数退避,第一次1秒,第二次2秒,第三次4秒,最多30秒。否则服务端重启时,几千个客户端同时重连,会把服务端打挂。
7. 一些踩坑之后的个人体会
这套方案我在几个内部系统里跑过,最深的体会是:WebSocket本身不难,难的是把它当生产级服务来运营。连接管理、心跳、重连、消息可靠性,每一个环节都需要仔细设计。最开始我只关注“消息能不能发出去”,后来才发现“消息发出去之后怎么确认对方收到了”“对方没收到怎么补发”“服务端重启后客户端怎么恢复”这些问题才是真正花时间的。
另一个体会是,不要过早引入复杂中间件。我一开始就想上Kafka做消息总线,结果运维成本高,调试困难。后来换成Redis Pub/Sub,简单够用,出问题也好排查。技术选型要匹配当前阶段的实际需求,留好扩展点就行,不必一步到位。
最后分享一个小技巧:在消息协议里加一个traceId字段,客户端生成,服务端透传。排查问题时,拿这个ID去日志里搜,从客户端发送到服务端处理到目标客户端接收,整条链路一目了然。这个字段平时没用,出问题时能省你几个小时。