Supabase Realtime 实战:三种模式对比与协作应用开发
凌晨三点,我盯着屏幕上那个”正在输入…”的提示符,第十七次刷新聊天页面。另一端的朋友明明在线,消息却死活出不来。那一刻我意识到:做一个真正”实时”的应用,比想象中难太多了。
WebSocket 的坑踩了不少——连接断开要重连、状态同步要处理、广播消息要设计。直到去年用 Supabase Realtime,才发现原来这些麻烦事可以交给别人。Supabase 提供了三种实时模式:Postgres Changes 监听数据库变更、Presence 跟踪用户状态、Broadcast 广播临时消息。这三种模式各有适用场景,用对了事半功倍,用错了就是给自己挖坑。
这篇文章里,我会把这三种模式掰开揉碎讲清楚——什么时候用哪种、代码怎么写、RLS 安全策略怎么配。最后我们还会把它们组合起来,做一个完整的协作聊天应用。
Supabase Realtime 三大核心功能对比
先说结论:三种模式各有各的活儿,别混着用。
Postgres Changes 监听数据库变更。适合聊天消息、通知、订单状态这类需要持久化的数据。数据存数据库,客户端只管订阅变更。
Presence 跟踪用户在线状态。适合显示”谁在线”、打字指示器、协作编辑中的光标位置。数据不存数据库,存在内存里,用户断开就没了。
Broadcast 广播临时消息。适合画布上的光标移动、游戏中的实时位置、临时性的操作同步。跟 Presence 的区别是:Broadcast 适合高频发送,Presence 适合状态同步。
一张表说清楚:
| 模式 | 数据存哪 | 典型场景 | 持久化 |
|---|---|---|---|
| Postgres Changes | PostgreSQL | 聊天消息、通知、订单状态 | 是 |
| Presence | 内存(Realtime 服务) | 在线用户、输入指示器 | 否 |
| Broadcast | 不存储(即时转发) | 光标移动、实时位置 | 否 |
你可能会想:为什么不干脆都用 Postgres Changes?说实话,我一开始也这么想过。后来做一个协作白板应用,每个光标移动都写数据库,结果数据库 CPU 直接飙到 90%。那一刻我才明白:有些数据根本不需要持久化。
选择哪种模式,记住这个简单原则:需要查历史记录的用 Postgres Changes,只关心当前状态的用 Presence,高频且临时的用 Broadcast。
Postgres Changes:监听数据库变更
这一节讲最常见的场景:监听数据库变化。比如聊天室来了新消息、订单状态变了、有人点了赞——这些都需要持久化的数据。
Supabase Realtime 通过 PostgreSQL 的 logical replication(逻辑复制)机制来实现变更监听。简单说,就是数据库每次有 INSERT、UPDATE、DELETE 操作,Realtime 服务都能捕获到,然后推送给订阅的客户端。
启用 Realtime 监听
首先要在数据库端启用 publication。在 Supabase SQL Editor 里执行:
-- 启用 Realtime publication
ALTER publication supabase_realtime ADD TABLE messages;
-- 对于需要监听 UPDATE 和 DELETE 的表,必须设置 REPLICA IDENTITY FULL
ALTER TABLE messages REPLICA IDENTITY FULL;
为什么要设置 REPLICA IDENTITY FULL?因为默认情况下,PostgreSQL 只记录变更行的主键。如果你需要拿到变更前后的完整数据(比如做审计日志),就得开启这个选项。注意:这会增加数据库的写入量,只对真正需要的表开启。
客户端订阅代码
接下来是客户端代码。以聊天消息为例:
import { createClient } from '@supabase/supabase-js'
import { useEffect, useState } from 'react'
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
interface Message {
id: string
content: string
user_id: string
created_at: string
}
export function useRealtimeMessages(roomId: string) {
const [messages, setMessages] = useState<Message[]>([])
useEffect(() => {
// 先获取历史消息
const fetchMessages = async () => {
const { data } = await supabase
.from('messages')
.select('*')
.eq('room_id', roomId)
.order('created_at', { ascending: true })
if (data) setMessages(data)
}
fetchMessages()
// 订阅新消息
const channel = supabase
.channel(`messages:${roomId}`)
.on(
'postgres_changes',
{
event: 'INSERT', // 只监听新增
schema: 'public',
table: 'messages',
filter: `room_id=eq.${roomId}` // 过滤特定房间
},
(payload) => {
// payload.new 包含新插入的数据
setMessages(prev => [...prev, payload.new as Message])
}
)
.subscribe()
// 清理订阅
return () => {
supabase.removeChannel(channel)
}
}, [roomId])
return messages
}
有几个细节值得注意:
- 先拉历史,再订阅增量:刚进聊天室得先显示历史消息,然后才能接收新消息。这是个常见坑,别只订阅不拉历史。
- filter 参数:用数据库字段过滤,避免收到无关消息。语法是
字段名=eq.值。 - 清理订阅:组件卸载时记得
removeChannel,不然会内存泄漏。
RLS 安全配置(重要!)
这块很多人会忽略,但它是生产级应用的关键。默认情况下,Realtime 会遵循表的 RLS(Row Level Security)策略。如果你没配置 RLS,客户端可能什么都收不到,或者收到不该收的数据。
举个例子,我们有一个聊天室表:
-- 消息表
CREATE TABLE messages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
room_id uuid REFERENCES rooms(id),
user_id uuid REFERENCES auth.users(id),
content text NOT NULL,
created_at timestamptz DEFAULT now()
);
-- 启用 RLS
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
-- 允许查看自己所在房间的消息
CREATE POLICY "Users can view messages in their rooms"
ON messages FOR SELECT
USING (
room_id IN (
SELECT room_id FROM room_members
WHERE user_id = auth.uid()
)
);
-- 允许房间成员发消息
CREATE POLICY "Room members can insert messages"
ON messages FOR INSERT
WITH CHECK (
room_id IN (
SELECT room_id FROM room_members
WHERE user_id = auth.uid()
)
);
Realtime 订阅会自动应用这些策略。用户只能收到他有权限看到的消息变更。这点特别重要——别想着在前端过滤,那不安全。
如果你发现订阅收不到数据,先检查两件事:
- 表是否加入了
supabase_realtimepublication - RLS 策略是否正确配置
Presence:跟踪用户在线状态
Presence 适合那些”此刻谁在线”的场景——聊天室显示在线人数、协作文档里谁在编辑哪块、谁正在打字。这些数据不需要存数据库,存在内存里就够了。
基本原理
Presence 的工作方式是:每个客户端加入频道后,调用 track() 方法注册自己的状态。Realtime 服务会维护一份所有客户端状态的快照,当有人加入、离开、更新状态时,所有订阅者都会收到通知。
实现在线用户列表
来看代码。这是一个显示聊天室在线用户的组件:
import { createClient } from '@supabase/supabase-js'
import { useEffect, useState } from 'react'
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
interface UserPresence {
user_id: string
username: string
online_at: string
}
export function useOnlineUsers(roomId: string, currentUser: { id: string; username: string }) {
const [users, setUsers] = useState<UserPresence[]>([])
useEffect(() => {
const channel = supabase.channel(`room:${roomId}`, {
config: {
presence: {
key: currentUser.id // 用用户 ID 作为 key
}
}
})
channel
.on('presence', { event: 'sync' }, () => {
// 同步时获取所有在线用户
const state = channel.presenceState()
// presenceState() 返回的是 { [key]: [UserPresence, ...] }
const onlineUsers = Object.values(state).flat() as UserPresence[]
setUsers(onlineUsers)
})
.on('presence', { event: 'join' }, ({ newPresences }) => {
// 有新用户加入
console.log('用户加入:', newPresences)
})
.on('presence', { event: 'leave' }, ({ leftPresences }) => {
// 有用户离开
console.log('用户离开:', leftPresences)
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
// 订阅成功后,注册自己的状态
await channel.track({
user_id: currentUser.id,
username: currentUser.username,
online_at: new Date().toISOString()
})
}
})
return () => {
supabase.removeChannel(channel)
}
}, [roomId, currentUser])
return users
}
使用起来很简单:
function ChatRoom({ roomId, currentUser }) {
const onlineUsers = useOnlineUsers(roomId, currentUser)
return (
<div className="flex items-center gap-2 mb-4">
<span className="text-sm text-gray-500">
{onlineUsers.length} 人在线
</span>
<div className="flex -space-x-2">
{onlineUsers.map(user => (
<div
key={user.user_id}
className="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white text-sm"
title={user.username}
>
{user.username[0]}
</div>
))}
</div>
</div>
)
}
输入指示器
Presence 还能做”正在输入”提示。思路是:当用户开始打字时,更新自己的状态;停打字一段时间后,清除状态。
export function useTypingIndicator(roomId: string, currentUser: { id: string }) {
const [typingUsers, setTypingUsers] = useState<string[]>([])
const channelRef = useRef<RealtimeChannel | null>(null)
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
const channel = supabase.channel(`room:${roomId}`)
channelRef.current = channel
channel
.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState()
const typing = Object.values(state)
.flat()
.filter((u: any) => u.is_typing && u.user_id !== currentUser.id)
.map((u: any) => u.username)
setTypingUsers(typing)
})
.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({
user_id: currentUser.id,
is_typing: false
})
}
})
return () => {
supabase.removeChannel(channel)
}
}, [roomId, currentUser])
// 用户开始打字时调用
const setTyping = (isTyping: boolean) => {
if (typingTimeoutRef.current) {
clearTimeout(typingTimeoutRef.current)
}
channelRef.current?.track({
user_id: currentUser.id,
is_typing
})
// 3秒后自动清除打字状态
if (isTyping) {
typingTimeoutRef.current = setTimeout(() => {
channelRef.current?.track({
user_id: currentUser.id,
is_typing: false
})
}, 3000)
}
}
return { typingUsers, setTyping }
}
这里有个小坑:别在每次按键都调用 track(),太频繁了会出问题。加个防抖,或者像上面这样,打完字几秒后自动清除状态。
Broadcast:光标追踪与即时消息
Broadcast 是三种模式里最”轻”的——消息不存储、不持久化,发了就忘,适合高频、临时的数据传输。
光标追踪示例
协作白板、多人编辑器这类应用,需要实时显示每个人的光标位置。这场景用 Broadcast 最合适——光标位置不需要存数据库,也没必要知道”历史光标在哪”。
import { createClient } from '@supabase/supabase-js'
import { useEffect, useState, useRef } from 'react'
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
interface CursorPosition {
user_id: string
username: string
x: number
y: number
color: string
}
export function useCursors(canvasId: string, currentUser: { id: string; username: string }) {
const [cursors, setCursors] = useState<Record<string, CursorPosition>>({})
const channelRef = useRef<RealtimeChannel | null>(null)
useEffect(() => {
const channel = supabase.channel(`canvas:${canvasId}`)
channelRef.current = channel
channel
.on('broadcast', { event: 'cursor' }, ({ payload }) => {
// 收到其他用户的光标位置
if (payload.user_id !== currentUser.id) {
setCursors(prev => ({
...prev,
[payload.user_id]: payload
}))
}
})
.subscribe()
return () => {
supabase.removeChannel(channel)
}
}, [canvasId, currentUser])
// 发送自己的光标位置
const sendCursor = (x: number, y: number) => {
channelRef.current?.send({
type: 'broadcast',
event: 'cursor',
payload: {
user_id: currentUser.id,
username: currentUser.username,
x,
y,
color: getUserColor(currentUser.id)
}
})
}
return { cursors, sendCursor }
}
// 根据用户 ID 生成颜色
function getUserColor(userId: string): string {
const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4']
const index = userId.charCodeAt(0) % colors.length
return colors[index]
}
在组件里用:
function CollaborativeCanvas({ canvasId, currentUser }) {
const { cursors, sendCursor } = useCursors(canvasId, currentUser)
const handleMouseMove = (e: React.MouseEvent) => {
sendCursor(e.clientX, e.clientY)
}
return (
<div className="relative w-full h-full" onMouseMove={handleMouseMove}>
{/* 显示其他用户的光标 */}
{Object.entries(cursors).map(([userId, cursor]) => (
<div
key={userId}
className="absolute pointer-events-none"
style={{ left: cursor.x, top: cursor.y }}
>
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: cursor.color }} />
<span className="text-xs ml-1">{cursor.username}</span>
</div>
))}
</div>
)
}
Broadcast vs Presence
你可能发现,Broadcast 和 Presence 有重叠。区别在于:
- Broadcast 用于高频发送(每秒可能几十次),比如光标移动、游戏中的位置同步
- Presence 用于状态同步(偶尔更新),比如”谁在线”、“正在打字”
两者配合使用效果最好。Broadcast 适合”动起来”的数据,Presence 适合”状态”数据。
综合实战:构建协作聊天应用
现在把三种模式组合起来,做一个真正的协作聊天应用。功能包括:
- 实时消息(Postgres Changes)
- 在线用户列表(Presence)
- 输入指示器(Presence)
数据库准备
先建表:
-- 房间表
CREATE TABLE rooms (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
created_at timestamptz DEFAULT now()
);
-- 房间成员表
CREATE TABLE room_members (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
room_id uuid REFERENCES rooms(id) ON DELETE CASCADE,
user_id uuid REFERENCES auth.users(id),
joined_at timestamptz DEFAULT now(),
UNIQUE(room_id, user_id)
);
-- 消息表
CREATE TABLE messages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
room_id uuid REFERENCES rooms(id) ON DELETE CASCADE,
user_id uuid REFERENCES auth.users(id),
content text NOT NULL,
created_at timestamptz DEFAULT now()
);
-- 启用 Realtime
ALTER publication supabase_realtime ADD TABLE messages;
-- 启用 RLS
ALTER TABLE messages ENABLE ROW LEVEL Security;
-- RLS 策略:只看自己所在房间的消息
CREATE POLICY "Users can view messages in their rooms"
ON messages FOR SELECT
USING (
room_id IN (
SELECT room_id FROM room_members WHERE user_id = auth.uid()
)
);
-- RLS 策略:只有房间成员能发消息
CREATE POLICY "Room members can send messages"
ON messages FOR INSERT
WITH CHECK (
room_id IN (
SELECT room_id FROM room_members WHERE user_id = auth.uid()
)
);
完整 React 组件
这是一个简化版的协作聊天组件,把三种模式都用上了:
import { createClient } from '@supabase/supabase-js'
import { useEffect, useState, useRef, useCallback } from 'react'
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
interface Message {
id: string
content: string
user_id: string
username: string
created_at: string
}
interface UserPresence {
user_id: string
username: string
is_typing?: boolean
}
export function CollaborativeChat({ roomId, currentUser }) {
const [messages, setMessages] = useState<Message[]>([])
const [onlineUsers, setOnlineUsers] = useState<UserPresence[]>([])
const [typingUsers, setTypingUsers] = useState<string[]>([])
const [inputValue, setInputValue] = useState('')
const channelRef = useRef<RealtimeChannel | null>(null)
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
// 创建一个 channel,三种功能共用
const channel = supabase.channel(`room:${roomId}`, {
config: { presence: { key: currentUser.id } }
})
channelRef.current = channel
// 1. Postgres Changes:监听新消息
channel.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'messages',
filter: `room_id=eq.${roomId}`
},
(payload) => setMessages(prev => [...prev, payload.new as Message])
)
// 2. Presence:监听在线用户和打字状态
channel.on('presence', { event: 'sync' }, () => {
const state = channel.presenceState()
const users = Object.values(state).flat() as UserPresence[]
setOnlineUsers(users)
const typing = users
.filter(u => u.is_typing && u.user_id !== currentUser.id)
.map(u => u.username)
setTypingUsers(typing)
})
// 订阅并注册 Presence
channel.subscribe(async (status) => {
if (status === 'SUBSCRIBED') {
await channel.track({
user_id: currentUser.id,
username: currentUser.username,
is_typing: false
})
// 加载历史消息
const { data } = await supabase
.from('messages')
.select('*')
.eq('room_id', roomId)
.order('created_at', { ascending: true })
if (data) setMessages(data)
}
})
return () => supabase.removeChannel(channel)
}, [roomId, currentUser])
const sendMessage = async () => {
if (!inputValue.trim()) return
await supabase.from('messages').insert({
room_id: roomId,
user_id: currentUser.id,
content: inputValue.trim()
})
setInputValue('')
setTyping(false)
}
const setTyping = useCallback((isTyping: boolean) => {
if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current)
channelRef.current?.track({
user_id: currentUser.id,
username: currentUser.username,
is_typing
})
if (isTyping) {
typingTimeoutRef.current = setTimeout(() => setTyping(false), 3000)
}
}, [currentUser])
return (
<div className="flex h-full">
{/* 左侧:在线用户 */}
<div className="w-64 border-r p-4">
<h3 className="font-bold mb-2">在线 ({onlineUsers.length})</h3>
{onlineUsers.map(user => (
<div key={user.user_id} className="py-1">
{user.username}
{user.is_typing && <span className="text-sm text-gray-500"> (输入中)</span>}
</div>
))}
</div>
{/* 右侧:聊天 */}
<div className="flex-1 flex flex-col">
<div className="flex-1 overflow-y-auto p-4">
{messages.map(msg => (
<div key={msg.id} className="mb-2">
<span className="font-bold">{msg.username}: </span>
{msg.content}
</div>
))}
{typingUsers.length > 0 && (
<div className="text-gray-500 text-sm">
{typingUsers.join(', ')} 正在输入...
</div>
)}
</div>
<div className="p-4 border-t">
<input
value={inputValue}
onChange={e => { setInputValue(e.target.value); setTyping(true) }}
onKeyDown={e => e.key === 'Enter' && sendMessage()}
placeholder="输入消息..."
className="w-full p-2 border rounded"
/>
</div>
</div>
</div>
)
}
关键设计点
- 共用一个 Channel:三种功能用同一个 channel,减少连接数。
- Presence 数据结构:把
is_typing放在同一个 presence 对象里。 - 清理工作:组件卸载时一定要
removeChannel。
性能与安全最佳实践
写到这里,代码都跑通了。但上线之前,还有些细节要处理。
连接管理
每个 channel 都占一个 WebSocket 连接。虽然 Supabase 支持多频道共用一个连接,但滥用还是会有问题。
建议做法:
- 一个页面最多 2-3 个 channel
- 相关功能共用一个 channel(像上面聊天示例那样)
- 离开页面立刻
removeChannel
// 清理示例
useEffect(() => {
const channel = supabase.channel('my-channel')
channel.subscribe()
return () => {
// 不要只 unsubscribe,要用 removeChannel
supabase.removeChannel(channel)
}
}, [])
RLS 是必须的
别想着”前端过滤就够了”。Realtime 订阅会自动应用 RLS 策略,用户只能收到他有权限看到的数据变更。
如果你发现订阅收不到数据,排查顺序:
- 表是否加入
supabase_realtimepublication - RLS 策略是否正确(用 Supabase Dashboard 的 RLS 测试工具)
- 用户是否已登录(
auth.uid()返回什么)
Pricing 注意点
Supabase Realtime 的计费按并发连接数算:每 1000 个峰值连接 $10。对于大多数中小应用,免费额度够用。但如果你的应用有大量用户同时在线,要注意控制 channel 数量。
官方有个演示项目 Multiplayer.dev,可以去看看三种模式实际跑起来是什么效果。
结论
说了这么多,选哪种模式其实很简单:
| 需要存历史? | 高频发送? | 推荐模式 |
|---|---|---|
| 是 | 否 | Postgres Changes |
| 否 | 否 | Presence |
| 否 | 是 | Broadcast |
如果你正在做协作类应用(聊天室、白板、文档编辑),三种模式大概率都要用到。Postgres Changes 存消息,Presence 跟在线状态,Broadcast 处理光标。
建议先去 Multiplayer.dev 玩玩,体验一下实际效果。然后动手写个小的聊天 demo——这是最快上手的方式。
下一篇打算写 Supabase Storage,文件上传和图片处理。如果你对 Realtime 有什么问题,欢迎留言讨论。
常见问题
Supabase Realtime 三种模式有什么区别?
• Postgres Changes 监听数据库变更,数据持久化,适合聊天消息、订单状态
• Presence 跟踪用户在线状态,数据存内存,适合在线列表、输入指示器
• Broadcast 广播临时消息,不存储,适合光标追踪、实时位置
为什么订阅收不到数据?
1. 表是否加入 supabase_realtime publication(执行 ALTER publication supabase_realtime ADD TABLE 表名)
2. RLS 策略是否正确配置(Realtime 订阅自动应用 RLS)
3. 用户是否有权限查看数据(检查 auth.uid() 返回值)
Postgres Changes 和 Broadcast 该选哪个?
Presence 数据会持久化吗?
Realtime 订阅对 RLS 有什么影响?
一个页面可以用多少个 channel?
9 分钟阅读 · 发布于: 2026年4月15日 · 修改于: 2026年4月15日
相关文章
Supabase 入门:PostgreSQL + Auth + Storage 一站式后端
Supabase 入门:PostgreSQL + Auth + Storage 一站式后端
Supabase 数据库设计:表结构、关系与 Row Level Security 完全指南
Supabase 数据库设计:表结构、关系与 Row Level Security 完全指南
Supabase Auth 实战:邮箱验证、OAuth 与会话管理

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