Supabase Realtime 入門與 Postgres Changes 即時訂閱 | Supabase 完整教學

2026/10/16
Supabase Realtime 入門與 Postgres Changes 即時訂閱 | Supabase 完整教學

檔案處理告一段落,這一篇我們正式走進 Supabase Realtime——把「靜態的資料」變成「即時的資料流」。你會先看懂 Realtime 三大功能(Postgres Changes、Broadcast、Presence)各自的定位,接著手把手用 channel().on('postgres_changes', ...).subscribe() 即時訂閱資料表的 INSERT/UPDATE/DELETE:從把表加入 supabase_realtime publication、用 event/schema/table/filter 做伺服器端過濾,到搞懂 Realtime 與 RLS 的關係,最後在元件卸載時 removeChannel 乾淨地取消訂閱。這是 Realtime 段的開場,Broadcast 與 Presence 留給後續。

前言

一句話定義本篇主題:Supabase Realtime 是一套讓你「不必自建 WebSocket 伺服器」就能做即時功能的服務,而本篇聚焦它最常用的第一個能力——Postgres Changes:訂閱一張資料表,資料庫每一次 INSERT/UPDATE/DELETE 都會即時推播到你的前端。

先接住一個關鍵事實:過去要做「資料一變、畫面就跟著變」的即時功能,你得自己架一台 WebSocket 伺服器、自己監聽資料庫變更、自己把訊息推給每個連線的客戶端——這是一整套基礎設施。 Supabase 把這件事變成前端幾行程式碼:你在客戶端「訂閱」一張表,資料庫的變更就會沿著 WebSocket 自動流到你手上。你不再需要輪詢(polling)每隔幾秒去問一次「有沒有新資料」,而是「有變更就通知你」,延遲低、也不浪費請求。

用一個生活類比幫你抓住全貌:Realtime 就像三種不同的「即時通知系統」,各自適合不同場景。

  • Postgres Changes 像銀行帳戶的交易通知簡訊:只要你的帳戶(資料表)有任何一筆入帳、扣款、更正(INSERT/UPDATE/DELETE),系統就發一則通知給你。它綁在「資料真的寫進資料庫」這件事上,可靠、有紀錄。
  • Broadcast 像對講機:一按就說、大家立刻聽到,訊息不留存、不寫資料庫,延遲極低,適合游標位置、打字指示這種「稍縱即逝」的高頻訊息。
  • Presence 像會議室門口的「誰在線上」名牌:即時顯示現在有哪些人在這個房間裡,有人進來、有人離開就更新。

這一篇我們只深入第一種——Postgres Changes,因為它最貼近你已經熟悉的資料庫,是進入 Realtime 世界最自然的起點。Broadcast 與 Presence 我們在後續文章再展開。

本篇你會學到:

  • Realtime 三大功能概覽:Postgres Changes、Broadcast、Presence 各自的定位、延遲、是否持久化,以及該用哪一個
  • channel 與 subscribe 的基本模型:supabase.channel('x').on('postgres_changes', {...}, callback).subscribe() 這條主鏈怎麼組
  • 訂閱 INSERT/UPDATE/DELETE:包含必做的前置設定「把資料表加入 supabase_realtime publication」,以及 event/schema/table/filter 四個關鍵欄位
  • Realtime 與 RLS 的關係:即時訂閱不會繞過 RLS,以及 DELETE 事件的特殊例外
  • 取消訂閱:為什麼元件卸載一定要 removeChannel,否則連線會洩漏

核心概念

要玩轉 Postgres Changes,先掌握三張心智圖:一張是「Realtime 三大功能的分工」,一張是「一筆資料變更如何流到前端的路徑」,一張是「channel 訂閱鏈的組成」。

一、Realtime 三大功能:先看清定位再選

Supabase Realtime 底層是用 Elixir/Phoenix 建置、透過 WebSocket 運作的服務,對外提供三種即時通訊機制。先用一張表看清它們的差異,你才知道每種功能適合什麼場景:

功能延遲是否寫入資料庫典型場景授權機制
Postgres Changes50–200ms是(監聽 DB 變更)訂單狀態、聊天訊息、通知、儀表板訂閱資料表的 RLS 政策
Broadcast<50ms否(訊息短暫存在)游標位置、打字指示、遊戲事件realtime.messages 表的 RLS
Presence~100ms否(記憶體中同步)線上狀態、協作參與者列表realtime.messages 表的 RLS

一句話抓住分工:要「資料有沒有真的存進資料庫」的變更通知,用 Postgres Changes;要「稍縱即逝、不必留存」的高頻訊息,用 Broadcast;要「誰在線上」,用 Presence。 本篇的主角 Postgres Changes,最大的特色是它直接綁在你的資料庫上——你平常怎麼寫入資料(insert、update、delete),前端就能即時收到對應事件,不必額外再發一次「通知訊息」。

二、一筆變更如何流到前端:Postgres Changes 的運作流程

Postgres Changes 背後的原理其實不複雜,理解它能幫你搞懂「為什麼要加 publication」「為什麼 RLS 有效」這兩件事。整個流程是:

1. 你的 App 對資料表做 INSERT / UPDATE / DELETE
        │
        ▼
2. PostgreSQL 把這筆變更寫進 WAL(Write-Ahead Log,預寫日誌)
        │
        ▼
3. Realtime 服務透過「邏輯複製」讀取 WAL 裡的變更
   (※ 只讀取有加入 supabase_realtime publication 的資料表)
        │
        ▼
4. 針對「每一個訂閱者」各執行一次 RLS 授權檢查
        │
        ▼
5. 通過檢查的訂閱者,才透過 WebSocket 收到該事件

這條流程解釋了兩個關鍵設計:

  1. 為什麼要把表加入 publication:步驟 3 中,Realtime 只會去讀「有加入 supabase_realtime 這個 publication」的資料表變更。沒加入的表,即使你訂閱了也一個事件都收不到——因為 Realtime 根本沒在監聽它。這是新手最常踩的坑。
  2. 為什麼 RLS 有效:步驟 4 中,每一筆變更都會針對每個訂閱者「各查一次」RLS 政策。所以若 100 個使用者訂閱同一張表,一次資料變更 Realtime 會執行 100 次授權檢查,確保每個人只收到自己有權存取的資料。即時訂閱不會繞過 RLS。

三、channel 訂閱鏈:三個動詞組成的主鏈

在客戶端,訂閱 Postgres Changes 的程式碼永遠是同一條「主鏈」,由三個動作串起來:

const channel = supabase
  .channel('任意頻道名稱')                    // 1. 建立(或複用)一個 channel
  .on('postgres_changes', { /* 設定 */ }, (payload) => {
    // 3. 收到事件時的 callback
  })                                          // 2. 掛上一個 postgres_changes 監聽器
  .subscribe()                                // 4. 正式訂閱、建立連線
  • channel(name):channel 是 Realtime 的「房間」抽象。name 只是一個識別字串,同名會複用既有實例。命名建議帶上情境,例如 todos:${userId},方便追蹤。
  • .on('postgres_changes', config, callback):宣告「我要監聽資料庫變更」。config 物件裡就是本篇的四個關鍵欄位(下方細講)。同一個 channel 可以鏈式掛多個 .on()。
  • .subscribe():真正建立 WebSocket 連線並開始接收。它可以帶一個 callback 接收連線狀態(SUBSCRIBED/CHANNEL_ERROR/TIMED_OUT/CLOSED)。

config 物件的四個關鍵欄位,是本篇的核心術語:

欄位意義範例值
event要監聽的事件類型'INSERT'、'UPDATE'、'DELETE'、'*'(全部)
schema資料表所在的 schema幾乎都是 'public'
table要監聽的資料表名稱'todos'、'messages'
filter伺服器端過濾條件(選填)'user_id=eq.某個uuid'

filter 用的是 PostgREST 風格的 欄位=運算子.值 語法,在伺服器端就先過濾,不符合的事件根本不會傳到客戶端,省流量也更安全。常用運算子:eq(等於)、neq(不等於)、gt/gte/lt/lte(大小比較)、in(在清單中)、like/ilike(萬用字元)、is(NULL 檢查)。多條件用逗號分隔代表 AND(目前不支援 OR)。

實作範例

下面用可直接執行的 SQL 與 TypeScript,完整跑一遍「建表 → 加 publication → 訂閱三種事件 → 更新 UI → 取消訂閱」。假設 supabase 客戶端已用 anon key 初始化,使用者已登入。

一、前置設定:建表、開 RLS、加入 publication(SQL)

這是最容易被忽略、但缺一不可的一步。我們建一張 todos 待辦事項表,開啟 RLS 並寫政策,最後把它加入 Realtime 的 publication:

-- 1. 建立範例資料表
CREATE TABLE todos (
  id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  task        TEXT NOT NULL,
  is_complete BOOLEAN DEFAULT FALSE,
  user_id     UUID REFERENCES auth.users(id),
  created_at  TIMESTAMPTZ DEFAULT NOW()
);

-- 2. 啟用 RLS(Row Level Security,列級安全)
ALTER TABLE todos ENABLE ROW LEVEL SECURITY;

-- 3. 授予 authenticated 角色 SELECT 權限(Realtime 需要能讀才能推送)
GRANT SELECT ON public.todos TO authenticated;

-- 4. 建立 RLS 政策:使用者只能看到自己的待辦事項
--    Realtime 會對每個訂閱者套用這條政策,只推送通過的事件
CREATE POLICY "使用者可讀取自己的待辦事項"
ON todos
FOR SELECT
TO authenticated
USING (auth.uid() = user_id);

-- 5. 【關鍵】把資料表加入 Realtime 的 publication
--    沒有這一步,訂閱了也收不到任何事件!
ALTER PUBLICATION supabase_realtime ADD TABLE todos;

第 5 步是整篇最重要的一行。你也可以在 Supabase Dashboard 的 Database → Replication → supabase_realtime 中勾選這張表,效果相同。若之後訂閱收不到事件,先回來確認這步做了沒。

二、訂閱單一事件類型:分別監聽 INSERT/UPDATE/DELETE

最清楚的寫法,是為每種事件掛一個 .on()。下面示範在同一個 channel 上,分別處理三種事件,並即時更新一份本地清單:

import type { RealtimeChannel } from '@supabase/supabase-js'

// 假設 currentUserId 是目前登入者的 id
function subscribeTodos(currentUserId: string): RealtimeChannel {
  const channel = supabase
    .channel(`todos:${currentUserId}`)
    // 監聽 INSERT:有人新增待辦
    .on(
      'postgres_changes',
      {
        event: 'INSERT',
        schema: 'public',
        table: 'todos',
        filter: `user_id=eq.${currentUserId}`, // 伺服器端只推自己的
      },
      (payload) => {
        console.log('新增:', payload.new)
        // payload.new 是剛插入的整筆資料
      },
    )
    // 監聽 UPDATE:待辦被修改(例如標記完成)
    .on(
      'postgres_changes',
      {
        event: 'UPDATE',
        schema: 'public',
        table: 'todos',
        filter: `user_id=eq.${currentUserId}`,
      },
      (payload) => {
        console.log('更新後:', payload.new)
      },
    )
    // 監聽 DELETE:待辦被刪除
    .on(
      'postgres_changes',
      {
        event: 'DELETE',
        schema: 'public',
        table: 'todos',
        // 注意:DELETE 事件不套用 RLS,filter 也僅在 REPLICA IDENTITY FULL 下有效
      },
      (payload) => {
        console.log('刪除的 id:', payload.old.id)
        // DELETE 預設只帶主鍵,payload.old 只有 { id }
      },
    )
    .subscribe((status) => {
      if (status === 'SUBSCRIBED') {
        console.log('✅ 已訂閱 todos 即時更新')
      } else if (status === 'CHANNEL_ERROR') {
        console.error('❌ 訂閱失敗(檢查 RLS 政策與登入狀態)')
      }
    })

  return channel
}

三個重點:一、每個 .on() 的 callback 收到的 payload 裡,payload.new 是變更後的新資料(INSERT/UPDATE 有),payload.old 是變更前的舊資料(UPDATE/DELETE 有);二、DELETE 預設 payload.old 只包含主鍵,若要拿到完整舊值,需對資料表設定 ALTER TABLE todos REPLICA IDENTITY FULL;;三、filter 讓伺服器端先過濾,前端不必自己判斷「這筆是不是我的」。

三、監聽全部事件:用 event: ‘*’ 一次接三種

若你想在一個 callback 裡處理全部三種事件(常見於「一份清單即時同步」的場景),把 event 設成 '*',再用 payload.eventType 分流:

interface Todo {
  id: number
  task: string
  is_complete: boolean
  user_id: string
}

// setTodos 是你的狀態更新函式(React useState 的 setter 之類)
function subscribeAllChanges(
  currentUserId: string,
  setTodos: React.Dispatch<React.SetStateAction<Todo[]>>,
): RealtimeChannel {
  return supabase
    .channel(`todos-sync:${currentUserId}`)
    .on<Todo>(
      'postgres_changes',
      {
        event: '*', // INSERT + UPDATE + DELETE 全收
        schema: 'public',
        table: 'todos',
        filter: `user_id=eq.${currentUserId}`,
      },
      (payload) => {
        // 依事件類型更新本地清單,讓畫面即時同步
        if (payload.eventType === 'INSERT') {
          setTodos((prev) => [...prev, payload.new])
        } else if (payload.eventType === 'UPDATE') {
          setTodos((prev) =>
            prev.map((t) => (t.id === payload.new.id ? payload.new : t)),
          )
        } else if (payload.eventType === 'DELETE') {
          setTodos((prev) => prev.filter((t) => t.id !== payload.old.id))
        }
      },
    )
    .subscribe()
}

這正是「即時清單」的標準寫法:初次載入時用 supabase.from('todos').select() 抓一次資料,之後就交給這個訂閱,資料庫一有變動,本地 todos 陣列就自動跟著 INSERT/UPDATE/DELETE,畫面隨之更新,不必再手動重新抓。

四、取消訂閱:removeChannel 收乾淨

訂閱一定要有對應的取消。在 React 中,把它放進 useEffect 的 cleanup function:

import { useEffect, useState } from 'react'

function TodoList({ userId }: { userId: string }) {
  const [todos, setTodos] = useState<Todo[]>([])

  useEffect(() => {
    // 建立訂閱
    const channel = subscribeAllChanges(userId, setTodos)

    // 【關鍵】元件卸載或 userId 改變時,取消訂閱、釋放連線
    return () => {
      supabase.removeChannel(channel)
    }
  }, [userId]) // userId 變了會先 cleanup 舊的、再建立新的

  return (
    <ul>
      {todos.map((t) => (
        <li key={t.id}>{t.task}</li>
      ))}
    </ul>
  )
}

supabase.removeChannel(channel) 會取消該 channel 的訂閱並釋放對應的 WebSocket 連線。若一次要清掉所有 channel,可用 supabase.removeAllChannels()。把「建立訂閱」和「取消訂閱」當成必須成對出現的動作,是避免連線洩漏的鐵律(下一節細說)。

常見錯誤與最佳實踐

坑一:訂閱了卻收不到任何事件——十之八九是忘了把表加入 publication。 這是 Postgres Changes 頭號地雷。你把 channel、.on()、.subscribe() 全寫對了,卻一個事件都收不到——因為 Realtime 只監聽「有加入 supabase_realtime publication」的資料表。正確做法:確認執行過 ALTER PUBLICATION supabase_realtime ADD TABLE 你的表;(或在 Dashboard 的 Database → Replication 勾選)。排查時可用這段 SQL 確認:

-- 確認資料表是否已在 Realtime publication 中
SELECT * FROM pg_publication_tables
WHERE pubname = 'supabase_realtime';

坑二:以為即時訂閱會繞過 RLS,於是把敏感資料當「反正只有我收得到」來用。 Postgres Changes 的授權機制正是「訂閱資料表的 RLS 政策」——Realtime 會對每個訂閱者各查一次 RLS,只推送通過的事件。所以:一、若你啟用了 RLS 卻沒寫 SELECT 政策,事件會被全部擋掉、你反而收不到(這也是「收不到事件」的另一個常見原因);二、即時訂閱不是繞過權限的後門,該設的政策一樣要設。正確做法:資料表啟用 RLS 後,務必寫對應的 SELECT 政策,並 GRANT SELECT 給對應角色。另外要特別記住一個例外:DELETE 事件不套用 RLS(因為記錄已刪除,Postgres 無法驗證存取權),所以設計上別讓 DELETE payload 帶出敏感欄位。

坑三:元件卸載忘了 removeChannel,連線像漏水一樣耗盡配額。 每個 .subscribe() 都佔一條 WebSocket channel,而各方案有並行連線上限(Free 約 200、Pro 約 500,以官方為準)。若在 useEffect 裡訂閱卻沒在 cleanup 取消,每次重新 render、切頁、或依賴改變重新訂閱,舊 channel 都不會釋放,連線數持續累積。到上限後新訂閱會「靜默失敗」——沒有明顯錯誤,只是使用者收不到更新,極難 debug。正確做法:在 useEffect 的 return(cleanup function)或 Vue 的 onUnmounted 中呼叫 supabase.removeChannel(channel),讓訂閱與取消訂閱成對出現。

坑四:想拿 UPDATE/DELETE 的「舊值」,卻發現 payload.old 幾乎是空的。 預設情況下,UPDATE/DELETE 事件的 payload.old 只包含主鍵(例如只有 { id }),因為 Postgres 的預設 Replica Identity 只記錄主鍵。正確做法:若你需要完整舊值(例如想比對「改前 vs 改後」),對資料表設定 ALTER TABLE 你的表 REPLICA IDENTITY FULL;,之後 payload.old 才會帶完整的變更前資料。DELETE 事件的 filter 也是在這個設定下才生效。

坑五:filter 亂帶浮動條件、或誤以為支援 OR。 filter 是伺服器端過濾,語法是 欄位=運算子.值,多條件用逗號分隔代表 AND——目前不支援 OR。想「A 或 B」只能訂寬一點(例如不加 filter)在 callback 端自己判斷,或拆成兩個訂閱。正確做法:用 filter 把範圍收到「這個使用者/這個房間」這種穩定條件(如 user_id=eq.${userId}),既省流量又符合權限邊界,別把它當成前端動態查詢用。

最佳實踐總結:

  • 先加 publication 再訂閱:ALTER PUBLICATION supabase_realtime ADD TABLE ...,這是收得到事件的前提
  • RLS 一起設好:即時不繞過 RLS,SELECT 政策沒寫會「反而收不到」;DELETE 例外,別帶敏感欄位
  • 訂閱與取消成對:useEffect cleanup/onUnmounted 一定 removeChannel,避免連線洩漏
  • 需要舊值就設 REPLICA IDENTITY FULL:否則 payload.old 只有主鍵
  • filter 收在伺服器端:用 user_id=eq.${userId} 之類穩定條件,省流量又貼合權限;多條件是 AND,無 OR
  • 不確定的細節以官方為準:方案連線上限、限制隨時可能調整,正式決策前查 Supabase 官方文件

小結

這是 Supabase 系列教學的第 027 篇,也是 Realtime 段的開場。上一篇《Image Transformations 與 S3》我們收尾了 Storage,把「檔案」做到又安全、又快、又能規模化;那是「靜態的資料」。這一篇,我們轉向「即時的資料流」——用 Postgres Changes 讓資料庫的每一次變動,都能即時推播到前端。

核心觀念濃縮成一句話:Supabase Realtime 有三大功能——Postgres Changes(監聽 DB 變更、綁 RLS)、Broadcast(低延遲短訊息)、Presence(線上狀態);本篇的 Postgres Changes 用 supabase.channel('x').on('postgres_changes', { event, schema, table, filter }, cb).subscribe() 這條主鏈訂閱資料表的 INSERT/UPDATE/DELETE,前提是把表加入 supabase_realtime publication,授權靠訂閱表的 RLS 政策,卸載時務必 removeChannel 取消訂閱。

  • 三大功能定位:要「資料是否寫進 DB」的通知用 Postgres Changes,要高頻短訊息用 Broadcast,要「誰在線上」用 Presence
  • 訂閱主鏈:channel() → .on('postgres_changes', { event, schema, table, filter }, cb) → .subscribe()
  • 兩個前提:表要 ADD TABLE 進 supabase_realtime publication;RLS 政策要寫對(即時不繞過 RLS)
  • 關鍵細節:payload.new/payload.old、event: '*' + eventType 分流、REPLICA IDENTITY FULL 取舊值、DELETE 不套 RLS
  • 避坑:忘加 publication、誤以為繞過 RLS、忘了 removeChannel 導致連線洩漏

到這裡,你已經能把「資料庫的每一次變更」變成「前端可以即時訂閱的事件」——這是即時應用最實用的一塊拼圖。但 Postgres Changes 有它的邊界:它綁在資料庫寫入上,訂閱者一多、RLS 逐一檢查的成本就會浮現;而有些場景(游標、打字指示、遊戲事件)根本不需要、也不該寫進資料庫。這時就輪到下一種功能上場。下一篇《Broadcast 廣播》,我們來看 Supabase 怎麼用不經過資料庫的低延遲廣播,把「稍縱即逝」的高頻訊息,在毫秒間送到每一個訂閱者手上。Postgres Changes 打底,Broadcast 提速,下一篇見。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。

本週主打

AI 自動化入門包

你每天手動在做的那些煩事,其實 AI 可以自己跑。這份給你 10 個照著做就會的自動化工作流 + 50 個複製即用的提示詞,不用會寫程式。

看看這個產品 →