Supabase Realtime Broadcast 廣播:低延遲用戶端訊息 | Supabase 完整教學
上一篇我們用 Postgres Changes 把資料庫的每一次變更變成前端可訂閱的即時事件。這一篇換上 Realtime 的第二種能力——Broadcast(廣播):一種不經過資料庫、直接在用戶端之間傳遞的低延遲訊息機制。你會學到用
channel.send({ type: 'broadcast', event, payload })發送、用.on('broadcast', { event }, cb)接收,並實作最經典的游標(cursor)同步;也會搞懂self/ack兩個選項、如何用觸發器從資料庫發 Broadcast,以及最關鍵的——什麼時候該用 Broadcast、什麼時候該用 Postgres Changes。
前言
一句話定義本篇主題:Broadcast 是 Supabase Realtime 提供的「用戶端對用戶端」低延遲訊息通道——訊息不寫進資料庫,直接透過 WebSocket 在伺服器端路由給所有訂閱同一個 channel 的客戶端,延遲通常低於 50ms。
先接住一個關鍵對比。上一篇的 Postgres Changes 綁在「資料真的寫進資料庫」這件事上:可靠、有紀錄、綁 RLS,但也因此有代價——每次變更都要讀 WAL、對每個訂閱者各跑一次授權檢查。可是有一大類即時需求,根本不需要、也不該寫進資料庫:滑鼠游標的座標、對方「正在輸入…」的提示、多人遊戲裡角色的即時位置。這些訊息每秒可能產生數十筆、看過即忘、下一秒就被新的取代。如果每一筆都 update 進資料表,你會白白付出資料庫寫入成本,還會撞上 Postgres Changes 的扇出瓶頸。
Broadcast 就是為這類「稍縱即逝的高頻訊息」而生。 它繞過資料庫,訊息從一個客戶端送出,經過 Supabase 的 Realtime 伺服器,直接路由給同房間的其他客戶端。因為不碰資料庫,它快、便宜、且訂閱者數量幾乎不受限制。
用一個生活類比幫你抓住差別:
- Postgres Changes 像掛號信:每封信都經過郵局登記、留存底根、可查詢,慎重但慢一點。
- Broadcast 像對講機:一按就說、同頻道的人立刻聽到,說完就消失、不留紀錄,即時但不持久。
本篇你會學到:
- Broadcast vs Postgres Changes:延遲、是否持久化、扇出效能、授權機制的完整對照,以及選型決策
- send/on 的基本模型:
channel.send({ type: 'broadcast', event, payload })與.on('broadcast', { event }, cb)怎麼配對 - 游標同步實戰:最經典的高頻 Broadcast 場景,含節流(throttle)避免灌爆頻寬
self與ack選項:讓發送者收到自己的訊息、以及等待伺服器確認- Broadcast from Database:用 PostgreSQL 觸發器 +
realtime.broadcast_changes從資料庫發廣播,取代大規模 Postgres Changes
核心概念
要用好 Broadcast,先建立三張心智圖:一張是「Broadcast 與 Postgres Changes 的分工對照」,一張是「一則廣播訊息如何流動」,一張是「send/on 的配對模型」。
一、Broadcast vs Postgres Changes:一張表看懂選型
這兩個功能常被混用,但定位其實南轅北轍。先用一張對照表釐清:
| 維度 | Broadcast(本篇) | Postgres Changes(上一篇) |
|---|---|---|
| 是否寫入資料庫 | 否(訊息短暫存在) | 是(監聽 DB 真實變更) |
| 延遲 | 通常 <50ms | 50–200ms |
| 典型場景 | 游標、打字指示、遊戲事件、即時反應 | 訂單狀態、聊天訊息、通知、儀表板 |
| 更新頻率 | 高頻(每秒數十次沒問題) | 低至中頻 |
| 訂閱者數量 | 幾乎不受限 | 建議 <3,000(超過改用 trigger + Broadcast) |
| 授權機制 | realtime.messages 表的 RLS(private channel) | 訂閱資料表的 RLS 政策 |
| 扇出成本 | 低(伺服器端純路由) | 高(每個訂閱者各跑一次 RLS) |
一句話抓住選型:要不要把資料存進資料庫?要,就用 Postgres Changes;不要(稍縱即逝的高頻訊息),就用 Broadcast。 這也回答了上一篇結尾埋的伏筆——游標、打字指示這類訊息,就是 Broadcast 的主場。
二、一則廣播訊息如何流動
Broadcast 的原理比 Postgres Changes 單純得多,因為它完全不碰資料庫的 WAL 與邏輯複製。訊息路徑是:
1. 客戶端 A 呼叫 channel.send({ type: 'broadcast', event, payload })
│
▼
2. 訊息透過 WebSocket 送到 Supabase Realtime 伺服器
│
▼
3. 伺服器把訊息路由給「所有訂閱同一 channel topic 且有掛對應 event 監聽器」的客戶端
(private channel 會在此檢查 realtime.messages 的 RLS)
│
▼
4. 客戶端 B、C、D... 的 .on('broadcast', { event }, cb) 觸發,收到 payload
(客戶端 A 預設收不到自己的訊息,除非開 self: true)
這條路徑解釋了三件事:
- 為什麼延遲極低:沒有資料庫來回,純粹是伺服器記憶體中的路由,所以通常 <50ms。
- 為什麼不持久化:訊息只在傳遞的那一刻存在,沒有被寫進任何資料表(除非你另外用 trigger 從資料庫發,那是後段主題)。晚加入的客戶端收不到之前的訊息。
- 為什麼發送者收不到自己:步驟 4 預設排除發送者,因為多數場景本地端已知結果,開
self: true才會收到。
三、send 與 on 的配對模型
Broadcast 只有兩個核心動作:發送(send) 和 接收(on),兩者靠 event 字串配對。
接收端用 .on('broadcast', { event }, cb) 宣告「我要聽某個事件名稱的廣播」:
const channel = supabase
.channel('game-room') // 1. 建立 channel(房間)
.on('broadcast', { event: 'cursor' }, (msg) => {
// 3. 收到 event 為 'cursor' 的廣播時觸發
console.log('收到游標:', msg.payload)
})
.subscribe() // 2. 正式訂閱
發送端用 channel.send({ type: 'broadcast', event, payload }) 送出:
channel.send({
type: 'broadcast', // 固定為 'broadcast'
event: 'cursor', // 要和接收端 on 的 event 對上
payload: { x: 100, y: 200 }, // 你的自訂資料
})
三個關鍵術語:
| 概念 | 說明 |
|---|---|
channel topic | Realtime 的「房間」。同名 channel 的客戶端互通訊息。命名建議帶情境,如 room:${roomId} |
event | 廣播事件名稱,是發送與接收配對的鑰匙。send 的 event 要對上 on 的 event。可用 '*' 監聽該 channel 全部廣播 |
payload | 你要傳的任意 JSON 資料。注意大小——payload 過大會拖慢延遲、吃掉頻寬配額 |
記住這組配對,Broadcast 的 API 你就掌握八成了。
實作範例
下面用可直接執行的 TypeScript 與 SQL,把 Broadcast 從最基本的收發、到經典的游標同步、到 self/ack 選項、再到從資料庫觸發,完整跑一遍。假設 supabase 客戶端已初始化、使用者已登入。
一、最小可用範例:發送與接收
先跑通「一端送、另一端收」。注意 send() 要在 subscribe() 成功(狀態為 SUBSCRIBED)之後才會透過 WebSocket 送出:
import type { RealtimeChannel } from '@supabase/supabase-js'
const channel: RealtimeChannel = supabase
.channel('room:demo')
// 接收端:監聽 event 為 'ping' 的廣播
.on('broadcast', { event: 'ping' }, (msg) => {
console.log('收到 ping:', msg.payload)
// msg.payload 就是對方 send 時帶的 payload
})
.subscribe((status) => {
if (status === 'SUBSCRIBED') {
// 訂閱成功後才發送,確保走 WebSocket
channel.send({
type: 'broadcast',
event: 'ping',
payload: { message: 'hello', at: Date.now() },
})
}
})
如果你在兩個瀏覽器分頁各跑一次(同樣的 channel 名稱 room:demo),A 分頁送出的 ping,B 分頁會收到;但 A 自己預設不會收到(見稍後的 self 選項)。
二、游標同步:最經典的高頻 Broadcast 場景
多人協作工具(白板、文件、設計工具)的「看到別人的滑鼠游標即時飄動」,就是 Broadcast 的招牌應用。重點有兩個:節流(throttle) 避免每個 mousemove 都送、以及在畫面上維護「每個使用者一個游標」。
import type { RealtimeChannel } from '@supabase/supabase-js'
interface CursorPayload {
userId: string
x: number
y: number
}
function setupCursorSync(roomId: string, currentUserId: string): RealtimeChannel {
const channel = supabase
.channel(`room:${roomId}:cursors`)
// 接收別人的游標,更新畫面
.on('broadcast', { event: 'cursor' }, (msg) => {
const { userId, x, y } = msg.payload as CursorPayload
renderRemoteCursor(userId, x, y) // 你的 UI 函式:畫出/移動該使用者的游標
})
.subscribe((status) => {
if (status !== 'SUBSCRIBED') return
// 訂閱成功後,開始廣播自己的游標(加節流)
const sendCursor = throttle((e: MouseEvent) => {
channel.send({
type: 'broadcast',
event: 'cursor',
payload: { userId: currentUserId, x: e.clientX, y: e.clientY },
})
}, 50) // 每 50ms 最多送一次(約 20fps),足夠流暢又不灌爆頻寬
window.addEventListener('mousemove', sendCursor)
})
return channel
}
// 簡單的節流工具:一段時間內最多執行一次
function throttle<T extends (...args: any[]) => void>(fn: T, ms: number) {
let last = 0
return (...args: Parameters<T>) => {
const now = Date.now()
if (now - last >= ms) {
last = now
fn(...args)
}
}
}
三個重點:一、一定要節流——原生 mousemove 每秒可觸發上百次,不節流會瞬間吃光訊息配額並拉高延遲;50ms(約 20fps)對游標來說肉眼已夠順。二、payload 帶上 userId,接收端才知道「這是誰的游標」,才能一人一個。三、這裡刻意不寫資料庫——游標位置留存毫無意義,這正是 Broadcast 相對 Postgres Changes 的價值所在。
三、self 選項:讓發送者也收到自己的廣播
預設發送者收不到自己的訊息。若你想用「同一條資料流」統一驅動 UI(本地與遠端游標走同一套渲染邏輯,不必為本地另寫一份),在建立 channel 時開 self: true:
const channel = supabase.channel('room:with-self', {
config: {
broadcast: { self: true }, // 發送者也會收到自己送出的廣播
},
})
channel
.on('broadcast', { event: 'cursor' }, (msg) => {
// 現在連自己送出的 cursor 也會進到這裡
renderCursor(msg.payload)
})
.subscribe()
要不要開 self,取決於你的架構偏好:開了程式碼更統一(單一資料流),不開則少一次回繞、本地反應更即時。兩種都常見。
四、ack 選項:等待伺服器確認收到
預設 send() 是「射後不理」——你不知道伺服器到底收到沒。若場景需要可靠性回饋,開 ack: true,send() 回傳的 Promise 會在伺服器確認後才解析:
const channel = supabase.channel('room:reliable', {
config: {
broadcast: { ack: true }, // 等待伺服器 ack
},
})
channel.subscribe(async (status) => {
if (status !== 'SUBSCRIBED') return
const result = await channel.send({
type: 'broadcast',
event: 'important',
payload: { data: 'critical' },
})
// result 為 'ok' 代表伺服器已確認收到(回傳值請以官方為準)
console.log('送出結果:', result)
})
一個重要澄清:ack 確認的是「伺服器收到了」,不保證「其他客戶端已經顯示」。它增加一次往返延遲,因此高頻游標同步通常不開 ack 以求最低延遲;只有在你真的需要「確定送達伺服器」的可靠場景才開。選項的確切回傳值與行為,以官方文件為準。
五、Broadcast from Database:用觸發器從資料庫發廣播
前面說 Broadcast「不寫資料庫」,但有一種強大用法反過來——讓資料庫的變更主動發出 Broadcast。這是官方建議在超過約 3,000 並行訂閱者時取代 Postgres Changes 的架構:資料照樣持久化(本來就寫進表了),但「把訊息扇出給大量訂閱者」改由效能更好的 Broadcast 處理,避開 Postgres Changes 逐訂閱者跑 RLS 的瓶頸。
做法是掛一個 PostgreSQL 觸發器,在函式裡呼叫 realtime.broadcast_changes(...)(API 名稱以官方為準):
-- 步驟一:建立觸發器函式,把資料表變更以 Broadcast 發出
CREATE OR REPLACE FUNCTION public.broadcast_order_changes()
RETURNS trigger
SECURITY DEFINER SET search_path = ''
AS $$
BEGIN
-- realtime.broadcast_changes 會把這筆變更廣播到指定 channel topic
PERFORM realtime.broadcast_changes(
'orders:' || NEW.id::text, -- channel topic(客戶端要訂閱同一個)
TG_OP, -- 事件名稱:INSERT / UPDATE / DELETE
TG_OP,
TG_TABLE_NAME,
TG_TABLE_SCHEMA,
NEW,
OLD
);
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
-- 步驟二:把觸發器掛到資料表上
CREATE TRIGGER broadcast_order_changes_trigger
AFTER INSERT OR UPDATE OR DELETE ON public.orders
FOR EACH ROW
EXECUTE FUNCTION public.broadcast_order_changes();
客戶端這一側,改用 .on('broadcast', ...) 監聽(不是 postgres_changes):
// 從資料庫發來的 Broadcast,走 private channel,需要先設定 auth
await supabase.realtime.setAuth() // 帶入使用者 JWT(授權走 realtime.messages 的 RLS)
const channel = supabase
.channel(`orders:${orderId}`, { config: { private: true } })
.on('broadcast', { event: 'UPDATE' }, (msg) => {
console.log('訂單更新:', msg.payload)
updateOrderUI(msg.payload)
})
.on('broadcast', { event: 'INSERT' }, (msg) => {
console.log('新訂單:', msg.payload)
})
.subscribe()
這個架構的精髓:資料還是有寫進 orders 表(持久化沒少),但訊息扇出改由 Broadcast 負責,效能遠優於讓上萬人直接用 Postgres Changes 訂閱同一張表。此時授權走的是 realtime.messages 表的 RLS(private channel),而不是 orders 表的 RLS——這點與 Postgres Changes 不同,設定政策時要注意。相關 API 與函式簽章請以 Supabase 官方文件為準。
常見錯誤與最佳實踐
坑一:用 Postgres Changes 做高頻游標/打字指示,把資料庫打爆。
這是最常見的架構誤用。有人為了「即時看到別人的游標」,把游標座標每次都 update 進一張表,再用 Postgres Changes 訂閱。結果是雙重災難:資料庫每秒吃進數十筆無意義的寫入(IOPS 爆掉),Postgres Changes 又得對每個訂閱者逐一跑 RLS(單執行緒,訂閱者一多每秒可處理的變更數急降)。正確做法:高頻、稍縱即逝、不需留存的訊息(游標、打字指示、遊戲操作、即時反應)一律用 Broadcast,它繞過資料庫、延遲 <50ms、訂閱者數量幾乎不受限。
坑二:payload 塞太大,延遲飆高、吃光配額。
Broadcast 雖快,但每則訊息都要透過 WebSocket 傳輸,payload 越大、延遲越高,也越快撞到方案的訊息/流量上限(payload 大小上限依方案不同,Free 較小,以官方為準)。有人把整個文件內容、整張圖片 base64 都塞進 payload 廣播。正確做法:payload 只帶「必要的增量」——游標只送 { userId, x, y },文件協作只送「哪一段改成什麼」的 diff,大型資料改走 Storage 或資料庫,Broadcast 只送「指標/通知」。
坑三:mousemove/keyup 不節流,訊息瞬間灌爆。
原生高頻事件(mousemove、scroll、input)每秒可觸發上百次,若每次都 send(),會瞬間耗盡訊息配額、拉高整體延遲,甚至讓其他重要訊息塞車。正確做法:一律節流(throttle)或防抖(debounce)。游標同步用 throttle 到 50ms(約 20fps)肉眼已夠順;打字指示用 debounce,停止輸入一小段時間後才送「停止輸入」。把「使用者操作頻率」和「網路發送頻率」解耦。
坑四:忘了 send 要在 SUBSCRIBED 之後、或忘了 removeChannel。
在 subscribe() 尚未成功前就呼叫 channel.send(),訊息可能送不出去(或走非預期路徑);元件卸載後不清理 channel,則會累積 WebSocket 連線直到耗盡配額(和上一篇 Postgres Changes 同一個坑)。正確做法:把即時發送的邏輯放在 subscribe((status) => { if (status === 'SUBSCRIBED') {...} }) 裡;在 React useEffect 的 cleanup(或 Vue onUnmounted)中呼叫 supabase.removeChannel(channel),讓訂閱與取消成對出現。
坑五:誤以為 Broadcast 有「歷史紀錄」,晚進來的人也看得到之前的訊息。 Broadcast 預設不持久化——它是「當下在線才收得到」。有人期待新加入的客戶端能自動補到之前的廣播,結果一片空白。正確做法:需要「初始狀態」的場景(例如新加入者要看到目前所有人的游標位置),應在加入時先透過資料庫或一次性請求抓當前快照,再用 Broadcast 訂閱後續增量;或評估 private channel 的訊息重放(replay,可取回近期訊息,上限與保留時間以官方為準)功能。別把 Broadcast 當成有歷史的訊息佇列用。
最佳實踐總結:
- 高頻短訊息一律 Broadcast:游標、打字、遊戲事件、即時反應,繞過資料庫、延遲最低
- payload 只帶必要增量:送指標、送 diff,不送整包大資料,省延遲又省配額
- 一律節流/防抖:把使用者操作頻率和網路發送頻率解耦(游標 throttle 50ms,打字 debounce)
- send 放在 SUBSCRIBED 之後、卸載時 removeChannel:與訂閱成對,避免送不出與連線洩漏
- 需要初始快照就另外抓:Broadcast 不持久化,晚加入者看不到歷史,先抓快照再訂閱增量
- 大規模扇出用 trigger + Broadcast:超過約 3,000 訂閱者,用
realtime.broadcast_changes取代 Postgres Changes - 不確定的細節以官方為準:
self/ack行為、payload 上限、replay 限制、函式簽章都可能調整,正式決策前查官方文件
小結
這是 Supabase 系列教學的第 028 篇。上一篇《Realtime 入門與 Postgres Changes》我們用 channel().on('postgres_changes', ...).subscribe() 訂閱資料庫的真實變更,把「資料寫進 DB」變成前端可訂閱的即時事件;那條路徑可靠、有紀錄、綁 RLS,但也綁在資料庫寫入上。這一篇,我們補上互補的另一半——Broadcast:不碰資料庫、延遲 <50ms、專治那些「稍縱即逝、不該留存」的高頻訊息。
核心觀念濃縮成一句話:Broadcast 是用戶端對用戶端的低延遲訊息通道,用 channel.send({ type: 'broadcast', event, payload }) 發送、用 .on('broadcast', { event }, cb) 接收,靠 event 字串配對;訊息不寫資料庫、不持久化,適合游標/打字/遊戲等高頻場景;self 讓發送者也收到自己、ack 等待伺服器確認;需要大規模扇出時,可用 PostgreSQL 觸發器 + realtime.broadcast_changes 從資料庫發廣播,取代 Postgres Changes。
- 選型口訣:要存進資料庫用 Postgres Changes,稍縱即逝的高頻訊息用 Broadcast
- 收發配對:
send({ type: 'broadcast', event, payload })對上.on('broadcast', { event }, cb) - 關鍵選項:
config: { broadcast: { self, ack } }——self 收自己、ack 等確認 - 游標同步:Broadcast 招牌場景,一定要節流、payload 只帶必要增量
- 從資料庫發:trigger +
realtime.broadcast_changes,大規模扇出取代 Postgres Changes(授權走realtime.messages的 RLS)
到這裡,Realtime 的兩大訊息機制你都握在手上了:Postgres Changes 管「有沒有寫進 DB」,Broadcast 管「當下的高頻互動」。但還剩一個常見需求沒解決——「現在有哪些人在線上?」 顯示協作房間裡的參與者名單、誰正在編輯、誰剛離開,這既不是資料庫變更、也不適合用一堆 Broadcast 手動維護。下一篇《Presence 線上狀態》,我們來看 Supabase 怎麼用 CRDT 幫你把「誰在線上」這件事,做到分散式環境下也能最終一致地同步。Broadcast 提速,Presence 顯示在場,下一篇見。