6.2 实时协作应用与聊天系统


6.2 实时协作应用与聊天系统

场景代入:你要做一个客服聊天,用户发一句、客服立刻看到、两边历史都在。传统做法要自己搭 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 把"一条消息从发送到双方上屏"的时序画清,区分了落库与推送两件事:

06-02-fig01

展开案例:客服系统支持同时多会话

背景:一个客服要同时处理多个用户会话,每个会话独立,消息不串。

操作过程:

  1. 数据模型加 room_members 表,把客服和用户关联到同一 room_id
  2. 每个会话一个 room,前端按当前打开的 room 订阅对应频道 room:${roomId}
  3. 客服切换会话时 channel.unsubscribe() 旧房间、订阅新房间,避免收错。
  4. 历史消息翻页用 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) 很重要:自己的消息不该被自己标成已读。若漏掉这个条件,客服一眼没看就会把"自己发的消息"算作已读,已读率虚高,反而掩盖漏回的问题。

历史翻页:游标分页而非 offset

实时只接最新增量,历史要靠查询拉。直接用 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 去重(用 MapSet 存已见 id),否则用户会看到两条一模一样的消息。

💡 关键直觉:实时和历史的"接缝"在 created_at。实时从"现在"往后收,历史从"现在"往前翻,二者以同一个时间锚点拼接,中间不重不漏。把这个锚点想清楚,分页逻辑就不会乱。

本节要点回顾

  • 聊天必须消息落库,实时只负责增量推送。
  • 房间隔离靠 room_members 关系 + RLS + 频道 filter。
  • 在线状态用 presence,正在输入用 broadcast(不落库)。

⚠️ 实时监听的 filter 是客户端声明的,不是安全边界。真正隔离靠 RLS 的 exists (room_members...) 策略——即使有人改 filter 听别的房间,RLS 也会拦掉无权限的行。

💡 我们建议翻页用 range 做游标分页,实时只接最新增量,不要把整段历史都靠实时拉。历史用查询、增量用推送,各司其职最稳。

下一节看数据驱动产品:分析平台、看板、报表,Supabase 在里头当什么角色、什么时候该让位。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U