
视频直播技术方案: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?评论区交流,看看大家的生产环境都踩了哪些坑。