不用绕弯子,直接说结论:在2024年做监控级大屏,还要追求“看得见、跟得上、扛得住”的实时体验,WebRTC + WebSocket这个组合是目前我实测下来最稳、下限最高的方案之一。这篇文章就把我封装这套低延迟视频流与状态驱动大屏的完整过程拆开讲,从协议选型、服务端接入、前端播放器封装,到WebSocket状态推送、大屏联动和排坑实录,全部都有可以直接抄走的代码和参数。适合正打算自研可视化大屏、或者被传统RTSP播放延迟和并发卡顿折磨过的开发同学参考。

1. 项目背景与整体架构设计思路

1.1 为什么选WebRTC而不是继续用RTSP/HTTP-FLV

先说一个我踩了无数次坑之后得出的结论:很多团队做大屏视频接入,第一反应都是沿用安防行业的老路子,摄像头输出RTSP流,然后通过流媒体网关转成HTTP-FLV或者HLS去播放。RTSP本身没有浏览器原生支持,必须在服务端转协议,HTTP-FLV延迟虽然低,但依赖TCP,弱网下丢包重传会导致延迟持续攀升,HLS更是分片传输,延迟少说三五秒起步,大屏需要和现场联动的时候,这个时延可能造成严重的信息错位。

WebRTC在传输模型上完全不同,它默认走UDP,配合SRTP加密、FEC前向纠错和JitterBuffer抖动缓冲,在同等网络环境下能做到亚秒级甚至200到400毫秒的端到端延迟,而且浏览器原生支持,不需要用户装插件。对监控大屏这种需要“实时视频+实时数据”双通道同步的场景来说,低延迟让视频和状态数据在时间轴上对齐成为可能。

另外还要提一下并发能力。传统方案里,拉流端和视频网关之间普遍是长连接,一路高清流折腾下来带宽和内存都不低,几百路并发就得考虑网关集群。WebRTC的P2P特性在理想情况下能分散一部分负载,虽然大规模监控场景大多数还是会借助SFU(选择性转发单元)在服务端统一分发,但单位带宽下能承载的并发路数依然比逐路转发的方案高不少。我做这套系统之前用Go语言写过一个简单的SFU转发模块,实测单机可以比较轻松地支撑数百路低码率预览流。

1.2 系统整体架构与数据流设计

整个系统在设计之初就明确了两个核心诉求:一是视频必须低延迟,二是数据必须实时驱动界面状态,两者不能互相干扰。因此我把架构拆成了两条独立的数据通道:

  • 视频通道:摄像头 RTSP -> 流媒体网关 -> WebRTC (SRTP/UDP) -> 浏览器播放器
  • 状态通道:业务系统/传感器 -> WebSocket服务 -> 前端状态中枢 -> 大屏组件

这两条通道在逻辑上完全解耦,但在前端设置了一个同步层来做时间对齐。简单说就是每个视频画面绑定若干状态维度,WebSocket推送的状态消息里携带服务端时间戳,WebRTC的帧元数据里也有渲染时间,大屏在收到两者之后,通过前端状态中枢做时间戳比对,然后触发对应的联动行为,比如弹窗、闪烁、定位到具体摄像头、刷新图表。

为什么这么设计?如果你把视频流和状态数据放在同一条通道里推,会有两个很现实的问题:

  1. 视频码率和信令状态混在一起,状态消息会被视频帧的传输节奏拖累,遇到稍微拥塞的网络,画面还没卡,数据先延迟了。
  2. 状态数据的可靠性要求明显高于视频数据。视频丢几帧无所谓,状态丢了可能漏报一条关键告警。WebRTC基于UDP,即便有重传机制,也只对关键帧做重传优先级处理,不适合承载需要高可靠交付的业务状态消息。

所以实际生产环境里,不要说把业务状态塞进WebRTC的DataChannel当成主要方案,尤其是告警、工单这类数据,老老实实用WebSocket来推才是稳健的做法。WebRTC通道专心做视频,WebSocket通道专心做状态,前端再做一个聪明的同步层,这是我认为最舒服的架构形态。

1.3 技术选型补充说明

服务端视频网关我用的方案是 RTSP拉流 + WebRTC推流分发 ,选型时对比过几个开源组件:

  • MediaMTX :配置简单,原生支持RTSP输入和WebRTC输出,对摄像头兼容性好,适合中小规模场景快速落地。
  • Janus :功能强大,支持完整SFU能力,但配置和学习成本偏高,适合需要大规模流转发、自定义信令逻辑的场景。
  • SRS 5.0 :国内社区活跃,WebRTC能力持续迭代,如果团队对SRS有一定积累,也是一个稳健选择。

因为项目要快速上线,我选了MediaMTX做基础网关,主要看重它的配置灵活性和稳定性。前端播放器则使用 ZegoLiveRoom 的开源版本?不是——实际我直接用浏览器原生的 RTCPeerConnection 做底层封装,上层组件自己控制,不为一个播放器多引入一整套SDK。后面会详细讲。

2. WebRTC低延迟视频流封装实战

2.1 服务端接入:从RTSP到WebRTC的关键配置

以MediaMTX为例,核心配置思路是把摄像头RTSP源接入进来,然后开启WebRTC所需的端口和ICE配置。先看一段精简版的配置:

# mediamtx.yml 关键片段
paths:
  cam_001:
    source: rtsp://admin:password@192.168.1.64:554/Streaming/Channels/101
    sourceOnDemand: yes
    sourceOnDemandStartTimeout: 10s

webrtc:
  listenPort: 8189
  localUDPPort: 8189
  iceCandidateType: 1
  iceServers:
    - url: stun:stun.l.google.com:19302

这里有几个关键点值得展开说。

第一, sourceOnDemand 要打开,意思是只有第一个客户端来拉流时才去连接摄像头RTSP源,避免网关启动就全量拉流导致摄像头连接数爆炸。摄像头本身并发能力不强,很多低端设备只能同时维持两三路RTSP会话,这里做个按需拉流,能大幅提升设备侧的稳定性。

第二, iceCandidateType 在局域网内使用可以设置为 1 (主机候选),避免不必要的STUN打洞流程,降低建连耗时。如果大屏部署在跨公网环境,则需要配置有效的STUN或TURN服务,否则视频流大概率协商失败。TURN我建议直接用Coturn,成熟稳定,公网穿透场景基本是事实标准。

第三,签名延迟还有一个隐藏优化项:如果摄像头支持子码流,比如主码流是1080p 4Mbps,子码流是720p 1Mbps,监控墙要的就是低延迟,我一般建议接子码流拉流,码率低一半以上,UDP传输出错概率指数级下降,画面清晰度在55寸大屏上拉远看也足够。

RTSP转WebRTC配置完成后,在浏览器做连通性测试前,建议先用 webrtc-sdp 工具或者 ffplay 验证一下源能不能正常拉流。如果RTSP源本身就不稳定,转出来的WebRTC流只会更糟,问题定位会非常痛苦。

2.2 前端播放器封装与关键参数调优

前端封装播放器的核心目标只有一个:用最少的代码稳定地播放WebRTC流,同时对抗各种网络抖动。基于原生 RTCPeerConnection 封装,我的实现里大概包含几个核心模块:

  • 信令通道:负责与网关完成SDP交换和ICE候选交换,通常走WebSocket。
  • 连接管理:处理 PeerConnection 的创建、重连、资源释放。
  • 播放器组件:兼容H5 video标签,处理自动播放策略和音量设置。
  • 性能监控:用 getStats 抓取延迟、丢包、码率信息,上报到大屏运维模块。

信令交换的代码逻辑大概是这样的:

// 信令流程:创建PeerConnection并主动发起offer
async function startPlay(videoElement, streamId, wsSignaling) {
  const pc = new RTCPeerConnection({
    iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
  });
  
  pc.addTransceiver('video', { direction: 'recvonly' });
  
  pc.onicecandidate = (event) => {
    if (event.candidate) {
      wsSignaling.send(JSON.stringify({
        type: 'candidate',
        streamId,
        candidate: event.candidate
      }));
    }
  };
  
  pc.ontrack = (event) => {
    videoElement.srcObject = event.streams[0];
    videoElement.play().catch(() => {
      // 需要用户交互时再尝试播放
    });
  };
  
  const offer = await pc.createOffer({
    offerToReceiveVideo: true,
    offerToReceiveAudio: false
  });
  await pc.setLocalDescription(offer);
  
  wsSignaling.send(JSON.stringify({
    type: 'offer',
    streamId,
    sdp: pc.localDescription
  }));
}

接下来是几个我实测非常影响体验的参数和技巧:

视频编码参数与分辨率的权衡

如果是通过SFU或网关转码,建议输出分辨率控制在1280x720,码率控制在1到1.5Mbps。1080p固然清晰,但在大屏多路同时播放时,UDP丢包率上升带来的画面撕裂和花屏,远比那点清晰度损失更让人崩溃。如果要求高画质,优先考虑提升码率而不是分辨率。

投帧策略与JitterBuffer

WebRTC内置的JitterBuffer已经做得很好了,调整空间有限。我们能调的主要是播放端的重缓冲策略:如果发现RTT增加且丢包率超过5%,建议主动做一次关键帧请求,让画面快速恢复干净。实现上可以通过 RTCRtpReceiver 的 getSynchronizationSources 来观察丢包和延迟指标。

自动重连机制

摄像头或网关重启之后,PeerConnection断开的概率很大。我的播放器封装里, pc.onconnectionstatechange 监听到 failed 或 disconnected 状态时,延迟2秒后自动清理旧连接并重新发起信令。重连时额外做一个指数退避:第一次失败等2秒,第二次等4秒,最多等待16秒,避免网关故障恢复期间大量播放器同时疯狂重连把服务打挂。这个机制上线前一定要压测,网关重启后几十个客户端同时发起offer的场景很容易验证。

自动播放策略的坑

浏览器对带声音的自动播放做了严格限制。大屏播放器我建议关掉音频,只在 play() 被Promise真正resolve之后才绑定其他交互。如果画面没有声音要求,在 createOffer 里直接把音频的 direction 设为 inactive ,比拿到流之后再 mute 要省事得多,也能省一点带宽。

收尾清理

页面卸载或组件销毁时,一定要做完整清理:

function stopPlay(pc, videoElement) {
  if (videoElement && videoElement.srcObject) {
    videoElement.srcObject.getTracks().forEach((track) => track.stop());
    videoElement.srcObject = null;
  }
  if (pc && pc.signalingState !== 'closed') {
    pc.close();
  }
}

很多线上大屏内存持续上涨,播放器切到后台、切换布局没有及时关闭PeerConnection就是一个重要原因。每一个RTCPeerConnection都占着编码器资源、网络端口和内存,长时间大屏展示必须严格管理生命周期。

3. WebSocket实时状态驱动的数据通道设计

3.1 状态消息协议设计与推送策略

解决了视频通道,接下来的重头戏是大屏的实时状态数据。WebSocket几乎是大屏可视化的标配方案,但实际做起来,很多人容易忽略的不是连接本身,而是消息协议和推送策略。

我的做法是定义一套极简的、带版本号的消息协议:

{
  "version": 1,
  "type": "cam_status|alert|metrics|heartbeat",
  "timestamp": 1735123492000,
  "payload": {
    "cameraId": "cam_001",
    "status": "online",
    "latencyMs": 320,
    "extra": {}
  }
}

这套协议的核心原则是:每个消息都自带 timestamp 、 cameraId 和 type ,前端收到后可以直接走分发到对应大屏组件,不需要再做二次映射。 timestamp 用的是服务端时间而不是客户端时间,这是为了避免前端展示端的时间同步问题。

推送策略方面,我总结了三个必须遵循的实践:

按需推送而不是全量推送。 刚进来建立连接后,服务端把全量状态快照发给大屏做初始化,之后只增量推送变更事件。如果每次都推全量,连接一多,服务端压力会指数级上升,前端ECharts组件也会因为频繁setOption全量更新导致卡顿。

合并短时间内的重复事件。 比如同一个摄像头在3秒内连续上报了10次在线状态,实际只需要推送一条。可以在服务端做一次窗口聚合,30毫秒内的同类状态合并成一条批量消息,降低前端渲染压力。

区分可靠数据和可丢数据。 告警事件需要到端到端确认,丢失了要补发;而设备的实时位置、帧率这类趋势型数据,丢几条不影响整体展示,可以不确认。明确了这两类,你就能给WebSocket服务设计两套发送队列,可靠消息走带确认的通道,趋势消息走火速通道。

3.2 心跳机制与断线重连的工程化实现

WebSocket断线重连几乎是必须手写的功能。很多人直接用现成库,但库只能管连接,管不了业务状态的重置和消息的续传。我封装了一套可复用的WS状态管理器,核心要点:

心跳保活

WebSocket本身没有内置心跳,NAT设备或负载均衡长期没有流量会主动断开空闲连接。所以应用层必须做心跳。我设置每30秒发送一次 {"type":"heartbeat","timestamp":...} ,服务端收到后原样返回pong。连续2次心跳未收到pong,就主动判定连接失效,触发重连。

断线重连与状态续传

断了之后第一时间要做的不是重新订阅,而是等待服务端推送全量快照。因为断线期间发生了什么,前端完全不知道。我的处理流程是:

  1. 连接关闭后,前端进入 RECONNECTING 状态,界面上对应区域显示“数据刷新中”。
  2. 每3秒尝试一次重连,最大重试20次,超过之后提示“连接已断开”。
  3. 重连成功后,服务端发现是重连连接,会重新推送一次全量快照,前端用快照整体覆盖本地状态,然后继续增量监听。

为什么是全局覆盖而不是合并?因为增量消息很可能在断线期间丢失,合并的结果必然是对不上真实状态。全量覆盖虽然简单粗暴,但状态一致性最好。

class WSStateManager {
  constructor(url, { heartBeatInterval = 30000, reconnectDelay = 3000 } = {}) {
    this.url = url;
    this.heartBeatInterval = heartBeatInterval;
    this.reconnectDelay = reconnectDelay;
    this.status = 'IDLE';
    this.listeners = new Map();
  }

  connect() {
    this.ws = new WebSocket(this.url);
    this.status = 'CONNECTING';

    this.ws.onopen = () => {
      this.status = 'OPEN';
      this.startHeartbeat();
      this.emit('open');
    };

    this.ws.onmessage = (event) => {
      const msg = JSON.parse(event.data);
      // 交给状态中枢做分发
      this.dispatch(msg);
    };

    this.ws.onclose = () => {
      this.status = 'RECONNECTING';
      this.stopHeartbeat();
      this.emit('close');
      // 指数退避重连
      setTimeout(() => this.connect(), this.reconnectDelay);
    };

    this.ws.onerror = (error) => {
      // 只需记录日志,onclose会触发重连
      this.emit('error', error);
    };
  }
}

大屏在弱网下的降级策略

如果长时间无法连接,大屏不能白屏,需要保留最后一次快照并在界面上标记“数据滞后”,同时提供刷新按钮。有些事件数据可以缓存到 localStorage 里,重连成功之后再续报。这个设计在可视化指挥中心特别重要,现场能不能用就在这种极端场景下体现。

4. 大屏可视化联动实现

4.1 视频流与图表数据的状态对齐

视频流和数据联动是大屏系统的灵魂。所谓联动,不是视频在左边、图表在右边这种物理位置上的“同屏”,而是状态事件触发视频画面的变化,或者视频流状态反过来驱动图表的更新。我用一个实际例子来说明,这个案例也是我在森林防火大屏项目里真正落地的场景。

当某个摄像头区域检测到烟感告警时,整个联动链路是这样的:

  1. WebSocket推送一条 alert 消息, payload 包含 cameraId 、 alertType 、 confidence 、 location 。
  2. 大屏状态中枢收到后,把告警记录加入本地告警列表,同时更新对应摄像头的状态为 alert 。
  3. 播放器组件监听到 cameraId 的状态变化,如果这个摄像头当前没有在大屏主窗口播放,会主动将画面切换到主播放窗口,并叠加警戒色边框。
  4. 联动的地图组件根据 location 信息,定位到对应的GIS位置,弹出告警卡片。
  5. 事件图表组件插入一条新的告警记录,更新时间轴曲线。

这个过程中最重要的就是对齐调度。我实现了一个简单的 Scheduler ,用队列方式处理高频联动事件,同一摄像头的事件触发合并,至少间隔500毫秒才执行一次完整联动动作,避免多个告警同时到来时大屏界面被频繁刷新卡死。

class ActionScheduler {
  constructor(minInterval = 500) {
    this.minInterval = minInterval;
    this.lastRun = new Map();
  }

  shouldRun(key) {
    const now = Date.now();
    const last = this.lastRun.get(key) || 0;
    if (now - last >= this.minInterval) {
      this.lastRun.set(key, now);
      return true;
    }
    return false;
  }
}

当摄像头因为网络原因断流时,联动逻辑也要反过来做一次降级:播放器状态变为 disconnected ,通知状态中枢将摄像头状态标记为 offline ,图表组件自动用灰色标记该摄像头在时间轴上的离线区间。这种双向联动,才是大屏可视化系统最有价值的部分。

4.2 ECharts组件与数据刷新的性能优化

大屏可视化最常用到的图表库就是ECharts,几百万像素的屏上密密麻麻全是图,性能优化做不好,数据刷新时满屏卡顿,非常影响观感。

先说结论:ECharts在数据量很大的情况下,最关键的性能优化点是 notMerge 和 lazyUpdate 这两个参数。

chart.setOption(
  {
    series: [{ data: newData }]
  },
  {
    notMerge: true,
    lazyUpdate: true
  }
);

notMerge 表示每次更新时直接替换整个series,而不是做复杂的数据合并。对于实时数据流,直接替换的视觉差异很小,但性能提升非常明显。 lazyUpdate 则是告诉ECharts,多次数据变更可以合并到一次重绘里,避免高频刷新导致的重绘风暴。

另外把Y轴和X轴的 animation 关掉,大屏图表本身对动画过渡的依赖很低,关闭动画能省掉大量跟渲染无关的CPU计算。坐标轴的刻度数量也要控制,X轴时间轴显示最近30分钟就够,超出部分自动滚动丢弃。

多张大屏图表的刷新频率也要差异化:事件统计图每5秒刷新一次即可,而设备状态指标图可以做到1秒甚至实时。如果所有图表都按最高频率刷新,再好的机器也经不起几十个图表同时重绘。

4.3 大屏布局与自适应的注意点

大屏的分辨率往往是带鱼屏、拼接屏,常规Web布局很容易在边缘被裁切或留黑边。我的经验是用 vw/vh 相对单位做整体缩放,外面包一层固定比例的容器,然后通过 transform: scale() 按实际屏幕分辨率动态缩放。这样不管屏幕是1080p还是4K,整个界面都能等比拉伸,保持设计比例。

视频窗口在放大到全屏布局时,要注意保持原始宽高比,用 object-fit: cover 填充,否则画面会被拉伸变形,监控场景下容易造成距离判断错误。地图组件建议独立一个图层,和视频播放器做叠加,而不是直接塞进一个容器里,这样联动定位时地图才能平滑移动而不会把视频顶开。

5. 常见问题与排查技巧实录

5.1 WebRTC黑屏、卡顿与花屏的排查套路

这几个问题在大屏项目里几乎必然遇到,我整理了排查顺序,按这个顺序查,基本十分钟内能定位到根因。

黑屏

黑屏先分清两个阶段:是SDP协商阶段失败,还是媒体传输阶段失败。可以打开 chrome://webrtc-internals ,看 PeerConnection 的状态。如果 iceConnectionState 一直是 checking ,说明ICE候选没打通,优先检查端口、防火墙和TURN配置。如果 iceConnectionState 已经是 connected 但画面黑屏,多半是视频编码格式不匹配,比如网关只发H.264但浏览器禁用了H.264,或者B帧设置和JitterBuffer兼容有问题。

卡顿与花屏

卡顿优先看 getStats 里的丢包率。丢包率低于1%画面卡顿,是典型的JitterBuffer缓冲太小;丢包率高于5%,说明网络已经承载不了当前码率,骈降到子码流或者降低码率是最直接的手段。花屏问题多半是视频关键帧间隔过长,长时间花屏后突然恢复,说明丢包后关键帧恢复慢,这时需要让服务端缩短关键帧间隔,设置GOP大小在2秒左右,实测体感会好很多。

多路播放时CPU占用过高

如果单页面播放超过16路视频,硬解能力可能会成为瓶颈。建议用 video 标签的 disablePictureInPicture 和适当窗口缩小策略,离开视频区超过30秒的窗口自动切换成静态快照,需要时再恢复播放。这个策略能让大规模大屏的CPU占用率下降一半以上。

5.2 WebSocket连接不稳定与消息丢失

WebSocket连接不稳定,绝大多数时候和心跳有关,少部分和网络代理有关。如果排查下来心跳正常但连接还是断开,看一下服务端是否有空闲连接超时配置,Nginx代理场景下还要额外设置 proxy_read_timeout 不小于心跳间隔的两倍。

关于消息丢失,我做了一套简单的消息序列号机制:

  • 每条消息带自增序号 seq 。
  • 前端记录最近处理过的最大 seq 。
  • 连续收到消息发现 seq 不连续时,主动拉取缺失区间。
  • 服务端把最近1000条消息缓存在内存中,支持按 seq 区间回查。

这套机制实现成本很低,但对可靠性的提升非常关键,尤其是告警这种关键消息,宁可重复拉取也不能丢失。

5.3 多显示器与多窗口全屏兼容性

大屏展示经常需要把视频窗口拖到扩展屏或者拼接屏上,但浏览器对多窗口全屏有诸多限制。我的方案是把大屏系统本身作为一个独立的Chromium实例运行,通过 --start-fullscreen --window-position 启动参数直接指定窗口位置和大小,同时播放器和状态中枢都跑在同一个页面里,避免跨窗口传数据的复杂度。

另外一个常被忽略的问题是系统的自动息屏和电源管理。大屏设备长时间运行,一定要通过组策略或 powercfg 关闭系统睡眠,否则半夜大屏自动休眠,第二天早上全屏任务栏都是唤醒的弹窗。这类小问题和它导致的实际事故,说出来都是泪。

最后再分享一个我自己的习惯

这套系统上线已经跑了将近一年,最大的感受是:WebRTC和WebSocket单独拎出来都不是新技术,难点在于怎么把视频的实时性和状态的实时性组合成一套稳定可维护的系统。我建议任何打算做同类大屏的团队,先别急着写业务代码,花两三天时间把信令流程、重连策略、消息协议和数据对齐方案画清楚,想明白再动手。协议设计上多做一层序列号,播放器封装里多考虑一个 onconnectionstatechange 分支,上线之后能少熬好几个大夜。希望这篇长文能帮你少踩几个我踩过的坑。

Logo

邀请您加入社区

更多推荐