
简介这是基于JSPServlet的多人聊天系统Java Web项目源码包面向正在学习Java Web动态网页开发、希望实践多人实时通信的开发者。压缩包共11个文件包含6个class编译文件、2个Java源文件以及classpath、prefs、project等工程配置整体仅12KBsrc目录保存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.jsJavaSpring 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、ackroom_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?roomdemo); 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 加 defaultsend 缓冲满就断开慢消费者。这个调整之后单条慢连接最多影响它自己房间内其他连接的消息延迟恢复到毫秒级。同时给每条连接的写操作设置 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 连接至少占用一个 fd3000 个连接远超默认限制。另一个隐蔽因素是代码里 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?roomtest) as ws: t0 time.perf_counter() msg json.dumps({client_msg_id: fm-{i}, payload: p}) await ws.send(msg) while json.loads(await ws.recv())[client_msg_id] ! fm-{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 有没有泄漏扇出耗时看广播路径有没有被慢消费者拖住断线重连次数看心跳参数和网络环境是否匹配。这三个数据比日志更能说明系统是否健康。多人聊天系统做到这个程度对于大多数内部群聊或客服场景已经够用。真正的瓶颈永远是那个朴素的问题一条消息到底要被写多少次。把扇出路径上的每一步都控制在可预期范围内这个方向就值得继续投入。这套习惯我保留至今希望帮到你。本文还有配套的精品资源点击获取