资讯动态

WebSocket群聊架构实战:握手鉴权、消息广播与断线重连

发布时间:2026/10/20 3:19:17 来源:尧图企业网站定制
做群聊功能之前我一直觉得WebSocket不过就是前端new一个socket后端onMessage收一下再广播的事。直到有一次压测时模拟200个客户端同时进房间刷消息服务端CPU直接飙到90%还有一批消息悄无声息地消失在了链路里——客户端没收到、日志查不到、数据库也没有。那次排查逼着我把WebSocket实时通信的完整链路重新捋了一遍从连接握手、心跳保活、房间映射到消息协议、广播策略、断线重连每个环节都有值得深挖的细节。这篇文章就用这套群聊方案做例子把我实际跑通的架构、代码和踩坑过程完整写出来给准备自研聊天室或实时消息推送的开发者做个参考。1. 为什么绕不开群聊场景下的实时通信选型1.1 短轮询和长轮询在群聊里的极限先说需求一个群聊房间几十上百人在线任何一个人发言其他所有人要尽量实时看到。最朴素的实现是短轮询前端每隔两三秒发一次HTTP请求问有没有新消息。表面逻辑很简单但人数一多问题就暴露了。100个人在线每人3秒轮询一次相当于服务器每分钟要接2000个请求其中绝大多数请求的响应都是没有新消息。无意义的请求不仅占带宽还白白消耗CPU去处理HTTP头、建立连接、解析参数。等房间涨到500人的时候请求量曲线会非常难看。长轮询稍微聪明一点客户端发请求后挂住连接服务器有消息才返回没消息就等超时再重连。这样把频繁请求变成持续等待消息实时性也提升到秒级。但代价是服务器必须同时维持大量半开连接每个长轮询连接都要占用一个线程或协程闲着也是闲着内存和网络栈的开销一点不少。而且一旦网络抖动大量长轮询连接会同时超时瞬间重新发起连接形成一波新的风暴。我当时的结论是HTTP轮询类方案适合低频、可容忍延迟的场景但群聊里一条消息要分发到几十上百个客户端每条消息都要产生几十上百次HTTP请求后端承受的是乘数级的压力。这时候就该把连接复用起来——让客户端和服务器之间保持一条持久、双向的通道这就是WebSocket的价值。1.2 WebSocket真正解决了什么问题WebSocket本质上是一条建立在TCP之上的全双工通道客户端和服务器都可以随时向对方发数据不再有请求-响应的被动模式。对群聊来说这意味着服务器收到一条消息后可以直接把消息推给房间内所有在线客户端不需要每个客户端来轮询。更重要的是连接是持久复用的消息头开销从HTTP的几百字节降到WebSocket帧的几字节单位时间内能承载的消息量大幅提升。但引入WebSocket不是零成本。它带来了连接管理复杂度连接是长久的就得考虑断线、心跳、重连连接是有状态的就得考虑服务器端如何跟踪谁在哪个房间里连接会占资源就得考虑系统的连接上限和扩展方案。换句话说WebSocket把实时性问题解决了一部分又把连接生命周期管理问题抛给了你。下面的每一节就是围绕这条路展开的。2. 连接层设计握手鉴权、心跳保活与僵尸连接清理2.1 握手阶段的鉴权细节别只在onOpen里判断WebSocket的握手是一次特殊的HTTP升级请求。很多初学者喜欢在连接建立之后的onOpen回调里做鉴权发现token不对就直接close。这个做法能用但有一个问题如果token在URL参数里那么它会被记到服务器访问日志、浏览器历史、代理缓存里存在泄露风险。更常见的做法是把token放在子协议Sec-WebSocket-Protocol或请求头里传递后端在HTTP升级阶段做校验。实际项目里我用的是子协议方式前端在创建WebSocket时通过protocols参数带上一个鉴权标识后端在握手处理器里先验证验证失败直接返回非101状态码浏览器端就会触发连接失败回调根本不会走到onOpen。// 前端连接时携带鉴权标识 const ws new WebSocket(wss://chat.example.com/ws, [chat, userToken]);# 后端握手阶段校验伪代码 import tornado.websocket class ChatWebSocket(tornado.websocket.WebSocketHandler): def select_subprotocol(self, subprotocols): token subprotocols[-1] # 这里取子协议中的token if self.validate_token(token): self.current_user self.get_user_by_token(token) return token # 校验通过允许握手 self.abort(403) # 校验失败终止握手 return None额外提醒一点握手成功之后服务端就处于带状态的连接状态要把这个连接对象保存到连接管理器里后面广播消息时要遍历它。这个Manager的设计我放在第三部分细说。2.2 心跳机制不是可选项是必选项WebSocket本身没有连接活着吗的检测机制。一条TCP连接如果长时间没数据中间的路由器、负载均衡器、运营商设备都可能把它静默回收而两端都不知道。群聊里用户可能挂着不说话但连接早就被网关掐断了——前端还显示在线实际消息根本到不了。解决心跳的常规做法是服务端每隔一段时间主动发一个Ping帧客户端收到后回Pong帧或者双方约定一个业务层的ping/pong消息。我这里用30秒一次的服务端Ping客户端在收到Ping后立即回一个Pong。实际踩过的坑是Nginx的超时设置。如果WebSocket经过Nginx反代而proxy_read_timeout和proxy_send_timeout默认是60秒那么只要客户端在60秒内没有任何数据交互Nginx就会主动断开连接。哪怕你用心跳保持连接心跳间隔如果大于Nginx超时时间照样会断。所以要么把超时时间调大要么心跳间隔小于超时时间还要留出网络波动的裕量location /ws { proxy_pass http://chat_backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_read_timeout 300s; proxy_send_timeout 300s; }2.3 僵尸连接服务端必须主动清理没有心跳的另一面是服务端堆积大量僵尸连接。客户端直接断网、手机切后台、电脑休眠TCP层可能要好几分钟甚至更久才能感知。如果服务端不及时清理连接对象会一直挂在内存里广播消息时还往一个已经失效的socket里写数据触发异常、堆积缓冲最终影响整个进程。我做的事情有两件。第一每次收到客户端任何消息包括Pong都更新该连接的最后活跃时间。第二起一个后台定时任务每10秒扫一遍连接池把超过90秒没有活跃的连接判定为僵尸主动关闭并移出管理器。这里的90秒是根据心跳周期算出来的比心跳间隔的3倍略短保证正常连接不会被误杀。# 伪代码定时清理僵尸连接 async def sweep_dead_connections(): while True: now time.time() for conn in connection_manager.all_connections(): if now - conn.last_active_time 90: conn.close(code4000, reasonheartbeat timeout) connection_manager.remove(conn) await asyncio.sleep(10)这套逻辑上线之后服务器上长时间无人说话的房间也不再消耗空连接了内存占用明显稳定。3. 房间模型与消息广播群聊数据结构的核心设计3.1 连接、用户、房间三者的映射关系群聊里最核心的数据结构是房间和连接的关系。每一个WebSocket连接对应一个当前用户在当前房间的会话。所以连接管理器要做两层映射一层是连接ID → 连接对象用于全局管理连接生命周期另一层是房间ID → 连接ID集合用于按房间广播。from collections import defaultdict class ConnectionManager: def __init__(self): self.connections {} # conn_id - WebSocket self.rooms defaultdict(set) # room_id - set of conn_id self.conn_to_room {} # conn_id - room_id def add_connection(self, conn_id, room_id, conn): self.connections[conn_id] conn self.rooms[room_id].add(conn_id) self.conn_to_room[conn_id] room_id def remove_connection(self, conn_id): room_id self.conn_to_room.get(conn_id) if room_id: self.rooms[room_id].discard(conn_id) if not self.rooms[room_id]: del self.rooms[room_id] self.connections.pop(conn_id, None) self.conn_to_room.pop(conn_id, None)看到这套结构你可能会问为什么不直接用用户ID → 连接因为同一个用户可能开多个标签页也就是多个WebSocket连接。群聊广播应该发给该房间的所有连接而不是所有用户。如果按用户维度去重反而要处理多端去重推送的问题复杂度更高。聊天室里更合理的做法就是连接即会话。3.2 加入房间和离开房间的时机加入房间的触发点建议放在握手完成之后客户端发一条业务消息join。有一个需要注意的地方如果用户在加入房间之前服务器就已经往连接上推房间历史消息逻辑会混乱。所以我在实现时把加入房间和获取历史消息设计成两个动作客户端先join服务端确认成功客户端再发送fetch_history服务端从消息存储里拉最近N条返回。这样职责单一不容易互相干扰。离开房间同样不能只依赖前端主动通知。用户关页面时WebSocket连接会走onclose流程服务端在onclose里调用remove_connection即可。但如果用户在A房间没退就切到B房间前端应该先发一个绑定消息服务端把该连接从A房间移除、再放进B房间。我在服务端做了一个约定一个连接同一时间只属于一个房间收到join请求时自动做切换房间操作。3.3 广播消息的协议与序列化消息在网络上传输用JSON就够了但字段设计要尽量精简、稳定。我用的协议结构大致是{ type: chat, roomId: 1001, sender: {uid: 123, nick: 小明}, content: 大家好, msgId: 1700000000123, ts: 1700000000123 }type字段用来区分消息类型聊天、系统通知、加入退出提示msgId用雪花ID或时间戳自增序列生成关系到后面的消息补发和顺序ts是消息产生时间前端用它做本地排序参考。广播时服务端干的事情很简单拿到这条消息往rooms[roomId]里的每个连接写数据。但写数据不能同步等待否则一个客户端网络的写缓冲满了会阻塞全局。正确做法是往每个连接的发送队列里异步入队由事件循环统一flushasync def broadcast_room(room_id, message_bytes): for conn_id in list(connection_manager.rooms.get(room_id, set())): conn connection_manager.connections.get(conn_id) if conn: try: await conn.write_message(message_bytes) except Exception as e: connection_manager.remove_connection(conn_id)这个try/except极其重要我项目里早期忘了处理单连接写失败的异常导致一个用户网络断开时整个广播流程崩掉房间内其他人也收不到消息。后来改成异常即断开并移除策略单个坏连接的影响就被隔离开了。4. 后端推送全链路从收到消息到推给所有人的每一步4.1 消息进入服务端的完整流程一条群聊消息从前端发出后服务端要经过解码 → 校验 → 持久化 → 广播四个步骤缺一不可。很多初学者只做了解码和广播不做持久化结果就是离线收不到、刷新就丢失。我的实际链路是这样的第一步on_message收到原始文本先解析JSON拿不到合法JSON就返回错误码。第二步校验用户有没有发言权限。群聊里有禁言逻辑用户被禁言了要拒绝写入。还要校验消息长度我设置的单个消息不超过2000字符超出的截断或者拒绝防止有人一次性刷屏。第三步写消息记录。消息在写入数据库前要先给一个全局递增的msgId我用的方案是Redis自增加时间戳前缀这样客户端可以根据msgId判断消息顺序。数据库这里我用了MySQL的一张消息表字段为id、room_id、sender_uid、content、create_time。单房间消息量大的时候可以按room_id做分表这个后面性能章节再展开。第四步才执行广播。注意顺序先持久化、后广播。这样即使广播瞬时失败消息也已经入库重连后还能通过历史消息拉取补救。如果先广播后写库广播时客户端收到了写库却失败了就会出现客户端有消息、服务端没记录的不一致很难收场。4.2 什么时候引入消息中间件业务初期单机Redis加MySQL完全够用。但如果你预期单房间在线数超过几千、每秒发消息量几千条或者要支撑半离线推送多端同步等能力就可以引入消息中间件了。中间件在这条链路里的作用是解耦WebSocket服务收到消息后把消息发布到主题比如room:1001专门的处理服务订阅这个主题负责写数据库、做风控、再回推给其他WebSocket节点做广播。这样即使某个节点重启消息也不会丢因为中间件里还有积压。用中间件还有一个隐性好处的场景是多机部署。单机WebSocket服务广播时直接遍历本机连接就行但多机部署后一个房间的成员可能分散在A、B两台机器上A机没法直接把消息推给连在B机上的用户。这时候就需要把广播动作变成一次发布所有节点都订阅同一个主题收到后再往自己机器上的房间连接推送。这个方案比节点间互相查连接表简单得多也更容易扩展。4.3 消息丢失的补偿离线消息和历史消息无论链路设计得多好总会有消息丢失客户端网络瞬间中断、服务器重启、广播写失败。所以我必须给消息链路加两层保险。第一层是离线消息补拉。客户端重连成功后带上本地最新一条消息的msgId服务端从数据库查出该msgId之后的消息补发回去。实现上就是一条SQLSELECT id, sender_uid, content, create_time FROM chat_message WHERE room_id %s AND id %s ORDER BY id ASC LIMIT 200;注意消息量和拉取上限的关系如果消息全部返回客户端可能瞬间被大量消息撑爆所以限制最多补发200条再早的让用户通过翻历史记录加载。第二层是重试机制。前端发送消息时如果连接断开需要把消息临时存到本地队列里等待重连成功后再发送。我当时的做法是前端维护一个pendingMessages数组在ws.onmessage确认消息已经被服务端接收返回一个ack后才出队否则一直保留。服务端因为先入库后广播所以ack的返回时机是消息已写入数据库而不是已广播成功。这样前端就能很确定消息到底有没有成功。5. 前端接入细节状态机、重连与消息展示5.1 WebSocket连接状态机别裸奔前端WebSocket有四种原生状态CONNECTING、OPEN、CLOSING、CLOSED。但如果界面上只根据open和close两种状态切换显示思考并不周全。比如断线后自动重连这期间用户发送消息怎么办重连成功后要不要刷新页面连不上要不要提示用户。我的做法是在原生状态之上封装一个连接状态机包含connecting、open、reconnecting、closed。核心逻辑是初次连接状态为connecting失败则进入reconnecting做指数退避重连。重连成功状态变为open同时触发历史消息补拉。重连失败继续退避退避上限为30秒。用户手动点击退出状态直接置为closed停止一切重连。let retry 0; function connect() { const ws new WebSocket(wss://chat.example.com/ws, [chat, token]); ws.onopen () { retry 0; setStatus(open); joinRoom(currentRoomId); pullHistory(); }; ws.onclose () { if (manualClose) return; setStatus(reconnecting); const delay Math.min(1000 * Math.pow(2, retry) Math.random() * 1000, 30000); retry 1; setTimeout(connect, delay); }; ws.onmessage (e) handleMessage(e.data); }这段代码里有几个细节一是退避延迟里加了随机抖动避免大量客户端同时断线后同时重连形成重连风暴二是重连成功之后必须重新加入房间和补拉历史消息这两个动作一旦遗漏会出现用户看起来在线但永远收不到新消息的诡异故障。5.2 消息顺序为什么按msgId而不是按到达时间WebSocket本身不保证消息按序到达的场景其实很少单条持久连接上消息基本是顺序的。但重连补拉的历史消息和实时推送的新消息可能混合到达。如果前端直接append就会出现新消息被插到历史消息前面的错乱。我的解决办法是前端维护一个消息数组插入时按msgId比较位置。实时消息到达时插入到正确的位置补拉的历史消息也是一样。只要msgId是递增的这个方案就能处理绝大多数顺序问题。界面渲染再配合ts时间戳做分组展示基本不会错。function insertMessage(msg) { const idx messages.findIndex(m m.msgId msg.msgId); if (idx -1) { messages.push(msg); } else { messages.splice(idx, 0, msg); } renderLatest(); }5.3 输入框、滚动与表情UI层容易被忽略的问题群聊UI有三件容易被忽略的事。第一消息列表在滚动到底部时才能自动跟随新消息如果用户往上翻看历史消息新消息到达时不能强制把他拽回底部要等用户主动回到底部才恢复跟随。第二发送消息的输入框在移动端会被软键盘顶起来WebSocket连接一般不受影响但页面布局要处理好。第三本地发消息要乐观更新消息发出后立刻在界面上显示同时标记发送中收到服务端ack后再把状态改成已发送。如果等ack才显示会感觉界面反应慢了半拍。前端这块我建议把WebSocket逻辑单独封装成一个类不要散落在组件里。类负责连接管理、消息收发、重连策略UI组件只订阅暴露出来的状态和消息流。这样后面维护起来省心很多。6. 上线前后实测的坑与优化建议6.1 压测时发现的消息消失连接失效后广播失败文章开头说的那次压测消息失踪的根因后来定位到了广播循环里用了一个缓存的连接列表而某些连接其实已经失效了写入时报错但异常被某个宽泛的handler吞掉循环直接continue导致后面所有连接都没收到消息。这句代码except: pass害人不浅。修复方案很明确广播必须使用内存中最新的连接列表每次广播前重新从管理器取写失败时把这个连接从管理器移除不能再让坏连接占用列表。同时广播逻辑里严禁捕获异常后无声无息地跳过。# 错误示范 for conn in cached_connections: try: conn.write_message(msg) except: pass # 消息消失的根源 # 正确做法 for conn_id, conn in connection_manager.snapshot_room(room_id): try: await conn.write_message(msg) except Exception: connection_manager.remove_connection(conn_id)排查这类问题我建议直接在压测脚本里专门模拟一边发消息一边断开连接破坏性场景能提前暴露这种隐患。6.2 系统层面的一连接一进程配置单机撑大几千到上万WebSocket连接不是代码写完就完事了操作系统默认配置经常不够用。最常见的是文件描述符上限。一个WebSocket连接最少占用1个fd压测时发现连接数到1024就不再增长就是ulimit -n限制。我当时的调优如下修改/etc/security/limits.conf把nofile调高到500000并重启服务。调整sysctl的net.ipv4.ip_local_port_range保证高并发时本地端口够用。如果使用Linux可以开启net.ipv4.tcp_tw_reuse来回收TIME_WAIT连接不过WebSocket长连接场景下TIME_WAIT不是主要矛盾短连接才会。内存方面也要心里有数每个WebSocket连接包含socket缓冲区、连接对象、协程栈粗算需要几十KB。一万个连接大概就是几百MB内存。压测时可以通过ss -s观察socket内存或者用ps看进程的RSS曲线。6.3 广播风暴高频率消息下的性能瓶颈群聊里如果几十人同时刷屏广播的压力是被放大的。假设一个房间100人每秒产生10条发言每条发言要推送100份也就是每秒1000次socket读写。单机协程模型扛得住但你要是不写异步、用同步阻塞IO这1000次写操作就会把事件循环卡死。我的优化经验有两条。第一广播时批量合并同一毫秒内到达同一房间的N条消息合成一条批量消息帧推给所有连接减少IO次数。第二给每个连接加一个发送队列队列积压到一定阈值比如超过200条说明该客户端消费速度跟不上就把连接强制重连避免消息无限堆积拖垮服务端。这里的取舍要权衡宁可让少数慢客户端重连补拉也不要让整体链路积压成雪崩。6.4 多机扩展从单机广播到分布式发布订阅如果业务再往上走单机不可能无限加连接。多机部署是必然方向。前面说过广播在多机场景下要借助发布订阅机制。但基建之外有一个坑需要提前规划连接断开后前端重连可能会被负载均衡分配到另一台机器上此时该机器上没有这个用户的历史连接记录补拉逻辑完全正常因为消息在数据库里。真正要注意的是房间内的在线人数统计如果每台机器只统计本机连接房间人数会不准确需要定期把各节点数据汇总或以Redis为中心做人数计数。另外进程重启时连接全部断开客户端会全部重连。如果同时有几千个客户端在几秒内重连会瞬间冲击服务端。我的建议是服务端采取滚动重启一台一台来前端做分散重连降低瞬间连接风暴的概率。6.5 写库压力与消息表的分库分表群聊消息的写库频率其实很高。100个活跃用户平均每人每分钟发1条消息每分钟就是100条插入一小时6000条看起来不大。但如果是大型活动群几千人同时发言每秒几十到几百条写操作单表MySQL就开始吃力。这个阶段我建议消息表按room_id做分片同一个房间的消息尽量落到同一张表保证历史消息查询效率。写入走批量插入把短时间内同房间的消息攒批提交减少事务开销。历史消息查询走独立的只读从库和写入主库分离。需要强调的是分组和排序不要依赖数据库来做join消息表里冗余sender_uid和nick字段即可。列表接口一次查出一页消息前端直接用别在查询时join用户表否则实时性越高性能越差。最后分享两个小技巧第一调试WebSocket问题时别只盯着业务日志。可以在Nginx层开access_log看升级请求的状态码在服务端把连接建立、断开、心跳超时都打上结构化日志包含connId、房间ID、时间戳排查问题会轻松很多。第二如果只是需要一个能用的群聊先别急着上微服务和消息中间件一台服务器加事件循环加MySQL完全能扛住初期几千人在线架构简单反而好维护。我这套方案也是先单机跑通等用户量涨了再逐步拆分出来的。技术选型永远是为业务阶段服务的先把链路跑扎实后面扩展路就好走多了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑