
简介这是一套面向 Java 初学者与课程设计需求者的局域网即时通信项目源码以 Socket 编程与 MySQL 数据库为核心实现了一个功能简化的「简易微信」。系统基于 Java8 开发涵盖用户注册登录、修改与找回密码、添加好友、好友列表展示、文字与本地图片聊天、群发消息等模块并支持局域网内客户端通信服务器未启动时客户端无响应服务器断开或聊天对象下线均有相应提示适合作为网络编程与数据库综合练习的参考案例。资源包共 120 个文件包含 26 个 java 源码、30 个 class 编译文件、26 个 png 与 18 个 jpg 界面素材以及 jar、exe、md 说明等压缩包约 11.36MB目录结构完整便于直接导入 Eclipse 运行调试。目前已有 257 人学习。读者可借此掌握 Socket 通信、多线程处理与 MySQL 持久化的完整实现思路并在此基础上扩展功能。1. 局域网里造一个“微信”JavaMySQL 能扛住多少真实消息办公室内网、实验室机柜、工厂车间这些地方经常没有外网但人就在隔壁工位消息却要靠喊。基于 JavaMySQL 实现基于局域网通信的简易微信解决的就是这个场景一台普通机器当服务端其他人用客户端连上来完成注册、登录、单聊、群聊、离线消息暂存。它不碰公网、不依赖第三方推送所有数据落在本地 MySQL 里适合拿来练 Java 网络编程、JDBC 事务和并发控制也适合做课程设计或内网工具原型。我见过太多人一上来就堆 Spring Boot 和 WebSocket结果连 Socket 粘包都没处理干净消息发三条丢一条。这篇笔记按“先跑通最小链路再补可靠性和避坑”的顺序写新手能照着敲熟手能直接看参数边界和翻车点。2. 先定通信骨架TCP 长连接、自定义协议与 MySQL 表结构局域网通信的简易微信核心不是界面多像微信而是消息能不能稳定从 A 到 B 再落库。选型上我一般坚持三条传输用原生 TCP Socket 而不是 HTTP 轮询因为局域网延迟低、长连接开销小协议用「长度字段 消息类型 JSON 体」的自定义帧避免直接 readLine 遇到粘包存储用 MySQL 单库单表起步别急着分库分表。下面把骨架拆开讲。2.1 为什么用 TCP 自定义帧而不是直接传 JSON 字符串Socket 的InputStream.read(byte[])不保证一次读完一条消息。如果客户端发{type:chat,content:你好}服务端可能第一次读到{type:ch第二次读到at,content:你好}。这就是粘包/拆包。常见做法是在每条消息前加 4 字节 int 表示正文长度服务端先读 4 字节拿到长度 N再循环读满 N 字节才算一条完整消息。// 服务端读取一条完整消息的最小实现 public static String readMessage(InputStream in) throws IOException { DataInputStream dis new DataInputStream(in); int length dis.readInt(); // 先读4字节长度 if (length 0 || length 1024 * 1024) { throw new IOException(非法消息长度: length); // 防御超大包 } byte[] body new byte[length]; dis.readFully(body); // 必须读满length字节 return new String(body, StandardCharsets.UTF_8); }逻辑说明readInt()固定读 4 字节readFully()保证读满指定长度两者配合才能把字节流切成一条条独立消息。参数上长度上限设 1MB 是防止恶意客户端发一个Integer.MAX_VALUE把服务端内存打爆实际聊天文本消息一般不超过 4KB图片走文件传输另开通道。注意DataInputStream不要每次调用都重新包一层否则缓冲区状态会乱我一般把它挂在连接对象上复用。2.2 MySQL 表结构用户、会话、消息三张表起步表结构不用一上来就仿微信几十张表。最小可用集合是三张user存账号session存会话单聊和群聊统一抽象message存消息。下面是我常用的建表语句字符集统一utf8mb4否则 emoji 存进去变问号。CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE, password_hash CHAR(64) NOT NULL, -- SHA-256别存明文 nickname VARCHAR(32) DEFAULT , online TINYINT DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE session ( id BIGINT PRIMARY KEY AUTO_INCREMENT, type TINYINT NOT NULL, -- 1单聊 2群聊 name VARCHAR(64) DEFAULT , created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE message ( id BIGINT PRIMARY KEY AUTO_INCREMENT, session_id BIGINT NOT NULL, from_user BIGINT NOT NULL, content TEXT NOT NULL, send_time DATETIME DEFAULT CURRENT_TIMESTAMP, status TINYINT DEFAULT 0, -- 0未读 1已读 INDEX idx_session_time (session_id, send_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;参数说明password_hash用 CHAR(64) 正好放 SHA-256 十六进制串message表建联合索引(session_id, send_time)因为拉历史消息永远是「某个会话按时间排序」没这个索引数据一多就全表扫描。status字段用于离线消息用户上线后把未读改成已读。别用TEXT存密码也别用utf8字符集这两个是新手最常见的翻车点。2.3 服务端连接管理一个 ConcurrentHashMap 撑起在线路由服务端要知道「用户 A 在线时他的 Socket 是哪一个」才能把 B 的消息推过去。我一般用一个ConcurrentHashMapLong, ClientHandlerkey 是 userIdvalue 是连接处理器。用户登录成功后 put断开时 remove。// 在线用户路由表 private static final MapLong, ClientHandler ONLINE new ConcurrentHashMap(); public void onLoginSuccess(Long userId, ClientHandler handler) { ClientHandler old ONLINE.put(userId, handler); if (old ! null) { old.close(); // 同账号重复登录踢掉旧连接 } } public void push(Long toUserId, String json) { ClientHandler target ONLINE.get(toUserId); if (target ! null) { target.send(json); // 在线直接推 } else { saveOfflineMessage(toUserId, json); // 离线落库 } }逻辑说明ConcurrentHashMap保证多线程下路由表读写安全put返回旧值可以顺手实现「顶号」。push方法先查在线表在线就写 Socket不在线就落库等下次登录拉取。参数上ClientHandler.send内部要加锁或串行化因为多个线程可能同时往同一个 Socket 写直接并发写会导致消息内容交错这是比粘包更隐蔽的坑。3. 把最小链路跑通注册、登录、单聊、离线消息四步落地骨架定完接下来是能跑起来的代码。这一章按「服务端启动 → 客户端连接 → 注册登录 → 发消息收消息」的顺序走每一步都给可抄的命令和代码。局域网通信的关键是 IP 和端口服务端绑定0.0.0.0才能被同网段其他机器访问绑127.0.0.1就只能本机自娱自乐。3.1 服务端启动类与端口绑定public class ChatServer { private static final int PORT 8888; public static void main(String[] args) throws IOException { ServerSocket server new ServerSocket(); server.bind(new InetSocketAddress(0.0.0.0, PORT)); // 监听所有网卡 System.out.println(服务端启动端口 PORT); ExecutorService pool Executors.newFixedThreadPool(50); while (true) { Socket socket server.accept(); pool.submit(new ClientHandler(socket)); // 每个连接一个任务 } } }逻辑说明bind到0.0.0.0表示接受任意网卡进来的连接局域网内其他机器用服务端内网 IP 就能连。线程池固定 50 是防止连接数暴涨拖垮机器简易微信场景几十人足够。参数上端口选 8888 只是习惯实际要避开 3306MySQL、8080 等常用端口如果 Windows 防火墙弹窗要允许专用网络入站否则同网段连不上这个坑我踩过不止一次。3.2 客户端连接与登录报文客户端启动后先连服务端再发登录报文。报文统一用 JSON外层带type字段区分动作。// 客户端发送登录请求 public void login(String username, String password) throws IOException { Socket socket new Socket(192.168.1.100, 8888); // 换成服务端内网IP JSONObject msg new JSONObject(); msg.put(type, login); msg.put(username, username); msg.put(password, password); byte[] body msg.toJSONString().getBytes(StandardCharsets.UTF_8); DataOutputStream out new DataOutputStream(socket.getOutputStream()); out.writeInt(body.length); // 先写长度 out.write(body); // 再写正文 out.flush(); }逻辑说明客户端和服务端必须用同一套「长度 正文」规则否则服务端readInt读到的就是正文前 4 个字节直接乱码。参数上192.168.1.100要换成服务端实际内网 IP用ipconfigWindows或ip addrLinux查。登录成功后服务端回一条{type:login_ok,userId:123}客户端拿到 userId 存本地后续消息都带上。3.3 单聊消息的落库与转发单聊的核心逻辑收到消息 → 落库 → 查对方是否在线 → 在线推送离线等拉取。落库和转发要在一个事务语义里考虑先落库再推送避免推送成功但落库失败导致消息丢失。public void handleChat(long fromUser, long toUser, String content) { long sessionId sessionService.getOrCreateSingleSession(fromUser, toUser); // 先落库 String sql INSERT INTO message(session_id, from_user, content, status) VALUES(?,?,?,0); try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setLong(1, sessionId); ps.setLong(2, fromUser); ps.setString(3, content); ps.executeUpdate(); } catch (SQLException e) { log.error(消息落库失败, e); return; // 落库失败不推送让发送方重试 } // 再推送 JSONObject push new JSONObject(); push.put(type, chat); push.put(from, fromUser); push.put(content, content); router.push(toUser, push.toJSONString()); }逻辑说明getOrCreateSingleSession先查两人是否已有单聊会话没有就建一条保证同一对用户只有一个 sessionId。落库用PreparedStatement防 SQL 注入参数按顺序 set。注意落库失败直接 return不推送这样发送方收不到 ack 可以重发比「推送成功但库里没有」要好排查。参数上status0表示未读对方上线拉取后改成 1。3.4 离线消息拉取登录后按会话分组查询用户登录成功后客户端发一条pull_offline服务端查status0且session_id属于该用户的所有消息按会话分组返回。-- 拉取某用户的离线消息 SELECT m.id, m.session_id, m.from_user, m.content, m.send_time FROM message m JOIN session s ON m.session_id s.id WHERE m.status 0 AND m.from_user ! ? AND (s.type 2 OR EXISTS ( SELECT 1 FROM session_member sm WHERE sm.session_id s.id AND sm.user_id ? )) ORDER BY m.send_time ASC;逻辑说明单聊会话需要一张session_member表记录参与者群聊同理这里为了聚焦主链路没展开建表实际项目要补上。查询条件from_user ! ?是排除自己发的消息status0只拉未读。参数上ORDER BY send_time ASC保证消息顺序客户端按session_id分组渲染。拉完后服务端批量UPDATE message SET status1 WHERE id IN (...)别一条条改否则离线消息一多就慢。4. 并发与数据一致性多人在线时最容易翻车的几个点单机跑通不等于能用。局域网里十几个人同时发消息问题就来了消息顺序乱、重复推送、数据库连接耗尽。这一章讲并发控制和数据一致性都是血泪经验换来的。4.1 消息顺序用数据库自增 ID 而不是时间戳排序很多人用send_time排序结果同一秒内多条消息顺序随机。正确做法是用message.id自增主键排序它天然单调递增。客户端渲染时按 id 排服务端推送也带上 id。// 落库后拿回自增ID try (PreparedStatement ps conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setLong(1, sessionId); ps.setLong(2, fromUser); ps.setString(3, content); ps.executeUpdate(); ResultSet rs ps.getGeneratedKeys(); if (rs.next()) { long msgId rs.getLong(1); // 这个ID就是全局顺序依据 push.put(msgId, msgId); } }逻辑说明RETURN_GENERATED_KEYS让 JDBC 回传自增主键省一次查询。参数上send_time只用于展示排序一律用 id。如果分库分表了自增 id 不全局唯一那要换雪花算法但简易微信单库用自增足够。4.2 数据库连接池别每次 new ConnectionDriverManager.getConnection每次建物理连接几十个并发就把 MySQL 连接数打满。必须用连接池HikariCP 是常见选择。HikariConfig config new HikariConfig(); config.setJdbcUrl(jdbc:mysql://127.0.0.1:3306/chat?useSSLfalsecharacterEncodingutf8mb4); config.setUsername(chat_user); config.setPassword(your_password); config.setMaximumPoolSize(20); // 最大连接数 config.setMinimumIdle(5); // 最小空闲 config.setConnectionTimeout(3000); // 获取连接超时3秒 DataSource ds new HikariDataSource(config);参数说明maximumPoolSize不是越大越好MySQL 默认最大连接 151多个服务端实例要留余量单实例 20 够用。useSSLfalse是局域网内网环境常见配置避免 SSL 握手开销如果 MySQL 8.0 报Public Key Retrieval is not allowed加allowPublicKeyRetrievaltrue。characterEncodingutf8mb4保证 emoji 不乱码。4.3 重复登录与消息去重同账号在多台机器登录或者网络抖动导致客户端重发都会产生重复消息。去重靠客户端生成clientMsgIdUUID服务端落库前查一下这个 id 是否已存在。-- message表加一列 client_msg_id建唯一索引 ALTER TABLE message ADD COLUMN client_msg_id VARCHAR(36) DEFAULT NULL; CREATE UNIQUE INDEX uk_client_msg ON message(client_msg_id);逻辑说明唯一索引让重复插入直接抛SQLIntegrityConstraintViolationException服务端捕获后返回「已接收」但不重复推送。参数上client_msg_id允许 NULL 是为了兼容历史数据新消息必须带。这个方案比先查后插更可靠因为查和插之间有并发窗口。5. 避坑与排查局域网简易微信最常见的 5 个翻车现场这一章全是实际调试中遇到的坑每条按「现象 → 原因 → 解决」写。局域网环境看似简单但防火墙、编码、连接状态这些细节能让整个系统时好时坏。5.1 同网段连不上本机却正常现象服务端本机telnet 127.0.0.1 8888通另一台机器连不上。原因服务端绑定了127.0.0.1而不是0.0.0.0或者 Windows 防火墙拦截了入站。解决检查bind地址Windows 在「高级安全 Windows Defender 防火墙」里给 Java 放行专用网络入站Linux 用firewall-cmd --add-port8888/tcp或iptables放行。注意公司网络可能隔离了客户端之间互访这种情况换交换机直连或找网管开策略。5.2 中文消息变问号现象英文正常中文显示???。原因new String(bytes)没指定字符集用了平台默认编码Windows 中文版是 GBK而发送端用 UTF-8。解决所有getBytes和new String都显式写StandardCharsets.UTF_8MySQL 连接串加characterEncodingutf8mb4建表用utf8mb4。三处缺一不可我见过只改一处然后怀疑人生的。5.3 消息发出去对方收不到但数据库里有现象A 发消息库里能查到B 在线却收不到。原因ONLINE路由表里 B 的 key 不对比如登录时存的是 username 字符串推送时用 userId 查。解决统一用 userId 做 key登录成功回包带上 userId客户端后续请求都带。排查时在push方法里打日志看ONLINE.get(toUserId)是否为 null。5.4 连接一段时间后自动断开现象客户端挂机几分钟后发消息失败。原因TCP 长连接被中间设备路由器、防火墙静默断开双方都不知道。解决加心跳客户端每 30 秒发一条{type:ping}服务端回pong连续 3 次没收到就重连。参数上心跳间隔别小于 10 秒太频繁浪费也别大于 60 秒很多 NAT 超时是 60 秒。5.5 MySQL 报 Too many connections现象并发一高就报连接数超限。原因每次操作都new Connection且没关闭或者连接池配太大。解决用 HikariCP 并确保try-with-resources关闭连接maximumPoolSize控制在 20 以内。排查用SHOW PROCESSLIST看有多少连接处于 SleepSleep 过多说明连接没归还。6. 进阶技巧用 Netty 替换原生 Socket 并做消息可靠性验证原生 Socket 跑通后如果连接数上百、要处理心跳和断线重连手写线程模型会很累。我一般会在这个阶段引入 Netty它把粘包处理、心跳、连接管理都封装好了。但别一上来就用先用原生 Socket 理解原理再换 Netty 才知道它在帮你做什么。6.1 Netty 的 LengthFieldBasedFrameDecoder 替代手写拆包// Netty 服务端 pipeline 配置 ServerBootstrap b new ServerBootstrap(); b.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializerSocketChannel() { Override protected void initChannel(SocketChannel ch) { ch.pipeline() .addLast(new LengthFieldBasedFrameDecoder( 1024 * 1024, // 最大帧长 0, 4, 0, 4)) // 长度字段偏移0占4字节解码后跳过4字节 .addLast(new StringDecoder(StandardCharsets.UTF_8)) .addLast(new ChatHandler()); } });参数说明LengthFieldBasedFrameDecoder四个参数分别是最大帧长、长度字段偏移量、长度字段字节数、解码后跳过的字节数。这里长度字段在开头偏移 0占 4 字节解码后跳过这 4 字节剩下的就是正文。和手写readInt readFully效果一样但 Netty 帮你处理了半包、粘包、超大帧拒绝。StringDecoder把 ByteBuf 转字符串ChatHandler里写业务逻辑。6.2 消息可靠性验证发 1000 条不丢不乱换完 Netty 别急着高兴要验证。我一般写个压测客户端循环发 1000 条带序号的 JSON服务端落库后统计条数和顺序。// 压测客户端片段 for (int i 0; i 1000; i) { JSONObject msg new JSONObject(); msg.put(type, chat); msg.put(to, targetUserId); msg.put(content, msg- i); msg.put(clientMsgId, UUID.randomUUID().toString()); channel.writeAndFlush(buildFrame(msg.toJSONString())); }验证方法发完后查SELECT COUNT(*) FROM message WHERE content LIKE msg-%应该是 1000再查SELECT content FROM message ORDER BY id序号应该递增无重复。如果少了看服务端日志有没有落库异常如果顺序乱检查是不是用了时间戳排序。这个验证跑通才算真正能投入内网使用。6.3 一个我坚持的习惯每次改完通信层我都会先用两台机器各开一个客户端互发 50 条再开第三个客户端登录同一账号验证顶号最后才上多人。这个顺序帮我省了很多「以为代码没问题其实是环境问题」的时间。局域网简易微信不难难的是把边界情况一个个堵住。希望帮到你。本文还有配套的精品资源点击获取