使用 Postgres 的视频会议
Source: PlanetScale Blog
请提供您想要翻译的具体文本内容,我将为您翻译成简体中文。
昨天在 X 上,SpacetimeDB 推特称他们已经完成了**“全球首个通过数据库进行的视频通话”**,并以自己的方式邀请其他人尝试。
致敬他们——这是个很酷的想法!
简而言之,他们构建了一个前端,实现了:
- 从浏览器的媒体 API 捕获音频和视频。
- 将其编码为紧凑的帧(PCM‑16LE 音频,JPEG 视频)。
- 将帧发送到充当实时消息中介的数据库。
- 将帧流式回传给另一位参与者的浏览器进行播放。
实现已开源(SpaceChatDB on GitHub)。
我决定看看使用PostgreSQL——全球最流行的开源数据库——来托管全球第二个通过数据库进行的视频通话会是什么样子。
工作原理
架构
- 前端 – 使用 SvelteKit 的应用,捕获媒体。
- 中继 – 小型 Node.js WebSocket 服务器(
pg‑relay),验证调用并将帧写入 PostgreSQL。 - 数据库 – $5 PlanetScale PostgreSQL 实例,存储帧并提供逻辑复制流。
呼叫流程(视频)
-
捕获 – 浏览器捕获摄像头帧,将其编码为 JPEG,并以二进制 WebSocket 消息发送到
pg‑relay。 -
插入 –
pg‑relay验证调用并运行:INSERT INTO video_frames ( session_id, from_id, to_id, seq, width, height, jpeg ) VALUES ($1, $2, $3, $4, $5, $6, $7); -
WAL – PostgreSQL 将该行写入 WAL(预写日志)。
-
复制 –
pg‑relay还在同一数据库上运行逻辑复制消费者。当新行出现在复制流中时,它:- 检查
to_id列。 - 将原始 JPEG 字节通过 WebSocket 转发给接收方。
- 检查
-
播放 – 接收方的浏览器从 JPEG 创建 Blob URL 并进行渲染。
音频 采用相同的模式,使用 audio_frames 表。
逻辑复制?
PostgreSQL 的逻辑复制为我们提供了一个 可靠、有序的变更流:
- 对发布中每个表的 INSERT、UPDATE 和 DELETE 事件都按提交顺序传递。
- 无需使用
SELECT语句以足够快的频率轮询表来渲染 15 fps 视频。
由于相同的机制用于推送视频帧,它同样可以推送:
- 聊天消息。
- 用户在线状态变化。
- 通话状态转换(例如,当用户断开连接时的行 DELETE)。
视频表模式
CREATE TABLE video_frames (
id BIGSERIAL PRIMARY KEY,
session_id UUID NOT NULL,
from_id TEXT NOT NULL,
to_id TEXT NOT NULL,
seq INT NOT NULL,
width SMALLINT NOT NULL,
height SMALLINT NOT NULL,
jpeg BYTEA NOT NULL,
inserted_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
这张表没有什么特别之处——只是一行行在
BYTEA列中存放 JPEG 的记录。
输出量适中,完全在像 PostgreSQL 这样的数据库能够处理的范围内。
Media → PostgreSQL pipeline
捕获端
- Video – 浏览器将摄像头帧绘制到离屏 canvas,调用
canvas.toBlob()获取 JPEG。 - Audio –
AudioWorkletNode收集 PCM 采样,重采样为 16 kHz 单声道,并将其编码为 16 位小端整数。 - 两个负载被打包进带有小型 JSON 头部(
session_id、seq、recipient)的二进制 WebSocket 帧,并发送到中继。
播放端
- Video – 接收到的 JPEG 被转换为 Blob URL,并设置为
<video>标签的src。 - Audio – 采样被解码回浮点数,并在带有小抖动缓冲区的
AudioBufferSourceNode上调度播放。
整个系统以 640 × 360 @ 15 fps、JPEG 质量 0.65 运行。
每帧大约 25–40 KB,相当于每个方向 ≈ 375–600 KB/s 的视频带宽。
累积行
在 15 fps 时,一个通话大约会产生 108 000 行每小时。
为避免无限增长,清理任务每 2 秒运行一次,删除超过 5 秒的帧:
DELETE FROM audio_frames
WHERE inserted_at = NOW() - INTERVAL '5 seconds'
GROUP BY from_id
ORDER BY frames_5s DESC;
结果(示例):
| from_id | frames_5s | approx_fps |
|---|---|---|
| 06cad97a128947a58e8ff754ec1171d4200c4d774b5c43c9b7637f11bba61036 | 76 | 15.2 |
| 4f984feaee1042939383b0fffb3f1fc172d28aa92b654551a02c618788021995 | 76 | 15.2 |
看看——我们只花 5 美元的 PostgreSQL 正在双向流式传输 15 fps 视频!
持久化优势
- 数据是崩溃安全的、可复制的,并且以后可以查询。
- 可以存储数小时的视频,以便后续分析或回放。
我甚至可以直接从数据库中提取单帧并在终端渲染(例如,使用 catimg 或 ANSI‑image 查看器)。
我们还能用别的方式吗?
LISTEN/NOTIFY
PostgreSQL 内置的 pub/sub(LISTEN/NOTIFY)看起来很有吸引力:
- 不需要额外的表——只需通过通知通道直接发送 JPEG 字节。
问题: 有效负载限制为 8 KB。
一张 640 × 360 的 JPEG(25–40 KB)需要拆分成 4–5 条通知,导致必须:
- 实现分块与重组逻辑。
- 提供顺序保证。
- 处理丢失的块。
这实际上在 NOTIFY 之上重新创建了类似 TCP 的协议,增加了不必要的复杂度。音频帧通常能装入 8 KB 以下,所以可以采用混合方案,但混用传输机制会破坏我想要的简洁性。
未记录表
另一种选择是使用 未记录表(unlogged tables)来存放入站数据:
- 未记录表跳过 WAL,消除 fsync 开销。
- 对写密集型工作负载更快,但牺牲了持久性(崩溃时数据会丢失)。
在本示例中,持久性是必须的特性,因此我保留了常规(已记录)表。
Summary
- PostgreSQL 的逻辑复制 提供了干净、有序且可靠的变更流,可以取代轮询实现实时媒体传输。
- 通过将数据库视为消息中间件,我们可以构建一个功能完整的视频通话系统,只需 最小的基础设施(一个小型 Node.js 中继和一个廉价的 PostgreSQL 实例)。
- 这种方法还能为后续分析提供 持久化、可查询的视频帧,而传统媒体服务器通常不具备此功能。
欢迎探索源代码并将该模式适配到其他实时使用场景!
崩溃恢复
插入更快,因为 PostgreSQL 并未对视频帧提供持久性保证。
我不喜欢这样,因为逻辑复制是从 WAL 中读取的。如果表不写入 WAL,它就不会出现在复制流中。要实现这一点,我们必须回退到轮询:
SELECT * FROM video_frames WHERE seq > $1;
…在循环中。这本来可能运行良好——甚至更好——但从 SELECT * 的轮询循环渲染视频的感觉总是不对劲。
结果如何?
由你来评判。 超出了我的预期。
我们的 $5 PlanetScale PostgreSQL 能够跟上实时视频和音频的插入速率,且浏览器的优化足以将原始 JPEG 帧转换为相当逼真的视频。
音视频同步微调
第一次成功运行后,我唯一的调整是添加一些边界以保持音频同步。视频帧即时渲染(我们只需交换图像),但音频需要提前缓冲和调度以避免间隙。要让它们保持同步,需要限制音频调度缓冲区,使其不会偏离实际时间太远:
const now = audioCtx.currentTime;
const clamped = nextPlayTime > now + 0.15 ? now + 0.02 : nextPlayTime;
const startAt = Math.max(clamped, now + 0.02);
你应该这么做吗?
不! 使用 WebRTC!
但如果你想了解逻辑复制的工作原理,并看看 PostgreSQL 作为通用实时后端能走多远,这是一种有趣的方式。整个中继服务器大约只有 400 行 TypeScript。
我的分支:
要是亚历山大·格雷厄姆·贝尔能看到我们现在的样子就好了。