信令服务器从头到尾看不见一个音视频包。 它传的是几张几百字节的纸片,业务逻辑只有「按房间转发」。
13.3.1 装好了 STUN 与 TURN,这一节装交换 SDP 的那个角色。 13.3.3 装发页面的 Web 服务器。
一、它不碰媒体,只传纸片
WebRTC 把两件事分得很干净: 媒体走 UDP 点对点,信令走另一条路——通常是 WebSocket。 信令服务器传的是下面这几张小纸片:
消息 方向 典型字节 作用
join 浏览器 → 服务器 42 报上自己在哪个房间 offer 浏览器 → 服务器 1462 我把我的 SDP 交上去 answer 浏览器 → 服务器 981 我把回应交上去 candidate 浏览器 → 服务器 118 我又试出一条路 bye 浏览器 → 服务器 36 我走了
五条加起来两千六百多字节,还不到 3 KB。 而一路 2 Mbps 的流跑一分钟就是 15 MB。
所以信令服务器再忙也不会是瓶颈,它的价值在「撮合」,不在「转发」。 真正要盯的指标是「同时在线多少个房间、多少条连接」,不是每秒多少字节。
二、握手就一步算术
SignalMaster 用的是 socket.io,底层就是 WebSocket。 握手只有一步算术:客户端给一个随机 key,服务器把它拼上一串固定 GUID, 算 SHA-1 再 base64,回给客户端。这个值对不上下游直接断。
RFC 6455 自己给了一组样例:key 是 dGhlIHNhbXBsZSBub25jZQ==,
应答应该是 s3pPLMBiTxaQ9kYGzzhZRbK+xOo=。
把它算一遍能对上,就说明 GUID 那一串没抄错。
两个固定长度值得记住: key 永远是 16 字节随机数编码成 24 个字符,accept 永远是 28 个字符。 如果算出来的长度不对,说明编码方式错了,不是 GUID 错了。
这个握手的好处是「无状态」:服务器不用记任何东西, 同样的 key 永远算出同样的 accept,所以负载均衡随便打。 代价是它只证明「你能算 SHA-1」,不证明你是谁——认证要在应用层另做。
三、按房间转发,不给自己
SignalMaster 的全部业务逻辑就一句话:同一个房间里,谁说话就转给其他人。 服务器按房间号分桶,桶内互转,不回声、不缓存。
「不回声」和「不缓存」这两条会带来两个可见现象: ① 你发给别人的消息,自己收不到,界面上的状态要自己维护; ② 后进房间的人,看不到前面的人说过的话。
第二条看起来像 bug,其实是设计: 信令是「当下」的交换,不是为了回放。 后进来的人如果也需要 offer,得让房间里的人重新发一份—— 这就是为什么多人会议里,新加入者会触发一串重新协商。
第三条规律是「转发会放大」: 一条消息出门按「同房间人数减一」复制。 两个人一间是 1 倍,十个人一间是 9 倍。 这是信令服务器唯一会放大的地方,也是它唯一的容量风险。
四、它的短板:房间表在内存里
SignalMaster 把房间存在内存里,进程一重启,所有人的房间关系全丢。 而且它是单进程的,两个实例之间不共享房间表,所以扩不了。
所以它只适合做实验。要上线,有两条路: ① 把房间表搬到 Redis,让多个实例共享; ② 干脆不用它,用现成媒体服务器自带的信令。
第二条路在 11.5.3 那节已经见过:SRS 把信令做进了 HTTP 接口, 浏览器一个 POST 就把 offer 换回 answer,根本不需要单独的 WebSocket 服务。 这也是为什么现在自建 WebRTC 越来越少单独部署信令服务。
但理解它仍然有价值: 知道信令要传什么、为什么不能缓存、为什么会放大, 换成任何一套现成方案,这些判断都用得上。
五、动手 Demo:搭建信令服务器 SignalMaster——它到底转发什么
真算一次 RFC 6455 的握手应答,再起一个按房间转发的服务端。 让四个客户端分别进两个房间,看谁收到什么。
GUID = b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
def ws_accept(key):
"""RFC 6455 的应答值:key 接上固定 GUID,SHA-1,再 base64。"""
return base64.b64encode(
hashlib.sha1(key + GUID).digest()).decode("ascii")
sample = b"dGhlIHNhbXBsZSBub25jZQ=="
print("RFC 样例算出", ws_accept(sample))
print("标准期望 s3pPLMBiTxaQ9kYGzzhZRbK+xOo=")
rand = base64.b64encode(os.urandom(16))
print("随机 key", len(rand), "字符,accept",
len(ws_accept(rand)), "字符")
# 按房间转发:谁说话就转给同房间的其他人
def dispatch(rooms, room, sender, text):
"""做一个服务端→客户端的文本帧(服务端不打掩码)。"""
raw = text.encode("utf-8")
n = len(raw)
hdr = struct.pack("!BB", 0x81, n) if n < 126 \
else struct.pack("!BBH", 0x81, 126, n)
data = hdr + raw
sent = 0
for peer in rooms.get(room, []):
if peer is sender:
continue # 不回声
peer.sendall(data)
sent += len(data)
return sent, len(rooms.get(room, [])) - 1
rooms = {"room1": [sock_a, sock_b, sock_d], "room2": [sock_c]}
for who in ("A", "B", "D"):
print(who, "进 room1,进完房间人数", 3)
实跑输出:
--------------------------------------------------------------------
搭建信令服务器 SignalMaster——它到底转发什么
--------------------------------------------------------------------
**① 信令服务器不碰媒体,它只传「纸片」**
**WebRTC 把两件事分得很干净:**
**媒体走 UDP 点对点,信令走另一条路——通常是 WebSocket。**
**信令服务器从头到尾看不见一个音视频包,它传的是下面这几张小纸片:**
消息 方向 典型字节 作用
----------------------------------------------------------------------
join 浏览器 → 服务器 42 报上自己在哪个房间
offer 浏览器 → 服务器 1462 我把我的 SDP 交上去
answer 浏览器 → 服务器 981 我把回应交上去
candidate 浏览器 → 服务器 118 我又试出一条路
bye 浏览器 → 服务器 36 我走了
**五条加起来 2639 字节,还不到 3 KB。**
**而一路 2 Mbps 的流跑一分钟就是 15 MB。**
**所以信令服务器再忙也不会是瓶颈,它的价值在「撮合」,不在「转发」。**
**② 真算一次 WebSocket 握手**
**SignalMaster 用的是 socket.io,底层就是 WebSocket。**
**握手只有一步算术:客户端给一个随机 key,服务器把它拼上一串固定 GUID,**
**算 SHA-1 再 base64,回给客户端。这个值对不上下游直接断。**
**RFC 6455 自己给的样例:**
key dGhlIHNhbXBsZSBub25jZQ==
accept s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
**对一下标准里的期望值 `s3pPLMBiTxaQ9kYGzzhZRbK+xOo=`,一字不差。**
**换一个真的随机 key 再看一遍:**
key ZTeHSm2uvBYDOoN5nmEA1Q== (24 字符)
accept Xi3vdPHfNdvXEWX9XJpOPtuwlAk= (28 字符)
**key 永远是 16 字节随机数编码成 24 个字符,accept 永远是 28 个字符。**
**GUID 那一串是写死的常量,抄错一个字母,所有浏览器都连不上。**
**③ 真跑一个按房间转发的信令服务**
**SignalMaster 的全部业务逻辑就一句话:同一个房间里,谁说话就转给其他人。**
**这里起一个真的 WebSocket 服务端,让四个客户端分别进两个房间试试。**
**四个客户端的收发情况:**
客户端 房间 收到几条 收到的都是什么
--------------------------------------------------------------
A room1 1 answer
B room1 2 offer、candidate
D room1 3 offer、answer、candidate
C room2 0 一条也没有
**A、B、D 三个人在 room1,C 一个人待在 room2。**
**结果:C 一条都没收到,D 收到三条,A 只收到 B 的 answer。**
**A 收得少是因为他自己发的那两条不会再回给他——服务器不回声。**
**D 收得最多是因为他一句话没说,别人说的全落他身上。**
**这三行摆在一起,就把「按房间分桶、桶内互转、不给自己」讲完了。**
**收发的字节数:服务端共读进 3284 字节,转发出去 5242 字节。**
**读进来的只算「每条消息一次」,转发出去的按「同房间人数减一」算。**
**room1 三个人,于是一条消息出门就变两份,字节数立刻顶上去。**
**十个人一间时,一份 offer 变九份流量。**
**这是信令服务器唯一会「放大」的地方,也是它唯一需要担心的容量问题。**
**④ 信令与媒体,量级差多远**
项目 字节 相当于
----------------------------------------------------
整场信令 3284 一条 3 KB 的图片
25 帧视频 10503 3.2 倍的整场信令
1 分钟 2 Mbps 15000000 4568 倍的整场信令
**信令只在一头一尾出现,媒体是全程奔流。**
**所以优化信令服务器没有意义——它本来就不占带宽,只占并发连接数。**
**SignalMaster 这类工具真正要盯的指标是「同时在线多少个房间、多少条连接」,**
**不是「每秒多少字节」。**
**⑤ 但它有一个绕不过去的短板**
**SignalMaster 把房间存在内存里,进程一重启,所有人的房间关系全丢。**
**而且它是单进程的,只能水平扩不了——两个实例之间不共享房间表。**
**所以它只适合做实验。要上线,信令层得换成带 Redis 的房间服务,**
**或者干脆用现成的媒体服务器(SRS、ZLMediaKit)自带的信令。**
**这一点在 11.5.3 那节已经见过:SRS 把信令做进了 HTTP 接口,**
**浏览器一个 POST 就把 offer 换回 answer,根本不需要单独的 WebSocket 服务。**
RFC 那组样例算出来一字不差,说明 GUID 没抄错。 随机 key 是 24 个字符,accept 是 28 个字符,长度固定。 服务端共读进 3284 字节,转发出去 5242 字节——转发比读进多六成。 因为 room1 有三个人,一条消息出门就复制两份。 四个人里 A 收一条、B 收两条、D 收三条、C 一条也没有。 A 收得少是因为自己发的不回声,D 收得多是因为他一句没说。 C 一条没有,是因为他一个人在 room2,房间隔离生效了。
30 秒实验
- 把 GUID 最后一个字母从
1改成2,猜会怎样——算出来的 accept 完全不同,所有浏览器都返回 400。 - 让 C 也进 room1,猜转发字节怎么变——每条消息复制三份,字节数再涨五成。
- 让 A 先发 offer 再让 D 进房间,猜 D 能不能收到——收不到,服务器不缓存历史,必须重新协商。
常见误区
- 「信令服务器带宽压力大」——它只传几 KB 的纸片。
- 「后进的人能看到之前的 offer」——不缓存,要重发。
- 「SignalMaster 可以直接上生产」——房间表在内存里,扩不了。
自测三问(点开对答案)
1. WebSocket 握手用哪个哈希算应答值? —— **SHA-1**,把 key 拼上固定 GUID 后 base64。 2. 信令服务器为什么「不回声」? —— **它只按房间转发给其他人**,发送方自己收不到。 3. 十个人一间时,一条消息会变成几份流量? —— **九份**,按同房间人数减一复制。- ☐ (本节完,下一节 13.3.3 安装 Web 服务器 Nginx)