场景代入:你要做一个客服聊天,用户发一句、客服立刻看到、两边历史都在。传统做法要自己搭 WebSocket 服务 + 消息表 + 在线状态,挺费劲。Supabase 把这三样(消息落库、变更推送、在线状态)都现成提供,这一节我们用聊天为例把它们装起来。
消息落库是底线——实时只管"增量推送",历史必须落表才能翻页、搜索、审计。
create table public.messages ( id bigint generated always as identity primary key, room_id uuid not null, sender_id uuid references auth.users(id), body text not null, created_at timestamptz default now() ); alter table public.messages enable row level security; -- 房间参与者可读 create policy "房间成员可读" on public.messages for select using ( exists ( select 1 from public.room_members rm where rm.room_id = messages.room_id and rm.user_id = auth.uid() ) ); -- 登录用户可发到自己在的房间 create policy "成员可发" on public.messages for insert with check ( exists ( select 1 from public.room_members rm where rm.room_id = messages.room_id and rm.user_id = auth.uid() ) );
前端监听 messages 表的 INSERT,新消息即时追加:
const channel = supabase .channel(`room:${roomId}`) .on( 'postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages', filter: `room_id=eq.${roomId}` }, (payload) => appendMessage(payload.new) ) .subscribe()
filter 只收本房间的变更,避免收到无关房间的消息浪费带宽。
用 presence 显示"谁在线",用 broadcast 发"正在输入"这类瞬态信号(不入表):
const room = supabase.channel(`room:${roomId}`, { config: { presence: { key: me } }, }) room .on('presence', { event: 'sync' }, () => showOnline(room.presenceState())) .on('broadcast', { event: 'typing' }, () => showTypingIndicator()) .subscribe() input.addEventListener('input', () => room.send({ type: 'broadcast', event: 'typing', payload: { who: me } }) )
下面 SVG 把"一条消息从发送到双方上屏"的时序画清,区分了落库与推送两件事:

背景:一个客服要同时处理多个用户会话,每个会话独立,消息不串。
操作过程:
room_members 表,把客服和用户关联到同一 room_id。room:${roomId}。channel.unsubscribe() 旧房间、订阅新房间,避免收错。from('messages').select().eq('room_id', id).order('created_at').range(0, 49),实时只管增量。// 切换房间 current?.unsubscribe() current = supabase.channel(`room:${newRoom}`) .on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'messages', filter: `room_id=eq.${newRoom}` }, append) .subscribe()
结果:客服在多个会话间切换流畅,消息按房间隔离,历史与实时都正确。
解读:聊天系统的关键在"房间隔离 + 历史落库 + 实时增量"三者配合。Supabase 把每条都现成化,你只需把数据模型设计对(room 与 member 关系),而非自己造推送服务。
变式:若要"已读回执",可加 read_by 数组列,接收方打开会话时 update 打标,配合实时推送让对方看到已读——仍落库,仍是同一套机制。
// 已读回执:打开会话时把当前用户写进 read_by 数组 await supabase .from('messages') .update({ read_by: supabase.sql`array_append(read_by, ${me})` }) .eq('room_id', roomId) .neq('sender_id', me)
上例里 neq('sender_id', me) 很重要:自己的消息不该被自己标成已读。若漏掉这个条件,客服一眼没看就会把"自己发的消息"算作已读,已读率虚高,反而掩盖漏回的问题。
实时只接最新增量,历史要靠查询拉。直接用 range(0, 49) 在首屏没问题,但滑到很深的聊天记录时,offset 越大越慢——Postgres 得先扫描并丢弃前面所有行。改用"游标分页",以最后一条消息的 created_at 为锚点向后翻:
// 以最后一条已知消息时间为游标,向后取更早的一页 const { data } = await supabase .from('messages') .select('*') .eq('room_id', roomId) .lt('created_at', lastCreatedAt) // 早于上一页最旧消息 .order('created_at', { ascending: false }) .limit(50)
lt('created_at', ...) 比 offset 稳:无论翻到第几页,都只扫一个索引区间,耗时恒定。注意同一毫秒可能有多条消息,真正上线时建议用 (created_at, id) 复合游标,避免边界丢消息或重复。
下面用一张表对比三种"取历史"的方式,帮你按阶段选型:
| 方式 | 写法 | 适合阶段 | 代价 |
|---|---|---|---|
| 首屏 range | .range(0, 49) |
刚上线、量小 | 深翻页变慢 |
| 游标分页 | .lt('created_at', 锚点) |
量上来后 | 需维护游标值 |
| 服务端预取 | Edge Function 批量拉 + 缓存 | 高并发读 | 多一层组件 |
⚠️ 翻页和实时叠加时要防"重复上屏":同一种消息可能既在历史查询里、又在 INSERT 推送里各出现一次。前端 appendMessage 必须按 id 去重(用 Map 或 Set 存已见 id),否则用户会看到两条一模一样的消息。
💡 关键直觉:实时和历史的"接缝"在 created_at。实时从"现在"往后收,历史从"现在"往前翻,二者以同一个时间锚点拼接,中间不重不漏。把这个锚点想清楚,分页逻辑就不会乱。
⚠️ 实时监听的 filter 是客户端声明的,不是安全边界。真正隔离靠 RLS 的 exists (room_members...) 策略——即使有人改 filter 听别的房间,RLS 也会拦掉无权限的行。
💡 我们建议翻页用 range 做游标分页,实时只接最新增量,不要把整段历史都靠实时拉。历史用查询、增量用推送,各司其职最稳。
下一节看数据驱动产品:分析平台、看板、报表,Supabase 在里头当什么角色、什么时候该让位。