视频直播技术方案:5个核心模块搞定高频面试题

发布时间:2026/9/23 20:49:40
视频直播技术方案: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':// 主播发送 OfferhandleOffer(senderId, data);break;case 'answer':// 观众发送 AnswerhandleAnswer(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 headmeta charset=UTF-8titleWebRTC Live Demo/titlestylevideo { width: 320px; height: 240px; background: #000; }#log { width: 100%; height: 100px; overflow-y: scroll; background: #eee; padding: 5px; font-family: monospace; }/style /head bodyh3角色选择/h3button onclick=init('broadcaster')我是主播/buttonbutton onclick=init('viewer')我是观众/buttondiv style=margin-top: 20px;h4本地视频/h4video id=localVideo autoplay muted/videoh4远程视频/h4video id=remoteVideo autoplay/video/divdiv id=log/divscript src=app.js/script /body /html2. 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. 创建 PeerConnectioncreatePeerConnection();// 3. 发送 Join 消息ws.send(JSON.stringify({ type: 'join', role: role }));// 4. 如果是主播,主动发起 Offerif (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?评论区交流,看看大家的生产环境都踩了哪些坑。