多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

聊天室项目背后的实时通信技术:WebSocket、心跳与消息广播

聊天室项目背后的实时通信技术:WebSocket、心跳与消息广播 做文字聊天室这个项目我前后折腾了三轮。最开始以为它就是个网络编程的练手项目无非是让两个浏览器互相发句话可真把需求拆开、再把线上跑起来之后才发现里面连连接管理、协议设计、并发广播、前端实时渲染全都串在一起复杂度比你想象的大得多。做这个项目最值钱的地方在于它几乎把“实时通信系统”这个方向的核心难点浓缩到了一个很小的范围里做完之后你再去看 IM、看直播弹幕、看客服系统思路会非常开阔。这篇文章我按自己的完整项目流程来写先讲整体架构和技术选型再拆服务端的连接与消息协议然后是前端交互的关键细节接着从一个分析者的角度去处理日志、指标和抓包数据最后把那些最容易踩的坑集中理一遍。无论你是刚开始做网络方向的学生还是想在公司内部搭一套实时协作工具的工程师这篇文章应该能让你少走不少弯路。1. 项目整体设计与技术选型1.1 这个“文字聊天室”到底解决什么问题聊天室最核心的功能老用户心里都有数登录后进入一个公共房间看到谁在线发一条文字消息其他所有人能立刻看到这条消息同时能看到整个聊天历史。听起来像个极简版微信群但背后真正要解决的是三个问题。第一个是双向实时通信。网页里传统的 HTTP 请求是“你问一句、它答一句”服务端没法主动把消息“塞”给浏览器。如果要做成轮询用户每发一条消息其他人都要等下一次询问才能看到秒级延迟已经算快的了。做聊天室必须换成长连接方案让服务端能主动推送消息这就是后来我们要讨论 WebSocket 的原因。第二个是消息的有序性和不丢失。你跟朋友聊天的时候消息到达顺序一旦错乱整个对话就看不懂。而真实网络环境里客户端发出的消息可能在网络层被重新排序服务端处理并发时也可能导致乱序需要在应用层做处理。第三个是在线状态和房间状态的一致性。谁上线了、谁下线了、房间里现在有多少人这不能靠拍脑袋维护一个内存数组就算完。连接断开、网络抖动、页面刷新各种异常情况都会让状态失真必须有明确的生命周期管理。所以与其说聊天室是一个“功能”不如说它是一个完整的实时通信小系统。把这三个问题解决掉聊天室就立住了。1.2 技术栈怎么选长连接方案与存储策略技术选型这里我直接说结论再讲理由。服务端我用的是 Python FastAPI长连接用 FastAPI 原生封装的 WebSocket 接口数据存储用 SQLAlchemy 接 SQLite生产环境可以平滑换 PostgreSQL。消息推送在单机阶段用进程内发布订阅后续多实例就切 Redis Pub/Sub。很多老工程师可能会问聊天室这种高并发场景为什么不用 Go 或者 Node.js我的回答是如果目标是学习原理、快速出结果Python 的代码可读性和调试效率是最好的。FastAPI 本身是异步框架基于 ASGIWebSocket 支持非常直观写起来不像 Flask 那样要额外挂一堆扩展。而且它的类型注解和自动文档对后面做分析统计接口特别方便。前端我选了轻量方案原生 JavaScript WebSocket API页面结构用简单的 HTML/CSS。不整 Vue 全家桶是为了让读者更清晰地看清“连接、发送、渲染”这条链路。如果你在公司做内部工具再套一层框架也不迟核心逻辑都一样。存储这里要想清楚一点聊天历史数据是典型的“写多读少且尾部热数据”场景SQLite 在单机几百人同时在线时完全够用但如果你预期有更大规模建议提前用 PostgreSQL表结构尽量预留房间维度字段。1.3 目录结构与整体架构图我自己项目的目录结构是这样的chatroom/ ├── backend/ │ ├── main.py # FastAPI入口路由和WebSocket挂载 │ ├── connection.py # 连接管理器维护所有在线WebSocket │ ├── protocol.py # 消息协议编解码 │ ├── room.py # 房间管理成员列表、广播逻辑 │ ├── models.py # SQLAlchemy模型历史消息表 │ └── requirements.txt ├── frontend/ │ ├── index.html │ ├── chat.js # 连接、重连、渲染逻辑 │ └── style.css └── scripts/ ├── start.sh ├── test_ws.py # 模拟客户端压测 └── docker-compose.yml整体流程可以这样理解浏览器发起 HTTP 握手服务端升级为 WebSocket 长连接之后所有聊天消息都走这条连接房间模块维护一个“房间 ID 到连接组”的映射表收到消息后先持久化成历史消息再广播给同房间其他连接。这个架构不是最复杂的但它是所有更高阶方案的地基。理解了它你再看那些带网关、带消息队列、带微服务的实时系统会发现只是在不同层做了拆分和扩展。2. 服务端核心实现连接管理、协议与广播2.1 连接生命周期与心跳保活这是稳定性关键WebSocket 连接从建立到关闭并不是“握手成功就万事大吉”。中间任何一层网络设备路由器、负载均衡、反向代理都可能因为长期空闲把连接关掉而客户端和服务端在 TCP 层未必能第一时间感知。最典型的例子是电脑休眠两小时再唤醒打开页面发现聊天室已经“悄悄”断了但界面还是显示在线。解决方案就是心跳机制。服务端每隔一段时间向客户端发一个 Ping 帧客户端按照协议必须回一个 Pong 帧如果连续几次都没收到就判定连接失效主动清理。我这里设置的是 30 秒发一次心跳60 秒内没有 Pong 就断开。用代码表示就是import asyncio from fastapi import WebSocket HEARTBEAT_INTERVAL 30 HEARTBEAT_TIMEOUT 60 async def heartbeat_loop(ws: WebSocket, connection_id: str): while True: try: await ws.send_text({type:ping}) await asyncio.sleep(HEARTBEAT_INTERVAL) except Exception: # 发送失败说明连接可能已中断 await on_disconnect(connection_id) break这里要注意一个细节浏览器原生 WebSocket API 在收到服务端的 Ping 帧后会自动回复 Pong 帧不需要前端写额外代码。但如果你在服务端自己实现客户端比如做自动化测试脚本别忘记也要被动回复 Ping否则会被服务端判死。心跳间隔不能太短也不能太长。太短会占用网络和 CPU太长则中间设备容易把空闲连接回收。30 秒是一个经过实践检验的常见值。2.2 消息协议设计别把数据结构想简单了很多新手在写聊天室的时候前端发什么后端就转发什么一个字一字字符串处理等聊天内容开始丰富起来各种问题就都冒出来了怎么区分系统通知和用户消息怎么知道消息发送成功没有消息的 room 信息放哪我建议从第一天就定一个统一的 JSON 协议。我用的结构长这样{ type: chat, data: { roomId: general, sender: { id: u10001, nickname: 阿杰 }, content: 晚上一起做压力测试吗, clientMsgId: u10001-1690000000000, timestamp: 1690000000000 } }type 字段用来区分消息类型目前有chat、system、ping、pong、online_list五种。对后端来说收到什么 type 就进入什么处理逻辑非常清晰。让我特别说一下clientMsgId这个字段。它是客户端生成的一个唯一标识作用有两个第一客户端发完消息后可以拿着这个 ID 去匹配服务端返回的确认如果超时未确认客户端可以选择重发第二服务端在收到重复消息时比如网络重试可以用这个 ID 做去重避免同一条消息出现两次。一个小小字段能省掉后面无数“消息重复”的麻烦。timestamp 统一用毫秒级 Unix 时间戳而不是让前端传一个2025-...这样的格式字符串。原因有两个一是数字比较和排序效率高二是不同时区的用户不会产生歧义显示时再在前端转换格式就好。2.3 房间、在线列表与消息广播房间模块的核心数据结构是一张映射表房间 ID 到连接组。我用一个全局字典来管理from collections import defaultdict # room_id - set[WebSocket] rooms defaultdict(set) # user_id - room_id user_room_map {} async def join_room(ws: WebSocket, room_id: str, user_id: str): rooms[room_id].add(ws) user_room_map[user_id] room_id await broadcast(room_id, { type: system, data: {content: f用户 {user_id} 加入了房间, roomId: room_id} }) await push_online_list(room_id)广播的实现就遍历房间里每个 WebSocket逐个发送。单机状态下这个方案简单可靠。它的性能瓶颈在于房间内连接数量如果房间里有 1000 个连接发一条消息就要循环 1000 次。这里分享一个我踩过的坑不要在遍历广播时直接修改集合。有个用户刚发送完消息就退出退出时会调用discard(ws)如果你正在遍历的过程中发生了这个操作Python 会直接抛RuntimeError: Set changed size during iteration。解决方案是先做list(room_connections)快照再遍历或者用锁包住整个广播操作。在线列表我这里用的是基于心跳的“被动感知”。理论上websocket 断开时能触发on_disconnect回调但实际网络断开未必能及时触发 TCP 连接关闭所以最终在线列表的准确度还是要依赖心跳清理逻辑。每次广播给房间成员时顺便把最新的在线用户列表带上前端就能实现“谁在线”的侧栏。2.4 并发与消息顺序的取舍聊到高并发很多人第一反应是用多线程。但在 Python 异步框架里单进程单线程配合事件循环本身就能扛住大量 I/O 密集型长连接因为每条连接大部分时间都在等待数据并不占用 CPU。真正需要注意的反而是一段耗时的同步操作比如把历史消息写入数据库的同步驱动这会阻塞整个事件循环。我的建议消息持久化做成异步任务不要放在广播主链路上。也就是说用户发消息到服务端服务端立刻先把消息广播出去再把“保存历史消息”的任务丢给后台队列。这样前端体验不会因为数据库写操作而卡顿。顺序问题则更微妙。单进程单线程下消息处理的先后顺序和事件循环处理任务的顺序是线性一致的所以单机场景不需要太操心。但如果后续做了多进程或多实例部署就需要一个统一的消息序号服务或者通过消息队列的有序消费来保证。早期版本不必过度设计但要留好这个扩展点。3. 前端交互聊天室体验的最后一公里3.1 连接建立、自动重连与状态提示前端的核心代码非常集中总共只有三件事建立连接、发送消息、接收渲染。但前端的难点在“异常恢复”。网络是脆弱的。Wi-Fi 切换、手机息屏、路由器重启连接说断就断。如果用户什么都没做聊天室就永远停留在“断开”状态那就很糟糕。所以前端一定要做自动重连。我的实现思路维护一个reconnectAttempts计数器断线后从 1 秒开始指数退避地重试最多间隔 30 秒。同时界面状态要明确提示小绿点是“已连接”黄点是“重连中”红点是“已断开”。这两个小细节能让用户体感完全不一样。function connect() { ws new WebSocket(ws://${location.host}/ws?roomIdgeneraluserId${userId}); ws.onopen () { setStatus(connected); reconnectAttempts 0; }; ws.onclose () { setStatus(reconnecting); const delay Math.min(1000 * 2 ** reconnectAttempts, 30000); reconnectAttempts 1; setTimeout(connect, delay); }; ws.onmessage (event) { const msg JSON.parse(event.data); handleMessage(msg); }; }这里要注意指数退避一定要加随机抖动。不然 100 个客户端同时断线重连逻辑会把所有重连时间排列成“群发”服务端会突然接到一波连接洪峰然后又集体挤掉线形成恶性循环。加个Math.random() * 500的随机毫秒数就能缓解。3.2 消息渲染与中文、表情输入的处理消息渲染看起来简单实则有几个容易出问题的地方。第一是XSS 问题。聊天室是典型的用户输入渲染场景如果直接把用户输入的img srcx onerror...当作 HTML 插进页面后果不堪设想。解决方案很简单创建文本节点textContent而不是用innerHTML拼接。聊天室需要支持链接高亮和艾特的时候再单独用白名单方式解析。第二是中文和表情的字节处理。WebSocket 默认发送的是文本帧消息本身是 UTF-8 编码处理中文和 emoji 都没问题。但这让我想起来一个真实的坑很多人把消息放进 URL 参数里或者存库时没注意编码导致中文变成乱码。建议前端发送时统一JSON.stringify后端接收后统一json.loads并确保数据库连接使用 UTF-8 字符集。第三是消息长度的上限。聊天室必须限制单条消息长度否则有人一贴就是几万字的日志服务端内存和前端渲染都会被打爆。我一般限制在 2000 字符以内超出则提示用户。3.3 滚动、未读计数与历史消息加载聊天气泡多了之后会有几个交互细节要处理。首先是“是否自动滚动到底部”用户如果正在往上翻看历史消息新消息到来时不能强行把滚动条拉到底部这非常影响体验。实现方案是监听scrollTop如果用户距离底部超过一定像素比如 100px就判定为“用户在看历史”此时新消息只推入数组不触发滚动并显示“你有 N 条新消息”的提示条。历史消息加载采用的是“进入房间加载最近 50 条上滑到底部再加 50 条”的分页策略。对应的后端接口app.get(/api/history) async def get_history(room_id: str, before_id: int -1, limit: int 50): query HistoryMessage.query.filter(HistoryMessage.room_id room_id) if before_id 0: query query.filter(HistoryMessage.id before_id) messages query.order_by(HistoryMessage.id.desc()).limit(limit).all() return [format_message(m) for m in messages]注意这里用id before_id而不是OFFSET因为基于OFFSET的分页在数据不断增长时会产生大量无用查询而且新消息插入会把结果集撑偏移用“锚点分页”更稳。4. 项目分析日志、指标与抓包定位瓶颈标题里的“分析”两个字我认为至少有三层含义一是对系统运行状态的监控分析二是对消息内容的统计分析三是对网络流的协议分析。这里我把三块都过一遍。4.1 可观测性基础结构化日志与关键指标做聊天室这种实时系统最忌讳的就是出了问题现场一问三不知。日志必须从一开始就是结构化的 JSON 格式而不是一大段拼出来的字符串。import logging logger logging.getLogger(chatroom) async def on_message_received(ws, message): logger.info(json.dumps({ event: message_received, room: message[data][roomId], user: message[data][sender][id], msg_size: len(message[data][content]), timestamp: time.time() }, ensure_asciiFalse))为什么用 JSON 日志因为后续接 ELK、ClickHouse 或者 Loki 做检索分析时可以直接解析字段。比较重要的指标有这么几项当前在线连接数以及变化趋势每秒消息接收数、每秒广播数广播耗时 P99从收到消息到最后一个连接发送完成的时间心跳超时断开数历史消息写入失败数这些指标我一般用 PrometheusGrafana 那一套来采集和展示。如果是个人项目简单打成日志然后写脚本统计也可以但日志字段必须完整这是后续一切分析的确定性前提。4.2 Wireshark 视角WebSocket 帧与 TCP 层分析网络协议的抓包分析是很多初中级开发者最容易忽略的能力。无论服务端逻辑写得再好一旦跑到局域网或无公网 IP 的复杂网络环境很多诡异问题都会落到 TCP 或者代理层这时候就必须搬出 Wireshark。先讲怎么抓。如果你在本地跑服务端和浏览器客户端Wireshark 选择 Loopback 接口过滤规则用tcp.port 8000 websocket然后重新发一条消息就能看到 WebSocket 帧。如果在远程部署可以在客户端抓包也可以服务端tcpdump抓完再导出到 Wireshark。抓到的包里我最关注几个点。第一个是 HTTP 101 握手响应它代表协议升级成功可以看到Sec-WebSocket-Accept头是否和Sec-WebSocket-Key匹配。第二个是帧类型Opcode 1 表示文本帧、Opcode 8 表示关闭帧、Opcode 9/10 是 Ping/Pong。如果看到大量 Opcode 8 且异常码是 1006说明连接是非正常关闭的大概率是网络层或代理层断掉了。第三个是 TCP 重传在 Wireshark 里用“分析-专家信息”可以快速看到 TCP Retransmission重传率高说明网络不稳定这是任何应用层优化都救不了的。有一次我排查线上问题用户报告“发消息延迟特别高”服务端日志显示消息接收和广播的耗时都在毫秒级前端也觉得奇怪。最后抓包才发现用户所在网络把 WebSocket 的 TCP ACK 延迟了整整 300 毫秒这是网络链路的问题不是代码问题。没有抓包这关我可能会在应用层瞎调好久。4.3 流量与消息频次分析用数据指导优化聊做分析不能只停留在网络抓包应用层的业务数据同样要会看。我做完聊天室之后做了一轮小规模压测用脚本模拟了 500 个并发用户统计了两组核心数据消息延迟分布和消息速率。消息速率的统计方式很简单日志里记录每秒消息数然后用脚本聚合。压测时我发现了两个问题。第一个是单条大消息对广播延迟的冲击。普通短消息广播耗时只有 2 毫秒但一条 100KB 的粘贴内容直接让当次广播的 P95 变成 200 毫秒因为它把连接发送队列瞬间打满了。后来我加了单条消息 2000 字符限制并采用分块发送策略问题立刻缓解。第二个是房间内全局广播的队列效应。在 500 人同时在线时如果大家都往同一个房间发消息短暂几十秒内消息就会堆积前端接收顺序看着正常但服务端 CPU 会突然飙升。我在日志里加了“广播队列深度”这个指标看到这个值 100 时就知道已经接近瓶颈了。对应的优化方案是把广播任务拆成多个并发协程分批发送而不是一条条串行遍历。最后再补充做一个简单的容量估算。假设单条消息平均 1KB在线 1000 人、同时活跃比例 20%每秒 200 条消息那么服务端出口带宽大约需要 1.6Mbps这个量级对普通云服务器绰绰有余。但如果每条消息广播给 500 人服务端的实际发送量就是 200×500×1KB等于每秒要推约 100MB这时单纯的进程内遍历就不可能扛住了需要引入扇出优化、连接聚合甚至 CDN 边缘推送。做分析的目的就是让你能说出“当前规模下瓶颈到底在哪”。5. 常见问题与排查技巧实录做聊天室的过程里我记录了一堆“看着奇怪、实际上都是套路”的问题集中整理成一个速查表。这些问题不是偶然而是所有实时通信系统的共性。症状常见原因解决方案连接建立后几十秒必断开没有心跳或心跳间隙过长中间设备回收空闲连接30 秒心跳、60 秒超时清理前端显示在线但收不到消息断线后未触发 onclose连接处于僵尸状态心跳失败即刻清理连接并通知前端消息重复客户端超时重发服务端未做去重增加 clientMsgId服务端按 ID 去重消息顺序错乱多线程处理或网络重排单连接串行处理全局序号兜底反向代理后 WebSocket 升级失败Nginx 未配置 Upgrade 头设置proxy_set_header Upgrade $http_upgrade中文乱码字符集不一致或传输过程二次编码统一 UTF-8JSON 序列化页面卡死/浏览器占用高消息量过大且渲染无节流虚拟滚动或分页渲染限制单条消息长度内存缓慢上涨连接关闭后未从 rooms 映射中移除在 finally 中统一清理映射重连风暴客户端断线后同时发起重连指数退避加随机抖动单条消息导致全房间阻塞一条超大消息占满发送队列限制长度或分块发送5.1 连接频繁掉线、消息丢失的排查顺序如果你遇到“连接老掉、消息丢了”这类问题我的建议是先按这个顺序排查第一步看网络用 Wireshark 抓包确认 TCP 是否正常、有没有重传第二步看代理层确认 Nginx 或负载均衡器有没有限制空闲超时时间是否配置了 Upgrade 头第三步看应用层确认心跳日志是否正常、有没有异常抛出导致协程退出。我遇到过最典型的一个场景用户 WIFI 信号不好TCP 连接三天两头断WebSocket 的 onclose 事件能触发但服务端因为 TCP 半开连接还没超时认为用户还在线。等用户重连成功后服务端又往旧连接上广播消息造成消息在旧连接上积压最后静默丢失。后来我在服务端做了“同一 userId 新连接建立后强制关闭旧连接”的逻辑才彻底解决。5.2 内存上涨、重复消息、顺序错乱内存在聊天室服务里涨上去绝大多数情况不是内存泄漏而是“没清理”。当用户关闭浏览器时如果服务端没有捕获到 WebSocket 断开事件那么这个连接对象会一直留在房间映射表里连接对应的发送队列也会一直积累数据内存就一点点涨上去了。所以我一再强调连接清理逻辑必须写进finally块里面无论正常断开还是异常断开都要执行。重复消息的问题用 clientMsgId 去重消息顺序问题则要保证“同一个连接的消息处理是串行的”在 Python asyncio 里天然满足在使用多线程时必须加锁或按 userId 做哈希分片。5.3 一组顺手可用的开发期工具建议FastAPI 自带/docs接口页面调试 HTTP 接口非常方便。前端调试用 Chrome DevTools 的 Network 面板专门有 WS 页签查看 WebSocket 帧内容。压测脚本用 Python 的websockets库写异步客户端比 Postman 更适合模拟高并发。Docker 部署可以写一个简单的Dockerfile用python:3.10-slim作为基础镜像镜像体积小且安全漏洞少。如果你在公司内部建议把日志接入统一的日志平台至少留 7 天否则出了问题连日志都没有分析就是空谈。我会在项目里用 Docker 编排前端、后端和一个轻量的 Redis生产运行基本就是一条命令的事。为方便复现贴一下核心 DockerfileFROM python:3.10-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY backend/ ./backend EXPOSE 8000 CMD [uvicorn, backend.main:app, --host, 0.0.0.0, --port, 8000, --workers, 2]这里特别提醒一点--workers 2在 uvicorn 里会启动 2 个进程如果你用的是内存广播方案两个进程之间无法共享在线列表和房间映射用户可能被分到不同进程导致消息互相看不见。多进程模式必须配合 Redis Pub/Sub 或消息队列来做跨进程广播。这是最容易踩的坑没有之一。最后分享一点实际体会做完这个项目后我对“长连接系统”的敬畏多了很多。表面上只是一个聊天框真实落地时却要跟网络可靠性、并发一致性、资源泄漏做长期斗争。我个人体会最深的一点是任何实时系统连接生命周期管理绝对要当作一等公民来设计。心跳、清理、重连、去重、顺序这些机制必须在第一版就写好否则后面上线了再补改造成本会翻倍。如果你看完也想动手做我的建议是别一上来就贪大求全。第一版只需要一个房间、一条消息类型、一个浏览器页面把链路完全跑通第二版再加上心跳、重连、历史记录第三版再考虑多房间、Redis 广播、分析与监控。这样一轮一轮迭代下来每一层你都理解得透透的而不是三天导出一篇“从零到一手写微信”的水文。聊天室虽小但它是一把能打开实时系统大门的钥匙值得你认真做完、认真分析一遍。
返回列表