chat_room 故障复盘、学习过程与修复说明 说明: 这份文档不是只写“最后修好了什么”,而是按学习过程来写,尽量把这次排查中遇到的每一个关键问题都讲清楚: 1. 现象是什么 2. 为什么会出现这种现象 3. 我是怎么一步一步定位的 4. 每一步排查时学到了什么 5. 代码层面具体改了什么 6. 部署层面具体改了什么 7. 最后怎么验证问题真的被解决了 一、问题背景 这个项目是一个基于 WebSocket 的简易聊天室,分为两部分: 1. 服务端 路径:D:\Network\chat_room\chat 技术:Java + Gradle + Java-WebSocket 作用:接收客户端连接,处理登录、广播、私聊、在线用户等逻辑。 2. 客户端 路径:D:\Network\chat_room\user 技术:Android Java 作用:手机端聊天界面,负责连接服务器、发送消息、接收消息、展示消息。 线上部署方式: 1. Java 聊天服务实际监听 8080。 2. Nginx 监听公网 80。 3. Nginx 把 WebSocket 请求反向代理到 127.0.0.1:8080。 4. 所以客户端默认连接地址是: ws://124.220.100.95 二、最开始用户看到的现象 当时用户看到的是: 1. App 可以打开。 2. 顶部状态一直显示“登录中”。 3. 输入框可以输入文字。 4. 发送按钮是灰色,点不动。 5. 两台手机之间不能像 QQ / 微信 那样正常互发消息。 6. 有时看起来像是“已经连上服务器了”,但消息并没有真正互通。 从用户角度看,这个问题很容易理解成: 1. 是不是按钮坏了 2. 是不是前端界面写错了 3. 是不是服务器地址没填对 4. 是不是端口没开 但真正的问题并不是单点故障,而是一个完整链路上的组合问题。 三、第一次判断时为什么容易误判 这次排查里,最容易误判的地方在于: 1. WebSocket 连接本身确实能建立。 2. Nginx 也确实在转发请求。 3. 所以看起来像是“网络是通的”。 为什么这会误导人? 因为很多人会把下面两件事混为一谈: 1. TCP / WebSocket 连接成功 2. 业务登录成功 实际上这是两层不同的事情: 1. WebSocket 连接成功,只说明底层链路通了。 2. 业务登录成功,说明服务端已经正确处理了 LOGIN,并把登录确认回给客户端。 这次问题里,第一层是通的,第二层是不通的。 这也是这次学习过程中最重要的一点之一: 排查实时通信问题时,不能只看“连上没连上”,一定要继续问: 1. 连接建立后,第一条业务消息有没有发出去 2. 服务端有没有收到 3. 服务端有没有返回确认 4. 客户端有没有收到确认 5. 客户端有没有根据确认更新自己的状态 四、发送按钮为什么会是灰色 这个问题必须先从客户端逻辑理解。 客户端新版本的发送按钮不是单纯由界面样式控制,而是由业务状态控制。 按钮可点的条件是: 1. WebSocket 已连接 2. 登录已完成 也就是类似: 1. isConnected == true 2. isLoggedIn == true 只要第二个条件不成立,按钮就会保持禁用状态。 所以发送按钮灰色,并不一定说明按钮组件坏了,而更可能说明: 1. 客户端认为自己还没完成登录 2. 客户端在等待服务端的登录确认 这就是为什么我后来没有去直接“强行点亮按钮”,因为那样只是把表面效果改了,真正的业务状态依然是错的。 学习点: 前端控件状态如果由业务状态驱动,就应该顺着业务状态去找问题,而不是直接改样式。 五、真正卡住的位置到底在哪里 问题真正卡住的位置是: 客户端发送 LOGIN 之后,没有正确收到服务端的登录确认回包。 正常链路应该是: 1. 客户端建立 WebSocket 连接 2. onOpen 触发 3. 客户端自动发送 LOGIN 4. 服务端处理 LOGIN 5. 服务端返回: - Welcome - joined the chat - ONLINE 6. 客户端收到这些消息后,把自己状态改为“已登录” 7. 发送按钮解锁 但当时实际发生的是: 1. 客户端建立了 WebSocket 连接 2. 客户端确实发出了 LOGIN 3. 服务端也确实收到了 LOGIN 4. 但服务端没有把欢迎消息和在线列表正确返回给客户端 5. 客户端一直停在“登录中” 6. 发送按钮一直保持灰色 所以,故障真正卡住的位置不是“按钮组件”,而是“服务端登录确认链路”。 六、我是怎么一步一步定位这个问题的 这一部分很重要,因为这是完整的学习过程。 第一步:看本地代码结构 我先检查了本地仓库,确认项目到底有哪些部分。 当时先看到的是: 1. D:\Network\chat_room\chat 通过代码搜索,我确认了这部分是服务端,核心信息包括: 1. 使用 Java-WebSocket 2. 默认监听 8080 3. 有 LOGIN / ALL / PRIVATE / ONLINE 等消息类型 这一阶段学到的是: 先不要急着猜问题,要先搞清楚项目结构,知道“谁是服务端、谁是客户端、协议长什么样、消息怎么流转”。 第二步:确认真正客户端在哪里 一开始用户口头说客户端是 usera,但目录里实际上没有 usera。 我继续检查 D:\Network\chat_room,发现真实目录是: 1. D:\Network\chat_room\chat 2. D:\Network\chat_room\user 后面确认 user 才是真正的 Android 客户端工程。 这一阶段学到的是: 用户口述路径不一定和实际路径完全一致,排查时应该以实际文件系统为准。 第三步:检查客户端逻辑 我看了客户端的 MainActivity,重点关注: 1. 什么时候连接服务器 2. 什么时候发送 LOGIN 3. 发送按钮什么时候启用 4. 收到什么消息后才算登录成功 由此确认: 1. 客户端会自动连接服务器 2. onOpen 后会自动发送 LOGIN 3. 发送按钮依赖登录状态,不是单纯依赖连接状态 这一步的结论非常关键: 按钮灰色不是 UI 渲染错误,而是客户端有意识地禁止发送。 第四步:检查服务端本地逻辑 我继续读服务端代码,重点检查: 1. ChatApplication.java 2. ChatWebSocketServer.java 3. ChatMessageRouter.java 4. ChatRoomManager.java 通过这些文件,我确认了服务端登录流程大致是: 1. 收到 LOGIN 2. register 用户 3. 给当前用户发送 Welcome 4. 向所有人广播 joined 5. 给当前用户发送 ONLINE 到这里,逻辑表面上看是对的。 但“表面上看是对的”不等于“实现细节就是对的”。 第五步:本地启动服务端做联通测试 我在本地启动了服务端,并用脚本模拟两个 WebSocket 客户端。 这里做了几轮测试: 1. 先测单用户 LOGIN 2. 再测双用户 LOGIN 3. 再测 PRIVATE 4. 再测 ALL 这一步发现了一个重要现象: 1. 群聊和私聊主链路大体是能走通的 2. 但是登录阶段会出现异常状态消息 当时我就开始怀疑: 服务端的“已注册会话”和“当前处理中的 sender 对象”可能不是同一个状态。 第六步:直接连公网地址做协议测试 为了确认问题到底在本地代码、部署、还是线上环境,我没有只看本地,而是直接连公网: 1. 连接 ws://124.220.100.95 2. 手动发送 LOGIN JSON 3. 观察有没有回 Welcome / ONLINE 测试结果是: 1. WebSocket 能建立连接 2. 但服务端没有回任何登录确认消息 然后我又做了第二轮公网测试: 1. 两个客户端同时连接 2. 两边都发 LOGIN 3. 再由一边发 ALL 结果是: 1. 登录没有回包 2. 群聊也没有回包 这一步的意义是: 把问题从“客户端界面问题”彻底缩小成“线上服务端业务处理或部署版本问题”。 七、服务端代码里真正的 bug 是什么 问题文件是: chat\src\main\java\com\campus\chat\server\ChatMessageRouter.java 问题函数是: handleLogin() 原来的问题逻辑: 1. 收到 LOGIN 时,当前 sender 其实是一个临时会话对象: SessionInfo(null, conn) 2. 代码调用 roomManager.register(username, sender.getConnection()) 3. register 会新建一个真正的已登录 SessionInfo(username, conn),并放进 sessionsBySocket / sessionsByUsername 4. 但是后续代码继续用旧的 sender 发 Welcome 和 ONLINE 5. 旧 sender 里的 username 仍然是 null 为什么这会出问题? 因为从 register 之后开始,真正应该代表当前用户的对象已经不是旧的 sender,而是 ChatRoomManager 里新注册进去的那个 SessionInfo。 如果继续拿旧 sender 发消息,就会出现: 1. 登录状态对象不一致 2. 某些地方认为已经登录 3. 某些地方仍然认为未登录 4. 最终客户端收不到完整稳定的登录确认 这一点本质上是“状态源不一致”的问题。 学习点: 注册用户、绑定连接、会话对象管理这类逻辑里,最怕的不是 if 写错,而是“系统里同时存在两个代表当前用户的对象”。 八、服务端 bug 是怎么修的 修复方式非常明确: 1. 先 register(username, sender.getConnection()) 2. 再从 roomManager.findBySocket(sender.getConnection()) 取回真正已注册的 SessionInfo 3. 用这个 registeredSender 发 Welcome 4. 用这个 registeredSender 发 ONLINE 也就是说: 1. register 2. refetch session 3. use registered session for all post-login actions 修完后: 1. 登录后的消息发送使用的是统一状态对象 2. 服务端能正确回 Welcome 3. 服务端能正确回 ONLINE 4. 客户端能真正完成登录 九、为什么“不能发消息”和“对方手机收不到消息”会一起出现 这两个现象的根因其实是一条线上的。 原因链条是: 1. 客户端没完成登录 2. 发送按钮不解锁 3. 即使绕过按钮,有些消息也会因为服务端认为 sender 未登录而处理失败 4. 广播逻辑依赖 sender 已正确注册 5. 所以最终表现为“不能发、不能收、对方看不到” 学习点: 在即时通信系统里,“登录是否成功”不是独立的小问题,而是整个消息链路的前置条件。 十、客户端本身有哪些设计问题 除了服务端 bug,这次还顺手暴露出客户端原来设计上的一些问题。 问题 1:聊天主界面像调试面板 现象: 1. 服务器地址输入框直接放在聊天页 2. 昵称输入框也直接放在聊天页 3. 界面像“开发测试工具”,不像“普通聊天软件” 为什么这是问题? 因为: 1. 普通用户不应该每次进入聊天页都看到服务器地址 2. 昵称这种配置项更适合首次设置或设置页 3. 主聊天页应该尽量只保留会话相关元素 解决: 1. 去掉服务器地址输入框 2. 去掉昵称输入框 3. 服务器地址改内置 4. 昵称改成首次弹窗设置 5. 设置按钮用来修改昵称 问题 2:默认昵称固定为 guest 现象: 1. 两台设备可能都叫 guest 2. 第二台会被服务端判定为重名 3. 用户会误以为“好像已经连上了,只是发不出去” 为什么会这样? 1. 默认值写死,没考虑多设备测试 2. 用户又不一定第一时间主动改昵称 解决: 1. 默认昵称改成根据设备信息自动生成 2. 首次进入弹窗要求确认昵称 3. 昵称保存到本地 4. 遇到 Username already in use. 时强制重新设置 问题 3:消息重复显示风险 现象: 1. 客户端发送时本地先插一条自己的消息 2. 服务端广播回来又显示一条 为什么会这样? 因为客户端做了 optimistic UI,而服务端也会回广播。 解决: 1. 改成主要依赖服务端真实广播回包来展示消息 这样做的好处: 1. 自己发出去的消息和别人收到的消息展示逻辑一致 2. 更接近真实的最终状态 十一、客户端代码修改过程中遇到的问题 这一部分是纯学习过程,记录的是“在写和改代码过程中遇到的技术问题”。 问题 1:原文件中文文案乱码,导致精确补丁容易失败 现象: 1. MainActivity.java 里有些中文字符串显示成乱码 2. 按原字符串做上下文匹配时补丁不稳定 原因: 1. 文件原有编码和内容已经存在混乱 解决: 1. 放弃小块 patch 2. 改成整文件替换 MainActivity、activity_main.xml、strings.xml 学习点: 当文件上下文已经不可靠时,整文件替换有时比硬做小补丁更稳。 问题 2:旧客户端构建报告里有 JSONException 编译错误 现象: 1. 构建报告里曾有未处理 JSONException 的编译错误 原因: 1. 旧版消息解析流程里异常处理不完整 解决: 1. 重写 MainActivity 的消息处理逻辑 2. 重新编译 assembleDebug 问题 3:illegal forward reference 现象: 1. 重写 MainActivity 后,编译器提示 illegal forward reference 原因: 1. Runnable 字段在初始化时直接依赖了还没完全建立好的成员字段 解决: 1. 把 reconnectRunnable 改成 this::runReconnect 2. 真正逻辑放进 runReconnect() 方法中 学习点: Java 字段初始化顺序是非常具体的,复杂字段逻辑最好延后到方法里。 问题 4:匿名回调里 client 变量“可能尚未初始化” 现象: 1. 编译器提示 client 可能尚未初始化 原因: 1. 匿名内部类中引用了正在构造过程中的 client 本身 解决: 1. 使用 final ChatWebSocketClient[] clientRef = new ChatWebSocketClient[1] 2. 构造完成后再赋值 clientRef[0] = client 学习点: 这类问题不是业务错误,而是 Java 语言层面的闭包/初始化限制。 问题 5:修完客户端后,按钮还是灰的 现象: 1. 新客户端装上后,按钮一度还是灰的 为什么会这样? 1. 客户端逻辑已经修了 2. 但手机实际连接的还是线上旧服务端 3. 旧服务端仍然不给 Welcome / ONLINE 解决: 1. 不继续在按钮逻辑上瞎改 2. 继续追服务器部署状态 学习点: 本地代码改对了,不代表线上就已经生效。一定要把“代码状态”和“部署状态”分开看。 十二、部署排查过程中遇到的问题 这一部分是部署学习过程。 问题 1:最开始容易怀疑云防火墙没放 8080 现象: 1. 服务端默认监听 8080 2. 云控制台截图里没有明确放开 8080 为什么容易误判? 因为很多人看到“端口不一致”就会先怀疑防火墙。 怎么解决? 1. 登录服务器查看 nginx -T 2. 查看 ss -ltnp 3. 确认真实链路是: 80 -> nginx -> 127.0.0.1:8080 结果: 1. 证明不是必须公网直接放 8080 2. 这不是主因 问题 2:需要确认线上到底跑的是哪个版本 现象: 1. 本地代码修了 2. 但公网行为没变 为什么会这样? 因为本地代码和线上部署不是同一个概念。 怎么解决? 1. 通过 SSH 登录服务器 2. 查看 /etc/systemd/system/chat.service 3. 查看 /opt/chat 目录 4. 查看 Java 启动命令 结果: 1. 确认线上 chat.service 启动的是 /opt/chat/bin/chat 2. 确认线上真正运行的 jar 在 /opt/chat/lib/chat-0.0.1-SNAPSHOT.jar 问题 3:如何确认服务端“收到消息但没回消息” 现象: 1. 客户端拿不到回包 为什么不能只靠客户端猜? 因为必须区分: 1. 消息有没有发到服务器 2. 服务器有没有收到 3. 服务器收到后有没有处理 4. 处理后有没有发送回包 怎么解决? 1. 查 systemctl status chat --no-pager 2. 看日志里的 Raw message received 结果: 1. 确认线上 Java 进程确实收到了 LOGIN 和 ALL 2. 问题不是“消息没到服务器”,而是“服务器收到后没正确回包” 问题 4:scp 上传部署包卡住 现象: 1. 直接 scp 上传时超时 为什么会这样? 1. scp 在等待交互输入密码 2. 自动化环境没法手工输入 怎么解决? 1. 改用 Python 的 paramiko + SFTP 2. 在脚本里显式提供用户名和密码 结果: 1. 部署包能稳定上传 问题 5:服务器本机没有 websockets Python 库 现象: 1. 想在服务器本机做 Python WebSocket 回环测试 2. 报错 ModuleNotFoundError: No module named 'websockets' 为什么会这样? 1. 系统 Python 里没装这个依赖 怎么解决? 1. 没有为了诊断去污染服务器环境安装新库 2. 改用已有证据链: - 公网直连测试 - systemd 日志 - nginx 配置 - Java 进程状态 结果: 1. 即使没有这条回环测试,也足够完成定位 学习点: 排查时不一定每个辅助工具都必须装齐,关键是证据链能闭环。 问题 6:线上跑的是旧版本 /opt/chat 现象: 1. 本地修好了 2. 客户端修好了 3. 公网测试还是旧行为 为什么会这样? 1. 服务器上 /opt/chat 还没替换成新构建 2. chat.service 仍然在跑旧 jar 怎么解决? 1. 本地重新构建 chat 2. 打包部署目录 3. 上传服务器 4. 停掉 chat.service 5. 备份旧的 /opt/chat 为 /opt/chat.bak 6. 解压新包到 /opt/chat 7. chown 给 chat:chat 8. 启动 chat.service 结果: 1. 线上服务真正切换到修复版 十三、线上部署实际执行了哪些事情 这是整个部署修复的完整过程。 1. 在本地执行: gradlew clean installDist test 目的: 1. 重新编译服务端 2. 生成新的运行目录 3. 跑服务端测试,确认本地构建没问题 2. 在本地把 build/install/chat 打成部署 tar 包 目的: 1. 把可运行目录整体封装,便于上传 3. 用 paramiko SFTP 上传到服务器 目的: 1. 避开 scp 交互密码问题 4. 在服务器上停止 chat.service 目的: 1. 避免覆盖运行中的目录 5. 备份旧目录 动作: 1. /opt/chat -> /opt/chat.bak 目的: 1. 保留旧版本,必要时可回滚 6. 把新 tar 包解压到 /opt/chat 目的: 1. 替换旧部署目录 7. 修正目录属主为 chat:chat 目的: 1. 保证 systemd 用 chat 用户运行时对目录有权限 8. 启动 chat.service 目的: 1. 让线上开始跑修复后的版本 9. 查看 systemctl status chat 目的: 1. 确认服务已经真正启动成功 十四、最终是怎么验证真的修好了 这一步非常重要,因为“感觉修好了”和“证明确实修好了”是两回事。 验证 1:公网登录验证 1. 直接连 ws://124.220.100.95 2. 手动发 LOGIN 3. 成功收到: - Welcome, verify_user. - verify_user joined the chat. - ONLINE 说明: 1. 登录确认链路已经恢复 验证 2:双端消息验证 1. 建两个公网 WebSocket 客户端 2. 分别登录 alice_fix 和 bob_fix 3. alice_fix 发 hello fixed 4. alice_fix 自己收到广播 5. bob_fix 也收到广播 说明: 1. 群聊广播链路恢复 2. 对方手机应该也能看到消息 验证 3:客户端行为验证 修好之后,客户端预期行为应该是: 1. 顶部状态从“登录中”切换为“在线” 2. 发送按钮解锁 3. 输入消息后可发送 4. 对方客户端能看到消息 十五、这次学习过程中最重要的收获 1. 连接成功不等于业务成功。 WebSocket 建立成功,只说明链路通;是否能聊天,要看 LOGIN、ONLINE、ALL 这些业务消息是否真正完成闭环。 2. UI 状态很多时候只是业务状态的表象。 按钮灰色,并不一定是按钮自身的问题,往往是后面的业务状态没有变。 3. 状态管理最怕“对象不一致”。 这次服务端 bug 的本质不是语法错误,而是 register 之后继续使用旧 SessionInfo,导致状态源错位。 4. 本地修复不等于线上生效。 本地代码、构建产物、服务器部署目录、systemd 服务,都是不同层。每一层都可能是问题点。 5. 实时通信故障要按链路排查。 最稳的方法不是猜,而是逐段验证: - 客户端有没有发 - 服务端有没有收 - 服务端有没有回 - 客户端有没有处理回包 6. 部署问题和代码问题常常同时存在。 这次不是“只修代码就完事”,也不是“只改服务器就完事”,而是两边都要改,最后再重新部署。 十六、最终结果 到本次修复结束时,主链路已经恢复: 1. 客户端可以连接公网地址 ws://124.220.100.95 2. 客户端可以收到 Welcome / ONLINE 3. 顶部状态会从“登录中”切换为“在线” 4. 发送按钮会解锁 5. 一台设备发消息,另一台设备可以收到 6. 线上服务已替换为新版本 7. 旧版本保留在 /opt/chat.bak 作为回滚备份 这说明: 1. 不是只修了界面 2. 不是只修了本地代码 3. 而是把“客户端逻辑 + 服务端逻辑 + 线上部署”这一整条链路修通了