切换语言
切换主题

Supabase Realtime 实战:WebSocket 连接管理与断线重连策略

凌晨三点,手机震了一下。

是客户发来的消息:“你们的聊天应用,用户说消息经常延迟,有时候刷新页面才能看到新消息。”

我盯着屏幕,心里咯噔一下。这个问题我太熟了——WebSocket 断了,但前端根本不知道。用户继续打字、发送,以为消息发出去了,实际上全丢在半路上。

说实话,第一次用 Supabase Realtime 的时候,我也踩过这个坑。那会儿做协作白板项目,觉得订阅数据库变更就是几行代码的事:

supabase.channel('board').on('postgres_changes', ...).subscribe()

结果上线没两天,同事反馈:“我们的同步经常卡住,画了一半的线条突然没了。”

排查才发现,WebSocket 连接已经悄无声息地断了。没有报错,没有提示,就是单纯地”死了”。那一刻我才意识到:实时订阅不只是写订阅代码那么简单,连接管理才是重头戏。

这篇文章,我把自己踩过的坑和摸索出来的解决方案都整理出来。重点讲 WebSocket 连接生命周期管理——这也是我发现市面上教程讲得最少的部分。先说三大功能的选型,再手把手实现 Postgres Changes 订阅,最后聊聊生产环境的断线重连策略和配置优化。

一、Supabase Realtime 三大功能,到底该用哪个?

刚接触 Supabase Realtime 的时候,我被三个名词搞晕了:Broadcast、Presence、Postgres Changes。文档说它们是三种不同的实时功能,但到底该用哪个?

先说结论,三者的核心区别在于数据存在哪里

功能数据存储典型场景延迟
Broadcast仅内存,不持久化客户端间消息传递、鼠标位置同步最低
Presence内存键值存储(CRDT)在线用户列表、协作状态同步
Postgres ChangesPostgreSQL 数据库聊天消息、订单状态变更中等

嗯,光看表格可能还是有点抽象。我换个说法:

Broadcast 就像一个”传声筒”。你喊一句话,所有在听的人都能听到,但说完就没了,不留痕迹。适合那些”转瞬即逝”的数据——比如协作编辑时的光标位置,你移动鼠标,别人看到你的光标跟着动,但没人关心你 5 秒前光标在哪。

Presence 像”签到簿”。每个人进来签个到,写上自己的状态(在线、离线、正在编辑…),所有人都能看到这份名单。关键点是:状态会自动同步,而且基于 CRDT(无冲突复制数据类型),不用担心两个人同时改同一行数据会冲突。

Postgres Changes 则是”数据库监听器”。数据库里的数据变了,你收到通知。这是最”重”的一个,但也是最可靠的——因为数据存在 PostgreSQL 里,就算断线重连,消息也不会丢。

怎么选?一个简单的判断方法

问自己两个问题:

  1. 数据需要持久化吗?

    • 需要持久化 → Postgres Changes
    • 不需要持久化 → 继续问第二个问题
  2. 数据是”事件”还是”状态”?

    • 事件(某个动作发生了)→ Broadcast
    • 状态(某人正在做什么)→ Presence

举个例子:聊天应用里,“发送消息”是事件,用 Broadcast 或 Postgres Changes;“正在输入”是状态,用 Presence;“新消息通知”需要持久化,用 Postgres Changes。

我那个协作白板项目,最后是这样分配的:

  • 画笔轨迹同步 → Broadcast(快,不需要保存)
  • 谁在线、谁在画哪一块 → Presence(状态同步)
  • 白板内容保存 → Postgres Changes(持久化到数据库)

二、实时订阅实战:Postgres Changes

确定用 Postgres Changes 后,第一件事是开启 publication

Supabase 默认不会把所有表的变更都广播出去——这太耗资源了。你需要明确告诉它:“这张表,我要监听。”

-- 在 Supabase SQL Editor 里执行
ALTER PUBLICATION supabase_realtime ADD TABLE messages;

执行完这条命令,messages 表的 INSERT、UPDATE、DELETE 操作就会被广播出去。

订阅代码怎么写?

先看一个完整示例——聊天室新消息实时推送:

import { createClient } from '@supabase/supabase-js'

const supabase = createClient(
  'https://your-project.supabase.co',
  'your-anon-key'
)

// 创建频道并订阅
const channel = supabase
  .channel('messages-channel')  // 频道名自定义
  .on(
    'postgres_changes',
    {
      event: 'INSERT',        // 只监听新增
      schema: 'public',
      table: 'messages'
    },
    (payload) => {
      console.log('收到新消息:', payload.new)
      // payload.new 就是新插入的行数据
      appendMessage(payload.new)
    }
  )
  .subscribe((status) => {
    console.log('订阅状态:', status)
  })

// 别忘了组件卸载时清理
// channel.unsubscribe()

这段代码看起来挺简单,但有几个细节容易踩坑:

坑一:event 参数可选值

event 可以是 'INSERT''UPDATE''DELETE',或者 '*' 监听所有事件。但如果你只关心新增消息,就别用 '*',省下不必要的网络流量。

坑二:payload 结构

payload 不是整条记录,而是一个对象:

  • payload.new:新数据(INSERT/UPDATE 有效)
  • payload.old:旧数据(UPDATE/DELETE 有效,需要开启 replica identity)
  • payload.eventType:事件类型
  • payload.schemapayload.table:来源信息

坑三:Row Level Security 会生效

这是很多人忽略的点:Realtime 订阅同样遵循 RLS 规则。

如果你配置了 RLS,用户只能收到他们”有权限看到”的变更。比如 messages 表限制了用户只能看自己参与的消息,那 Realtime 也只会推送这些消息——不是所有消息都推送过来再前端过滤。

这其实是 Supabase Realtime 的一大优势:安全逻辑不需要写两遍。

开启旧数据获取(replica identity)

默认情况下,UPDATE 和 DELETE 事件的 payload.old 是空的。如果你需要旧数据(比如记录”谁把什么改成了什么”),要开启 replica identity:

ALTER TABLE messages REPLICA IDENTITY FULL;

但要注意,这会增加写入时的开销和 WAL 日志体积。生产环境谨慎评估是否真的需要。

三、WebSocket 连接管理的那些坑

回到开头那个问题:WebSocket 断了,前端不知道。

Supabase Realtime 底层用的是 Phoenix Channels,连接状态变化会触发回调。但你得主动监听,否则什么消息都收不到。

连接状态一览

订阅回调的 status 参数有几种值:

状态含义你该做什么
SUBSCRIBED成功订阅正常工作,收消息
CHANNEL_ERROR连接出错记录日志,尝试重连
TIMED_OUT超时(无响应)可能网络波动,触发重连
CLOSED连接关闭用户主动断开或服务端关闭

看起来挺清晰,但实际用起来有个坑:状态切换可能很快,来不及处理

比如网络抖动时,可能瞬间经历 CHANNEL_ERROR → CLOSED → SUBSCRIBED(自动重连成功),你甚至没注意到中间出问题了。

我后来加了个全局的状态监控,把每次状态变化都记下来:

const channel = supabase
  .channel('messages-channel')
  .on('postgres_changes', { ... }, handler)
  .subscribe((status, err) => {
    logConnectionStatus(status, err)  // 记录状态和时间戳

    if (status === 'CHANNEL_ERROR' || status === 'TIMED_OUT') {
      showReconnectingToast()  // 给用户一个提示
    }

    if (status === 'SUBSCRIBED') {
      hideReconnectingToast()
      syncMissedMessages()  // 补齐断线期间丢失的消息
    }
  })

心跳检测:它怎么知道连接活着?

Supabase Realtime 内部有心跳机制(源码在 keep_alive.ex),服务端每隔一段时间会发一个心跳包,客户端收到后回一个确认。

如果客户端连续几次没回应,服务端就认为连接死了,主动断开。反过来,如果客户端一段时间没收到心跳,也会触发超时重连。

但你不用手动处理心跳——Supabase SDK 自动帮你做了。真正需要关心的,是超时后的重连策略。

断线重连:指数退避 vs 立即重连

Supabase 默认的自动重连用的是指数退避:第一次重连等 1 秒,第二次等 2 秒,第三次等 4 秒… 最多等 30 秒左右。

好处是:如果服务端暂时过载,不会被大量重连请求打垮。坏处是:用户可能等很久才恢复。

对于协作类应用(白板、文档编辑),我会改用更激进的重连策略

// 手动重连,不依赖默认的指数退避
let reconnectAttempts = 0
const MAX_RECONNECT = 10

function handleDisconnect() {
  if (reconnectAttempts >= MAX_RECONNECT) {
    showFatalError('无法恢复连接,请刷新页面')
    return
  }

  // 前几次快速重连,后面逐步放慢
  const delay = reconnectAttempts < 3 ? 1000 : 3000

  setTimeout(() => {
    reconnectAttempts++
    channel.subscribe()  // 再次尝试订阅
  }, delay)
}

重连后,断线期间的消息怎么办?

这是最头疼的问题:断线 30 秒,期间别人发了 10 条消息,你怎么补回来?

方案一:前端请求 API 补齐

重连成功后,立即调用一个 API,获取”上次成功消息 ID 之后”的所有消息:

// 记住最后收到的消息 ID
let lastMessageId = null

function syncMissedMessages() {
  supabase
    .from('messages')
    .select('*')
    .gt('id', lastMessageId)
    .order('created_at', { ascending: true })
    .then(({ data }) => {
      // 把漏掉的消息追加到列表
      appendMessages(data)
      lastMessageId = data[data.length - 1]?.id
    })
}

方案二:服务端推送”断线期间的变更”

这需要后端配合,在数据库里存一份”未推送的变更”,等客户端重连后批量推送。复杂度更高,但更可靠。

对于小项目,方案一够用了。关键是:一定要在重连成功后立即同步,不要等用户手动刷新

四、Broadcast 和 Presence:不只是聊天室

前两章主要讲了 Postgres Changes,这章说说另外两个功能——Broadcast 和 Presence。

Broadcast:协作编辑器的光标同步

多人协作编辑文档时,看到别人的光标在哪里,体验会好很多。这个场景用 Broadcast 最合适:

// 发送自己的光标位置
const broadcastChannel = supabase.channel('editor-cursors')

// 监听别人的光标
broadcastChannel
  .on('broadcast', { event: 'cursor-move' }, (payload) => {
    updateRemoteCursor(payload.userId, payload.x, payload.y)
  })
  .subscribe()

// 自己移动时广播出去
document.addEventListener('mousemove', (e) => {
  broadcastChannel.send({
    type: 'broadcast',
    event: 'cursor-move',
    payload: {
      userId: currentUser.id,
      x: e.clientX,
      y: e.clientY
    }
  })
})

几个注意点:

  • broadcastChannel.send() 是主动发送,不是订阅后的回调
  • 频道名可以自定义,不同编辑器用不同频道就能隔离
  • 光标位置这种数据不需要持久化,Broadcast 的”即发即忘”特性刚好合适

Presence:谁在线,一目了然

Presence 适合展示”状态类”信息。比如在线用户列表:

const presenceChannel = supabase.channel('online-users', {
  config: {
    presence: {
      key: 'user_id'  // 用于识别唯一用户
    }
  }
})

presenceChannel
  .on('presence', { event: 'sync' }, () => {
    const state = presenceChannel.presenceState()
    // state 是一个对象,key 是 user_id,value 是状态数组
    renderOnlineUsers(Object.keys(state))
  })
  .on('presence', { event: 'join' }, ({ newPresences }) => {
    // 新用户加入
    showToast(`${newPresences[0].user_name} 加入了`)
  })
  .on('presence', { event: 'leave' }, ({ leftPresences }) => {
    // 用户离开
    showToast(`${leftPresences[0].user_name} 离开了`)
  })
  .subscribe()

// 上线时登记自己的状态
presenceChannel.track({
  user_id: currentUser.id,
  user_name: currentUser.name,
  online_at: new Date().toISOString()
})

track() 方法告诉频道”我在这里”。状态会自动同步给所有订阅者,而且基于 CRDT,不用担心冲突。

私有频道:限制谁能订阅

默认情况下,任何有 anon key 的人都能订阅公开频道。但有些场景需要限制访问——比如某个团队私有的协作空间。

Supabase 支持通过 RLS Policy 控制频道访问权限:

-- 在 realtime Schema 里创建 Policy
CREATE POLICY "Only team members can join private channel"
ON realtime.channels
FOR ALL
USING (
  -- 检查用户是否属于该团队
  EXISTS (
    SELECT 1 FROM team_members
    WHERE team_id = channel.team_id
    AND user_id = auth.uid()
  )
);

这样,只有团队成员才能订阅 private-team-xxx 频道,其他人会被拒绝。

五、生产环境:这些配置参数必须知道

本地开发跑得好好的,上线后问题一堆。原因往往在配置。

Realtime 服务端的几个关键参数

Supabase Realtime 默认配置对大多数项目够用,但高并发场景需要调优:

参数默认值建议作用
DB_POOL_SIZE10根据并发连接数调整PostgreSQL 连接池大小
DB_QUEUE_TARGET100ms调低可减少延迟,但增加 CPU批量推送消息的等待时间
SUBSCRIBER_LIMIT200根据用户量调整单频道最大订阅者数

如果发现消息延迟明显增加,可以调低 DB_QUEUE_TARGET(比如 50ms)。代价是服务端更频繁地检查变更,CPU 使用率上升。

多租户架构的连接限制

一个常见坑:多租户系统里,每个租户一个频道,总频道数很快爆炸。

Supabase Realtime 对单个项目的总订阅数有限制(Pro 计划是 5000 个并发订阅)。如果你的系统有 1000 个租户,每个租户平均 5 人在线,刚好卡在边界。

解决方案:

  • 合并频道:不需要每个租户独立频道,可以在一个频道里用 filter 分离
  • 选择性订阅:用户只订阅当前所在的租户频道,不订阅所有
// 用 filter 过滤只属于当前租户的消息
supabase
  .channel('tenant-messages')
  .on(
    'postgres_changes',
    {
      event: 'INSERT',
      schema: 'public',
      table: 'messages',
      filter: 'tenant_id=eq.123'  // 只收租户 123 的消息
    },
    handler
  )
  .subscribe()

与竞品对比:Supabase vs Pusher vs Firebase

最后简单对比下几个主流实时方案:

方案成本功能丰富度学习曲线
Supabase Realtime免费(Pro $25/月)高(三合一 + 数据库绑定)
Pusher$29 起中(纯 WebSocket)
Firebase Realtime DB按用量计费中(绑定 Firebase 生态)

Supabase 的优势在于:Postgres Changes 直接监听数据库变更,不需要额外写推送逻辑;RLS 自动生效,安全逻辑统一。劣势是:需要理解 PostgreSQL 机制,学习曲线稍陡。

如果你已经在用 Supabase 做 Auth 和 Storage,Realtime 直接加进来很顺手。如果只是需要简单的 WebSocket,Pusher 可能上手更快。

总结

说了这么多,核心就三点:

选对功能:Broadcast 传事件,Presence 状态同步,Postgres Changes 数据持久化。问自己两个问题——数据要不要持久化、是事件还是状态——答案就出来了。

管好连接:订阅成功不代表一直能收消息。主动监听状态变化,给用户提示”正在重连”,重连后立即同步漏掉的数据。这几件事做好了,实时体验才稳定。

调好配置:生产环境不是本地开发的放大版。DB_POOL_SIZE、QUEUE_TARGET 这些参数,直接影响延迟和吞吐量。上线前至少看一眼默认值,心里有数。

我最早踩的那个坑——WebSocket 断了不知道——后来用状态监控 + 重连提示解决了。用户体验立马好了一截:断网了会看到”正在恢复连接”,而不是傻等;重连成功后消息自动补齐,不用手动刷新。

如果你还没用过 Supabase Realtime,建议先从 Postgres Changes 开始——最简单,也是最常用的场景。配上之前写的 Auth 系列文章(邮箱验证、OAuth 配置),就能搭出一个完整的实时后端了。

有问题可以留言,或者直接看 Supabase 官方文档。架构那篇写得挺清楚,想深入了解 Phoenix Channels 和 PG2 adapter 的可以去读读源码。

常见问题

Supabase Realtime 三种功能有什么区别?
Broadcast 用于客户端间事件传递(如光标同步),Presence 用于状态同步(如在线用户),Postgres Changes 用于数据库变更监听。选型看两个问题:数据是否需要持久化,是事件还是状态。
WebSocket 断线后如何恢复?
Supabase 默认使用指数退避重连。你也可以自定义策略:

• 前几次快速重连(1秒)
• 后面逐步放慢(3秒)
• 重连成功后立即同步漏掉的消息
Realtime 订阅是否遵循 RLS 规则?
是的,Realtime 订阅同样遵循 Row Level Security 规则。用户只能收到他们有权限看到的变更,安全逻辑不需要写两遍。
生产环境需要关注哪些配置参数?
三个关键参数:

• DB_POOL_SIZE:PostgreSQL 连接池大小,默认 10
• DB_QUEUE_TARGET:批量推送等待时间,默认 100ms
• SUBSCRIBER_LIMIT:单频道最大订阅者数,默认 200
多租户系统如何避免频道爆炸?
使用 filter 参数在一个频道内过滤消息,而不是为每个租户创建独立频道。例如 filter: "tenant_id=eq.123" 只收特定租户的变更。
Supabase Realtime 与 Pusher/Firebase 对比如何?
Supabase 优势是 Postgres Changes 直接监听数据库、RLS 自动生效。劣势是学习曲线稍陡。如果已在用 Supabase Auth/Storage,Realtime 很顺手;如果只需简单 WebSocket,Pusher 上手更快。

12 分钟阅读 · 发布于: 2026年4月26日 · 修改于: 2026年4月29日

当前属于系列阅读 第 9 / 9 篇

Supabase 实战指南

如果你是从搜索进入这篇文章,建议顺手补上上一篇或继续下一篇,这样更容易把同一主题读完整。

查看系列总览

相关文章

BetterLink

想持续收到这个主题的更新?

你可以直接关注作者更新、订阅 RSS,或者继续沿着系列入口往下读,避免下次又回到搜索结果重新找。

关注公众号

评论

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