一次跨端实时通信架构升级的实战经验分享

小王的技术栈
2025-06-17 03:22
阅读 246

作为一名全栈开发工程师,在过去几年中,我参与了不少中大型互联网产品的研发工作。而在这其中,最令我印象深刻的一个项目,是一个面向教育行业的在线协作平台。这个产品需要支持 PC、移动端(iOS/Android)以及 Web 端同时在线互动,并且对实时性要求极高 —— 不仅仅是聊天功能,还涉及到文档协同编辑、白板操作同步、音视频流传输等复杂场景。

今天我想和大家分享一下在这个项目中我们如何从最初的简单 WebSocket 长连接方案,一步步演进到最终采用 基于 WebRTC + 自研信令服务器 + Redis Stream 消息队列 的混合架构的过程。这期间我们踩了很多坑,也积累了不少实战经验。

项目背景:从单一直播平台到多人协作教室

项目背景:从单一直播平台到多人协作教室

这个项目的最早版本其实是公司内部一个直播教学系统,最初只支持老师上课时通过 RTMP 直播给学生看,学生无法直接交互。随着业务发展,公司决定将这个平台升级为“线上虚拟教室”,不仅要有音视频互动,还要支持白板共享、文档协作、实时文字聊天、作业批改反馈等多个模块。

在这种需求背景下,原有的技术架构明显跟不上了。原来的后端用的是 Spring Boot + Netty 处理 WebSocket 消息推送,前端是 Vue.js + 原生 WebSocket 连接,整个消息体系非常松散,很多模块之间的数据传递依赖前端各自维护状态,极易出错。

在一次灰度上线测试中,我们在某个班级内并发 300 人时,服务端出现了严重的丢包和延迟问题。更糟的是,由于前端没有统一的状态管理机制,不同用户看到的信息出现了不一致。那次灰度上线失败后,我们意识到必须彻底重构实时通信架构。

第一阶段:WebSocket + Node.js 微服务尝试失败

第一阶段:WebSocket + Node.js 微服务尝试失败

初期我们尝试保留 WebSocket 协议,但将原来的 Netty 改为使用 Node.js 实现的微服务结构,引入 socket.io 来简化前后端通信流程,并通过 RabbitMQ 解耦部分非实时消息,比如历史记录、通知等。

当时设计的总体思路是:

  • 使用 socket.io 统一处理前端连接
  • 拆分出多个 Node.js 微服务处理不同类型的消息(例如 chat、whiteboard、document)
  • 所有实时消息通过 Redis 发布/订阅进行广播
  • 异步任务走 RabbitMQ

看起来挺美好,但实际运行中我们遇到了几个严重问题:

  1. Socket.IO 自带的重连机制不够灵活
    我们发现在断网重连或页面刷新后,前端有时候会丢失状态,导致再次连接进来后看不到之前的聊天记录或白板内容。虽然我们后来引入了一个 session id 来做状态恢复,但在高并发下仍然存在问题。

  2. Redis Pub/Sub 无法保障顺序性和一致性
    某些情况下,前端会收到一条后续消息比前一条更早到达的情况。这个问题在文档协同编辑的时候尤为致命 —— 因为我们用的是 Operational Transformation(OT 算法),如果消息顺序错误,会导致文档内容混乱甚至崩溃。

  3. Node.js 微服务在压测下表现不佳
    当我们模拟 500 并发接入时,发现某些 Node.js 服务 CPU 使用率飙升至 90%以上,甚至出现 OOM。分析发现 socket.io 在高频连接时存在较多性能瓶颈,尤其是在处理大量事件监听器和中间件的时候。

  4. 多服务之间状态难以一致
    比如当一个用户退出房间时,需要通知各个服务清理状态,但由于某些服务宕机或消息丢失,经常会出现“用户已经离开,但其他成员还能看到他的信息”这类问题。

这一阶段尝试失败后,我们意识到,仅靠 WebSocket 是无法满足当前业务需求的。我们需要重新思考整体通信架构的设计。

第二阶段:引入 WebRTC 构建低延迟实时通道

第二阶段:引入 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);
}


![开发流程示意-1](https://code-guide.oss.shanghai.autogptai.club/common/file/download?name=date2025061703/b05b0ab1-e523-49d5-93c8-a4a0a8783e6e.jpg)


// 接收到 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); // 广播给所有用户
  });
});

技术选型的心路历程与权衡

技术对比分析-2

在整个迭代过程中,我们也尝试过一些替代方案,比如:

  • 使用 MQTT 替代 Redis Pub/Sub(但社区活跃度不高,文档较少)
  • 引入 gRPC 实现实时通信(协议复杂,不适合前端直接对接)
  • 使用 Firebase Realtime Database(但成本过高,且不能自定义逻辑)

最终我们选择 Redis + WebRTC + 自研信令服务器的组合,原因如下:

  1. Redis 在事件广播和持久化上表现优异
  2. WebRTC 提供了原生支持的低延迟通道
  3. 自己控制信令服务器可以灵活处理各种边界情况
  4. 整体架构拆解清晰,便于后续扩展

架构升级后的实际效果

改造完成后,我们进行了多轮压力测试,并最终上线到生产环境。以下是主要提升指标:

指标 改造前 改造后
同时支持最大教室人数 150人 600人
操作同步延迟(平均) 800ms < 200ms
服务崩溃频率 每周2次 每月<1次
用户反馈卡顿频次 显著降低

更重要的是,系统的可维护性和扩展性得到了大幅提升。新同事入职后,只需要理解核心状态模型和事件流转机制,就能较快上手开发模块。

总结:几点经验和建议

经过这次项目实践,我总结了几点关于技术探索与实践的经验教训,希望能帮助大家少走弯路:

🔧 不迷信新技术,适合的才是最好的

WebRTC 很强大没错,但如果你的产品不需要严格的实时性,或者用户群体中有很多弱网环境,那么坚持使用 WebSocket 反而更稳妥。WebRTC 的确在延迟上有优势,但它带来的 NAT、STUN、ICE、SDP 等一系列复杂性也需要你去面对。

🧠 保持架构的简单性,避免过度工程

我们团队初期曾想搞一个超级大而全的“实时通信引擎”,结果写着写着发现难以维护。后来调整思路,把功能模块化、职责分离,反而更容易推进。

💡 日志、监控、可视化工具一定要尽早建设

在调试过程中,如果没有完善的日志追踪和链路分析工具,排查问题会非常困难。我们后来接入了 Prometheus + Grafana 做监控,同时使用 ELK 做日志集中采集,大大提高了排障效率。

👥 小团队协作建议:定好规范、文档齐全

尤其是像我们这种前后端协同开发、多个模块并行推进的项目,沟通成本非常高。所以我们制定了统一的事件命名规范、接口格式标准,并用 Postman 文档和 Swagger 分别管理 HTTP 和 WebSocket API。

☁️ 云服务是双刃剑:省力但也贵

刚开始我们都想用现成的 SaaS 或者 PaaS 服务来节省开发成本,但随着用户增长,账单也开始疯涨。后期我们逐渐将这些服务迁回自建,虽然运维难度加大了,但长期来看更划算。

写在最后

回顾这一次技术架构的重构之路,真的是痛并快乐着。我们踩过的坑不少,但也因此积累了大量的实战经验。我希望这篇文章不仅能帮你解决某个具体的技术问题,更能让你在面对复杂系统设计时多一些思考的角度和信心。

技术探索从来都不是一蹴而就的事情,它需要不断的试错、反思和沉淀。希望你也一样,在自己的项目中找到最适合的方案,不断突破技术边界,做出令人骄傲的产品。

评论 0

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝