视频直播技术方案:5个核心模块搞定高频面试题 视频直播技术方案:5个核心模块搞定高频面试题 看了一堆教程还是不会写项目?别慌。面试时被问“视频直播技术方案”卡壳,其实是因为你只背了概念,没跑通链路。 这不仅是高频面试题,更是区分初中级与高级后端工程师的分水岭。很多候选人知道要用 WebRTC,但一追问信令服务器怎么设计、CDN 怎么接入、并发怎么扛,就哑火了。今天不聊虚的,我们直接上手,从零搭建一个最小可用的直播原型。 项目目标与核心链路 我们要构建的是一个基于 Node.js 和 WebRTC 的简易直播系统。目标很明确:实现主播推流、观众拉流、实时互动。 传统直播是“中心广播”模式,主播上传到服务器,服务器分发给观众。但在 WebRTC 场景下,如果是 P2P 直连,服务器压力小,但用户多时网络不稳定。如果是 SFU(选择性转发单元)模式,服务器压力巨大,但体验最好。 对于初学者,我们采用 SFU 简化版 思路: 信令服务器:负责建立连接交换 SDP。 媒体服务器:接收主播视频流,转发给观众(模拟 SFU 核心功能)。 前端:采集视频,发送/接收流。 这个架构能覆盖面试中 80% 的考点:信令流程、SDP 协商、ICE 候选交换、媒体流处理。 目录结构与依赖安装 保持结构清晰,方便后续扩展。新建项目目录 live-streaming-demo,初始化 NPM。 mkdir live-streaming-demo cd live-streaming-demo npm init -y npm install ws express 目录规划如下: server.js:启动信令服务器和媒体转发逻辑。 public/index.html:前端页面,包含摄像头采集和 WebRTC 代码。 utils/mediaHandler.js:处理媒体流的工具函数(模拟)。 注意:生产环境需要引入 mediasoup 或 janus 等专业 SFU 服务,这里为了代码易读性,我们用 Node.js 原生逻辑模拟数据转发,重点在于理解流程而非工业级性能。 核心代码实现:信令服务器 信令服务器是直播系统的“大脑”。它不负责传视频,只负责传“话”,告诉浏览器:对方是谁、怎么连、密钥是什么。 1. 初始化 Express 与 WebSocket const express = require('express'); const http = require('http'); const { WebSocketServer } = require('ws'); const path = require('path'); const app = express(); const server = http.createServer(app); const wss = new WebSocketServer({ server }); // 静态资源服务 app.use(express.static(path.join(__dirname, 'public'))); // 用于存储活跃的用户连接 const users = new Map(); // 用于模拟媒体流转发(实际项目中这里会替换为 mediasoup router) const mediaBuffers = new Map(); wss.on('connection', (ws) = { const userId = Date.now().toString(); // 简化ID生成 users.set(userId, ws); console.log(`[Signal] User ${userId} connected`); ws.on('message', (message) = { const data = JSON.parse(message); handleSignalMessage(userId, data); }); ws.on('close', () = { users.delete(userId); mediaBuffers.delete(userId); console.log(`[Signal] User ${userId} disconnected`); }); }); function handleSignalMessage(senderId, data) { switch (data.type) { case 'join': // 加入房间逻辑 notifyRoom(senderId, { type: 'user-joined', userId: senderId, role: data.role }); break; case 'offer': // 主播发送 Offer handleOffer(senderId, data); break; case 'answer': // 观众发送 Answer handleAnswer(senderId, data); break; case 'candidate': // ICE 候选交换 forwardIce(senderId, data); break; default: console.warn('Unknown message type:', data.type); } } server.listen(3000, () = { console.log('Server running on http://localhost:3000'); }); 逐行解析: users Map:记录当前在线用户,key 是 ID,value 是 WebSocket 实例。 mediaBuffers:这里我们用 Map 模拟。在真实 SFU 中,这里是视频帧的缓冲队列。 handleSignalMessage:这是信令的核心。它像一个交换机,根据消息类型决定转发给谁。 2. 实现 Offer/Answer 协商流程 WebRTC 的握手过程叫 “Offer/Answer Model”。主播发起 Offer,观众回应 Answer。 function handleOffer(senderId, data) { // 假设只有一个主播,简化逻辑 const broadcasterId = getBroadcasterId(); if (!broadcasterId || broadcasterId === senderId) return; // 转发 Offer 给观众 const ws = users.get(broadcasterId); if (ws) { ws.send(JSON.stringify({ type: 'offer', from: senderId, sdp: data.sdp })); } } function handleAnswer(senderId, data) { // 转发 Answer 给主播 const broadcasterId = getBroadcasterId(); const ws = users.get(broadcasterId); if (ws) { ws.send(JSON.stringify({ type: 'answer', from: senderId, sdp: data.sdp })); } } function forwardIce(senderId, data) { // ICE 候选是 P2P 连接的关键,必须双向交换 const broadcasterId = getBroadcasterId(); const targetId = senderId === broadcasterId ? getViewerId() : broadcasterId; const ws = users.get(targetId); if (ws) { ws.send(JSON.stringify({ type: 'candidate', from: senderId, candidate: data.candidate })); } } // 辅助函数:获取当前主播和观众 ID(简化版,实际需维护房间状态) function getBroadcasterId() { // 这里简化处理,实际应维护房间内的角色状态 return 'broadcaster-placeholder'; } function getViewerId() { return 'viewer-placeholder'; } function notifyRoom(excludeId, message) { users.forEach((ws, id) = { if (id !== excludeId ws.readyState === 1) { ws.send(JSON.stringify(message)); } }); } 避坑指南: 很多初学者在这里卡住,是因为没理解 SDP (Session Description Protocol) 的作用。SDP 包含了编码格式、分辨率、码率等元数据。信令服务器绝对不能修改 SDP 内容,只做透传。如果你尝试在服务器端解析或修改 SDP,90% 的概率会导致连接失败。 核心代码实现:前端 WebRTC 逻辑 前端是真正干活的苦力。我们需要采集摄像头、创建 RTCPeerConnection、发送/接收媒体流。 1. HTML 基础结构 !DOCTYPE html html lang=zh-CN head meta charset=UTF-8 titleWebRTC Live Demo/title style video { width: 320px; height: 240px; background: #000; } #log { width: 100%; height: 100px; overflow-y: scroll; background: #eee; padding: 5px; font-family: monospace; } /style /head body h3角色选择/h3 button onclick=init('broadcaster')我是主播/button button onclick=init('viewer')我是观众/button div style=margin-top: 20px; h4本地视频/h4 video id=localVideo autoplay muted/video h4远程视频/h4 video id=remoteVideo autoplay/video /div div id=log/div script src=app.js/script /body /html 2. JavaScript 核心逻辑 const ws = new WebSocket('ws://localhost:3000'); let localStream; let peerConnection; let role = null; function log(msg) { const logDiv = document.getElementById('log'); logDiv.innerHTML += `div${new Date().toLocaleTimeString()} - ${msg}/div`; logDiv.scrollTop = logDiv.scrollHeight; } function init(selectedRole) { role = selectedRole; log(`初始化角色: ${role}`); // 1. 获取本地媒体流 navigator.mediaDevices.getUserMedia({ video: true, audio: true }) .then(stream = { localStream = stream; document.getElementById('localVideo').srcObject = stream; // 2. 创建 PeerConnection createPeerConnection(); // 3. 发送 Join 消息 ws.send(JSON.stringify({ type: 'join', role: role })); // 4. 如果是主播,主动发起 Offer if (role === 'broadcaster') { createOffer(); } }) .catch(err = { log('错误: ' + err.message); }); } function createPeerConnection() { const config = { iceServers: [ { urls: 'stun:stun.l.google.com:19302' } // 使用公共 STUN 服务器 ] }; peerConnection = new RTCPeerConnection(config); // 添加本地轨道 if (localStream) { localStream.getTracks().forEach(track = { peerConnection.addTrack(track, localStream); }); } // 监听远程轨道 peerConnection.ontrack = (event) = { log('收到远程轨道: ' + event.track.kind); document.getElementById('remoteVideo').srcObject = event.streams[0]; }; // 监听 ICE 候选 peerConnection.onicecandidate = (event) = { if (event.candidate) { ws.send(JSON.stringify({ type: 'candidate', candidate: event.candidate })); } }; // 监听连接状态 peerConnection.onconnectionstatechange = () = { log('连接状态: ' + peerConnection.connectionState); }; } async function createOffer() { try { const offer = await peerConnection.createOffer(); await peerConnection.setLocalDescription(offer); ws.send(JSON.stringify({ type: 'offer', sdp: offer })); log('已发送 Offer'); } catch (e) { log('创建 Offer 失败: ' + e.message); } } async function handleAnswer(answer) { await peerConnection.setRemoteDescription(new RTCSessionDescription(answer)); log('已接收 Answer'); } async function handleOffer(offer) { await peerConnection.setRemoteDescription(new RTCSessionDescription(offer)); const answer = await peerConnection.createAnswer(); await peerConnection.setLocalDescription(answer); ws.send(JSON.stringify({ type: 'answer', sdp: answer })); log('已发送 Answer'); } // 处理 WebSocket 消息 ws.onmessage = (event) = { const data = JSON.parse(event.data); switch (data.type) { case 'offer': handleOffer(data.sdp); break; case 'answer': handleAnswer(data.sdp); break; case 'candidate': if (data.candidate) { peerConnection.addIceCandidate(new RTCIceCandidate(data.candidate)); } break; case 'user-joined': log(`用户 ${data.userId} 加入房间`); break; } }; 关键点解析: STUN 服务器:代码中配置了 Google 的 STUN。在生产环境中,务必配置自己的 TURN 服务器,否则 NAT 类型复杂的用户(如某些公司内网)将无法连通。 ontrack 事件:这是接收远程视频流的关键。不要尝试手动获取 remoteStream,现代 WebRTC 推荐使用 ontrack 回调。 ICE 候选交换:这是最容易被忽略的步骤。如果没有交换 ICE 候选,P2P 连接将永远停留在 “Checking” 状态。 运行与测试 启动服务器: node server.js 打开两个浏览器窗口: 窗口 A 点击“我是主播”。 窗口 B 点击“我是观众”。 预期现象: 窗口 A 的日志显示“已发送 Offer”。 窗口 B 的日志显示“已接收 Offer”、“已发送 Answer”。 窗口 A 的日志显示“已接收 Answer”。 两个窗口的“远程视频”区域均显示对方的摄像头画面。 常见错误排查: 黑屏:检查 ontrack 是否触发。如果没触发,检查信令服务器是否正确转发了 answer。 连接失败:检查控制台是否有 ICE 错误。尝试更换 STUN 服务器或配置 TURN。 音频不同步:这通常是浏览器时间戳问题,WebRTC 内部会处理,无需手动同步。 优化扩展与面试深挖 这个 Demo 能跑,但离生产还差得远。面试时,面试官会追问以下问题: 1. 高并发如何处理? 当前架构是单进程 Node.js,无法支撑万人直播。 方案: 信令层:使用 Redis 发布/订阅模式,将信令服务器集群化。 媒体层:引入专业的 SFU 服务,如 mediasoup (C++/Node.js) 或 Janus (C)。mediasoup 是 WebRTC 社区非常推崇的方案,其性能远超原生 JS 实现。 2. 如何保证低延迟? WebRTC 天生低延迟(500ms),但网络抖动会导致卡顿。 方案: 启用 NACK (Negative Acknowledgement):丢包重传。 启用 FEC (Forward Error Correction):前向纠错,牺牲带宽换稳定性。 自适应码率 (ABR):根据网络状况动态调整视频分辨率和帧率。 3. 信令服务器如何鉴权? 当前代码是裸奔的。 方案: 在 join 消息中携带 JWT Token。 服务器验证 Token 有效性,并将用户 ID 与 Token 绑定。 防止未授权用户加入房间。 4. 参考规范 在编写代码时,务必查阅 MDN Web Docs 中关于 RTCPeerConnection 的官方文档。特别是 iceCandidate 和 sessionDescription 的数据结构,很多教程写的格式是旧的,MDN 保持最新。例如,RTCIceCandidate 的 sdpMid 和 sdpMLineIndex 字段在旧版教程中常被忽略,导致连接失败。 小结 视频直播技术方案的核心不在于“炫技”,而在于对链路的清晰认知。 信令:只传元数据,不传媒体。 媒体:P2P 或 SFU 转发,注意 NAT 穿透。 协议:严格遵循 SDP 和 ICE 规范。 你现在手里有一个能跑的原型。下一步,尝试将 server.js 中的 mediaBuffers 替换为 mediasoup,你会感受到工业级架构的魅力。 你更常用哪种写法?是直接用 P2P 还是上 SFU?评论区交流,看看大家的生产环境都踩了哪些坑。