☰
Java网络聊天室实战:Socket通信、多线程与消息持久化
2026/11/2 15:46:33 网站建设 项目流程

简介:这是一份面向Java初学者与课程设计者的网络聊天室项目源码,围绕Socket通信、多线程并发和数据库管理三条主线,提供从客户端界面到服务端消息广播的完整实现,适合用于网络编程实验、期末项目参考或技术栈串联练习。压缩包共46个文件、约199KB,包含7个java源文件、20个class编译文件、10张jpg与2张gif界面素材,以及txt说明、db数据库和工程配置文件,源码、资源与可运行产物相对齐全,便于直接导入IDE后对照阅读。现有194人学习下载,说明其在同类教学项目中具备一定参考热度。读者可从中梳理用户注册登录验证、在线状态跟踪、多对多消息广播、聊天记录入库及异常捕获等关键流程,也能观察服务端为每个连接分配线程、客户端监听并刷新界面的具体写法,为后续调试与功能扩展提供可复用的代码骨架。

1. 需求不是「能聊天就行」:一个局域网聊天室真正要解决的四个问题

「三个人同时登录就翻车」——这是 java 网络聊天室 项目里最常见的第一次事故。两个人一对一聊得好好的,第三个人进来,消息开始串台、中文变成问号、有人掉线却一个报错都没有。多数人第一反应是 Socket 通信 写错了,其实读写就那么几十行,真正难的是两件事:一条消息到哪里算结束,一个连接从建立到断开之间经历了哪些状态。多线程技术和数据库技术 在这类项目里不是加分项,而是被这两个问题硬逼出来的必需品——多条连接同时跑就必须有并发模型,消息要能离线补发、要能查历史就必须有持久化。这套方案交付的是一个支持私聊、群聊、在线列表、离线补发和心跳检测的完整网络聊天室,适合正在做课程设计或毕业设计的同学照着搭,也适合需要一份能真正跑起来的局域网通信方案的人直接改。

2. 总体架构与通信协议:三层结构与 8 字节帧头的分工

我一般不会一上来就写new ServerSocket(8888)。动手之前先定两件事:谁负责搬字节,谁负责决定消息发给谁。这两个职责一旦混在一个类里,后期加群聊、加离线消息、加已读回执,每改一个功能都要动核心循环,改到第三次基本就要重写。分层不是为了好看,是为了让「新增一个消息类型」变成只加一个分支,而不是重排整条链路。

2.1 三层结构:接入层、业务层、存储层的职责边界

接入层只认字节和连接,它手里有一个ClientSession,管着 socket、输入输出流、最近活跃时间、是否已登录这几样东西。业务层只认消息对象和用户 ID,它负责「这条消息该投给哪些 session」。存储层只认 SQL,负责用户、消息、群成员的增删改查。

给这三层划边界,我用一条很土但有效的规则:接入层不允许出现User、Message这种业务对象,只允许出现sessionId和byte[];业务层不允许出现InputStream、OutputStream,只允许调用 session 的send(Frame);存储层不允许System.out.println,所有异常往上抛,由业务层决定是记日志还是回错误码。

层核心类只做绝不做
接入层ChatServer、ClientSession、FrameCodec接受连接、拆帧、心跳、关闭清理查库、判断业务权限
业务层MessageRouter、OnlineRegistry、HeartbeatGuard路由、在线状态、群成员展开直接读写流、拼 SQL 字符串
存储层UserDao、MessageDao、GroupDao参数化 SQL、事务、批量更新持有 socket 引用、做线程调度

这样切完,一次私聊消息的路径就很清楚了:客户端 A 写出一帧 → A 对应的ClientSession读线程拆出CHAT_PRIVATE→ 交给MessageRouter→ 路由查OnlineRegistry拿到 B 的 session → 调session.send()写出 → 同时MessageDao.save()落库并把delivered置 0,等 B 回 ACK 再置 1。全链路没有一处需要知道对方是本地还是远端,也没有一处需要知道消息存在哪张表。

2.2 帧格式设计:TCP 是字节流,不是消息流

这是整个项目里最容易翻车、也最值得先写的一层。TCP 只保证字节有序到达,不保证一次read正好读到一条完整消息。发得快的时候两条消息会粘在一起(粘包),发得大或者网络抖动的时候一条消息会被拆成两次到达(半包)。如果直接readUTF()或按行读,本地测试永远正常,一上多人并发就开始出现「A 的消息里带着 B 的内容」这种玄学现象。

解决办法是给每条消息加一个固定长度的帧头,帧头里写明载荷有多长,接收方先读满帧头,再按长度读满载荷,读不满就继续等下一次read。

字段长度说明
magic2 字节固定魔数 0xCAFE,用于丢弃脏数据、快速判定协议错位
version1 字节协议版本,从 1 开始,便于后续字段扩展
type1 字节消息类型,见 2.3
length4 字节载荷字节数,不含 8 字节帧头
payload变长UTF-8 编码的 JSON 字节数组
public final class FrameCodec { public static final short MAGIC = (short) 0xCAFE; public static final int HEADER_LEN = 8; // 2 + 1 + 1 + 4 public static final int MAX_PAYLOAD = 64 * 1024; // 单帧上限 64KB,防恶意长度撑爆内存 /** 组装一帧,返回的 ByteBuffer 已 flip,可直接 write 到通道 */ public static ByteBuffer encode(byte type, byte[] payload) { if (payload.length > MAX_PAYLOAD) { throw new IllegalArgumentException("payload too large: " + payload.length); } ByteBuffer buf = ByteBuffer.allocate(HEADER_LEN + payload.length); buf.putShort(MAGIC); buf.put((byte) 1); // version buf.put(type); buf.putInt(payload.length); // 只写载荷长度,不含帧头本身 buf.put(payload); buf.flip(); return buf; } /** * 从累积缓冲区里尝试取出完整一帧。 * 返回 null 表示数据还不够,调用方应继续 read 后再调一次。 */ public static Frame tryDecode(ByteBuffer in) { if (in.remaining() < HEADER_LEN) return null; in.mark(); // 数据不足时要能回退到帧头起点 short magic = in.getShort(); if (magic != MAGIC) throw new ProtocolException("bad magic: " + magic); byte version = in.get(); byte type = in.get(); int len = in.getInt(); if (len < 0 || len > MAX_PAYLOAD) throw new ProtocolException("bad length: " + len); if (in.remaining() < len) { in.reset(); return null; } // 半包,等下次 byte[] payload = new byte[len]; in.get(payload); return new Frame(version, type, payload); } }

几个参数值得单独解释。MAX_PAYLOAD一定要有,没有它的话,对端只要发一个声明长度为 2GB 的帧头,服务端就会new byte[2G]直接 OOM,这是最典型的入门级安全问题。mark()/reset()是半包处理的关键,读帧头之前先打标记,发现载荷不够就退回去,等下一批字节到了从头再解析,否则帧头会被吃掉一半,后面全乱。version字段现在用不上,但协议一旦发布就很难改,留一个字节的成本远低于将来加字段时要兼容旧客户端的成本。

提示:接收端不要一次read一次解析。正确做法是每个连接维护一个ByteBuffer累积缓冲区,循环tryDecode直到返回 null,再回到read。

2.3 消息类型与载荷约定:用 JSON 换取可调试性

类型码只用一字节,所以够用很久。载荷统一用 JSON,不用 Java 原生序列化。原因很实际:原生序列化把类名和字段签名写进字节流,客户端和服务端稍微不同步就抛InvalidClassException,而且抓包看到的是一堆乱码,联调时完全没有排查余地。JSON 抓包直接能读,加字段也不会让老客户端崩。

类型码名称方向载荷关键字段
0x01LOGIN_REQC→Susername, passwordHash
0x02LOGIN_RESPS→Ccode, userId, nickname, token
0x03CHAT_PRIVATEC→S / S→CmsgId, from, to, content, ts
0x04CHAT_GROUPC→S / S→CmsgId, from, groupId, content, ts
0x05HEARTBEAT双向ts
0x06ACK双向msgId, status
0x07ONLINE_LISTS→Cusers[]
0x08OFFLINE_PULLC→SsinceId, limit
{"msgId":"9f2c1a7b4e8d4c31","from":1001,"to":1002,"content":"在吗","ts":1710000000000}

msgId是客户端生成的 16 字节随机串,作用有两个:一是幂等,客户端超时重发时服务端靠它去重;二是回执关联,服务端投递成功后客户端回一个带同样msgId的 ACK,服务端才知道这条可以标成已送达。没有这个字段,离线消息就永远分不清「发出去了」和「对方收到了」,后面 4.3 的补发逻辑根本没法写。

3. Socket 通信与多线程并发:连接生命周期与线程池模型

协议定完,接入层就可以动手了。这一层的关键不是accept(),而是想清楚一个连接的完整生命周期:建立 → 注册 → 认证 → 活跃 → 超时或异常 → 清理。这六个状态里,任何一步没处理干净,表现出来的都是「服务端跑一晚上就卡死」或者「在线列表里挂着一堆早就不在的人」。

3.1 服务端接入与连接注册

主线程只做一件事:不停accept,把拿到的 socket 包装成ClientSession丢给线程池,自己立刻回去等下一个连接。绝不能在主线程里处理业务,否则一个慢查询就能让整个服务停止接受新连接。

public void start() throws IOException { try (ServerSocket server = new ServerSocket()) { server.setReuseAddress(true); // 重启时不必等 TIME_WAIT 释放端口 server.bind(new InetSocketAddress(8888), 128); // backlog=128,等待队列长度 while (running) { Socket socket = server.accept(); socket.setTcpNoDelay(true); // 聊天是小包高频,关掉 Nagle 降低延迟 socket.setKeepAlive(true); socket.setSoTimeout(90_000); // 90 秒内没收到任何字节即判定超时 pool.execute(new ClientSession(socket, router, registry)); } } }

setReuseAddress(true)是血泪经验:不加这一行,调试期间反复重启服务端,会频繁遇到Address already in use,明明进程已经退了端口还占着,只能干等或换端口。setTcpNoDelay(true)影响的是延迟,聊天场景下消息体很小,默认的 Nagle 算法会攒包再发,主观感受就是「点了发送要愣一下才到」。setSoTimeout是心跳机制的最后一道防线,它保证即使应用层心跳逻辑写错了,阻塞的read也会在 90 秒后抛SocketTimeoutException,让这个连接有机会被回收,而不是永远挂在那里。

连接进来之后立刻注册到一个全局在线表,key 用自增的sessionId而不是用户名,因为登录前也要能发消息(比如登录失败要回错误码)。

3.2 并发模型:为什么不能给每个连接开一个裸线程

教科书上的写法是每个连接new Thread(...)。几十个连接确实没问题,但这个模型有两个隐患:线程栈默认 1MB,几百个连接就是几百 MB 的地址空间;更麻烦的是线程创建和销毁完全不受控,一旦有人用脚本疯狂重连,服务端会被线程创建拖垮。

这个项目我用固定线程池,核心 16、最大 64、有界队列 1000。为什么是这组数:几十到几百人在线的局域网场景,单条消息的业务处理耗时基本在毫秒级,16 个核心线程足够;最大 64 是给登录高峰、群发这种瞬时峰值留的余量;队列必须有界,无界队列在压力下只会把 OOM 推迟几秒。

ThreadPoolExecutor pool = new ThreadPoolExecutor( 16, 64, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(1000), r -> { Thread t = new Thread(r, "chat-worker-" + SEQ.getAndIncrement()); t.setDaemon(true); // 不阻止 JVM 退出 return t; }, new ThreadPoolExecutor.AbortPolicy() // 拒绝时不静默丢弃,抛出去让上层记日志 );

线程名统一加chat-worker-前缀,这个细节在做故障排查时价值极高:jstack打出来一眼就能看出是业务线程卡住了还是 GC 线程在跑,不用靠线程编号猜。拒绝策略选AbortPolicy而不是DiscardPolicy,因为静默丢弃会让「为什么有些消息没到」变成悬案,抛异常至少日志里有痕迹。

注意:这里刻意没有上 NIO。NIO 在几千连接的场景下才有明显收益,代价是选择器、半包状态机、写缓冲区管理全部要自己维护,代码量翻三倍。几百连接的课程设计或毕设,阻塞 IO + 线程池是性价比最高的选择,把复杂度留给真正需要的项目。

3.3 心跳与断线检测:read返回 -1 远远不够

很多人以为连接断了read会返回 -1。这只在对方正常关闭(发 FIN)时成立。如果对方是拔网线、断电、WiFi 切换,服务端这一侧的 socket 完全无感知,read会一直阻塞下去,在线列表里就会挂着一个永远不说话的幽灵用户,发给他的消息全部石沉大海。这就是典型的半开连接。

解决办法是应用层心跳。客户端每 15 秒发一帧HEARTBEAT,服务端每收到任意一帧就更新lastActiveAt,另起一个调度线程每 15 秒扫一遍,超过 90 秒没动静就强制关闭。

// ClientSession 内部 private volatile long lastActiveAt = System.currentTimeMillis(); public void touch() { this.lastActiveAt = System.currentTimeMillis(); } // 独立的调度线程,与业务线程池隔离,避免被慢业务拖住 scheduler.scheduleAtFixedRate(() -> { long now = System.currentTimeMillis(); for (ClientSession s : registry.snapshot()) { if (now - s.lastActiveAt() > 90_000L) { s.close(CloseReason.HEARTBEAT_TIMEOUT); // 内部统一走注销 + 落库 + 广播下线 } } }, 15, 15, TimeUnit.SECONDS);

lastActiveAt用volatile修饰,因为写它的是 IO 线程、读它的是调度线程,不加的话调度线程可能一直读到旧值。超时时间设成心跳间隔的 6 倍,是为了容忍网络抖动和客户端 GC 停顿,设得太紧会误杀正常连接,表现出来就是「用户抱怨偶尔无故掉线」。关闭逻辑必须收敛到close()一个方法里,所有路径(超时、异常、用户主动退出)都调它,用AtomicBoolean保证只执行一次,否则注销逻辑跑两遍会把刚重连上来的新 session 从在线表里删掉。

3.4 客户端收发分离与界面线程安全

客户端这边最常见的错误是「点发送按钮就直接往输出流写」。Swing 的事件线程只有一个,任何阻塞写都会让界面假死;更严重的是,如果同时在跑的还有心跳线程,两个线程往同一个OutputStream写,字节会交错,对端收到的就是一帧长度对不上内容的坏数据。

正确做法是两个线程各司其职:读线程死循环阻塞读帧,写线程从BlockingQueue里取帧串行写出。所有想发消息的地方都只是往队列里offer,不碰流。

// 写线程:唯一持有 OutputStream 的线程,天然串行,不会出现帧交错 new Thread(() -> { try { while (running) { Frame f = sendQueue.take(); // 队列空时阻塞,不空转 out.write(FrameCodec.encode(f.type(), f.payload())); out.flush(); } } catch (Exception e) { /* 通知界面重连 */ } }, "chat-writer").start(); // 读线程:收到消息后必须切回 EDT 才能动 UI new Thread(() -> { while (running) { Frame f = FrameCodec.readFrom(in); // 阻塞直到读满一帧 FrameHandler.handle(f); // 内部用 invokeLater 切线程 } }, "chat-reader").start();

sendQueue用LinkedBlockingQueue并且要设上限,比如 500。不设上限的话,网络卡住时用户连点发送,队列会无限增长直到内存爆掉;设了上限之后offer返回 false,界面就能提示「网络繁忙」,这才是可用的交互。界面更新一律走SwingUtilities.invokeLater,Swing 组件不是线程安全的,从读线程直接append到文本域,轻则显示错乱,重则抛NullPointerException而且堆栈完全看不出原因。

4. 数据库设计与消息持久化:三张核心表撑起离线补发

到了这一步,聊天已经能跑通了,但用户一关客户端,刚才的对话就消失了,离线期间别人发的消息也永远收不到。持久化要解决的就是这两件事,同时还得保证在高并发写入下不出重复、不漏消息。

4.1 表结构:把索引建在查询路径上

表设计我遵循一条原则:先写出最频繁的那条查询,再倒推索引。这个项目最频繁的两条是「按用户名查密码」和「按收件人拉未投递消息」,索引就围绕它们建。

CREATE TABLE t_user ( id BIGINT NOT NULL AUTO_INCREMENT, username VARCHAR(32) NOT NULL, password_hash CHAR(64) NOT NULL COMMENT 'SHA-256(明文密码 + salt)', salt CHAR(16) NOT NULL, nickname VARCHAR(32) NOT NULL, online TINYINT NOT NULL DEFAULT 0 COMMENT '0离线 1在线,仅作展示,真实状态以内存为准', last_login DATETIME NULL, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_message ( id BIGINT NOT NULL AUTO_INCREMENT, msg_id CHAR(16) NOT NULL COMMENT '客户端生成,用于幂等去重', from_id BIGINT NOT NULL, to_id BIGINT NOT NULL COMMENT '私聊为对方用户ID,群聊为群ID', chat_type TINYINT NOT NULL COMMENT '1私聊 2群聊', content VARCHAR(2000) NOT NULL, send_time DATETIME(3) NOT NULL, delivered TINYINT NOT NULL DEFAULT 0 COMMENT '0未投递 1已投递', PRIMARY KEY (id), UNIQUE KEY uk_msg_id (msg_id), KEY idx_pull (to_id, delivered, id) -- 直接服务离线拉取,顺序与 WHERE/ORDER BY 一致 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_group_member ( group_id BIGINT NOT NULL, user_id BIGINT NOT NULL, join_time DATETIME NOT NULL, PRIMARY KEY (group_id, user_id), KEY idx_user (user_id) -- 反查"我加入了哪些群" ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

idx_pull (to_id, delivered, id)这个联合索引是整张表的性能关键。离线拉取的 SQL 是WHERE to_id=? AND delivered=0 ORDER BY id ASC LIMIT 200,字段顺序、等值条件、排序方向三者完全对齐,走的是覆盖索引扫描,不需要回表也不需要额外排序。索引顺序写反成(delivered, to_id, id)就会退化成扫描全表未投递消息。utf8mb4而不是utf8,因为utf8在 MySQL 里其实是三字节的阉割版,存不了四字节字符(比如某些表情),聊天内容里出现就会直接报错或截断。

4.2 DAO 层:幂等插入、批量更新与事务边界

写入消息最怕重复。客户端因为超时重发,同一条消息可能到达两次,如果直接INSERT,收件人就会看到两遍。用唯一索引 +ON DUPLICATE KEY UPDATE来处理,既保证幂等又不用先查后插(先查后插在并发下有竞态,两个线程同时查不到就会都插进去)。

public class MessageDao { private final DataSource ds; // HikariCP,maximumPoolSize=20 /** 幂等落库:msg_id 冲突时什么都不做,不抛异常也不产生重复行 */ public void save(Message m) throws SQLException { String sql = "INSERT INTO t_message(msg_id,from_id,to_id,chat_type,content,send_time,delivered) " + "VALUES(?,?,?,?,?,?,0) " + "ON DUPLICATE KEY UPDATE msg_id = msg_id"; // 空更新,仅吞掉重复 try (Connection c = ds.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { ps.setString(1, m.getMsgId()); ps.setLong(2, m.getFromId()); ps.setLong(3, m.getToId()); ps.setInt(4, m.getChatType()); ps.setString(5, m.getContent()); ps.setTimestamp(6, Timestamp.valueOf(m.getSendTime())); ps.executeUpdate(); } } /** 上线时拉未投递消息,limit 必须带,否则老账号一次性拉几万条会把内存打满 */ public List<Message> pullUndelivered(long userId, int limit) throws SQLException { String sql = "SELECT id,msg_id,from_id,chat_type,content,send_time " + "FROM t_message WHERE to_id=? AND delivered=0 ORDER BY id ASC LIMIT ?"; List<Message> list = new ArrayList<>(); try (Connection c = ds.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { ps.setLong(1, userId); ps.setInt(2, limit); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) list.add(map(rs)); } } return list; } }

ON DUPLICATE KEY UPDATE msg_id = msg_id这个写法看着别扭,但它是 MySQL 里成本最低的空更新:不修改任何值,只让语句合法返回,同时把重复键异常吞掉。也有人用INSERT IGNORE,区别是IGNORE会顺带吞掉其他类型的错误(比如字段超长被截断),把真正的问题掩盖掉,不建议。

连接池大小设 20 而不是更大,因为数据库端连接数是有限的,池子开太大反而会增加上下文切换和锁争用。DAO 里所有方法都是短连接模式,用完立刻归还,绝不在业务逻辑里持有Connection做多次往返。

4.3 离线消息投递:拉取、回执、清理三段式

登录成功后的流程要严格按顺序走:先鉴权拿到 userId,再查未投递消息,批量推送给该用户的 session,等客户端回 ACK 之后再批量标记已投递。这里有个容易被忽略的点——不能推一条就UPDATE一条,几十条消息就是几十次往返,登录会明显卡顿。批量更新一次搞定。

public int markDelivered(List<Long> ids) throws SQLException { if (ids == null || ids.isEmpty()) return 0; String placeholders = String.join(",", Collections.nCopies(ids.size(), "?")); String sql = "UPDATE t_message SET delivered=1 WHERE id IN (" + placeholders + ")"; try (Connection c = ds.getConnection(); PreparedStatement ps = c.prepareStatement(sql)) { for (int i = 0; i < ids.size(); i++) ps.setLong(i + 1, ids.get(i)); return ps.executeUpdate(); } }

按主键id更新而不是按msg_id,因为主键更新直接定位行,msg_id虽然是唯一索引但还要多一次索引查找。占位符用String.join动态拼,值仍然走setLong,不存在注入风险——这一点必须区分清楚:拼的是占位符数量,不是用户数据。

清理策略也要提前想好,否则表会无限涨。我的做法是保留 30 天,每天凌晨跑一次分批删除,每批 500 行加LIMIT,避免一条DELETE锁住太多行导致其他写入排队。

注意:数据库操作千万不要在 socket 的读线程里同步执行。一条慢 SQL 就能让这个连接彻底停止收发,表现出来就是「这个人卡住了但别人都正常」。我的做法是给持久化单独一个小线程池(4~8 个线程),路由线程只负责投递,落库异步提交。

5. 避坑与排查:五个最容易翻车的点,从现象倒推原因

这一章是我带项目时踩过的坑里挑出来的。它们的共同特征是:本地单人测试 100% 正常,多人一上就出问题,而且报错信息完全没有指向性。

5.1 连接与并发类问题

现象一:第三个人登录后,前两个人互相收不到消息了,服务端没有任何异常。

原因基本可以锁定在线表。很多人用HashMap<Long, ClientSession>存 session,多个 IO 线程同时put、remove,HashMap在扩容时会形成链表环,之后get直接死循环或者返回 null。更隐蔽的是遍历时remove,抛ConcurrentModificationException,如果这个异常被catch吞掉,表现就是「消息随机丢失」。

解决是把在线表换成ConcurrentHashMap,key 用 sessionId,另外维护一个userId → sessionId的映射用于消息路由。遍历统一用snapshot()拿快照再操作,不要在迭代过程中直接改表。如果确实需要原子性的「检查并加入」,用computeIfAbsent而不是先get再put。

现象二:服务端跑一晚上 CPU 跑到 100%,日志里刷屏Connection reset。

原因是异常处理写成了不跳出循环。典型代码是catch (IOException e) { e.printStackTrace(); }然后继续下一轮while。对端已经断开,下一次read立刻又抛同样的异常,几万次每秒的空转就把一个核吃满了。

解决是异常分支必须break,并且统一走finally里的关闭逻辑。我的写法是读循环只有三种出口:正常读到帧、读返回 -1、抛异常,后两种都break出去,在finally里从在线表注销、把未投递消息标记回未送达、广播下线通知。printStackTrace在 100% CPU 场景下本身也是负担,改用带上下文的日志并且加限流。

现象三:A 收到本该发给 B 的消息内容,或者收到的 JSON 解析失败。

这是帧交错,多个线程同时往同一个OutputStream写导致的。write(byte[])不是原子操作,两个线程各写半帧,字节就混在一起了,对端按长度解析出来自然是别人的内容。

解决是每个 session 只允许一个线程写。最轻量的做法是给 session 加一把写锁,synchronized (writeLock) { out.write(...); out.flush(); };更彻底的做法是像 3.4 那样给每个连接配一个写队列加单写线程。我个人倾向前者,锁的粒度小、代码少,几百连接完全够用。顺带提醒,flush()一定要在锁内,否则数据还留在缓冲区里,锁就白加了。

5.2 数据与编码类问题

现象四:中文在服务端日志里正常,到客户端变成问号或者方块。

编码问题几乎百分百是「一端用了平台默认编码」。new String(bytes)和getBytes()无参版本都依赖file.encoding,Windows 默认 GBK、Linux 默认 UTF-8,本地跑没问题,换个环境就乱码。

解决是所有字符串和字节的转换都必须显式指定字符集:new String(payload, StandardCharsets.UTF_8)、content.getBytes(StandardCharsets.UTF_8)。数据库连接串里也要带characterEncoding=utf8mb4,表本身也必须是utf8mb4。三层里只要有一层漏了,前面的努力全部白费,所以这个规范值得写成团队约定。

现象五:对方明明只发了一条消息,收件人看到了两条,重复内容完全一样。

原因是客户端超时重发而服务端没有做幂等。客户端发出去之后等 ACK,超过 3 秒没收到就重发,但第一条其实已经到达并落库了。

解决靠msg_id唯一索引加ON DUPLICATE KEY UPDATE,也就是 4.2 里那个写法。另外 ACK 也要带msgId,客户端收到之后按msgId从待确认队列里移除,不能只靠超时判断。这个坑的隐蔽之处在于:测试时网络好,从来不超时,一上真实环境就开始偶发重复。

5.3 出问题先看哪三样东西

排查这类项目,我一般按固定顺序看三样,比漫无目的地加日志快得多。

jstack <pid>看线程都在干什么:如果大量线程卡在socketRead0,说明对端不发数据、连接空转,检查心跳;如果卡在getConnection,说明数据库连接池被打满了,去查谁没还连接。

netstat -ano | grep 8888看连接状态:大量CLOSE_WAIT说明服务端没关 socket,是finally里漏了close();大量SYN_RECV说明有人疯狂重连或者 accept 队列满了。

服务端日志按 sessionId 打前缀。这一点在并发场景下是决定性的——没有 sessionId 的日志混在一起,根本分不清哪行属于哪个用户,等于没有日志。

6. 验收与答辩:八条测试用例和三个必答问题

功能跑通不等于项目做完了。答辩现场最容易被问倒的不是「你怎么实现的」,而是「你怎么证明它是对的」。我的建议是提前准备一张测试清单,每一条都能当场演示,比讲十分钟架构有说服力。

编号用例操作通过标准
T1单聊基本通路A 发给 BB 200ms 内收到,内容与编码正确
T2群聊广播A 在 5 人群内发消息其余 4 人全部收到,发送者不回显重复
T3在线列表同步C 登录后 A、B 观察双方列表 1s 内出现 C
T4离线补发关闭 B,A 发 3 条,B 登录B 登录后按序收到 3 条,顺序不乱
T5幂等用脚本对同一 msgId 连发 5 次库中只有 1 行,B 只收到 1 条
T6心跳超时杀掉客户端进程不发 FIN90s 内服务端判定下线并广播
T7并发压力60 个模拟连接同时发心跳 10 分钟内存平稳,无异常日志,CPU 低于 30%
T8断线重连拔网线 10s 后恢复自动重连,重连期间消息不丢

T7 用一段脚本就能做,不需要专业压测工具。开 60 个 socket 持续发心跳,观察服务端线程数和内存曲线,这一条最能证明线程池模型是站得住的。

import socket, threading, time, struct MAGIC, VERSION, HEARTBEAT = 0xCAFE, 1, 0x05 def frame(payload=b'{}'): return struct.pack('>HBBi', MAGIC, VERSION, HEARTBEAT, len(payload)) + payload def worker(n): s = socket.create_connection(('127.0.0.1', 8888)) try: while True: s.sendall(frame()) # 每 15 秒一次心跳,与客户端行为一致 time.sleep(15) finally: s.close() for i in range(60): threading.Thread(target=worker, args=(i,), daemon=True).start() time.sleep(600) # 持续 10 分钟,期间用 jstack 观察线程数是否稳定在 16~64

这段脚本的价值在于它绕过了图形界面,直接构造原始帧,能顺带验证 2.2 的帧格式是否真的按字节对齐——如果 Java 端的ByteBuffer字节序写成了小端,这里发过去服务端会直接报 bad magic,比在界面上盲猜快得多。

答辩的提问基本集中在三个点上,提前把答案压成一两句话,比背稿子管用。

第一个是「你怎么处理粘包半包」。答:8 字节定长帧头,长度字段只算载荷,接收端累积缓冲 +mark/reset,读不满就退回等下次read。第二个是「多线程怎么保证安全」。答:在线表用ConcurrentHashMap,每个连接一把写锁保证帧不交错,lastActiveAt用volatile,关闭逻辑用AtomicBoolean保证只执行一次。第三个是「消息会不会重复或丢失」。答:msg_id唯一索引保证不重复,delivered标记加 ACK 保证不丢,登录时按sinceId拉取补偿。

我自己的习惯是,每接一个新项目,都强制先手写一遍帧编解码的单测,再写业务。第一次做这个聊天室时我是反过来的,先写界面先连 socket,结果合并代码那天发现两个人的长度字段一个含帧头一个不含,联调了整整一个下午,抓包看了几百帧才定位。从那以后,协议这层不写测试就不往下走,已经成为硬规矩。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询