极简群聊:OpenResty + Redis,把语音也塞进 WebSocket

Posted on in Tech

Abstract 极简群聊:OpenResty + Redis,把语音也塞进 WebSocket
Authors Walter Fan
Category Tech
Version v1.0
Updated 2026-11-09
License CC-BY-NC-ND 4.0

大纲

展开看看
  • 先泼冷水:为什么“媒体也走 WebSocket”在真 WebRTC 里不成立
  • 两条路摆清楚:真 WebRTC + SFU,还是“语音当消息”
  • 架构:一张网关、两条连接——文字走 /ws/chat 的 JSON 帧,语音走 /ws/voice 的二进制帧
  • Redis 里存什么:成员、在线、文字历史;语音不落盘
  • 一帧语音的一生:时延账、带宽账、一张时序图
  • OpenResty 骨架:订阅器、聊天与语音两个 handler、消息与语音转发
  • 你放弃了什么:回声消除、弱网表现——换来了什么
  • 容量粗算:一台 4C8G 能扛多少
  • 什么时候回到真 WebRTC:升级是加进程,不是推翻重来

有一阵子我盘算着给自己搭个群聊:能打字,也能几个人同时说话。条件写得不大——一台轻量服务器、一套尽量省心的架构。可翻来翻去,第一反应都是上 WebRTC:浏览器采集声音,一台叫 SFU 的服务器负责转发,配个 mediasoup 之类的媒体模块。架构图画出来挺唬人,心里却明白:这已经不叫极简了,这是一台服务器上同时养着 OpenResty、Redis、Node.js,还得伺候 DTLS、SRTP、ICE 这一大串名词。

于是我想偷个懒:信令走 WebSocket 我能理解,媒体为什么不能也走 WebSocket?反正都是连着的一根 TCP 长线,让 OpenResty 连着一起转发不就完了。

可惜浏览器不答应——这篇就是讲清楚“为什么不答应”,以及它如何把我逼上了“把语音当消息”这条更简单的路。

极简群聊的真答案,不是把 WebRTC 塞进 WebSocket,而是干脆不做 WebRTC——把语音当成“更快的消息”。

说清楚两条边:这篇里的代码是能跑起来的形状,不是开箱即用的完整项目;带宽和时延的数字全是估算,不是压测结果。


一、先泼盆冷水:媒体塞不进 WebSocket

先补两个背景名词,不然下面的话没法说:

  • WebRTC:网页实时通信,就是浏览器里打电话、开视频会议的那套标准。声音不是你想象的音频文件,而是切成一小段一小段的包,一秒钟几十个,实时地发出去。
  • SFU(Selective Forwarding Unit,选择性转发单元):一种媒体中转服务器,把每个人的音频包收下,再转发给房间里其他每个人。

浏览器里的 WebRTC,媒体通道长这样:先用 ICE 打洞(穿透 NAT,找到两边能互通的那条路),再用 DTLS 握手换好密钥,最后用 SRTP(加了密的 RTP 语音包)跑起来了。这一整套要么走 UDP,要么走 TCP,唯独没有“塞进 WebSocket 帧”这一项——到今天为止,主流浏览器没有一个给你开这个开关。

有人会说:那我用 nginx 的 UDP 转发不就行了?不行。关键是那一层加密:每个浏览器和服务器之间用各自的密钥加密,服务器要把 Alice 的音频转给 Bob,得先解开 Alice 的包、再用 Bob 的密钥重新加密——这活儿叫“终结媒体会话再转发”,是 SFU 的本职。OpenResty 是 HTTP/TCP 这一层的网关,Lua 脚本再聪明也顶不住这种按包加解密、按会话维护状态的事。别硬拧,拧不动。

所以答案先给一半:“极简到只留一个 OpenResty”和“真 WebRTC 语音”不可兼得。 想通这一点,路反而宽了。


二、两条路,先摆清楚

维度 真 WebRTC + SFU 语音当消息(本文走这条路)
浏览器采集 getUserMedia + RTCPeerConnection getUserMedia,把声音切成帧
服务器 网关 + Redis + SFU(Node/C++)三进程 OpenResty + Redis 两进程
网络 UDP + SRTP + ICE,要开一堆 UDP 端口 全走 443 的 TCP,一个端口
回声消除 自带 没有,得靠耳机或自己做
弱网表现 丢包隐藏、自动调速,现成的 没有,网络一抖就断续
复杂程度 高 低
适合 公网、多人、正经开会 内部小工具、实验室、对讲机式的群聊

一句话点破差别:不在“转发”本身,而在“你这台服务器要不要懂那些加密的音频包”。 真 WebRTC 必须懂,所以要养一个懂媒体的大户;语音当消息这条路,服务器从头到尾只把音频当成一堆不透明的字节,跟转发一条文字消息没有本质区别。


三、架构:一张网关,两条连接

核心洞察没变——服务端根本不区分文字和语音:两者都走“收到一条消息,按群转发给其他连接”这一套。分界出在浏览器一侧:文字和语音各占一条 WebSocket 连接,谁也别拖累谁。

flowchart LR
  A(浏览器) -->|文字:/ws/chat · JSON 帧| G[OpenResty 网关]
  A -->|语音:/ws/voice · 二进制帧| G
  G -->|PUBLISH 到群频道| R[(Redis)]
  R -->|每个 worker 一条订阅| G
  G -->|按群转发给对应连接| A

为什么非得分两条线?四条理由,条条经得起掂量:

  • 生命周期不同:进不进语音是另一个动作。语音连接开了 = 进了语音房,关掉 = 退房;文字连接可以一直挂着,不说话也不碍事。
  • 故障隔离:语音每秒几十帧,一个慢客户端把语音队列堆满,原本要丢几个事件就该了事,不能把聊天记录一起拖死。
  • TCP 队头阻塞:同一条 TCP 上,语音某一帧丢了要重传,会把后面本来能到的文字一起堵住。两条线,各通各的堵点。
  • 负荷参数不同:文字要可靠、按 group_id 路由、4KB 限长、还要落库补发;语音帧可大可小、丢了就丢。限制和策略不该挤在一个连接上设。

还白捡一个好处:语音再怎么作妖,聊天这条线永远是通的——开会掉线了,至少还保得住那根能打字的。

消息分发有个关键设计,坑过不少第一次动手的人:不能让 1000 个 WebSocket 连接各自开一条 Redis 订阅,那等于 1000 个长连接全挂在 Redis 上,第一个并发高峰就把订阅器打垮。正确做法是反过来,每个 OpenResty worker 只开一条订阅,谁家的消息进了这个 worker,就把它转发给本 worker 里在线的连接:

              Redis Pub/Sub
                    |
          +---------+---------+
          |         |         |
        worker1   worker2   worker3
          |         |         |
        队列      队列      队列
          |         |         |
        WS: A, B    WS: C, D    WS: E, F

图里 WS: A, B 只是示意“这个 worker 上挂着的连接”而已——每个连接既可能是聊天也可能是语音,一个用户同一时刻甚至就有两条;worker 只认“这是条在线连接”,不关心它是什么类型。

每个连接挂着一条有界队列,WebSocket handler 在自己那个反复收帧的循环里,顺路把队列里的东西推给客户端。慢客户端(网速跟不上、消息一直堆着)就靠队列上限挡住:满了就丢非关键事件,或者直接断开,别让它拖垮整个网关。

两个 OpenResty 的细节也记一下:

  • WebSocket 连接对象不能塞进跨 worker 共享的内存字典(ngx.shared.DICT),连对象带状态只在 worker 内部活着;队列想跨 worker 共享,可以用共享字典做“序号 + 环形缓冲”,那是生产化才需要的功夫,骨架里先在 worker 内部简单做。
  • Redis 订阅是一根“专用线”,连着就一直占着;普通命令(发消息、查成员)走连接池里的另一根线,两码事。文字和语音虽然各占一条连接,但背后那套按群转发的 Redis 机制是同一套——这也是“一张网关”的意思。

四、Redis 里存什么

第一版的数据模型,三样东西就够:

数据 Redis 结构 干什么
群成员 group:1001:members(Set) 谁能进群、谁能发言
在线状态 presence:conn1(String + 60s TTL) 心跳保活,异常断开自动过期
文字历史 group:1001:msg(Stream,MAXLEN ~1000) 断线重连后按 last_message_id 补发

在线状态有一个容易犯的错:别用 user:u1 = online。一个人可能开两个标签页、电脑手机同时在线,谁断线不该影响别人。所以正确的粒度是“连接”,一个人可以有多个连接,每个连接一条带 TTL 的 key,每 20 秒心跳续一次命,断了不续,60 秒后 Redis 自己清掉。

那个 Stream 可以理解成 Redis 自带顺序、永远是追加的消息队列,MAXLEN ~1000 表示最多留一千条,超了自动挤掉最旧的,正适合当群聊的“最近一千条历史”。

值得专门说的一条规则,也是语音和文字最大的分界:

文字可补发,语音丢了就丢了。

文字一秒钟也没几条,落进 Stream 不心疼,重连补发让人不丢上下文。可语音是 20 毫秒一帧,一分钟就 3000 帧,每帧都要持久化、要按序重放,代价和收益完全不成比例。所以设计上干脆:语音只走 Redis 的广播(Pub/Sub),一条都不落盘。Pub/Sub 本身就是“发出去就完,不保证你一定收到”,正中语音下怀——群聊里漏掉半句话,没人会计较,真计较就打字。

这条分界顺着推下去,正好接上上一节的两条连接:文字要可靠、要补发,一句都不能丢;语音图快、丢了就算了。 两条线各有各的性子,别让它们挤在同一个 socket 里互相踩脚。


五、一帧语音的一生

说清楚一条语音帧从麦克风到别人耳朵的路,整个设计就通了。

发送端: 浏览器用 AudioWorklet(浏览器里处理原始声音的那个工作线程)拿到原始采样,每 20 毫秒攒成一帧。16kHz 采样、16 位量化,一帧 640 字节。这个数字很好看:640 字节 = 每秒 50 帧 = 未压缩的 256 kbps。这帧数据不送什么媒体服务器,就当我们是“一条更快的消息”,贴上“我是 Alice、我在群 1001”的小标签,走 /ws/voice 这条语音连接,用二进制帧发出去。

服务端: 语音连接的 handler 认出这是二进制帧——这条连接天然只收语音,连“这到底是文字还是语音”都不用猜。handler 给它盖上发送者的名字,PUBLISH 到 group:1001:voice 这个频道。

广播: 各 worker 的订阅器收到,转发给本 worker 里在这个群的连接,顺手把“发给自己”的那份丢掉——不然你会听见自己的回声。

接收端: 别人的浏览器把帧收进一个小队列,攒够一两帧再播(这就是抖动缓冲,缓解网络到达不齐;要展开讲,我在《抖动缓冲:同一条网络轨迹下,三种策略谁更好?》里专门跑过实验)。

sequenceDiagram
    participant A as Alice(浏览器)
    participant G as OpenResty
    participant R as Redis Pub/Sub
    participant B as Bob(浏览器)
    A->>A: AudioWorklet 攒 20ms PCM(640 字节)
    A->>G: /ws/voice 二进制帧(贴 sender、group)
    G->>R: PUBLISH group:1001:voice
    R-->>G: 各 worker 订阅器收到
    G->>B: 从 B 的 /ws/voice 推过去(发送者的那一路不回)
    B->>B: 抖动缓冲 + AudioContext 播放

时延账(估算,见注释):

环节 时延
采集攒帧 10–20 ms
TCP 上行 + 服务端转发 5–30 ms
抖动缓冲 20–40 ms
播放调度 ≤ 20 ms
合计 约 60–110 ms

真 WebRTC 走 UDP 通常能压到几十毫秒。所以这个方案“够用,但不惊艳”——正好配它的定位:对讲机、内部工具、实验室,绰绰有余。

带宽这账本里有个特别值得记的数字。 上面那 640 字节是未压缩的 PCM。同样这段语音,用 Opus(语音压缩编码)压一下只要 24–48 kbps,省 5–10 倍。而群聊转发是按人翻倍的:10 个人一起说话,服务器入口是 10 路,出口是 10 × 9 = 90 路(每路转发给另外 9 人):

每路带宽 10 路入口 90 路出口
PCM(不压缩) 256 kbps ≈ 2.6 Mbps ≈ 23 Mbps
Opus 32 kbps ≈ 0.32 Mbps ≈ 2.9 Mbps

同一个系统,编码一换,出口带宽差一个数量级。 骨架先用 PCM,因为哪都跑得动、不挑浏览器;真要省带宽,把 PCM 换成 Opus(Chrome 系用 WebCodecs 的 AudioEncoder,别的浏览器用 wasm 的 opus 库),服务端一行都不用改——它从来不管帧里是什么。


六、OpenResty 骨架

不关心代码的可以直接跳到第七节,结论在那边。下面是搭出来的形状,主要动作在这几个文件里。

nginx.conf 里最关键的一行是让每个 worker 在初始化时起一条订阅;两个端点各指一个 handler:

lua_package_path "/usr/local/openresty/lualib/?.lua;;";

init_worker_by_lua_block {
    local subscriber = require "subscriber"
    subscriber.start()          -- 每个 worker 一条 Redis 订阅
}

server {
    listen 443 ssl;
    ssl_certificate     /etc/nginx/certs/fullchain.pem;
    ssl_certificate_key /etc/nginx/certs/privkey.pem;

    location /ws/chat {
        content_by_lua_file /app/lua/chat.lua;     -- 文字
    }
    location /ws/voice {
        content_by_lua_file /app/lua/voice.lua;    -- 语音
    }
    location / {
        root /app/web;
        index index.html;
    }
}

subscriber.lua:每个 worker 起一个常驻协程,占一条 Redis 订阅,把消息转发给本 worker 的相关连接:

local redis   = require "resty.redis"
local clients = require "clients"

local function loop()
    local red = redis:new()
    red:set_timeout(0)                       -- 订阅要长超时
    local ok, err = red:connect("redis", 6379)
    if not ok then return nil, err end
    red:psubscribe("group:*")                -- 订阅所有群频道
    while true do
        local reply = red:read_reply()       -- 阻塞等消息
        if type(reply) == "table" and reply[1] == "pmessage" then
            clients.route(reply[3], reply[4])   -- 按频道转发
        end
    end
end

function _M.start()
    ngx.timer.at(0, function()
        local ok, err = pcall(loop)
        if not ok then ngx.log(ngx.ERR, "subscriber: ", err) end
    end)
end

return _M

chat.lua(文字连接的收发循环):只认文本帧,把下行 JSON 顺路推出去:

local server  = require "resty.websocket.server"
local clients = require "clients"
local message = require "message"
local cjson   = require "cjson.safe"

local wb, err = server:new({ timeout = 1000, max_payload_len = 8192 })
if not wb then return ngx.exit(444) end

local me = clients.register("chat", ngx.var.auth_user_id, ngx.var.request_id)

local function flush()
    while true do
        local item = me:pop()
        if not item then return true end
        local ok = wb:send_text(item.data)   -- 文字连接只推 JSON 文本
        if not ok then return nil, "push failed" end
    end
end

while true do
    local data, typ, err = wb:recv_frame()
    if typ == "close" then break end
    if typ == "text" then
        local msg = cjson.decode(data)
        if msg then
            if msg.type == "send_message" then
                message.send(me.user_id, msg.payload.group_id, msg.payload.text)
            elseif msg.type == "join_group" then
                clients.join(me, msg.payload.group_id)
            elseif msg.type == "heartbeat" then
                clients.refresh(me)          -- 续命 presence:connX
            end
        end
    elseif err and err ~= "timeout" then
        break
    end
    if not flush() then break end
end

clients.unregister(me)
wb:send_close()

redis.lua:普通命令的连接池。整个系统就这一个 Redis 工具类,订阅是另一根专用线,共用:

local redis = require "resty.redis"

local _M = {}

function _M.get()
    local red = redis:new()
    red:set_timeout(1000)
    local ok, err = red:connect("redis", 6379)
    if not ok then ngx.log(ngx.ERR, "redis: ", err) return nil, err end
    return red
end

function _M.release(red)
    if red then red:set_keepalive(10000, 100) end
end

return _M

message.lua(文字):校验 → 验成员 → 先落 Stream 再广播,这个顺序不能反,反了重连就补不全:

local redis  = require "redis"
local cjson  = require "cjson.safe"

local function too_long(text)
    return type(text) ~= "string" or #text == 0 or #text > 4096
end

function _M.send(user_id, group_id, text)
    if too_long(text) then return nil, "invalid message" end

    local red, err = redis.get()
    if not red then return nil, err end

    if red:sismember("group:" .. group_id .. ":members", user_id) ~= 1 then
        redis.release(red)
        return nil, "not a group member"
    end

    -- 先落 Stream(留历史、供补发),拿到消息号再广播
    local id, err = red:xadd("group:" .. group_id .. ":msg",
                             "MAXLEN", "~", 1000, "*", "payload",
                             cjson.encode({ group_id = group_id,
                                            sender_id = user_id,
                                            text = text, t = ngx.time() }))
    if not id then redis.release(red) return nil, err end

    red:publish("group:" .. group_id .. ":msg",
                cjson.encode({ type = "message", message_id = id,
                               group_id = group_id, sender_id = user_id,
                               text = text }))
    redis.release(red)
    return id
end

return _M

voice.lua(语音连接的收发循环):这条连接只干两件事——把音频帧发进自己的群,收别人群里发来的音频帧。群号在握手时由 JWT 定死,连接一开不用猜:

-- 语音群在握手时由 JWT 决定并校验,连接一开就知道待在哪个群里
local server        = require "resty.websocket.server"
local clients       = require "clients"
local redis         = require "redis"

local wb, err = server:new({ timeout = 1000, max_payload_len = 65536 })
if not wb then return ngx.exit(444) end

local group_id = ngx.var.auth_voice_group
local me = clients.register("voice", ngx.var.auth_user_id, ngx.var.request_id, group_id)

local function flush()
    while true do
        local item = me:pop()
        if not item then return true end
        local ok = wb:send_binary(item.data)   -- 语音连接只推二进制帧
        if not ok then return nil, "push failed" end
    end
end

while true do
    local data, typ, err = wb:recv_frame()
    if typ == "close" then break end
    if typ == "binary" then
        -- 贴 sender 的小二进制头:1 字节名字长度 + 名字 + 帧数据
        local frame = string.char(#me.user_id) .. me.user_id .. data

        local red, e = redis.get()
        if red then
            red:publish("group:" .. group_id .. ":voice", frame)  -- 广播,Redis 不挑内容
            redis.release(red)
        end
    elseif typ == "text" then
        -- 应用级 ping/pong:探测半开连接,比只听 TCP 关闭靠谱
        if data == '{"type":"ping"}' then wb:send_text('{"type":"pong"}') end
    elseif err and err ~= "timeout" then
        break
    end
    if not flush() then break end
end

clients.unregister(me)
wb:send_close()

这段有个值得记的点:连接分开了,但语音帧的形态还是那句老话——“贴了个 sender 的消息”。一个简单二进制头(1 字节名字长度 + 名字)加上帧数据,别为了省事上 base64 多背 33% 的字节;将来换成 Opus,也只是把 data 从 PCM 换成压缩包,这一行都不用动。

clients 模块管着那张 worker 内的连接表和每个人的有界队列:注册(带 chat / voice 两种类型)、按频道转发、刷新心跳、断线清表。route 按频道后缀决定投给谁——…:msg 只投聊天连接,…:voice 只投语音连接,天然各走各的。转发时两类还各有一个细节:文字连发送者自己那份也回一行,好让发消息的人立刻看到自己的消息上屏;语音把发送者那一路跳过,免得听见自己的回声。这段是唯一要小心并发的地方,但骨架在单 worker 内是单协程进出,天然不会两头写坏。

这些都是骨架,生产上线还差这么几件(照抄清单):握手阶段验 JWT,不能信 URL 里的 user_id;/ws/voice 的群号由 JWT 下发并校验,不能靠 URL 裸传(聊天连接可以加多个群,语音连接一次只待在一个群);聊天连接的 join/leave 也要查成员资格;group_id 和文本长度格式校验;消息频率限制;以及 Redis 的 Stream 与 Pub/Sub 之间的一致性——把“先 XADD 再 PUBLISH”包进一个 Redis Lua 脚本或事务里,别让它们中间断掉。

客户端:两条连接,各发各的

浏览器这边也就三件事:拉两条连接、把声音切成帧发出去、把收到的帧攒起来播。都贴着 640 字节、20 毫秒的思路走:

// 两条连接,各发各的:文字走 /ws/chat,语音走 /ws/voice
// (token 由服务端签发,语音那支的 token 里带着群号)
const wsChat  = new WebSocket("/ws/chat?token="  + token);
const wsVoice = new WebSocket("/ws/voice?token=" + token);

const sendText = (group_id, text) =>
  wsChat.send(JSON.stringify({ type: "send_message",
                               payload: { group_id, text } }));
// 采集:AudioWorklet 每 20ms 给 320 个 float32(16kHz)
class Mic extends AudioWorkletProcessor {
  process(inputs) {
    const ch = inputs[0][0];
    if (ch) this.port.postMessage(ch.slice());
    return true;
  }
}
registerProcessor("mic", Mic);

// 发送:float32 转 int16 后,走 /ws/voice 的二进制帧
micNode.port.onmessage = (e) => wsVoice.send(float32ToInt16(e.data).buffer);
// 接收:文字上屏走 wsChat;语音帧攒两帧再播,就是 40ms 的抖动缓冲
wsChat.onmessage = (e) => appendMessage(JSON.parse(e.data));

const q = [];
wsVoice.binaryType = "arraybuffer";
wsVoice.onmessage = (e) => {
  if (e.data instanceof ArrayBuffer) {
    q.push(e.data);
    if (q.length >= 2) {                 // 至少攒两帧,稳一点
      schedulePlayback(q.shift());
    }
  }
};

骨架里这几段在 Chrome 上就能跑,其他浏览器对 AudioWorklet 的支持也齐了;唯一要留神的还是那句:不同浏览器、不同版本的采集细节各有脾气,真上线前挨个浏览器过一遍。文字那条断了自动重连、按 last_message_id 补历史;语音那条断了,重连起来接着听就行——反正它从来不留历史。


七、你放弃了什么

极简从来不是白拿。这套方案替你省掉的每一个部件,都对应一份付出:

WebRTC 自带 极简版现状 影响
回声消除(AEC) 没有 不戴耳机时,自己的麦克风会听到喇叭声,产生回声甚至啸叫。小组语音建议戴耳机,或靠设备自带的处理
丢包隐藏 / 抗抖动 没有,靠抖动缓冲 + TCP 重传 网络一抖,TCP 会重传而不是丢帧,表现成“说话断断续续”,不如 UDP 平滑
自适应码率 没有 带宽波动不会自动降质;但语音本身量小,影响不大
静音检测 自己写(量一下能量低于阈值就停发) 不做也能用,只是开着会一直传空音频,白占点带宽
NAT 穿越 不需要,全走 443 反而省事:TURN 都省了,443 在很多墙里墙外的网络都能过

摊开看,代价集中在两块——“回声”和“弱网下的表现”;换来的东西也很明确:一台服务器、两个进程、一个端口,全系统里没有一行代码需要懂加密的音频包。这条路能走通,不是因为它更聪明,而是因为它的野心更小:先让十来个熟人能在一个网页里聊起来,再谈别的。


八、容量粗算

一台 4 核 8G、100 Mbps 出口的机器,能扛多少?给一组估算(都不是保证值,是压测应该去撞的拐点):

指标 估算目标 最先撞上的墙
WebSocket 连接 约 1000(语音用户一人两条,约合四五百个活跃用户) OpenResty worker 栈内存、文件描述符
文字 500 msg/s,每条推给 1000 条连接 每秒 50 万次推送,是 Lua 队列吞吐的墙
语音(Opus,10 人/房) 每房出口 ≈ 2.9 Mbps 100 Mbps 出口理论上能扛 ~30 间房
语音帧率 10 人同说 = 500 帧/s 入口 帧率比带宽先爆,盯 CPU 和 PPS

这里有个容易误判的点:群聊语音的瓶颈通常不是带宽,是帧率。一帧 640 字节听着很小,但它是每秒 50 次、按人头翻倍地进出网关;带宽绰绰有余的时候,OpenResty 的 Lua 队列调度和服务器网卡的 PPS(每秒包数)会先顶不住。真到压测那天,盯着这三个数看:CPU、PPS、队列溢出率。


九、什么时候回到真 WebRTC

走这条极简路的,不用担心走到死胡同。它有一条很顺的升级梯:

  1. 先换 Opus。编码一换,带宽先省下一个数量级,服务端一行不改。
  2. 再加静音检测,量一下能量阈值,没人说话就别发帧。
  3. 回声是新问题,就用 WebRTC 系的处理库,或干脆约定戴耳机。
  4. 真要公网、多人、开会级别,再上 SFU——OpenResty 退回去只做信令,媒体交给 mediasoup 或 Pion 那类东西。到那一步你会发现,握手、鉴权、群成员、文字面板、Redis 结构,全都能原样留着,升级是“加进程”,不是“推翻重来”。信令归信令、媒体归媒体,本来就是分层的老道理。

所以这篇和真 WebRTC 不是对立关系:它是同一条螺旋楼梯的低层台阶。先用手里的工具把“能聊”跑出来,再按需往上加,而不是第一版就把一整座 SFU 的复杂度背在身上。


总结:极简的真相,是想清楚放弃什么

回到开头那个问题:一台轻量服务器能扛多少并发语音和文字?在“语音当消息”这条路上,答案是——取决于你愿意放弃什么。放弃 WebRTC 那套温室(回声、抗弱网、自动调速),换来的是两个进程、一个端口、全系统不用懂加密音频包的架构;在这个前提下,几百人同时在线打打字、几十人分几个房间对着讲话,是这台小机器摸得到的边。

值得记下来的是那个判断顺序,它比任何代码都通用:

先问“我这台服务器要不要懂我的媒体”,再问“我用什么转发”。 两个答案都是“不需要,WebSocket 就够”时,就是极简真正立住的时候。

要是你也被“要么上 SFU、要么土”逼到墙角,不妨先试着把语音当消息。先把文字跑通,再把语音接上——两个晚上,你就能亲手摸到那张时延账里每一行的分量。

全文思维导图

@startmindmap
<style>
mindmapDiagram {
  node {
    BackgroundColor #F8F9FA
    RoundCorner 10
    Padding 10
    FontSize 13
  }
  :depth(0) {
    BackgroundColor #1E3A5F
    FontColor white
    FontSize 18
    FontStyle bold
  }
  :depth(1) {
    FontSize 15
    FontStyle bold
  }
  :depth(2) {
    FontSize 13
  }
}
</style>

* 极简群聊
** 为什么媒体塞不进 WebSocket
*** 浏览器只认 ICE/DTLS/SRTP
*** OpenResty 终结不了加密会话
** 两条路
*** 真 WebRTC + SFU:三进程、UDP、强
*** 语音当消息:两进程、443、简
** 一张网关两条连接
*** /ws/chat 文字 JSON 帧
*** /ws/voice 语音二进制帧
*** 每 worker 一条 Redis 订阅
*** 有界队列挡慢客户端
** Redis 三样东西
*** 成员 Set
*** 在线 TTL
*** 文字 Stream
*** 语音只广播不落盘
** 一帧语音的一生
*** 20ms / 640B / 256kbps
*** Opus 省一个数量级
*** 时延约 60-110ms
** 放弃与容量
*** 回声、弱网表现
*** 权衡:带宽 vs 帧率
** 升级梯
*** Opus → 静音检测 → SFU
*** 升级是加进程不是推翻
@endmindmap

本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。 欢迎在我的个人网站 https://www.fanyamin.com 访问原文并评论。

相关文章