如何使用RDS Supabase后端BaaS能力:实时订阅与推送(Realtime)
文章目录
微信里弹出一条新消息,行情软件的报价跳动,在线文档里能看到同伴的光标划过——这些“数据一变、界面跟着变”的体验,背后都需要一套实时推送。商用产品要具备同样的能力,自建并不轻松:前端定时轮询延迟明显,无数据变更时也持续查询、空耗数据库资源;改走 WebSocket,连接管理、断线重连、消息鉴权又得从零搭建,往往耗时数周、上线周期大幅延后。
RDS Supabase 把这套推送做成了现成能力:客户端与数据库之间维持一条 WebSocket 长连接,数据写入 Postgres 即自动推送给在线订阅端,连接重连与权限校验由服务端承担。开发者不用自建推送服务,只需声明“监听什么、推给谁”。Realtime 提供三项能力——Postgres Changes 监听表的增删改、Broadcast 在客户端之间互发瞬时消息、Presence 跟踪频道里谁在线。
一个实时聊天室,正好同时用到这三项能力:消息收发靠 Postgres Changes、“对方正在输入”靠 Broadcast、在线名单靠 Presence。本篇就从这里开始,从建表配置到监听、广播、在线状态,分步实现并讲清每个关键点。
Part 01 — Realtime 概述与三大能力#
Realtime 把数据库的每次变更同步到客户端:表里增、删、改一行,订阅者无需刷新页面或主动请求即可秒级收到。三大能力各司其职:
• Postgres Changes — 监听表的 INSERT/UPDATE/DELETE,变化即通知
• Broadcast — 客户端之间直接互发,适合瞬时通知、协同编辑
• Presence — 跟踪频道里“谁在线”

典型场景有实时数据展示(订单、库存、监控指标即时刷新)、即时通讯(消息收发、在线名单、“对方正在输入”)、协同编辑与实时通知。三者都由 @supabase/supabase-js 统一管理,频道(channel)是命名消息通道——订阅某频道,只收该频道的事件。
Part 02 — 配置启用#
Supabase 出于性能考虑,默认不把任何表的变更推给客户端。因此需先登记:把表加入名为 supabase_realtime 的 publication(发布清单),列入的表其变更才会被发出。聊天室示例涉及两张表:
表1 聊天室数据表
|
表名 |
用途 |
主要字段 |
|
profiles |
用户信息表 |
id(主键,关联 auth.users)、name、avatar |
|
messages |
消息表 |
id(主键)、room_id、user_id(关联 profiles)、content、created_at |
建表 SQL:
CREATE TABLE profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
avatar TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE messages (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
room_id TEXT NOT NULL,
user_id UUID REFERENCES profiles(id),
content TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
权限配置(RLS):聊天室需登录,只对 authenticated 放行;消息的修改与删除仅限发送者本人。下面给出读、写各一条示范,其余同理:
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
CREATE POLICY "messages_select_all" ON public.messages
FOR SELECT TO authenticated USING (true);
CREATE POLICY "messages_insert_own" ON public.messages
FOR INSERT TO authenticated WITH CHECK (auth.uid() = user_id);
-- 修改、删除限本人同理:UPDATE 需 USING + WITH CHECK (auth.uid() = user_id)
把要监听的表加入发布清单,并按需开启副本标识(拿到 UPDATE/DELETE 旧值需 FULL,默认只记主键):
ALTER PUBLICATION supabase_realtime ADD TABLE public.messages;
ALTER TABLE messages REPLICA IDENTITY FULL;
被监听的表必须有主键:逻辑复制靠主键定位改的是哪一行。加入清单不代表客户端一定收到——中间还有一道 RLS 权限校验,Supabase 依据客户端 JWT 声明的角色判定身份:
表2 客户端角色与可见范围
|
客户端角色 |
可见范围 |
|
anon(未登录) |
仅允许 anon 角色查看的行变更 |
|
authenticated(已登录) |
仅允许 authenticated 角色查看的行变更 |
|
service_role(服务端密钥) |
所有行变更(带 BYPASSRLS,绕过规则) |

工作原理:表中某行被增删改时,Postgres 通过原生逻辑复制将变更打包发出;Realtime 服务订阅 supabase_realtime 发布,收到变更后经 WebSocket 长连接转发给已连接的浏览器。
Part 03 — 监听数据变更:Postgres Changes#
Postgres Changes 用于监听表的数据变化。客户端订阅一个频道、指明监听哪个表的什么操作,此后该表发生对应变化时,Realtime 经 WebSocket 将变化推送给回调。Realtime 只负责推送,不负责写入——消息写入走 REST,写入 Postgres 后由 WAL 触发推送(写入侧见 Part 06)。下面围绕 messages 表展开:有人发消息、编辑、撤回时,订阅者实时收到。
示例假定已创建 supabase 客户端:
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(URL, KEY)
提示:订阅不会立即生效。调用 subscribe() 后,频道需先与服务器握手成功(status 为 SUBSCRIBED)才算真正上线,正式代码应按状态分支处理(写法见本节末)。
监听范式与事件类型#
下例监听 messages 表的 INSERT:创建频道、用 on() 声明监听 public schema 的 messages 表,调用 subscribe() 上线;此后该表每插入一条消息,回调触发一次,新行数据在 payload.new 里。其余事件类型只需改 event 字段,对照表见下。
const channel = supabase
.channel('new-messages')
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'messages'
}, (payload) => {
console.log('新消息:', payload.new)
})
.subscribe()
表3 事件类型对照
|
event |
回调拿到的数据 |
典型场景 |
|
INSERT |
payload.new |
新消息即时上屏 |
|
UPDATE |
payload.new + payload.old |
编辑同步(对比前后差异) |
|
DELETE |
payload.old |
撤回即时移除 |
|
* |
payload.new || payload.old |
不区分类型,刷新缓存/审计 |
REPLICA IDENTITY:UPDATE/DELETE 要拿到整行旧值,表要设为 FULL(已于 Part 02 配置);否则 payload.old 为空——Postgres 默认只记主键。
带过滤条件的监听#
聊天室按房间划分,客户端通常只关心当前房间的消息。filter 限定监听范围,让服务端只推送满足条件的行:
const channel = supabase
.channel('room-1001-messages')
.on('postgres_changes', {
event: '*',
schema: 'public',
table: 'messages',
filter: 'room_id=eq.room-1001'
}, (payload) => {
console.log('房间消息:', payload.new || payload.old)
})
.subscribe()
filter 格式为 <列名>=<操作符>.<值>,常用操作符如下:
表4 常用 filter 操作符
|
操作符 |
含义 |
示例 |
|
eq / neq |
等于 / 不等于 |
room_id=eq.room-1001 |
|
gt / gte |
大于 / 大于等于 |
created_at=gte.2026-01-01 |
|
lt / lte |
小于 / 小于等于 |
created_at=lt.2026-01-01 |
|
in |
包含 |
room_id=in.(room-1001,room-1002) |
还可指定 select 只推送需要的列(如 select: 'id,content'),减少无关字段传输。filter 另有 like/ilike 模糊匹配、is 空值判断等操作符,还可用 not. 取反、逗号 AND 组合多条件。
错误处理范式:正式项目应按订阅状态分支处理,避免频道没握手好就靠它:
channel.subscribe((status) => {
if (status === 'SUBSCRIBED') {
// 订阅成功,可依赖监听
} else if (status === 'CHANNEL_ERROR') {
console.error('订阅失败:权限不足或频道异常')
} else if (status === 'TIMED_OUT') {
console.warn('订阅超时,SDK 将自动重试')
}
})
正式用法见 Part 06——一个频道可用多个 .on() 同时监听不同事件,无需为每类变更各开一个频道。
Part 04 — 消息广播:Broadcast#
Postgres Changes 是“数据库发生变化时通知你”,Broadcast 则是另一类:“其他客户端发出消息时通知你”。发送方将消息投递到频道,Realtime 立即转发给该频道所有在线订阅者。两个特点:只发给在线客户端、不落库。聊天室里“对方正在输入…”提示正是典型用法——瞬时变化、无需落库。
下例创建一个广播频道并监听 typing 事件。config 中 self: false 表示发送方收不到自己发出的广播,避免打字者自己也收到提示;实际聊天室的同一频道可同时启用 broadcast 与 presence(见 Part 06)。
const channel = supabase.channel('chat:room-1001', {
config: { broadcast: { self: false } }
})
channel.on('broadcast', { event: 'typing' }, (payload) => {
console.log('收到输入状态:', payload)
})
channel.subscribe()
发送广播:通过 channel.send() 发送,type 固定为 'broadcast',event 为自定义事件名,payload 携带任意数据。
await channel.send({
type: 'broadcast',
event: 'typing',
payload: { name: '小张', typing: true }
})
实际项目应对 typing 信号节流(如 300ms 内只发一次),避免每次按键都广播。
Part 05 — 在线状态:Presence#
Postgres Changes 解决消息持久化与实时同步,Broadcast 解决瞬时状态互发,但二者都无法回答“此刻频道里有哪些人在线”。Presence 补上这一环:跟踪频道中的在线用户,底层采用 CRDT——一种多端状态同步算法,保证各客户端最终看到一致的在线名单。聊天室的在线成员列表、“某某加入了聊天”提示就靠它实现。成员的展示名与头像取自 profiles 表,在线状态仅在频道内同步、不入库。

上线并注册状态#
用户进入聊天页,订阅频道,握手成功后调 track() 登记自身状态。两个要点:通过 presence.key 为当前用户指定唯一标识,同 key 的状态会被合并;要在 subscribe() 回调中确认 status === 'SUBSCRIBED' 后再 track(),否则频道尚未握手、状态无法登记。
const channel = supabase.channel('chat:room-1001', {
config: { presence: { key: 'user-42' } }
})
channel.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({ name: '小张', avatar: '🦊' })
}
})
同步在线成员:sync / join / leave#
Presence 有三类事件。sync 在各方状态合并完成时触发,调用 presenceState() 获取频道中所有人的最新状态,适合一次性渲染完整名单;join 在有人上线时触发,leave 在有人下线时触发,回调分别给出加入与离开的成员,适合做“某某加入/离开聊天”的增量提示。
// 全量:状态合并时刷新在线名单
channel.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState()
console.log('在线成员:', Object.values(state).flat().map(p => p.name))
})
// 增量:有人加入 / 离开
channel.on('presence', { event: 'join' }, ({ newPresences }) => {
console.log('加入:', newPresences.map(p => p.name))
})
channel.on('presence', { event: 'leave' }, ({ leftPresences }) => {
console.log('离开:', leftPresences.map(p => p.name))
})
“离开”如何触发?对方关闭页面或取消订阅时,Realtime 就判定它下线,向其他人推送 leave。
更新自身展示状态#
状态变化时再调一次 track() 覆盖自身状态,其他成员经 sync/join 收到。注意 track() 是整体覆盖:每次都需传入完整状态(name、avatar、status 缺一不可),漏传的字段会被清空。
await channel.track({ name: '小张', avatar: '🦊', status: '忙碌' })
status 仅为频道内展示状态,不写入 profiles 表。“对方正在输入…”这种瞬息状态不适合放 Presence(频繁 track 开销大),已交给 Broadcast。
Part 06 — 完整聊天室:三者协同#
下面把三大能力合到一个聊天室频道里——发消息走 REST 写入 messages 表,由 WAL 触发 Postgres Changes 推送;输入提示走 Broadcast;在线状态走 Presence。换作传统方案,这三件事要拼 Socket.IO、Redis Pub/Sub、在线状态服务三件套;这里一个 SDK、一条频道全包。接收侧与发送侧合起来,才是一个能跑的聊天室。

聊天室需登录,RLS 据登录凭证放行。
接收侧:一个频道、一次 subscribe() 同时跑三类事件。
const channel = supabase.channel('chat:room-1001', {
config: {
broadcast: { self: false },
presence: { key: 'user-42' }
}
})
// 消息实时同步(Postgres Changes)
.on('postgres_changes',
{ event: '*', schema: 'public', table: 'messages',
filter: 'room_id=eq.room-1001' },
(payload) => updateMessageList(payload)
)
// 输入提示(Broadcast)
.on('broadcast', { event: 'typing' },
(payload) => showTypingHint(payload)
)
// 在线成员(Presence)
.on('presence', { event: 'sync' },
() => renderOnlineList()
)
.subscribe()
发送侧:发消息用 REST 写入,输入提示与上线登记复用同一频道。
// 发消息:写入 messages 表,Realtime 自动推给同房间订阅者
const { id } = (await supabase.auth.getUser()).data.user // 当前登录用户
await supabase.from('messages').insert({
room_id: 'room-1001',
user_id: id, // 须为 profiles 表中存在的 UUID
content: '你好,世界'
})
// 发输入提示:经 Broadcast 即时转发,不落库
await channel.send({
type: 'broadcast',
event: 'typing',
payload: { name: '小张', typing: true }
})
// 上线登记:需在订阅成功(SUBSCRIBED)后调用,见 Part 05
await channel.track({ name: '小张', avatar: '🦊' })
上例中的 updateMessageList、showTypingHint、renderOnlineList 为业务侧示意函数,需自行实现。一个易错点:发送者自己也是频道订阅者,会收到自己发的消息推送,前端需按消息 id 去重,避免重复上屏:
(payload) => {
// 撤回:按旧 id 移除
if (payload.eventType === 'DELETE') {
setMessages(messages.filter(m => m.id !== payload.old.id))
return
}
const i = messages.findIndex(m => m.id === payload.new.id)
// 自己发的新消息已乐观上屏,跳过避免重复
if (payload.eventType === 'INSERT' && i >= 0) return
if (i >= 0) {
const next = [...messages]; next[i] = payload.new; setMessages(next) // 编辑:更新内容
} else {
setMessages([...messages, payload.new]) // 新消息
}
}
卸载清理:页面或组件卸载时要取消订阅、释放连接。removeAllChannels() 断开所有频道,channel.unsubscribe() 只断开指定频道;对于 Presence,断开还会触发频道里其他人的 leave 事件。
supabase.removeAllChannels() // 取消所有频道
channel.unsubscribe() // 取消指定频道
Part 07 — 适用边界与注意事项#
Realtime 适合“中低频写入、多端实时同步”的场景,使用前有几条边界需留意:
• 连接与频率上限:并发连接数与推送频率有配额限制(以控制台为准),高频写入需评估推送压力与 WAL 开销。
• 断线与重连:SDK 自动重连,但业务侧仍建议对回调异常做容错,避免单条推送失败影响界面。
• 瞬态与持久取舍:需持久化的走 Postgres Changes,瞬时状态走 Broadcast;高频小信号若落库反而拖累性能。
• 密钥安全:前端只用 anon 密钥订阅,service_role 密钥只放服务端,避免越权绕过 RLS。
体验#
Realtime 把“数据一变、全端即达”变成几行代码的事:一条 WebSocket 长连接串起客户端与数据库,写入即推送,连接重连与行级权限都由服务端承担。
公测 Region:华北-北京四、华东-上海一、华南-广州-友好用户环境、华南-广州、西南-贵阳一、中国-香港。其他局点暂处邀测阶段,可提交申请。
原文来自 华为 技术团队,版权归原作者。
正文采集于 ,内容更新以原文为准。