Realtime: Postgres Changes Troubleshooting
Last edited: 10/14/2026
一个实时订阅连接后,数据库中的某一行发生变化,但客户端什么都没有收到。没有错误,也没有事件——一片寂静。这是开发者在使用 Supabase Realtime 时比较常遇到的问题之一,而原因几乎总是集中在几种情况之一。
🌐 A Realtime subscription connects, a row changes in the database, and nothing arrives on the client. No error, no event — silence. This is one of the more common issues developers run into with Supabase Realtime, and the cause is almost always one of a handful of things.
下面的顺序反映了每个问题实际上出现的频率。即使订阅代码看起来没问题,也要尽早检查 RLS——它是导致“悄无声息缺失”事件的最大来源,而且差距很大。
🌐 The order below reflects how often each one turns out to be the actual problem. Check RLS early, even if the subscription code looks fine — it's the single biggest source of "silently missing" events, by a wide margin.
有关更多实时特定问题的索引,请参阅 故障排除 目录。
🌐 For a broader index of Realtime-specific issues, see the Troubleshooting directory.
步骤 1:这个表格在实时发布中吗? #
🌐 Step 1: Is the table in the Realtime publication?
Realtime 默认不会监视每个表。每个表都必须被添加到一个叫做 supabase_realtime 的发布中。如果没有添加,Realtime 根本看不到那个表——不管客户端订阅怎么设置。
🌐 Realtime doesn't watch every table by default. Each one has to be added to a publication called supabase_realtime. If it isn't, Realtime has no visibility into that table at all — no matter how the client subscription is set up.
select * from pg_publication_tables where pubname = 'supabase_realtime';如果结果中没有表格:
🌐 If the table's missing from the results:
alter publication supabase_realtime add table your_table;或者在仪表板的 数据库 → 发布物 中切换开启。
🌐 Or toggle it on from Database → Publications in the dashboard.
这一点在最近创建的表上常被忽视——创建表和启用实时功能是两个独立的步骤:
🌐 This gets overlooked on a table created recently — creating the table and enabling Realtime on it are two separate steps:
alter publication supabase_realtime add table messages;第二步:RLS是不是悄悄地阻止了这一行? #
🌐 Step 2: Is RLS quietly blocking the row?
这是最常见的原因,也是最浪费时间的,因为没有出错。事件没有显示出来。
🌐 This is the most common cause, and it's the one that wastes the most time, because nothing errors. The event doesn't show up.
实时会像普通查询一样以订阅客户端的角色执行 RLS。SUBSCRIBED 只是表示 WebSocket 已连接,并不意味着该客户端可以看到数据。
🌐 Realtime enforces RLS the same way a normal query would — as the subscribing client's role. SUBSCRIBED only means the WebSocket connected. It says nothing about whether that client is allowed to see the data.
直接用任一选项测试一下:
🌐 Test it directly with either options:
使用客户端相同的凭证,尝试选择已更改的行:
🌐 Using the same credentials the client uses, try to select the row that changed:
const { data, error } = await supabase.from('messages').select('*').eq('id', theRowIdThatChanged)console.log({ data, error })或者在仪表板上,打开表格编辑器,尝试以订阅用户的角色查看该行。
🌐 Or on the dashboard, open Table Editor, and try to view the row as the subscribing user's role.
空的 data?那就是答案。策略正在阻止这个用户的这一行,实时功能正按预期运行。
🌐 Empty data? That's the answer. The policy is blocking this row for this user, and Realtime is doing exactly what it's supposed to.
一个可能的解决方法是:
🌐 A fix might look like:
create policy "Users can view messages in their rooms"on messages for selectusing ( exists ( select 1 from room_members where room_members.room_id = messages.room_id and room_members.user_id = auth.uid() ));针对 UPDATE 事件的一个特定陷阱:RLS 通常必须同时允许旧行和新行的状态。如果一个策略只允许 status = 'active',而一次更新把状态改成了 archived,这个事件可能无法传递——这行数据刚刚还可见,但新状态不再通过检查。
🌐 One trap specific to UPDATE events: RLS generally has to allow both the old and new row state. If a policy only permits status = 'active', and an update flips the status to archived, the event can fail to deliver — the row was visible a second ago, but the new state no longer passes the check.
查看 行级安全 了解底层模型,和 为什么一个查询可能返回空数据数组,这与上面的测试直接相关。
🌐 See Row Level Security for the underlying model, and why a select can return an empty data array, which is directly relevant to the test above.
步骤 3:payload.old#
🌐 Step 3: Missing fields in payload.old?
如果事件在到达,但 payload.old 大部分是空的或者是 null,那是复制标识问题,不是传递问题。
🌐 If events are arriving but payload.old is mostly empty or null, that's a replica identity problem, not a delivery problem.
默认情况下,Postgres 只会在 UPDATE 和 DELETE 的“旧行”中发送主键。如果你的代码比较旧值和新值,那样不够用:
🌐 By default, Postgres only sends the primary key in the "old row" for UPDATE and DELETE. If your code compares old vs. new values, that's not enough:
alter table messages replica identity full;如果表启用了行级安全(Row Level Security),仅仅有 replica identity full 是不够的:old 记录仍然只包含主键。在启用 RLS 的情况下无法绕过这一点——不要依赖其他 payload.old 字段来访问受策略保护的表。
🌐 If the table has Row Level Security enabled, replica identity full isn't enough by itself: the old record still contains only the primary key. There's no way around this while RLS is on — don't rely on other payload.old fields for a policy-protected table.
一个典型的情况:status 列从 pending 移动到 completed,然后客户进行检查
🌐 A typical case: a status column moves from pending to completed, and the client checks
if (payload.old.status !== payload.new.status) { notifyUser()}没有 replica identity full(或者启用了 RLS),payload.old.status 是 undefined,而 undefined !== 'completed' 是 true —— 所以这个检查在每次更新时都会触发 notifyUser(),而不仅仅是在状态真正变化的时候。
🌐 Without replica identity full (or with RLS enabled), payload.old.status is undefined, and undefined !== 'completed' is true — so the check fires notifyUser() on every update, not just the ones where status actually changed.
步骤4:检查订阅代码本身 #
🌐 Step 4: Check the subscription code itself
一旦排除了发布和 RLS,就看看订阅配置。表名、架构、过滤语法——这里的小不匹配很常见。
🌐 Once publication and RLS are ruled out, look at the subscription config. Table name, schema, filter syntax — small mismatches here are common.
- 表名完全匹配,包括大小写
- 模式是正确的(
public,除非你在使用自定义的) - 过滤器语法是正确的:
room_id=eq.abc123,不是room_id = abc123
const channel = supabase .channel('room-messages') .on( 'postgres_changes', { event: 'UPDATE', schema: 'public', table: 'messages', filter: `room_id=eq.${roomId}`, }, (payload) => { console.log('Got an update:', payload.new) } ) .subscribe((status) => { console.log('Subscription status:', status) })记录状态回调。不要以为 .subscribe() 成功了。
🌐 Log the status callback. Don't assume .subscribe() worked.
SUBSCRIBED— 已连接CHANNEL_ERROR— 检查错误信息CLOSED— 通道被关了TIMED_OUT— 连接问题;参见 实时连接出现TIMED_OUT错误
一个卡在 CHANNEL_ERROR 的通道和一个可以正常连接但从不触发的通道是不同的问题。在进一步操作之前,先搞清楚你遇的是哪种情况。
🌐 A channel stuck at CHANNEL_ERROR is a different problem than one that connects fine but never fires. Figure out which one you're dealing with before going further.
步骤5:过时或重复的订阅 #
🌐 Step 5: Stale or duplicate subscriptions
这个几乎总是 React/Next.js 的问题。
🌐 This one is almost always a React/Next.js problem.
一个组件重新渲染,roomId 发生变化,而旧的通道没有被清理。现在你有两个订阅在运行,或者其中一个在监听过时的参数。它并不总是看起来出问题——有时候看起来像是事件重复,或者是错误房间的事件。
🌐 A component re-renders, roomId changes, and the old channel doesn't get cleaned up. Now you've got two subscriptions running, or one listening against a stale parameter. It doesn't always look broken — sometimes it looks like duplicate events, or events for the wrong room.
useEffect(() => { const channel = supabase .channel(`room:${roomId}`) .on( 'postgres_changes', { event: 'UPDATE', schema: 'public', table: 'messages', filter: `room_id=eq.${roomId}` }, (payload) => console.log(payload.new) ) .subscribe() return () => { supabase.removeChannel(channel) }}, [roomId])那行清理代码是大家常常跳过的部分。如果没有它,旧的通道会在后台继续对之前的 roomId 运行,而且界面上不会有任何提示。查看 Next.js 13/14 更改 RLS 或表数据时的陈旧数据 了解同一问题的另一个版本。
🌐 That cleanup line is the part people skip. Without it, the old channel keeps running against the previous roomId in the background, and nothing in the UI tells you it's happening. See Next.js 13/14 stale data when changing RLS or table data for an adjacent version of the same bug.
第6步:紧跟在 SUBSCRIBED#
🌐 Step 6: Writing right after SUBSCRIBED
客户端报告 SUBSCRIBED 和后端复制监听器准备好流式传输之间有一个确认的时间差。在这个时间差内进行的写入可能会被遗漏。
🌐 There's a confirmed timing gap between the client reporting SUBSCRIBED and the backend's replication listener being ready to stream. A write made in that gap can get missed.
这种情况最常出现在自动化测试中,当订阅和写入紧密相连时:
🌐 This shows up most in automated tests, where subscribe and write happen back-to-back:
// Risky — writing immediately after subscribingconst channel = supabase.channel('test-channel').on(/* ... */).subscribe()await supabase.from('messages').insert({ text: 'hello' })可靠的解决方法不是固定延迟——而是等待后台通过它发出的 system 消息确认 postgres_changes 扩展正在监听:
🌐 The reliable fix isn't a fixed delay — it's waiting for the backend to confirm the postgres_changes extension is listening, via the system message it emits:
supabase .channel('room1') .on('system', '*', (payload) => { if (payload.extension === 'postgres_changes' && payload.status === 'ok') { console.log('changes are ready', payload) // safe to write now } }) .on('postgres_changes', { event: '*', schema: '*' }, (payload) => { console.log('Change received!', payload) }) .subscribe()这里不需要 await 什么——在触发写操作之前,在处理器里对消息做出反应(例如设置一个标志)。这个 system 消息还没有正式文档说明。
🌐 No need to await anything here — react to the message inside the handler (e.g. set a flag) before triggering the write. This system message isn't formally documented yet.
如果你现在不能接好这个,SUBSCRIBED 之后短暂的固定延时是一个较弱的备选方案:
🌐 If you can't wire this up right now, a short fixed delay after SUBSCRIBED is a weaker fallback:
channel.subscribe(async (status) => { if (status === 'SUBSCRIBED') { await new Promise((resolve) => setTimeout(resolve, 1000)) await supabase.from('messages').insert({ text: 'hello' }) }})第7步:实时功能开启了吗? #
🌐 Step 7: Is Realtime enabled?
有时候答案很直接。看看:
🌐 Sometimes the answer is straightforward. Check:
- 项目设置 → 实时 — 在项目层面启用了吗?
- 数据库 → 发布物 — 要为这个特定表格开启吗?
在项目暂停后重新启动时再次检查——复制槽可能需要重新连接。如果项目的负载比平时高,也要检查实时“并发峰值连接”配额——达到该限制可能看起来就像订阅出问题了一样。
🌐 Check this again after a project's been paused and restarted — the replication slot can need to reconnect. If the project's under heavier load than usual, also check the Realtime "Concurrent Peak Connections" quota — hitting that limit can look a lot like a broken subscription.
第8步:查看实时日志 #
🌐 Step 8: Read the Realtime logs
还卡住吗?去仪表板的 日志 → 实时 看看。留意连接中断、复制错误和速率限制。
🌐 Still stuck? Logs → Realtime in the dashboard. Look for connection drops, replication errors, rate limiting.
如果你想获得比默认日志更多的细节,请参阅 使用 Logger 和日志级别进行实时调试。如果连接看起来是间歇性断开而不是完全失败,可以查看 实时:在后台应用中处理静默断开 和 理解与监控实时心跳。
🌐 For more detail than the default logs give you, see Debug Realtime with Logger and Log Levels. If the connection seems to drop intermittently rather than failing outright, check Realtime: Handling Silent Disconnections in Background Applications and Understanding and Monitoring Realtime Heartbeats.
无论如何都要考虑的一点:实时通信并不能保证每条消息都能送达。网络小故障或重连可能会导致某些事件丢失。如果遗漏的更新很重要,就把实时事件当作重新获取状态的一个信号,而不是唯一的真实信息来源。这样的话,丢失的事件只是一个小麻烦,而不是程序 bug。
🌐 One thing to design around regardless: Realtime doesn't guarantee every message gets delivered. Network blips and reconnects can drop an event here and there. If a missed update matters, treat the Realtime event as a signal to re-fetch state — not as the only source of truth. Then a dropped event is an inconvenience, not a bug.
还是没动静吗? #
🌐 Still nothing?
排除网络层:
🌐 Rule out the network layer:
- 公司防火墙或代理阻止 WebSocket 连接
- 过时的
supabase-js版本——查看最近的更新说明
希望这份指南能帮你解决这类问题。
🌐 Hopefully this guide is helpful to you in resolving these types of issues.