简介:这是基于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.js | Java(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 是客户端时间戳,只做展示用,排序以服务端分配的序号为准。
| 字段 | 类型 | 作用与边界 |
|---|---|---|
| type | string | 消息类型,服务端按它分发处理逻辑 |
| room_id | string | 房间标识,广播范围依据 |
| client_msg_id | string | 客户端生成,服务端去重依据 |
| sender_id | string | 发送者标识,不能信任客户端填写的昵称 |
| payload | string | 业务内容,按 type 不同解析 |
| ts | number | 客户端时间,服务端只存不排序 |
这里最容易被忽略的是 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 有没有泄漏;扇出耗时看广播路径有没有被慢消费者拖住;断线重连次数看心跳参数和网络环境是否匹配。这三个数据比日志更能说明系统是否健康。
多人聊天系统做到这个程度,对于大多数内部群聊或客服场景已经够用。真正的瓶颈永远是那个朴素的问题:一条消息到底要被写多少次。把扇出路径上的每一步都控制在可预期范围内,这个方向就值得继续投入。这套习惯我保留至今,希望帮到你。
本文还有配套的精品资源,点击获取