一次跨端实时通信架构升级的实战经验分享
作为一名全栈开发工程师,在过去几年中,我参与了不少中大型互联网产品的研发工作。而在这其中,最令我印象深刻的一个项目,是一个面向教育行业的在线协作平台。这个产品需要支持 PC、移动端(iOS/Android)以及 Web 端同时在线互动,并且对实时性要求极高 —— 不仅仅是聊天功能,还涉及到文档协同编辑、白板操作同步、音视频流传输等复杂场景。
今天我想和大家分享一下在这个项目中我们如何从最初的简单 WebSocket 长连接方案,一步步演进到最终采用 基于 WebRTC + 自研信令服务器 + Redis Stream 消息队列 的混合架构的过程。这期间我们踩了很多坑,也积累了不少实战经验。
项目背景:从单一直播平台到多人协作教室

这个项目的最早版本其实是公司内部一个直播教学系统,最初只支持老师上课时通过 RTMP 直播给学生看,学生无法直接交互。随着业务发展,公司决定将这个平台升级为“线上虚拟教室”,不仅要有音视频互动,还要支持白板共享、文档协作、实时文字聊天、作业批改反馈等多个模块。
在这种需求背景下,原有的技术架构明显跟不上了。原来的后端用的是 Spring Boot + Netty 处理 WebSocket 消息推送,前端是 Vue.js + 原生 WebSocket 连接,整个消息体系非常松散,很多模块之间的数据传递依赖前端各自维护状态,极易出错。
在一次灰度上线测试中,我们在某个班级内并发 300 人时,服务端出现了严重的丢包和延迟问题。更糟的是,由于前端没有统一的状态管理机制,不同用户看到的信息出现了不一致。那次灰度上线失败后,我们意识到必须彻底重构实时通信架构。
第一阶段:WebSocket + Node.js 微服务尝试失败

初期我们尝试保留 WebSocket 协议,但将原来的 Netty 改为使用 Node.js 实现的微服务结构,引入 socket.io 来简化前后端通信流程,并通过 RabbitMQ 解耦部分非实时消息,比如历史记录、通知等。
当时设计的总体思路是:
- 使用
socket.io统一处理前端连接 - 拆分出多个 Node.js 微服务处理不同类型的消息(例如 chat、whiteboard、document)
- 所有实时消息通过 Redis 发布/订阅进行广播
- 异步任务走 RabbitMQ
看起来挺美好,但实际运行中我们遇到了几个严重问题:
Socket.IO 自带的重连机制不够灵活
我们发现在断网重连或页面刷新后,前端有时候会丢失状态,导致再次连接进来后看不到之前的聊天记录或白板内容。虽然我们后来引入了一个 session id 来做状态恢复,但在高并发下仍然存在问题。Redis Pub/Sub 无法保障顺序性和一致性
某些情况下,前端会收到一条后续消息比前一条更早到达的情况。这个问题在文档协同编辑的时候尤为致命 —— 因为我们用的是 Operational Transformation(OT 算法),如果消息顺序错误,会导致文档内容混乱甚至崩溃。Node.js 微服务在压测下表现不佳
当我们模拟 500 并发接入时,发现某些 Node.js 服务 CPU 使用率飙升至 90%以上,甚至出现 OOM。分析发现 socket.io 在高频连接时存在较多性能瓶颈,尤其是在处理大量事件监听器和中间件的时候。多服务之间状态难以一致
比如当一个用户退出房间时,需要通知各个服务清理状态,但由于某些服务宕机或消息丢失,经常会出现“用户已经离开,但其他成员还能看到他的信息”这类问题。
这一阶段尝试失败后,我们意识到,仅靠 WebSocket 是无法满足当前业务需求的。我们需要重新思考整体通信架构的设计。
第二阶段:引入 WebRTC 构建低延迟实时通道

在调研了市场上主流的实时通信方案后,我们决定探索 WebRTC,因为它天生具备点对点通信能力,并且延迟能控制在几百毫秒以内。尤其对于文档协作和白板这类需要快速响应的操作来说,WebRTC 成为了我们考虑的重点方向。
但我们遇到的第一个问题是:WebRTC 默认是 P2P 通信,但如果要实现多人教室这样的场景怎么办?
于是我们提出了一个折中的架构:
- 教师作为“主讲者”,所有学生与教师建立单独的 PeerConnection,形成星型结构。
- 教室内部所有文档操作由教师统一协调并广播给所有学生。
- 所有实时状态维护在中心化的 Redis 中,并通过 Redis Stream 持久化关键操作记录(方便断线重连后恢复状态)。
- 聊天、通知等功能仍然使用长连接(后来换成了 SSE)来保证兼容性和可靠性。
这套架构的核心组件如下:
+-------------------+
| Client(学生) |
+-------------------+
|
| RTC PeerConnection
v
+---------------------+
| Teacher Peer |
+----------+----------+
|
| 将白板/文档变更推送到:
v
+---------------------------+ +------------------+
| Redis Stream (Stream) | <--> | Redis Consumer |
+---------------------------+ +------------------+
|
v
+------------------------+
| Document Sync Server |
+------------------------+
这种设计的好处在于:
- 教师是唯一写入文档的一方,避免了多用户并发修改的问题
- 白板操作由教师统一分发,可以有效减少网络冲突
- Redis Stream 保证了事件顺序性和持久化能力
- Redis Consumer 可用于异步任务处理,比如生成文档快照、归档等
然而,这条路也不是一帆风顺的……
踩坑经验分享:那些年我们在 WebRTC 上栽的跟头
❌ 坑一:NAT 和防火墙穿透失败
WebRTC 默认是 P2P 的,这就意味着两端客户端要能互相直连。但在实际部署过程中,特别是在企业网络环境下,由于 NAT 或防火墙的存在,很多用户的设备根本连不上教师那台机器。
我们一开始是想着让所有用户都走 STUN 服务器来获取公网 IP,结果发现大多数免费的 STUN 服务都不稳定,特别是高峰期经常超时。后来我们搭建了自己的 TURN 服务器,但成本又上去了 —— 每增加一个学生连接,就要占用额外的中继带宽。
解决方案: 最终我们将所有文档和白板的实时更新改为由教师统一发往服务端,再由服务端广播给所有学生。这样就不再依赖 P2P,而是全部走 HTTPS + WebSocket 中转,虽然略微增加了一点延迟,但极大地提升了稳定性。
❌ 坑二:频繁建立/关闭 PeerConnection 导致资源泄漏
早期我们没太注意 PeerConnection 的生命周期管理,每当用户切换页面或者刷新浏览器时,旧的连接不会被及时关闭。再加上我们当时用了 socket.io 作为连接协调器,导致一段时间后,服务端内存暴涨,GC 频繁触发,最终导致服务挂掉。
解决方案:
我们引入了一个“连接池”机制,每次新建连接之前先检查是否已有未关闭的连接;在前端设置合理的超时机制,在一定时间内未收到来自对方的 ICE 请求,则主动关闭连接。此外,在后端加了一个定期扫描任务,自动清理超过阈值时间的无效连接。
❌ 坑三:WebRTC 兼容性问题
你以为只要写了 RTCPeerConnection 就能在所有浏览器上跑起来了吗?Too young.
我们在测试阶段发现:
- Chrome 对某些 SDP 格式容忍性较高,但 Safari 和 Firefox 却不行
- 某些安卓手机的 WebRTC 版本过老,对 simulcast(多码率编码)不支持
- 移动端页面刷新后,摄像头权限会被清除,再次调用 getMedia 时会报错
这些问题都需要逐一修复:
- 引入 adapter.js 来统一浏览器差异
- 对 SDP 协商过程做异常兜底,如果协商失败则降级回传统 WebSocket 方案
- 缓存用户权限授予状态,下次进入页面时不再强制请求权限
❌ 坑四:文档 OT 冲突仍然存在
虽然文档操作统一由教师发起,但由于某些边缘情况,例如学生 A 在本地做了修改后还没发送成功,教师就已经收到了另一个学生的操作并应用了变化,这时候就会出现冲突。
为此我们借鉴了一些协同编辑引擎的做法:
- 所有操作带上时间戳和操作序列号
- 每次接受到变更前,比较时间戳,如果有冲突则采用 “last-write-wins” 策略(即以后来的操作为准)
- 如果判断出操作链断裂,则触发一次全量文档同步(相当于拉取最新版本)
关键代码片段与实现细节
下面是一些关键技术模块的核心代码,供参考。
✅ WebRTC 连接初始化示例(前端)
const pc = new RTCPeerConnection(iceServers);
pc.onicecandidate = event => {
if (event.candidate) {
// 发送 candidate 到远端
sendToSignalingServer('ice', event.candidate);
}
};
pc.ontrack = event => {
remoteVideoElement.srcObject = event.streams[0];
};
// 接收到远端 offer 后
async function handleOffer(offer) {
await pc.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
sendToSignalingServer('answer', answer);
}

// 接收到 ice candidate
function handleCandidate(candidate) {
pc.addIceCandidate(new RTCIceCandidate(candidate));
}
✅ Redis Stream 存储操作记录
我们用 Redis Stream 来存储每一个文档操作,便于断线恢复。
const Redis = require('ioredis');
const client = new Redis();
// 添加一个文档变更事件
async function logDocumentChange(roomId, change) {
await client.xadd(`room:${roomId}:stream`, '*', 'data', JSON.stringify(change));
}
// 获取指定房间最近的操作
async function getRecentChanges(roomId) {
const result = await client.xrange(`room:${roomId}:stream`, '-', '+');
return result.map(([id, fields]) => JSON.parse(fields[1]));
}
✅ 文档状态同步逻辑(Node.js 服务端)
io.on('connection', socket => {
socket.on('joinRoom', async (roomId, cb) => {
// 从 redis stream 加载最近状态
const history = await getRecentChanges(roomId);
socket.join(roomId);
cb({ ok: true, history });
});
socket.on('change', (roomId, change) => {
logDocumentChange(roomId, change);
io.to(roomId).emit('update', change); // 广播给所有用户
});
});
技术选型的心路历程与权衡

在整个迭代过程中,我们也尝试过一些替代方案,比如:
- 使用 MQTT 替代 Redis Pub/Sub(但社区活跃度不高,文档较少)
- 引入 gRPC 实现实时通信(协议复杂,不适合前端直接对接)
- 使用 Firebase Realtime Database(但成本过高,且不能自定义逻辑)
最终我们选择 Redis + WebRTC + 自研信令服务器的组合,原因如下:
- Redis 在事件广播和持久化上表现优异
- WebRTC 提供了原生支持的低延迟通道
- 自己控制信令服务器可以灵活处理各种边界情况
- 整体架构拆解清晰,便于后续扩展
架构升级后的实际效果
改造完成后,我们进行了多轮压力测试,并最终上线到生产环境。以下是主要提升指标:
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 同时支持最大教室人数 | 150人 | 600人 |
| 操作同步延迟(平均) | 800ms | < 200ms |
| 服务崩溃频率 | 每周2次 | 每月<1次 |
| 用户反馈卡顿频次 | 高 | 显著降低 |
更重要的是,系统的可维护性和扩展性得到了大幅提升。新同事入职后,只需要理解核心状态模型和事件流转机制,就能较快上手开发模块。
总结:几点经验和建议
经过这次项目实践,我总结了几点关于技术探索与实践的经验教训,希望能帮助大家少走弯路:
🔧 不迷信新技术,适合的才是最好的
WebRTC 很强大没错,但如果你的产品不需要严格的实时性,或者用户群体中有很多弱网环境,那么坚持使用 WebSocket 反而更稳妥。WebRTC 的确在延迟上有优势,但它带来的 NAT、STUN、ICE、SDP 等一系列复杂性也需要你去面对。
🧠 保持架构的简单性,避免过度工程
我们团队初期曾想搞一个超级大而全的“实时通信引擎”,结果写着写着发现难以维护。后来调整思路,把功能模块化、职责分离,反而更容易推进。
💡 日志、监控、可视化工具一定要尽早建设
在调试过程中,如果没有完善的日志追踪和链路分析工具,排查问题会非常困难。我们后来接入了 Prometheus + Grafana 做监控,同时使用 ELK 做日志集中采集,大大提高了排障效率。
👥 小团队协作建议:定好规范、文档齐全
尤其是像我们这种前后端协同开发、多个模块并行推进的项目,沟通成本非常高。所以我们制定了统一的事件命名规范、接口格式标准,并用 Postman 文档和 Swagger 分别管理 HTTP 和 WebSocket API。
☁️ 云服务是双刃剑:省力但也贵
刚开始我们都想用现成的 SaaS 或者 PaaS 服务来节省开发成本,但随着用户增长,账单也开始疯涨。后期我们逐渐将这些服务迁回自建,虽然运维难度加大了,但长期来看更划算。
写在最后
回顾这一次技术架构的重构之路,真的是痛并快乐着。我们踩过的坑不少,但也因此积累了大量的实战经验。我希望这篇文章不仅能帮你解决某个具体的技术问题,更能让你在面对复杂系统设计时多一些思考的角度和信心。
技术探索从来都不是一蹴而就的事情,它需要不断的试错、反思和沉淀。希望你也一样,在自己的项目中找到最适合的方案,不断突破技术边界,做出令人骄傲的产品。

评论 0