news 2026/10/14 13:27:14

多人聊天系统架构设计:WebSocket、心跳保活与广播实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
多人聊天系统架构设计:WebSocket、心跳保活与广播实践

简介:这是基于JSP+Servlet的多人聊天系统Java Web项目源码包,面向正在学习Java Web动态网页开发、希望实践多人实时通信的开发者。压缩包共11个文件,包含6个class编译文件、2个Java源文件以及classpath、prefs、project等工程配置,整体仅12KB;src目录保存Java源码,bin目录为编译产物,工程文件可直接被Eclipse等IDE识别。项目核心涵盖JSP页面、Servlet业务处理及数据库连接相关代码,通过分析可理解session维护用户会话、request接收消息、Servlet负责消息存储与广播、再配合AJAX实现无刷新聊天的完整思想。服务器端借助Servlet广播消息,客户端通过JavaScript/AJAX异步刷新,代码量不大却覆盖完整通信链路,并可进一步扩展WebSocket、聊天记录持久化等方向,是理解Web实时交互的典型范例。已有676人学习下载,适合课程设计、毕业设计或想快速掌握JSP/Servlet+数据库交互的初中级开发者参考。

1. 多人聊天系统,难点从来不在「收发消息」

多人聊天系统这四个字,看起来是实时通信里最容易下手的项目:用户连上服务端,说一句话,房间里所有人收到。我第一次接某聊天模块时就吃了这种看法的亏,单房间十几个人在线,消息已经开始延迟,接着是反复断连、重复消息、离线消息丢失。问题从来不在 WebSocket 本身,而在连接生命周期、心跳保活、广播扇出、消息可靠性和离线补偿这些 demo 不会替你写的细节。这篇笔记按「连接模型 → 服务端 → 前端 → 排查 → 扩展」的顺序,把多人聊天系统做成一条可复现的落地路径,送给想自建群聊、客服系统或直播评论组件的人。

2. 先搭骨架:多人聊天的连接模型与技术选型

动手写代码前,先把模型想清楚。多人聊天和单聊最大的区别在于扇出:单聊是一条消息写一个接收方,多人聊天是一条消息要写房间里的 N 个接收方。N 越大,广播路径越长,系统瓶颈越明显。后面所有架构决策,本质上都是在回答一个问题:当一条消息需要被 N 个连接消费时,怎么让延迟和资源消耗都可控。

2.1 连接模型:长连接、房间与会话表

多人聊天的主流载体是 WebSocket 长连接。短轮询和长轮询在浏览器端实现更简单,但每秒一次甚至更频繁的 HTTP 请求,在在线人数上来后会产生大量无效请求,服务端压力主要耗在握手和头部解析上;SSE 虽然能服务端推送,但它是单向的,客户端上行消息仍要另走 HTTP,消息顺序要额外对齐。所以做多人聊天,我一般直接选 WebSocket,客户端到服务端是同一套双向信道,语义最简单。

连接建立后,服务端需要维护三张表:全局连接表、房间表、以及每条连接对应的用户标识。全局连接表负责记录当前在线的人;房间表负责把用户分组;用户标识负责在消息里告知「这句话是谁说的」。路由过程可以压缩成一句:客户端把消息发给服务端,服务端根据消息里的房间号找到该房间的所有连接,然后逐一扇出。听起来简单,但这里有第一个坑:广播是写操作,而且是对 N 个连接各写一次,不是一次写成功就行。每条连接的网络状况不同,有人 4G 弱网,有人刚切 WiFi,如果广播时同步等待每条连接写完,一个慢连接就会拖住整个房间的消息。

常见的解法是把每个连接拆成两个独立的循环:读循环只负责从连接上收消息,收到后丢给广播器;写循环只负责从该连接专属的发送队列里取消息,再写到连接上。广播器只需把消息放进每个连接的发送队列,不用等它真正写出去。发送队列一般是有缓冲的 channel 或队列,缓冲满了说明这个连接消费太慢,直接断开比堵住全房间更划算。

房间粒度也要提前设计。按「房间 ID 哈希取模」分片,或者按「房间维度建 Hub」,本质都是把广播范围缩小。我见过一些项目把全部在线连接放在一个大 map 里,每条消息都遍历全量连接再判断是否属于该房间,在线人数过千后 CPU 明显上涨。正确做法是提前按房间把连接分桶,广播只遍历目标桶,遍历成本从 O(在线人数) 降为 O(房间人数)。

2.2 技术选型:单机起步怎么选语言和框架

多人聊天系统的技术选型,主要矛盾在并发连接数和内存占用。下表是我在单机起步阶段常用的对比维度:

维度Go(我首选)Node.jsJava(Spring Boot)
并发模型goroutine,每连接一个协程,开销约 2KB 起步单线程事件循环,靠异步回调线程池,连接多了要调优
内存占用中等偏低,适合大量长连接较低,但回调链一长就难维护偏高,启动即占数百 MB
部署编译成单个二进制,扔到服务器就能跑需要 Node 运行时,依赖安装需要 JRE 和打包,启动偏重
生态成熟度标准库够用,WebSocket 第三方库齐全npm 生态丰富生态丰富但样板代码多
适合规模单机数千到数万连接可扛单机数千连接容易,再往上要调 GC适合团队已有 Java 栈的存量系统

我的习惯是单机起步直接用 Go。原因不是 Node.js 不行,而是 Go 的并发模型和聊天业务天然匹配:每来一个连接就开一个 goroutine 处理,代码写起来像同步逻辑,不用手动管理异步状态;内存上,一个空闲连接占用的 goroutine 栈远小于 Java 线程栈。如果团队只有前端,Node.js 也能做,但回调里处理广播扇出和心跳超时,代码复杂度会随功能增加涨得很快。Java 不是不能做,只是单机长连接场景下要额外处理线程池参数,投入产出比不如 Go 直接。

选型时不要只看语言,还要看部署环境。我踩过一次坑:在某公司内网服务器上部署 Node.js 版本,服务器没有外网 npm 源,只能手动拷贝依赖包,版本差异折腾了大半天。Go 编译成单二进制后,拷过去就能跑,这个优势在隔离网络环境下非常实用。

2.3 消息协议:一封 JSON 信封里装什么

多人聊天的消息协议不需要复杂,但字段要够用。我给客户端和服务端定义统一的 JSON 信封,所有消息都走同一个结构:

{ "type": "chat", "room_id": "room-001", "client_msg_id": "a1b2c3d4-0001", "sender_id": "user-42", "payload": "hello everyone", "ts": 1700000000123 }

字段设计有几个讲究。type 用来区分消息类型,常见的有 join、leave、chat、heartbeat、ack;room_id 决定消息路由到哪个房间;client_msg_id 是客户端生成的全局唯一 ID,服务端用它做幂等去重;sender_id 是发送者标识;payload 是业务内容;ts 是客户端时间戳,只做展示用,排序以服务端分配的序号为准。

字段类型作用与边界
typestring消息类型,服务端按它分发处理逻辑
room_idstring房间标识,广播范围依据
client_msg_idstring客户端生成,服务端去重依据
sender_idstring发送者标识,不能信任客户端填写的昵称
payloadstring业务内容,按 type 不同解析
tsnumber客户端时间,服务端只存不排序

这里最容易被忽略的是 client_msg_id。没有它,客户端断线重发消息时,服务端无法区分「新消息」和「重发消息」,会导致同一句话被广播两次。有了它,服务端只要在消息落库时对 (room_id, sender_id, client_msg_id) 加唯一索引,重复消息自然会被数据库挡掉。

3. 服务端落地:用 Go 跑通连接管理、心跳与广播核心

模型定好后就可以写服务端。这章给一个能直接跑起来的 Go 骨架,覆盖连接管理、心跳保活、房间隔离和消息落库四件事。代码不追求完整生产级,但把多人聊天最核心的链路串起来。

3.1 连接管理与广播骨架:Hub 加双循环

服务端核心是两个结构:Hub 管理所有连接和房间,Client 代表一条 WebSocket 连接。先看 Hub 和广播逻辑:

package main import ( "encoding/json" "log" "net/http" "sync" "github.com/gorilla/websocket" ) type Message struct { Type string `json:"type"` // join / leave / chat / heartbeat RoomID string `json:"room_id"` // 房间标识 ClientMsgID string `json:"client_msg_id"` // 客户端消息ID,用于幂等去重 SenderID string `json:"sender_id"` // 发送者标识 Payload string `json:"payload"` // 业务内容 } type Client struct { conn *websocket.Conn send chan []byte // 带缓冲的写队列 hub *Hub } type Hub struct { mu sync.RWMutex rooms map[string]map[*Client]bool // roomID -> 该房间的连接集合 } func (h *Hub) broadcast(roomID string, data []byte) { h.mu.RLock() defer h.mu.RUnlock() for client := range h.rooms[roomID] { select { case client.send <- data: default: // 写队列已满,说明该连接消费不过来,断开它避免拖住广播 close(client.send) delete(h.rooms[roomID], client) client.conn.Close() } } }

广播函数用 RLock 保护房间 map,遍历房间内所有连接,把消息投递到各自的 send channel。select 的 default 分支是关键:如果某个连接的 send 缓冲已满,说明它已经落后太多,继续阻塞会让广播者卡住,直接关闭这个连接是多人聊天里的常规操作。参数上,send channel 缓冲一般设为 64 或 256。64 适合消息频率低的群聊,256 适合直播评论这类高频场景。缓冲太小容易误杀慢连接,太大则内存消耗上升,每个连接多 256 条消息的开销在万人场景下是可见的。

再来看每个连接的双循环。读循环负责收消息并广播,写循环负责从 send channel 取消息写回连接:

func (c *Client) readPump() { defer func() { c.hub.mu.Lock() delete(c.hub.rooms, c) // 简化示意,实际需要按房间精确删除 c.hub.mu.Unlock() c.conn.Close() }() for { _, raw, err := c.conn.ReadMessage() if err != nil { log.Println("read error:", err) return } var msg Message if err := json.Unmarshal(raw, &msg); err != nil { continue } c.hub.broadcast(msg.RoomID, raw) } } func (c *Client) writePump() { for data := range c.send { if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil { return } } }

读循环把收到的 JSON 原样广播出去,不做额外解析,这样消息延迟最低。写循环一直在等 send channel,拿到就写。两个循环之间没有任何锁,数据传递完全靠 channel,这是 Go 写长连接服务比较顺手的地方。注意读循环里 defer 的清理逻辑,实际项目还要处理房间维度删除、从 Hub 全局表移除连接,这里只保留了核心思路。广播时把 sender 自己的消息也发回去,前端根据 client_msg_id 决定是否显示,这样可以省掉一个「回执确认」的通道。

3.2 心跳保活:Ping/Pong 与断线感知

WebSocket 长连接不会主动告诉你网络断了。WiFi 切换、手机锁屏、中间网络设备重启,TCP 连接可能处于半开状态,服务端读不到数据也不知道对方已消失。没有心跳机制的长连接服务,最终会积累大量僵尸连接,内存和文件描述符被慢慢吃光。心跳的标准做法是服务端定时发 Ping,客户端回 Pong,服务端据此判断连接是否存活。

const ( pongWait = 75 * time.Second // 等待 Pong 的最长时间 pingPeriod = 30 * time.Second // 发送 Ping 的间隔 writeWait = 10 * time.Second // 单次写操作的超时 ) func (c *Client) readPump() { c.conn.SetReadDeadline(time.Now().Add(pongWait)) c.conn.SetPongHandler(func(string) error { c.conn.SetReadDeadline(time.Now().Add(pongWait)) return nil }) // ... 继续原读循环 }

参数设置有三个讲究。第一,pingPeriod 要小于 pongWait,留出至少两到三个周期的余量,否则网络稍有抖动就会误判断线。第二,pongWait 不应设置得太短,75 秒是比较通用的起步值,移动网络下可以压到 60 秒以内。第三,互联网出口设备对空闲 TCP 连接有回收机制,空闲超过一定时间会静默丢掉连接,常见的回收阈值在 60 秒左右,所以 pingPeriod 设在 30 秒左右既能保住连接,又不会产生太多无效包。写循环里也要同步维护写超时,防止某个连接的对端一直不读数据导致写阻塞:

func (c *Client) writePump() { ticker := time.NewTicker(pingPeriod) defer ticker.Stop() for { select { case data, ok := <-c.send: if !ok { return } c.conn.SetWriteDeadline(time.Now().Add(writeWait)) c.conn.WriteMessage(websocket.TextMessage, data) case <-ticker.C: c.conn.SetWriteDeadline(time.Now().Add(writeWait)) c.conn.WriteMessage(websocket.PingMessage, nil) } } }

心跳的两个方向都要有超时:读方向靠 ReadDeadline 加 PongHandler,写方向靠 WriteDeadline。只做 Ping 不做 deadline,断线检测形同虚设。这个组合是多人聊天系统里最容易抄错的部分,很多项目只加了 Ping,结果僵尸连接照常累积。

3.3 房间隔离与消息落库:离线消息怎么补

广播只覆盖在线连接,离线消息需要另一套机制。房间隔离的逻辑比较简单:连接进入房间时加入对应 map,离开时删除。真正复杂的是消息落库和离线补偿的配合。聊天消息不能每条都同步写数据库,否则广播延迟会被磁盘 IO 拖高;也不能完全不落库,否则用户刷新页面后历史消息全丢。

我的方案是内存广播为主,异步落库为辅。服务端收到消息后先广播,再通过带缓冲的异步队列把消息交给写入协程批量落库。这样广播路径上没有数据库等待,写入失败时还可以根据 client_msg_id 补偿。落库表结构按多人聊天的查询模式设计:

CREATE TABLE message ( id BIGINT AUTO_INCREMENT PRIMARY KEY, room_id VARCHAR(64) NOT NULL, sender_id VARCHAR(64) NOT NULL, payload TEXT NOT NULL, client_msg_id VARCHAR(64) NOT NULL, seq BIGINT NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), UNIQUE KEY uk_room_client_msg (room_id, sender_id, client_msg_id), KEY idx_room_seq (room_id, seq) ) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

seq 字段是服务端为每条消息分配的自增序号,客户端用它来校正消息顺序。唯一索引 uk_room_client_msg 保证同一用户同一条消息不会重复入库,这是幂等的最后一道防线。idx_room_seq 索引支撑离线消息查询:用户上线时,客户端带上自己最后收到的 seq,服务端执行「SELECT ... WHERE room_id = ? AND seq > ? ORDER BY seq LIMIT ?」拉取增量消息。离线消息不追求全量,一般限制最近 500 条或最近 24 小时,超过范围引导用户向上滚动分页加载。

这里的坑在于 seq 的分配时机。如果广播和落库并行,可能发生后到的消息先落库、先到的消息后落库,导致 seq 不是严格单调。我的做法是广播前先在内存里给消息分配 seq,带着 seq 广播;落库时用相同的 seq。这样客户端无论从实时流还是从离线补偿拿到消息,都能以 seq 排序。如果使用数据库自增 id 当 seq,可能因为事务提交顺序不同而出现 id 大的消息先在聊天记录里出现,排序就乱了。

4. 前端接入:浏览器客户端如何处理断线、重连与消息乱序

服务端再可靠,前端把连接管理做砸了,用户感知到的依然是「这聊天工具真难用」。浏览器端的 WebSocket API 很薄,onopen、onmessage、onclose 三个回调就覆盖了全部生命周期,但生产环境下的断线重连、发送缓冲和消息乱序,需要自己补一套逻辑。

4.1 浏览器 WebSocket 客户端的最小实现

先看最小可用的连接代码:

const ws = new WebSocket(`ws://${location.host}/ws?room=demo`); ws.onopen = () => { // 进入房间,携带客户端生成的幂等ID ws.send(JSON.stringify({ type: 'join', room_id: 'demo', client_msg_id: crypto.randomUUID(), sender_id: 'user-42' })); }; ws.onmessage = (event) => { const msg = JSON.parse(event.data); // 消息按 seq 排序后渲染 appendMessage(msg); }; ws.onclose = () => { scheduleReconnect(); }; ws.onerror = () => { // 触发 onclose,不需要在这里重复处理重连 };

几个参数要注意。连接地址里的 room 参数是查询参数,服务端从握手请求里解析房间号,比在 body 里传房间号更早拿到,可以在 WebSocket 握手阶段就完成房间校验。join 消息的 client_msg_id 必须每次生成新值,不能复用。onerror 和 onclose 是成对触发的,错误发生后一定会走到 onclose,所以重连逻辑只写在 onclose 里,避免双重调度。appendMessage 这一步,不能直接渲染原始消息,要先经过排序和去重处理,否则乱序消息会直接体现在界面上。

4.2 断线重连与发送缓冲:指数退避加队列上限

断线重连最忌讳的是无脑立即重连。服务端重启时,所有客户端同时发起重连请求,瞬间打满握手线程,造成雪崩。指数退避是通用解法,第一次等 1 秒,第二次 2 秒,第三次 4 秒,封顶 30 秒:

function connectWithBackoff() { let retry = 0; const maxDelay = 30_000; function attempt() { const ws = new WebSocket(WS_URL); ws.onopen = () => { retry = 0; // 连接成功后重置退避 flushPending(ws); }; ws.onclose = () => { const delay = Math.min(1000 * 2 ** retry, maxDelay); retry += 1; setTimeout(attempt, delay); }; ws.onmessage = (event) => handleIncoming(event.data); return ws; } return attempt(); } const pendingQueue = []; function sendMessage(ws, msg) { if (ws.readyState === WebSocket.OPEN) { ws.send(msg); } else { pendingQueue.push(msg); // 队列不能无限增长,超过 200 条就丢弃最旧的 if (pendingQueue.length > 200) pendingQueue.shift(); } } function flushPending(ws) { while (pendingQueue.length > 0) { ws.send(pendingQueue.shift()); } }

指数退避的封顶值 30 秒是一个合理起点。退避算法本身有随机抖动更好,固定延迟会让大量客户端同时重连,抖动能打散重连请求。retry 归零的时机要放在 onopen 里,而不是重连成功后,否则一次连接成功但马上又断开,退避还停留在高位,恢复慢。发送缓冲用一个数组即可,上限 200 条是经验值,低于这个值在弱网恢复后能快速补齐,高于这个值说明网络长时间不可用,保留再多旧消息也没有意义。

4.3 消息顺序与去重:以服务端 seq 为准

多人聊天消息乱序有两个来源。一是断线重连后,离线补偿拉取的消息和实时推送的消息同时到达,前端无法确认谁先谁后;二是服务端多副本部署后,不同节点处理消息的速度不同,广播到达客户端的顺序可能和发送顺序不一致。客户端唯一能依赖的是服务端分配的 seq。

前端要维护两个状态:lastSeq 是当前已渲染消息的最大序号,pendingSet 是最近 500 条消息的 client_msg_id 集合。收到一条消息时,先检查 client_msg_id 是否在 pendingSet 里,在则视为重复消息直接丢弃;不在则加入集合,然后按 seq 插入到消息列表的正确位置。pendingSet 用 Set 实现,500 条是内存和去重效果之间的折中,超过后移除最早的条目。

离线补偿拉取时,请求参数里带上 lastSeq,服务端只返回 seq 大于 lastSeq 的消息。客户端把补偿消息和实时消息放进同一个缓冲区,统一按 seq 排序再渲染。这里不能使用消息里的 ts 字段排序,因为客户端时间戳可能不准,而且同一客户端在不同设备上的时间本来就有偏差。我见过一个版本按 ts 排序,结果用户手机时间调快了 5 分钟,他发的消息永远排在别人后面。seq 是服务端唯一权威顺序,前端越早统一到这个口径上,后面排查问题越省事。

5. 多人聊天系统踩坑排查:四个高频故障与修复记录

这套系统跑起来之后,问题不会出现在教科书路径上,而是出现在网络环境、资源限制和极端场景里。这一章记录四个高频故障,每条按现象、原因、解决的顺序拆开。

5.1 现象:广播延迟飙升,但服务端 CPU 和内存看起来都不高

原因

问题出在慢消费者拖累广播路径。早期的广播实现里,我直接在广播循环里调用 WriteMessage,某个客户端网络差时,写缓冲很快填满,WriteMessage 阻塞在 TCP 写等待上。单条连接阻塞几十秒,整个房间的消息都被堵住,服务端资源占用反而不高,因为大家都是在等待 I/O。

解决

改成「广播只投递到 send channel,写循环独立消费」的双循环结构。广播循环里用 select 加 default,send 缓冲满就断开慢消费者。这个调整之后,单条慢连接最多影响它自己,房间内其他连接的消息延迟恢复到毫秒级。同时给每条连接的写操作设置 10 秒超时,超过即断开,避免无限等待。

5.2 现象:连接每过一段时间就自动断开,服务端没打印任何错误

原因

互联网出口设备和 NAT 网关对空闲 TCP 连接有回收策略,连接空闲超过一定时间就被静默丢弃。服务端读循环一直等数据,永远等不到;客户端也不知道连接已经没了。第一个版本只做了一个 60 秒的 PongHandler,没有周期性 Ping,空闲连接在 60 秒窗口内没有触发任何数据交换,被网关回收后双方无感知。

解决

加周期性 Ping,服务端每 30 秒发一次 Ping,客户端回 Pong。Ping 本身就是数据包,恰好卡在大多数网关回收阈值之前,连接被保活。同时把 PongHandler 里刷新 ReadDeadline,确保超过 75 秒没收到 Pong 才判定断线。这个组合让空闲连接的存活时间从十几分钟提升到数小时,后台在线人数曲线明显不再周期性跳水。

5.3 现象:消息顺序错乱,先发的消息后显示,尤其集中在断线重连之后

原因

断线期间用户发送的消息进入本地队列,恢复连接后先通过实时通道发出,同时离线补偿又把缺失的消息补拉回来。实时消息和补偿消息在客户端是两条异步路径到达,没有统一顺序。当时用了客户端时间戳排序,部分手机系统时间不准,顺序彻底乱掉。

解决

服务端在广播前给每条消息分配全局自增 seq,落库时存储相同值。客户端无论消息来自实时通道还是离线补偿,先暂存进缓冲区,按 seq 排序后再渲染。重连补偿时把 lastSeq 传给服务端,只补拉该值之后的消息。最终界面上的顺序只认 seq,不再信任任何客户端时间。

5.4 现象:单机连接数到 3000 左右时,新连接握手开始失败

原因

操作系统默认文件描述符上限通常是 1024,虽然可以在运行时临时调高,但很多人忘了改。每一条 WebSocket 连接至少占用一个 fd,3000 个连接远超默认限制。另一个隐蔽因素是代码里 goroutine 泄漏:读循环退出时,defer 里的清理逻辑没关掉 send channel,写循环还挂在 channel 上读,goroutine 一直不退出,fd 也无法释放。

解决

先把系统 fd 上限调高,临时生效用 ulimit -n 65535,永久生效要改 /etc/security/limits.conf。代码层面统一清理顺序:读循环退出后 close(send),写循环检测到 channel 关闭后自行退出,释放 goroutine。再通过压测脚本模拟 5000 个连接跑 30 分钟,观察 goroutine 数和 fd 数是否平稳。记住,fd 和 goroutine 这两个指标在聊天系统里的重要性,和数据库连接池在业务系统里一样。

6. 从单机到多副本:水平扩展与压测验证

单机最多扛到几千连接,在线人数再涨就要考虑多副本。多人聊天的水平扩展核心是把广播从「单机遍历」变成「跨节点扇出」。

6.1 多副本广播:用消息总线转发跨节点消息

多副本部署后,同一个房间的用户可能分散在多个节点上。处理方式是每个节点维护本地的房间连接表,同时订阅全局消息总线。节点收到消息后,先广播给本节点房间内连接,再把消息发到总线;其他节点从总线收到消息后,广播给各自节点上的同房间连接。常见做法是 Redis Pub/Sub,消息格式沿用之前的 JSON 信封,加一个原始节点标识避免重复处理。房间与节点的映射关系不需要精确,总线广播天然把消息送到所有可能有该房间用户的节点。

6.2 用压测脚本验证系统能力

扩展后先用脚本验证再上线。一个简单的压测思路:开 1000 个 WebSocket 连接,每个连接发送一条消息并等待自己那条消息返回,统计延迟分位数。

import asyncio, json, time, websockets async def bench(i): async with websockets.connect("ws://127.0.0.1:8080/ws?room=test") as ws: t0 = time.perf_counter() msg = json.dumps({"client_msg_id": f"m-{i}", "payload": "p"}) await ws.send(msg) while json.loads(await ws.recv())["client_msg_id"] != f"m-{i}": pass return time.perf_counter() - t0 async def main(): lat = await asyncio.gather(*[bench(i) for i in range(1000)]) lat.sort() print("p95:", round(lat[949] * 1000, 1), "ms") asyncio.run(main())

跑之前先确认服务端会把消息广播回发送者自己,否则脚本会一直等不到结果。1000 个协程在单机上可以跑,但网络栈可能成为瓶颈,建议分三批执行。验收指标是连接成功率接近 100%、p95 延迟低于 500ms、消息丢失为零。达不到就先看广播路径有没有阻塞点。

6.3 我最后养成的检查习惯

我现在每次改动聊天系统,都会在灰度环境同时盯三件事:在线连接数曲线、广播扇出耗时、客户端断线重连次数。连接数曲线能看到内存和 fd 有没有泄漏;扇出耗时看广播路径有没有被慢消费者拖住;断线重连次数看心跳参数和网络环境是否匹配。这三个数据比日志更能说明系统是否健康。

多人聊天系统做到这个程度,对于大多数内部群聊或客服场景已经够用。真正的瓶颈永远是那个朴素的问题:一条消息到底要被写多少次。把扇出路径上的每一步都控制在可预期范围内,这个方向就值得继续投入。这套习惯我保留至今,希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 13:26:48

疫情应急AI实战:轻量模型+规则引擎的公共卫生响应方案

1. 项目概述&#xff1a;当公共卫生事件遇上智能系统“疫情下的AI”这个标题乍看像一句新闻短语&#xff0c;实则是一类真实存在、持续演进的跨领域实践集合。它不指代某个具体产品或开源项目&#xff0c;而是描述在突发性、大规模、高不确定性公共卫生事件中&#xff0c;人工智…

作者头像 李华
网站建设 2026/10/14 13:25:27

TCP命令行发送工具的底层原理与系统调用解析

1. 这不是“发个包”那么简单&#xff1a;一个命令行TCP发送工具背后的网络真相你有没有试过在终端里敲下SendTCP 192.168.1.100 54321 192.168.1.200 80&#xff0c;然后就指望数据真的像快递一样稳稳送到对方端口&#xff1f;很多刚接触网络编程的人&#xff0c;第一反应是&a…

作者头像 李华
网站建设 2026/10/14 13:24:38

Electron构建Markdown编辑器:架构、性能优化与避坑实战

简介&#xff1a;DBeditor是一款面向开发与写作人群的跨平台Markdown编辑器&#xff0c;支持Linux、macOS、Windows等主流系统&#xff0c;在不同平台上保持统一优秀的编辑体验。界面采用简洁扁平、大量留白的UI风格&#xff0c;去除繁琐菜单&#xff0c;适合快速记录、技术文档…

作者头像 李华
网站建设 2026/10/14 13:24:23

GESP真题解析与能力诊断:从刷题到计算思维成长

简介&#xff1a;本资源是面向青少年编程学习者与GESP考级备考者的全周期辅导资料包&#xff0c;覆盖CCF编程能力等级认证从Scratch图形化到Python、C的1–8级完整体系&#xff0c;解决知识点分散、真题匮乏、提纲模糊等核心备考痛点。压缩包共339MB&#xff0c;以PDF真题试卷、…

作者头像 李华