引言
很多团队选 Supabase 是为了省掉自己搭 WebSocket 服务器的功夫。结果上手第一周就踩坑:一条 UPDATE 让整个前端收到几百条事件、在线状态统计是错的、移动端电量唰唰掉。
Realtime 不是"一个 socket 推送所有变化"这么简单。它有三套完全不同的机制,用途不同,用错代价很大。本文用一个多房间聊天应用的演进过程,把三套机制——Postgres Changes、Presence、Broadcast——讲透,并给出可落地的工程实践。
一、先搞清楚三套机制
| 机制 | 触发时机 | 典型用途 | 适用场景 |
|---|---|---|---|
| Postgres Changes | 数据库行变化 | 多人看到同一份数据更新 | 协作、动态列表、看板 |
| Presence | 客户端连接状态 | 谁在线、光标位置、打字状态 | 在线状态、竞技场 |
| Broadcast | 手动发送消息 | 临时通知、图形状态同步 | 游戏、白板、聊天(不自持) |
Postgres Changes = 数据发生了,大家都要同步 → 以数据库为准
Presence = 谁在这,连没连 → 以客户端会话为准
Broadcast = 我喊一嗓子,听见的人自取 → 以事件为准很多人只用 Postgres Changes 包打天下,把"打状态"也塞进数据库,结果一场打字状态同步就把连接数打爆。每种机制解决一类问题,混用才是正确姿势。
二、聊天室:用 Postgres Changes 同步消息
2.1 表结构与订阅
create table messages (
id bigint generated always as identity primary key,
room_id uuid not null references rooms(id) on delete cascade,
user_id uuid not null references auth.users(id),
body text not null,
created_at timestamptz not null default now()
);
alter publication supabase_realtime add table messages;前端订阅(注意过滤器,别订阅全表):
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(URL, ANON_KEY)
// 只订阅当前房间,且只关心 insert
const channel = supabase
.channel(`room:${roomId}`)
.on(
'postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'messages', filter: `room_id=eq.${roomId}` },
(payload) => {
appendMessage(payload.new)
}
)
.subscribe()三个关键点:
- event 明确写
INSERT。默认是*,会把 UPDATE/DELETE 一起推给你,前端要做很多无用分支。 - filter 写死
room_id=eq.xxx。不写过滤器 = 订阅整个表,房间里的人会收到别人房间的消息。 - channel 名用
room:${roomId}隔离。不同房间不同 channel,断线重连只影响一个房间。
2.2 顺序问题:不要依赖实时事件的顺序
错误假设:WebSocket 事件的到达顺序 = 数据库写入顺序
事实:
- 网络重传、多实例可能乱序
- 乐观 UI 会先于服务端确认渲染
正确做法:
- 消息列表排序永远以 created_at / id 为准
- 用 id 做去重,而不是"收到了就追加"// 正确的追加方式:按 id 排序插入 + 去重
function upsertMessage(list, msg) {
const idx = list.findIndex((m) => m.id === msg.id)
if (idx !== -1) return list
return [...list, msg].sort((a, b) => a.id - b.id)
}三、在线状态:Presence 的正确打开方式
3.1 为什么不能用 Postgres Changes 做在线状态
把"用户上线"写成一条数据库 UPDATE,会带来一连串问题:
- 每 5 秒心跳 → 每个人对全房间广播 UPDATE
- 断网时状态停留在"在线",因为没有 UPDATE 触发
- 在线人数统计永远不准
Presence 机制专门解决这个:
- 心跳由 Realtime 层自动处理
- 断开连接自动触发 leave
- join/leave 事件区分清晰3.2 用 Presence 实现"谁在房间里"
const presenceChannel = supabase.channel('presence:room:' + roomId)
presenceChannel
.on('presence', { event: 'sync' }, () => {
// 完整的新状态快照
setOnlineUsers(presenceChannel.presenceState())
})
.on('presence', { event: 'join' }, ({ key, newPresences }) => {
addUsers(newPresences.map((p) => p.user))
})
.on('presence', { event: 'leave' }, ({ key, leftPresences }) => {
removeUsers(leftPresences.map((p) => p.user))
})
await presenceChannel.subscribe()
// 发布自己的状态
await presenceChannel.track({ user: user.id, name: user.name })注意 sync 事件:它给你的是完整快照,重连后用它整体替换状态,是最可靠的恢复手段。join / leave 是增量,适合做动画过渡,但别拿它当唯一数据源。
四、打字指示:Broadcast,别碰数据库
"有人正在输入"这种高频、瞬时、丢了也无所谓的状态,丢给 Postgres Changes 是灾难——每秒几十次写入,Row Level Security 也要扛不住。Broadcast 是正解:
const typingChannel = supabase.channel('typing:room:' + roomId)
// 发送
await typingChannel.send({
type: 'broadcast',
event: 'typing',
payload: { user_id, is_typing: true }
})
// 接收
const typingChannel = supabase.channel('typing:room:' + roomId)
.on('broadcast', { event: 'typing' }, ({ payload }) => {
showTypingIndicator(payload)
})
.subscribe()什么时候用 Broadcast:
✓ 光标移动 / 打字状态 / 涂鸦笔迹
✓ 临时 toast / 通知
✓ 状态变化频繁、且服务端不需要留存
什么时候别用 Broadcast:
✗ 消息本身(需要持久化,必须走数据库)
✗ 任何需要审计/回放的数据记住判据:这个事件丢了会怎样? 丢了无所谓 → Broadcast;丢了不行 → Postgres Changes。
五、权限:Realtime 也要过 RLS
5.1 Realtime 尊重 RLS,但有个前提
Supabase Realtime 默认不绕过 Row Level Security——除非你在表上显式开启 REPLICA IDENTITY FULL 或把 realtime 订阅列为可公开。所以正确的姿势是:你的业务 RLS 策略直接约束实时推送。
-- 只有房间成员能看到消息的实时更新
alter policy "members can read room messages"
on messages for select
using (
exists (
select 1 from room_members rm
where rm.room_id = messages.room_id
and rm.user_id = auth.uid()
)
);5.2 别把敏感字段推进实时流
-- 坏:整行推给前端
update users set last_seen_at = now() where id = auth.uid();
-- 前端在 payload.new 里会拿到整行,包括你不该下发的列
-- 好:拆一个只含公开字段的视图 / 或单独广播安全规则:Postgres Changes 的 payload 是整行数据。凡是表里含敏感列(email、token、手机号),要么拆表,要么用 Broadcast 只发需要公开的字段。
六、连接与生命周期管理
6.1 不用的 channel 一定要退订
组件卸载 / 路由切换时:
useEffect(() => {
const channel = supabase.channel(...).on(...).subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [roomId])不 removeChannel,channel 会一直挂着,页面切换十次,就积了十个 WebSocket 订阅。生产环境最容易犯的错。
6.2 移动端:进后台暂停订阅
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
supabase.removeAllChannels()
} else {
// 重新建立需要的 channel
}
})后台开着 WebSocket 是电量杀手。visibility 切后台就断开,回来再重建——体验损失几乎为零,省的电量非常可观。
6.3 重连策略
Supabase 客户端自带重连,但你要处理一件事:重连后状态要能恢复。
重连后:
1. 重新 track presence(在线状态快照会刷新)
2. 拉一次最新数据兜底(补掉断线期间错过的消息)
3. 依赖 sync 事件重建完整状态,而不是堆积增量七、性能与监控清单
上线前过一遍:
□ 每个订阅都带 filter,没有裸订阅全表
□ event 明确(INSERT/UPDATE/DELETE),不用 *
□ 组件卸载有 removeChannel
□ presence 心跳交给机制本身,没有自己造轮子
□ 打字/光标状态走 broadcast,没进数据库
□ payload 里没有敏感列
□ RLS 覆盖实时通道
□ 移动端处理了 visibilitychange
□ 后台有指标监控:连接数、单 channel 消息量诊断工具:Supabase Dashboard 的 Realtime 面板能看每个 channel 的连接和消息吞吐。如果某个 channel 消息量异常高,多半是 filter 没写或者该用 Broadcast 的用成了 Postgres Changes。
结语
三套机制各司其职,合起来就是一套完整的实时系统:
Postgres Changes → 数据本身(持久、一致、按权限过滤)
Presence → 谁在线(会话级、自动心跳)
Broadcast → 瞬时状态(高频、丢失无妨)下回拿到一个"做个实时功能"的需求,先问三个问题:要同步的是持久数据还是瞬时状态?要不要留痕?断线了重连要不要恢复?——答案会直接告诉你该用哪套机制。