27、SFU 调试:分析 SFU 的转发日志,验证订阅/取消订阅逻辑

说实话,SFU 的调试,最让人头疼的不是媒体流卡顿,而是「订阅/取消订阅」的逻辑对不对。

你想想看,一个房间几十个人,每个人看到的画面都不一样。有人只看大屏,有人要听所有人的声音。如果订阅逻辑出了错,轻则多浪费带宽,重则直接黑屏。

我个人习惯,遇到这类问题,第一件事就是翻转发日志。日志不会骗人,它清清楚楚地告诉你:谁订阅了谁,什么时候订阅的,什么时候取消的。

转发日志里到底有什么?

SFU 的转发日志,说白了就是一张「谁在看谁」的流水账。我常用的 mediasoup 和 Janus,日志格式虽然不同,但核心字段都一样。

我拿 mediasoup 的日志举个例子:

// mediasoup 的订阅日志示例
[INFO] Transport::Produce() | transportId:"1234" kind:"video" ssrc:123456
[INFO] Router::AddConsumer() | consumerId:"abcd" producerId:"efgh" kind:"video"
[INFO] Consumer::SetPreferredLayers() | consumerId:"abcd" spatialLayer:2 temporalLayer:0
[INFO] Consumer::Pause() | consumerId:"abcd" reason:"user_mute"
[INFO] Consumer::Resume() | consumerId:"abcd"
[INFO] Consumer::Close() | consumerId:"abcd" reason:"unsubscribe"

你看,每一行都带着时间戳和操作类型。我最关注的是 AddConsumerCloseSetPreferredLayers 这三类。

核心要点: 订阅 = 创建 Consumer;取消订阅 = 关闭 Consumer。日志里如果出现 Consumer 创建后没有对应的 Close,那就是泄漏了。

验证订阅逻辑的四个关键步骤

我在项目中遇到过好几次「订阅了但没收到流」的诡异问题。后来总结了一套排查流程,分享给你:

  1. 检查 Consumer 创建时机 —— 是不是在用户加入房间后才创建的?
  2. 检查 Producer 是否存在 —— 如果 Producer 还没产生,Consumer 创建了也是空转。
  3. 检查 PreferredLayers —— 订阅的是全分辨率还是缩略图?日志里有没有设置 spatialLayer?
  4. 检查 Consumer 状态 —— 是不是被 Pause 了?Resume 回来了吗?

嗯,这里要注意:很多 SFU 在用户静音时会自动 Pause Consumer,但取消静音后如果 Resume 没触发,那用户就永远听不到声音了。我曾经因为这个被用户投诉过……

取消订阅的常见陷阱

取消订阅看起来简单,不就是调个 consumer.close() 吗?但实际坑不少。

  • 重复取消 —— 前端点了两次取消按钮,后端收到两个 Close 请求,第二个会报错。
  • 忘记清理 —— 用户离开房间时,只关了 PeerConnection,没关 Consumer。SFU 那边还留着资源。
  • 异步竞争 —— 先取消订阅,紧接着又订阅同一个 Producer。如果顺序没处理好,新的 Consumer 可能创建失败。
避坑指南: 我曾经在 mediasoup 里遇到一个 bug:用户快速切换摄像头(先关闭再打开),导致旧的 Consumer 还没完全销毁,新的 Consumer 就创建了。结果两个 Consumer 指向同一个 Producer,日志里出现大量 "Consumer already exists" 警告。后来我加了一个 200ms 的延迟,确保旧 Consumer 彻底关闭后再创建新的。

用日志验证订阅/取消订阅的完整流程

我建议你写一个小脚本,实时监控 SFU 的日志,然后模拟用户操作。比如:

# 伪代码:监控订阅日志
def monitor_subscriptions(log_file):
    active_consumers = {}
    for line in tail(log_file):
        if "AddConsumer" in line:
            consumer_id = extract_id(line, "consumerId")
            producer_id = extract_id(line, "producerId")
            active_consumers[consumer_id] = producer_id
            print(f"[订阅] {consumer_id} -> {producer_id}")
        elif "Close" in line and "unsubscribe" in line:
            consumer_id = extract_id(line, "consumerId")
            if consumer_id in active_consumers:
                del active_consumers[consumer_id]
                print(f"[取消订阅] {consumer_id}")
        # 检查泄漏
        if len(active_consumers) > expected_max:
            print(f"[警告] 活跃 Consumer 数量异常: {len(active_consumers)}")

这个脚本虽然简单,但很实用。我每次做压力测试都会跑着它,一旦发现 Consumer 数量只增不减,就知道有泄漏了。

订阅/取消订阅的状态机

为了让你更直观地理解,我画了一张状态图。它展示了 Consumer 从创建到销毁的完整生命周期:

订阅请求 Consumer 创建 设置 PreferredLayers Pause(暂停) Resume(恢复) Close(销毁) 直接取消 正常流程 异常/取消

从图里你能看到,Consumer 创建后,可以设置分辨率层级,也可以被暂停、恢复,最终被销毁。如果日志里出现「创建 -> 暂停 -> 销毁」而没有「恢复」,那说明用户可能只是暂时静音,但 SFU 却把 Consumer 关了——这就不对了。

实战:用日志定位一个典型的订阅 Bug

我记得有一次,用户反馈「我看不到某位主播的画面」。我立刻去翻 SFU 的日志,发现:

10:23:01 [AddConsumer] consumerId:"c1" producerId:"p1" kind:"video"
10:23:02 [SetPreferredLayers] consumerId:"c1" spatialLayer:0 temporalLayer:0
10:23:05 [Pause] consumerId:"c1" reason:"producer_paused"
10:23:10 [Resume] consumerId:"c1"
10:23:11 [Close] consumerId:"c1" reason:"unsubscribe"

看起来一切正常?但仔细看时间:10:23:10 恢复了,10:23:11 就取消了。这中间只隔了 1 秒。

我怀疑是前端逻辑问题:用户点了「取消订阅」按钮,但后端同时收到了「恢复」和「取消」两个请求。因为网络延迟,恢复请求先到了,取消请求后到了。结果就是:Consumer 刚恢复就被关了。

我的建议: 在 SFU 端加一个「防抖」逻辑。如果收到 Resume 请求后 500ms 内又收到 Close 请求,忽略 Close。或者让前端保证:取消订阅前,先确保没有未完成的恢复操作。

总结一下

分析 SFU 的转发日志,说白了就是盯着 Consumer 的「生老病死」。创建、设置层级、暂停、恢复、销毁——每一步都有对应的日志。只要你能把日志和用户操作对应起来,99% 的订阅问题都能定位。

我个人习惯,每次上线新功能,都会先跑一遍日志监控脚本,确保订阅/取消订阅的数量是匹配的。嗯,这招帮我避免了好几次线上事故。

一句话记住: 订阅看 AddConsumer,取消看 Close。如果数量对不上,那肯定有泄漏。
公众号:蓝海资料掘金营,微信 deep3321