SFU vs MCU: La Decisión de Arquitectura que Determina Todo
Antes de comparar servers, tienen que entender la elección de arquitectura fundamental porque condiciona todo lo demás.
MCU (Multipoint Conferencing Unit): El servidor recibe todos los streams de los participantes, los decodifica, los compone en un único stream mixeado, re-encodea, y manda un stream a cada participante. Cada participante recibe y decodifica un solo stream sin importar cuánta gente hay en la llamada.
SFU (Selective Forwarding Unit): El servidor recibe el stream de cada participante y lo reenvía a los demás sin transcodificar. Cada participante recibe N-1 streams (donde N es el número de participantes) y los decodifica localmente.
Arquitectura MCU:
Participante A ──video──▶ ┌─────────┐ ──mixed──▶ Participante A
Participante B ──video──▶ │ MCU │ ──mixed──▶ Participante B
Participante C ──video──▶ │ Server │ ──mixed──▶ Participante C
Participante D ──video──▶ └─────────┘ ──mixed──▶ Participante D
CPU del server: O(n) decode + composite + encode
Bandwidth del server: O(n) in + O(n) out
Arquitectura SFU:
Participante A ──video──▶ ┌─────────┐ ──A,B,C──▶ Participante D
Participante B ──video──▶ │ SFU │ ──A,B,D──▶ Participante C
Participante C ──video──▶ │ Server │ ──A,C,D──▶ Participante B
Participante D ──video──▶ └─────────┘ ──B,C,D──▶ Participante A
CPU del server: O(1) por stream (solo forwarding)
Bandwidth del server: O(n) in + O(n²) out (sin simulcast)
Un punto de partida común es un approach MCU a escala pequeña (Janus con el plugin videoroom en modo MCU) para un producto de sala de directorio de 20 personas. Funciona bien a esa escala. Pero cuando los requerimientos cambian a 500+ participantes, MCU se vuelve impracticable inmediatamente, el costo de CPU de decodificar 500 streams de video, componerlos y re-encodear es astronómico. El cambio a SFU se vuelve necesario.
El problema de bandwidth con SFUs (O(n²) de salida) se resuelve con simulcast y SVC. Más sobre eso abajo.
mediasoup: El Bisturí del Cirujano
mediasoup no es un servidor, es una librería C++ con un wrapper Node.js. Construís tu propio servidor alrededor. Esto te da control total y responsabilidad total.
Así se ve un setup básico de mediasoup:
const mediasoup = require('mediasoup');
// Crear un worker (uno por core de CPU)
const worker = await mediasoup.createWorker({
logLevel: 'warn',
rtcMinPort: 40000,
rtcMaxPort: 49999,
});
// Crear un router (uno por sala, define codecs)
const router = await worker.createRouter({
mediaCodecs: [
{
kind: 'audio',
mimeType: 'audio/opus',
clockRate: 48000,
channels: 2,
},
{
kind: 'video',
mimeType: 'video/VP9',
clockRate: 90000,
parameters: {
'profile-id': 2,
},
},
{
kind: 'video',
mimeType: 'video/H264',
clockRate: 90000,
parameters: {
'packetization-mode': 1,
'profile-level-id': '42e01f',
'level-asymmetry-allowed': 1,
},
},
],
});
// Crear WebRTC transport para un producer (publisher)
const transport = await router.createWebRtcTransport({
listenIps: [{ ip: '0.0.0.0', announcedIp: '203.0.113.1' }],
enableUdp: true,
enableTcp: true,
preferUdp: true,
initialAvailableOutgoingBitrate: 1000000,
});
La configuración crítica para escalar es simulcast. Sin esto, cada suscriptor recibe el stream en resolución completa incluso si lo está renderizando como thumbnail de 160x90:
// Lado del producer: enviar 3 capas simulcast
const producer = await transport.produce({
kind: 'video',
rtpParameters: {
codecs: [/* ... */],
encodings: [
{ rid: 'r0', maxBitrate: 100000, scaleResolutionDownBy: 4 },
{ rid: 'r1', maxBitrate: 300000, scaleResolutionDownBy: 2 },
{ rid: 'r2', maxBitrate: 900000, scaleResolutionDownBy: 1 },
],
},
});
// Lado del suscriptor: pedir capa específica
const consumer = await transport.consume({
producerId: producer.id,
rtpCapabilities: device.rtpCapabilities,
preferredLayers: { spatialLayer: 1, temporalLayer: 2 },
});
// Cambiar capas dinámicamente según layout de UI
async function onTileResize(consumerId, width) {
const consumer = consumers.get(consumerId);
if (width < 200) {
await consumer.setPreferredLayers({ spatialLayer: 0 });
} else if (width < 600) {
await consumer.setPreferredLayers({ spatialLayer: 1 });
} else {
await consumer.setPreferredLayers({ spatialLayer: 2 });
}
}
Para escalar más allá de un solo servidor, mediasoup usa pipe transports para conectar routers entre workers o máquinas:
// Cascadear salas entre servidores
const { pipeProducer, pipeConsumer } = await router1.pipeToRouter({
producerId: someProducer.id,
router: router2,
});
Las pruebas muestran que mediasoup maneja ~350 streams de video simultáneos (720p, VP9 simulcast, 3 capas) en un c6i.2xlarge (8 vCPU) antes de que la CPU pegue 80%. Impresionante para forwarding, recordar que el SFU no está transcodificando.
LiveKit: La Apuesta por Plataforma
LiveKit es lo que usás cuando no se quiere construir infraestructura desde cero. Es un SFU basado en Go con:
- Escalado multi-nodo con ruteo de salas respaldado por Redis
- Grabación y egress (RTMP, HLS, S3)
- SDKs de cliente para toda plataforma
- Bridging SIP
- Framework de agentes AI
# livekit-server.yaml
port: 7880
rtc:
port_range_start: 50000
port_range_end: 60000
use_external_ip: true
tcp_fallback_port: 7881
redis:
address: redis-cluster.internal:6379
room:
max_participants: 500
empty_timeout: 300
video:
dynacast: true
codecs:
- mime: video/av1
fmtp: ""
- mime: video/vp9
fmtp: profile-id=0
- mime: video/h264
fmtp: level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f
La característica más destacada es dynacast. Cuando un suscriptor no está mirando un stream particular (ej: está fuera de pantalla o minimizado), LiveKit le dice al publisher que deje de mandar esa capa. En una sala de 100 personas donde cada participante ve 9 tiles en pantalla, esto significa que ~91% de las capas de alta calidad están pausadas. El ahorro de bandwidth es enorme.
// LiveKit client SDK, publicar con simulcast
const room = new Room({
dynacast: true,
adaptiveStream: true,
videoCaptureDefaults: {
resolution: VideoPresets.h720.resolution,
},
publishDefaults: {
simulcast: true,
videoSimulcastLayers: [
VideoPresets.h90,
VideoPresets.h216,
VideoPresets.h720,
],
videoCodec: 'av1',
backupCodec: { codec: 'vp9', encoding: {} },
},
});
await room.connect(LIVEKIT_URL, token);
const track = await createLocalVideoTrack();
await room.localParticipant.publishTrack(track);
room.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
if (track.kind === Track.Kind.Video) {
track.attach(document.getElementById(`video-${participant.identity}`));
}
});
Para 10K participantes, la arquitectura de LiveKit usa múltiples nodos SFU con un router respaldado por Redis:
┌─────────────┐
│ Redis │
│ (routing) │
└──────┬──────┘
│
┌──────────────┼──────────────┐
│ │ │
┌───────┴───────┐ ┌───┴──────┐ ┌────┴───────┐
│ LiveKit │ │ LiveKit │ │ LiveKit │
│ Nodo 1 │ │ Nodo 2 │ │ Nodo 3 │
│ (salas 1-50) │ │(salas │ │ (salas │
│ │ │ 51-100) │ │ 101-150) │
└───────────────┘ └──────────┘ └────────────┘
Cuando una sala abarca múltiples nodos (ej: 2000 participantes), LiveKit automáticamente crea conexiones relay entre nodos. Pruebas con 3 nodos manejando una sala de 3000 participantes (10 speakers activos, resto viewers), la latencia end-to-end fue 180-220ms, aceptable para eventos en vivo aunque no para llamadas interactivas.
Janus: La Navaja Suiza
Janus es un gateway basado en C con arquitectura de plugins. Su plugin videoroom es el SFU, pero también tiene plugins para SIP, streaming, grabación y screen sharing. Esto importa si se necesita hacer bridge entre WebRTC e infraestructura telecom legacy.
La fortaleza de Janus es la flexibilidad. ¿Necesitás grabar un stream WebRTC a disco? Plugin Record. ¿Inyectar un stream RTSP en una sala WebRTC? Plugin Streaming. ¿Bridge SIP-to-WebRTC para un call center? Plugin SIP.
Pero Janus escala mal comparado con mediasoup y LiveKit. Su arquitectura de proceso único (con un thread pool) pega un techo alrededor de 200 streams de video por instancia en hardware equivalente. No tiene cascading nativo, así que escalar horizontalmente requiere una capa de ruteo externa que construís vos.
VP9 vs AV1: La Batalla de Negociación de Codecs
VP9 con SVC (Scalable Video Coding) viene siendo el caballo de batalla de los SFUs WebRTC porque encodea múltiples capas de calidad en un solo stream. AV1 va más lejos con soporte SVC nativo y 30-40% mejor compresión.
Los benchmarks de costo de CPU de encoding (un stream 720p30, c6i.xlarge):
| Codec | Modo | Uso CPU | Bitrate | Score VMAF |
|---|---|---|---|---|
| VP8 | Simulcast 3 capas | 18% | 1.3 Mbps | 82.1 |
| VP9 | SVC L3T3 | 24% | 0.9 Mbps | 84.7 |
| H.264 | Simulcast 3 capas | 15% | 1.5 Mbps | 81.3 |
| AV1 | SVC L3T3 | 72% | 0.6 Mbps | 86.2 |
AV1 entrega la mejor calidad al bitrate más bajo pero come 3x la CPU de VP9. El approach recomendado: VP9 SVC como codec primario con H.264 simulcast como fallback para Safari. Utilizar AV1 solo para viewers, no publishers, salvo que puedas confirmar soporte de encoder hardware.
Escalando a 10K: La Arquitectura que Realmente Deployamos
Esta es una arquitectura deployada para una plataforma de eventos en vivo manejando hasta 10.000 viewers con 8 speakers activos:
┌──────────────┐
│ Signaling │
│ Server │
│ (WebSocket) │
└──────┬───────┘
│
┌───────────┴───────────┐
│ │
┌───────┴───────┐ ┌───────┴───────┐
│ Speaker SFU │ │ Viewer SFU │
│ (LiveKit) │◄─────►│ Cascade │
│ 8 publishers │ relay │ (LiveKit x4) │
│ + 50 viewers │ │ 2500 viewers │
└───────────────┘ │ c/u │
└────────────────┘
El throttling de PLI (Picture Loss Indication) es crítico. Sin él, la pérdida de paquetes de incluso un par de viewers dispara una cascada de pedidos de keyframe que satura el uplink del speaker. Esto se volvió evidente durante un evento de 5000 personas donde el video de un speaker se congeló para todos porque su conexión de 50Mbps quedó saturada por keyframe requests disparados por PLI.
Costo total de infra para capacidad de 10K: 5 instancias c6i.2xlarge (speakers + 4 clusters de viewers) + 2 servers TURN + 1 server de signaling. Alrededor de $3,200/mes en AWS, más bandwidth a ~$0.09/GB de salida. Un evento de 2 horas con 10K viewers a 1Mbps promedio = ~9TB de salida = ~$810 en bandwidth.
Ese es el costo real. Todos los artículos de "escalá WebRTC a millones" convenientemente omiten los costos de bandwidth. A escala, resulta más conveniente usar HLS/DASH para viewers no interactivos y reservar WebRTC para los participantes interactivos.
La Comparación Honesta
| Feature | mediasoup | LiveKit | Janus |
|---|---|---|---|
| Lenguaje | C++/Node.js | Go | C |
| Arquitectura | Librería | Server/Plataforma | Server (plugins) |
| Max streams/nodo | ~350 video | ~500 video | ~200 video |
| Cascading | Manual (pipe) | Built-in | Manual |
| Grabación | Implementarlo | Built-in | Plugin |
| Bridge SIP | No | Sí | Plugin |
| AV1 SVC | Sí | Sí | Parcial |
| SDKs cliente | Comunidad | Oficiales (todos) | Comunidad |
| Curva aprendizaje | Empinada | Moderada | Moderada |
Para proyectos nuevos: LiveKit para la mayoría de los proyectos, mediasoup solo si se necesita control absoluto del pipeline de media o se tiene requerimientos inusuales que las abstracciones de LiveKit no acomodan. Janus para integración con telecom legacy. Los tres son production-grade; la diferencia es cuánto código de infraestructura se quiere escribir vs. usar.