Supabase Realtime 实战:WebSocket 连接管理与断线重连策略
凌晨三点,手机震了一下。
是客户发来的消息:“你们的聊天应用,用户说消息经常延迟,有时候刷新页面才能看到新消息。”
我盯着屏幕,心里咯噔一下。这个问题我太熟了——WebSocket 断了,但前端根本不知道。用户继续打字、发送,以为消息发出去了,实际上全丢在半路上。
说实话,第一次用 Supabase Realtime 的时候,我也踩过这个坑。那会儿做协作白板项目,觉得订阅数据库变更就是几行代码的事:
supabase.channel('board').on('postgres_changes', ...).subscribe()
结果上线没两天,同事反馈:“我们的同步经常卡住,画了一半的线条突然没了。”
排查才发现,WebSocket 连接已经悄无声息地断了。没有报错,没有提示,就是单纯地”死了”。那一刻我才意识到:实时订阅不只是写订阅代码那么简单,连接管理才是重头戏。
这篇文章,我把自己踩过的坑和摸索出来的解决方案都整理出来。重点讲 WebSocket 连接生命周期管理——这也是我发现市面上教程讲得最少的部分。先说三大功能的选型,再手把手实现 Postgres Changes 订阅,最后聊聊生产环境的断线重连策略和配置优化。
一、Supabase Realtime 三大功能,到底该用哪个?
刚接触 Supabase Realtime 的时候,我被三个名词搞晕了:Broadcast、Presence、Postgres Changes。文档说它们是三种不同的实时功能,但到底该用哪个?
先说结论,三者的核心区别在于数据存在哪里:
| 功能 | 数据存储 | 典型场景 | 延迟 |
|---|---|---|---|
| Broadcast | 仅内存,不持久化 | 客户端间消息传递、鼠标位置同步 | 最低 |
| Presence | 内存键值存储(CRDT) | 在线用户列表、协作状态同步 | 低 |
| Postgres Changes | PostgreSQL 数据库 | 聊天消息、订单状态变更 | 中等 |
嗯,光看表格可能还是有点抽象。我换个说法:
Broadcast 就像一个”传声筒”。你喊一句话,所有在听的人都能听到,但说完就没了,不留痕迹。适合那些”转瞬即逝”的数据——比如协作编辑时的光标位置,你移动鼠标,别人看到你的光标跟着动,但没人关心你 5 秒前光标在哪。
Presence 像”签到簿”。每个人进来签个到,写上自己的状态(在线、离线、正在编辑…),所有人都能看到这份名单。关键点是:状态会自动同步,而且基于 CRDT(无冲突复制数据类型),不用担心两个人同时改同一行数据会冲突。
Postgres Changes 则是”数据库监听器”。数据库里的数据变了,你收到通知。这是最”重”的一个,但也是最可靠的——因为数据存在 PostgreSQL 里,就算断线重连,消息也不会丢。
怎么选?一个简单的判断方法
问自己两个问题:
-
数据需要持久化吗?
- 需要持久化 → Postgres Changes
- 不需要持久化 → 继续问第二个问题
-
数据是”事件”还是”状态”?
- 事件(某个动作发生了)→ Broadcast
- 状态(某人正在做什么)→ Presence
举个例子:聊天应用里,“发送消息”是事件,用 Broadcast 或 Postgres Changes;“正在输入”是状态,用 Presence;“新消息通知”需要持久化,用 Postgres Changes。
我那个协作白板项目,最后是这样分配的:
- 画笔轨迹同步 → Broadcast(快,不需要保存)
- 谁在线、谁在画哪一块 → Presence(状态同步)
- 白板内容保存 → Postgres Changes(持久化到数据库)
二、实时订阅实战:Postgres Changes
确定用 Postgres Changes 后,第一件事是开启 publication。
Supabase 默认不会把所有表的变更都广播出去——这太耗资源了。你需要明确告诉它:“这张表,我要监听。”
-- 在 Supabase SQL Editor 里执行
ALTER PUBLICATION supabase_realtime ADD TABLE messages;
执行完这条命令,messages 表的 INSERT、UPDATE、DELETE 操作就会被广播出去。
订阅代码怎么写?
先看一个完整示例——聊天室新消息实时推送:
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
'https://your-project.supabase.co',
'your-anon-key'
)
// 创建频道并订阅
const channel = supabase
.channel('messages-channel') // 频道名自定义
.on(
'postgres_changes',
{
event: 'INSERT', // 只监听新增
schema: 'public',
table: 'messages'
},
(payload) => {
console.log('收到新消息:', payload.new)
// payload.new 就是新插入的行数据
appendMessage(payload.new)
}
)
.subscribe((status) => {
console.log('订阅状态:', status)
})
// 别忘了组件卸载时清理
// channel.unsubscribe()
这段代码看起来挺简单,但有几个细节容易踩坑:
坑一:event 参数可选值
event 可以是 'INSERT'、'UPDATE'、'DELETE',或者 '*' 监听所有事件。但如果你只关心新增消息,就别用 '*',省下不必要的网络流量。
坑二:payload 结构
payload 不是整条记录,而是一个对象:
payload.new:新数据(INSERT/UPDATE 有效)payload.old:旧数据(UPDATE/DELETE 有效,需要开启 replica identity)payload.eventType:事件类型payload.schema、payload.table:来源信息
坑三:Row Level Security 会生效
这是很多人忽略的点:Realtime 订阅同样遵循 RLS 规则。
如果你配置了 RLS,用户只能收到他们”有权限看到”的变更。比如 messages 表限制了用户只能看自己参与的消息,那 Realtime 也只会推送这些消息——不是所有消息都推送过来再前端过滤。
这其实是 Supabase Realtime 的一大优势:安全逻辑不需要写两遍。
开启旧数据获取(replica identity)
默认情况下,UPDATE 和 DELETE 事件的 payload.old 是空的。如果你需要旧数据(比如记录”谁把什么改成了什么”),要开启 replica identity:
ALTER TABLE messages REPLICA IDENTITY FULL;
但要注意,这会增加写入时的开销和 WAL 日志体积。生产环境谨慎评估是否真的需要。
三、WebSocket 连接管理的那些坑
回到开头那个问题:WebSocket 断了,前端不知道。
Supabase Realtime 底层用的是 Phoenix Channels,连接状态变化会触发回调。但你得主动监听,否则什么消息都收不到。
连接状态一览
订阅回调的 status 参数有几种值:
| 状态 | 含义 | 你该做什么 |
|---|---|---|
SUBSCRIBED | 成功订阅 | 正常工作,收消息 |
CHANNEL_ERROR | 连接出错 | 记录日志,尝试重连 |
TIMED_OUT | 超时(无响应) | 可能网络波动,触发重连 |
CLOSED | 连接关闭 | 用户主动断开或服务端关闭 |
看起来挺清晰,但实际用起来有个坑:状态切换可能很快,来不及处理。
比如网络抖动时,可能瞬间经历 CHANNEL_ERROR → CLOSED → SUBSCRIBED(自动重连成功),你甚至没注意到中间出问题了。
我后来加了个全局的状态监控,把每次状态变化都记下来:
const channel = supabase
.channel('messages-channel')
.on('postgres_changes', { ... }, handler)
.subscribe((status, err) => {
logConnectionStatus(status, err) // 记录状态和时间戳
if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
showReconnectingToast() // 给用户一个提示
}
if (status === 'SUBSCRIBED') {
hideReconnectingToast()
syncMissedMessages() // 补齐断线期间丢失的消息
}
})
心跳检测:它怎么知道连接活着?
Supabase Realtime 内部有心跳机制(源码在 keep_alive.ex),服务端每隔一段时间会发一个心跳包,客户端收到后回一个确认。
如果客户端连续几次没回应,服务端就认为连接死了,主动断开。反过来,如果客户端一段时间没收到心跳,也会触发超时重连。
但你不用手动处理心跳——Supabase SDK 自动帮你做了。真正需要关心的,是超时后的重连策略。
断线重连:指数退避 vs 立即重连
Supabase 默认的自动重连用的是指数退避:第一次重连等 1 秒,第二次等 2 秒,第三次等 4 秒… 最多等 30 秒左右。
好处是:如果服务端暂时过载,不会被大量重连请求打垮。坏处是:用户可能等很久才恢复。
对于协作类应用(白板、文档编辑),我会改用更激进的重连策略:
// 手动重连,不依赖默认的指数退避
let reconnectAttempts = 0
const MAX_RECONNECT = 10
function handleDisconnect() {
if (reconnectAttempts >= MAX_RECONNECT) {
showFatalError('无法恢复连接,请刷新页面')
return
}
// 前几次快速重连,后面逐步放慢
const delay = reconnectAttempts < 3 ? 1000 : 3000
setTimeout(() => {
reconnectAttempts++
channel.subscribe() // 再次尝试订阅
}, delay)
}
重连后,断线期间的消息怎么办?
这是最头疼的问题:断线 30 秒,期间别人发了 10 条消息,你怎么补回来?
方案一:前端请求 API 补齐
重连成功后,立即调用一个 API,获取”上次成功消息 ID 之后”的所有消息:
// 记住最后收到的消息 ID
let lastMessageId = null
function syncMissedMessages() {
supabase
.from('messages')
.select('*')
.gt('id', lastMessageId)
.order('created_at', { ascending: true })
.then(({ data }) => {
// 把漏掉的消息追加到列表
appendMessages(data)
lastMessageId = data[data.length - 1]?.id
})
}
方案二:服务端推送”断线期间的变更”
这需要后端配合,在数据库里存一份”未推送的变更”,等客户端重连后批量推送。复杂度更高,但更可靠。
对于小项目,方案一够用了。关键是:一定要在重连成功后立即同步,不要等用户手动刷新。
四、Broadcast 和 Presence:不只是聊天室
前两章主要讲了 Postgres Changes,这章说说另外两个功能——Broadcast 和 Presence。
Broadcast:协作编辑器的光标同步
多人协作编辑文档时,看到别人的光标在哪里,体验会好很多。这个场景用 Broadcast 最合适:
// 发送自己的光标位置
const broadcastChannel = supabase.channel('editor-cursors')
// 监听别人的光标
broadcastChannel
.on('broadcast', { event: 'cursor-move' }, (payload) => {
updateRemoteCursor(payload.userId, payload.x, payload.y)
})
.subscribe()
// 自己移动时广播出去
document.addEventListener('mousemove', (e) => {
broadcastChannel.send({
type: 'broadcast',
event: 'cursor-move',
payload: {
userId: currentUser.id,
x: e.clientX,
y: e.clientY
}
})
})
几个注意点:
broadcastChannel.send()是主动发送,不是订阅后的回调- 频道名可以自定义,不同编辑器用不同频道就能隔离
- 光标位置这种数据不需要持久化,Broadcast 的”即发即忘”特性刚好合适
Presence:谁在线,一目了然
Presence 适合展示”状态类”信息。比如在线用户列表:
const presenceChannel = supabase.channel('online-users', {
config: {
presence: {
key: 'user_id' // 用于识别唯一用户
}
}
})
presenceChannel
.on('presence', { event: 'sync' }, () => {
const state = presenceChannel.presenceState()
// state 是一个对象,key 是 user_id,value 是状态数组
renderOnlineUsers(Object.keys(state))
})
.on('presence', { event: 'join' }, ({ newPresences }) => {
// 新用户加入
showToast(`${newPresences[0].user_name} 加入了`)
})
.on('presence', { event: 'leave' }, ({ leftPresences }) => {
// 用户离开
showToast(`${leftPresences[0].user_name} 离开了`)
})
.subscribe()
// 上线时登记自己的状态
presenceChannel.track({
user_id: currentUser.id,
user_name: currentUser.name,
online_at: new Date().toISOString()
})
track() 方法告诉频道”我在这里”。状态会自动同步给所有订阅者,而且基于 CRDT,不用担心冲突。
私有频道:限制谁能订阅
默认情况下,任何有 anon key 的人都能订阅公开频道。但有些场景需要限制访问——比如某个团队私有的协作空间。
Supabase 支持通过 RLS Policy 控制频道访问权限:
-- 在 realtime Schema 里创建 Policy
CREATE POLICY "Only team members can join private channel"
ON realtime.channels
FOR ALL
USING (
-- 检查用户是否属于该团队
EXISTS (
SELECT 1 FROM team_members
WHERE team_id = channel.team_id
AND user_id = auth.uid()
)
);
这样,只有团队成员才能订阅 private-team-xxx 频道,其他人会被拒绝。
五、生产环境:这些配置参数必须知道
本地开发跑得好好的,上线后问题一堆。原因往往在配置。
Realtime 服务端的几个关键参数
Supabase Realtime 默认配置对大多数项目够用,但高并发场景需要调优:
| 参数 | 默认值 | 建议 | 作用 |
|---|---|---|---|
DB_POOL_SIZE | 10 | 根据并发连接数调整 | PostgreSQL 连接池大小 |
DB_QUEUE_TARGET | 100ms | 调低可减少延迟,但增加 CPU | 批量推送消息的等待时间 |
SUBSCRIBER_LIMIT | 200 | 根据用户量调整 | 单频道最大订阅者数 |
如果发现消息延迟明显增加,可以调低 DB_QUEUE_TARGET(比如 50ms)。代价是服务端更频繁地检查变更,CPU 使用率上升。
多租户架构的连接限制
一个常见坑:多租户系统里,每个租户一个频道,总频道数很快爆炸。
Supabase Realtime 对单个项目的总订阅数有限制(Pro 计划是 5000 个并发订阅)。如果你的系统有 1000 个租户,每个租户平均 5 人在线,刚好卡在边界。
解决方案:
- 合并频道:不需要每个租户独立频道,可以在一个频道里用
filter分离 - 选择性订阅:用户只订阅当前所在的租户频道,不订阅所有
// 用 filter 过滤只属于当前租户的消息
supabase
.channel('tenant-messages')
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: 'tenant_id=eq.123' // 只收租户 123 的消息
},
handler
)
.subscribe()
与竞品对比:Supabase vs Pusher vs Firebase
最后简单对比下几个主流实时方案:
| 方案 | 成本 | 功能丰富度 | 学习曲线 |
|---|---|---|---|
| Supabase Realtime | 免费(Pro $25/月) | 高(三合一 + 数据库绑定) | 中 |
| Pusher | $29 起 | 中(纯 WebSocket) | 低 |
| Firebase Realtime DB | 按用量计费 | 中(绑定 Firebase 生态) | 低 |
Supabase 的优势在于:Postgres Changes 直接监听数据库变更,不需要额外写推送逻辑;RLS 自动生效,安全逻辑统一。劣势是:需要理解 PostgreSQL 机制,学习曲线稍陡。
如果你已经在用 Supabase 做 Auth 和 Storage,Realtime 直接加进来很顺手。如果只是需要简单的 WebSocket,Pusher 可能上手更快。
总结
说了这么多,核心就三点:
选对功能:Broadcast 传事件,Presence 状态同步,Postgres Changes 数据持久化。问自己两个问题——数据要不要持久化、是事件还是状态——答案就出来了。
管好连接:订阅成功不代表一直能收消息。主动监听状态变化,给用户提示”正在重连”,重连后立即同步漏掉的数据。这几件事做好了,实时体验才稳定。
调好配置:生产环境不是本地开发的放大版。DB_POOL_SIZE、QUEUE_TARGET 这些参数,直接影响延迟和吞吐量。上线前至少看一眼默认值,心里有数。
我最早踩的那个坑——WebSocket 断了不知道——后来用状态监控 + 重连提示解决了。用户体验立马好了一截:断网了会看到”正在恢复连接”,而不是傻等;重连成功后消息自动补齐,不用手动刷新。
如果你还没用过 Supabase Realtime,建议先从 Postgres Changes 开始——最简单,也是最常用的场景。配上之前写的 Auth 系列文章(邮箱验证、OAuth 配置),就能搭出一个完整的实时后端了。
有问题可以留言,或者直接看 Supabase 官方文档。架构那篇写得挺清楚,想深入了解 Phoenix Channels 和 PG2 adapter 的可以去读读源码。
常见问题
Supabase Realtime 三种功能有什么区别?
WebSocket 断线后如何恢复?
• 前几次快速重连(1秒)
• 后面逐步放慢(3秒)
• 重连成功后立即同步漏掉的消息
Realtime 订阅是否遵循 RLS 规则?
生产环境需要关注哪些配置参数?
• DB_POOL_SIZE:PostgreSQL 连接池大小,默认 10
• DB_QUEUE_TARGET:批量推送等待时间,默认 100ms
• SUBSCRIBER_LIMIT:单频道最大订阅者数,默认 200
多租户系统如何避免频道爆炸?
Supabase Realtime 与 Pusher/Firebase 对比如何?
12 分钟阅读 · 发布于: 2026年4月26日 · 修改于: 2026年4月29日
相关文章
Supabase 入门:PostgreSQL + Auth + Storage 一站式后端
Supabase 入门:PostgreSQL + Auth + Storage 一站式后端
Supabase 数据库设计:表结构、关系与 Row Level Security 完全指南
Supabase 数据库设计:表结构、关系与 Row Level Security 完全指南
Supabase Auth 实战:邮箱验证、OAuth 与会话管理

评论
使用 GitHub 账号登录后即可评论