极简群聊: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
走这条极简路的,不用担心走到死胡同。它有一条很顺的升级梯:
- 先换 Opus。编码一换,带宽先省下一个数量级,服务端一行不改。
- 再加静音检测,量一下能量阈值,没人说话就别发帧。
- 回声是新问题,就用 WebRTC 系的处理库,或干脆约定戴耳机。
- 真要公网、多人、开会级别,再上 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 访问原文并评论。