引言

很多团队选 Supabase 是为了省掉自己搭 WebSocket 服务器的功夫。结果上手第一周就踩坑:一条 UPDATE 让整个前端收到几百条事件、在线状态统计是错的、移动端电量唰唰掉。

Realtime 不是"一个 socket 推送所有变化"这么简单。它有三套完全不同的机制,用途不同,用错代价很大。本文用一个多房间聊天应用的演进过程,把三套机制——Postgres Changes、Presence、Broadcast——讲透,并给出可落地的工程实践。


一、先搞清楚三套机制

机制 触发时机 典型用途 适用场景
Postgres Changes 数据库行变化 多人看到同一份数据更新 协作、动态列表、看板
Presence 客户端连接状态 谁在线、光标位置、打字状态 在线状态、竞技场
Broadcast 手动发送消息 临时通知、图形状态同步 游戏、白板、聊天(不自持)
code
Postgres Changes  = 数据发生了,大家都要同步 → 以数据库为准
Presence          = 谁在这,连没连 → 以客户端会话为准
Broadcast         = 我喊一嗓子,听见的人自取 → 以事件为准

很多人只用 Postgres Changes 包打天下,把"打状态"也塞进数据库,结果一场打字状态同步就把连接数打爆。每种机制解决一类问题,混用才是正确姿势。


二、聊天室:用 Postgres Changes 同步消息

2.1 表结构与订阅

sql
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;

前端订阅(注意过滤器,别订阅全表):

ts
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()

三个关键点:

  1. event 明确写 INSERT。默认是 *,会把 UPDATE/DELETE 一起推给你,前端要做很多无用分支。
  2. filter 写死 room_id=eq.xxx。不写过滤器 = 订阅整个表,房间里的人会收到别人房间的消息。
  3. channel 名用 room:${roomId} 隔离。不同房间不同 channel,断线重连只影响一个房间。

2.2 顺序问题:不要依赖实时事件的顺序

code
错误假设:WebSocket 事件的到达顺序 = 数据库写入顺序

事实:
- 网络重传、多实例可能乱序
- 乐观 UI 会先于服务端确认渲染

正确做法:
- 消息列表排序永远以 created_at / id 为准
- 用 id 做去重,而不是"收到了就追加"
ts
// 正确的追加方式:按 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,会带来一连串问题:

code
- 每 5 秒心跳 → 每个人对全房间广播 UPDATE
- 断网时状态停留在"在线",因为没有 UPDATE 触发
- 在线人数统计永远不准

Presence 机制专门解决这个:
- 心跳由 Realtime 层自动处理
- 断开连接自动触发 leave
- join/leave 事件区分清晰

3.2 用 Presence 实现"谁在房间里"

ts
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 是正解:

ts
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()
code
什么时候用 Broadcast:
✓ 光标移动 / 打字状态 / 涂鸦笔迹
✓ 临时 toast / 通知
✓ 状态变化频繁、且服务端不需要留存

什么时候别用 Broadcast:
✗ 消息本身(需要持久化,必须走数据库)
✗ 任何需要审计/回放的数据

记住判据:这个事件丢了会怎样? 丢了无所谓 → Broadcast;丢了不行 → Postgres Changes。


五、权限:Realtime 也要过 RLS

5.1 Realtime 尊重 RLS,但有个前提

Supabase Realtime 默认不绕过 Row Level Security——除非你在表上显式开启 REPLICA IDENTITY FULL 或把 realtime 订阅列为可公开。所以正确的姿势是:你的业务 RLS 策略直接约束实时推送。

sql
-- 只有房间成员能看到消息的实时更新
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 别把敏感字段推进实时流

sql
-- 坏:整行推给前端
update users set last_seen_at = now() where id = auth.uid();
-- 前端在 payload.new 里会拿到整行,包括你不该下发的列

-- 好:拆一个只含公开字段的视图 / 或单独广播

安全规则:Postgres Changes 的 payload 是整行数据。凡是表里含敏感列(email、token、手机号),要么拆表,要么用 Broadcast 只发需要公开的字段。


六、连接与生命周期管理

6.1 不用的 channel 一定要退订

组件卸载 / 路由切换时:

ts
useEffect(() => {
  const channel = supabase.channel(...).on(...).subscribe()
  return () => {
    supabase.removeChannel(channel)
  }
}, [roomId])

不 removeChannel,channel 会一直挂着,页面切换十次,就积了十个 WebSocket 订阅。生产环境最容易犯的错。

6.2 移动端:进后台暂停订阅

ts
document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    supabase.removeAllChannels()
  } else {
    // 重新建立需要的 channel
  }
})

后台开着 WebSocket 是电量杀手。visibility 切后台就断开,回来再重建——体验损失几乎为零,省的电量非常可观。

6.3 重连策略

Supabase 客户端自带重连,但你要处理一件事:重连后状态要能恢复。

code
重连后:
1. 重新 track presence(在线状态快照会刷新)
2. 拉一次最新数据兜底(补掉断线期间错过的消息)
3. 依赖 sync 事件重建完整状态,而不是堆积增量

七、性能与监控清单

code
上线前过一遍:

□ 每个订阅都带 filter,没有裸订阅全表
□ event 明确(INSERT/UPDATE/DELETE),不用 *
□ 组件卸载有 removeChannel
□ presence 心跳交给机制本身,没有自己造轮子
□ 打字/光标状态走 broadcast,没进数据库
□ payload 里没有敏感列
□ RLS 覆盖实时通道
□ 移动端处理了 visibilitychange
□ 后台有指标监控:连接数、单 channel 消息量

诊断工具:Supabase Dashboard 的 Realtime 面板能看每个 channel 的连接和消息吞吐。如果某个 channel 消息量异常高,多半是 filter 没写或者该用 Broadcast 的用成了 Postgres Changes。


结语

三套机制各司其职,合起来就是一套完整的实时系统:

code
Postgres Changes → 数据本身(持久、一致、按权限过滤)
Presence         → 谁在线(会话级、自动心跳)
Broadcast        → 瞬时状态(高频、丢失无妨)

下回拿到一个"做个实时功能"的需求,先问三个问题:要同步的是持久数据还是瞬时状态?要不要留痕?断线了重连要不要恢复?——答案会直接告诉你该用哪套机制。