返回探索
指南VibeFix 编辑部更新于 2026年10月13日

别再 setInterval 轮询了:vibe 项目的实时功能实战

AI 写"实时"最爱 setInterval 轮询:账单爆炸、延迟、过期数据三宗罪。这篇指南给 vibe 项目的实时实战:轮询 / SSE / WebSocket / WebRTC 四选一决策表、频道鉴权、断线重连与断点续传、Presence、多实例 pub/sub 扩展,以及连接数成本模型和 8 项上线验收。附 Supabase / PartyKit / 自建 WebSocket 接入代码。

实时协作主题配图:视频通话界面中多人在线协作,象征实时连接

你的 AI 编程助手刚给你写完一个"实时通知"功能。你点开看了:能跑,消息三秒后出现在页面上。你很满意,部署上线。一周后账单来了,数据库连接池在高峰期被打满,用户群里有人在问"为什么我看到的数字是三分钟前的"。这不是你运气不好——这是 AI 写实时代码的标准结局。

这篇指南讲 vibe 项目的实时功能怎么做才不翻车:为什么 AI 总给你写 setInterval 轮询,轮询 / SSE / WebSocket / WebRTC 到底怎么选,频道鉴权、断线重连、Presence、多实例扩展怎么做,以及上线前那份 checklist。目标只有一个:你的实时功能在地铁里断网重连之后,还能自己把丢的消息补回来。

多说一句这篇指南的立场:不教你"WebSocket 协议怎么写"——那是文档的事。这篇讲的是 AI 不会告诉你的判断:什么时候轮询其实够用,什么时候必须上 WebSocket;鉴权卡在哪一层;重连的坑在哪;多实例那天怎么不重构。每一节都有"一人团队的判断",拿走即用。读完这篇,你会知道 AI 交出来的实时代码该怎么审、怎么改、怎么上线。

一、AI 为什么写不好实时代码:轮询三宗罪

先说一个观察:你让 AI 写"实时"功能,它十有八九给你 setInterval(() => fetch('/api/updates'), 3000)。不是它偷懒——训练语料里轮询的示例最多、无状态、一次就能跑通,这是大模型的"最小惊讶路径"。但"看起来能跑"是 vibe coding 里最贵的幻觉,轮询有三宗罪,每一宗都在生产环境里收税。

第一宗罪:账单。轮询把"实时"翻译成了"请求数 = 用户数 × 在线时长 / 间隔"。算一笔账:1 万日活,每人每天在线 10 分钟,每 3 秒轮询一次——每人每天 200 次请求,一天就是 200 万次。每次请求都要建连接、跑鉴权、查数据库,你的连接池和按量计费的后端服务会先替你感受到"实时"。更狠的是,90% 的轮询返回的是"无更新",你花钱买的全是空转。

第二宗罪:延迟。轮询的数据天然过期一个轮询间隔。你把间隔从 5 秒缩到 1 秒,延迟是下来了,账单直接翻 5 倍。这是轮询的不可能三角:延迟、成本、实时性三者最多取其一。协作编辑、游戏对战这种场景,1 秒的延迟就是"对方已经走了你才看到",产品直接不可用。

第三宗罪:过期数据。轮询给你的永远是"上一次请求时刻"的世界。两个人同时改同一条数据、聊天里的"对方正在输入"、直播的在线人数——轮询看到的都是过去式。用户不会跟你说"你的架构有问题",他只会觉得"这产品好卡、好假"。

一句话判断:轮询不是"简单方案",它是"把复杂度转移到账单和用户体验上"的方案。它只配出现在两个地方:原型验证阶段,以及正式方案的降级兜底。除此之外,看到 AI 给你写 setInterval 做实时,先问一句"有没有更好的"。

给你一个真实量级的体感:某 vibe 做的直播答题,5000 人同时在线,每 2 秒轮询一次排行榜——每秒 2500 次请求,每次请求跑一次聚合查询。上线 20 分钟,连接池告警先响,接着是账单告警。改成 WebSocket 推送后,同样 5000 人,服务端只在有新答题时广播一次,每秒不到 10 次写操作。同样的"实时",成本差了两个数量级。这就是为什么选型必须在写代码之前,而不是账单之后。

还有一个隐性成本:轮询让你的"实时"永远带着"上一拍"的延迟。在 AI 对话场景里,用户发完问题,轮询要等下一个 3 秒 tick 才开始拉流式输出——体感上就是"点了发送,卡了 3 秒才有反应"。这种延迟不是网络问题,是架构问题,加钱也买不回来。

最后,送你一份"AI 实时代码审查清单"——拿到 AI 生成的实时代码,先查这 5 处,每处都是一个翻车现场:

  1. 有没有鉴权?搜一下握手代码里有没有验 token,没有就是裸奔。
  2. 断线了怎么办?搜 onclose / reconnect,没有就是"偶尔在线"。
  3. 消息有 id 吗?没有单调递增 id,断点续传和去重都无从谈起。
  4. 心跳在哪?没有 ping/pong,三个月后你就是僵尸连接农场主。
  5. 压测过吗?问 AI"2000 并发下内存多少",它答不上来——因为它没跑过。

5 处全绿,代码才能用;有一处红,返工重写。把这份清单贴进你的 code review 模板,让 AI 自己先对着查一遍,再交给你看。

二、四选一决策表:轮询 / SSE / WebSocket / WebRTC

实时方案说到底就四个,选错的代价是重写。先给结论:90% 的 vibe 项目,答案在 SSE 和 WebSocket 之间二选一;轮询只配做降级;WebRTC 别碰,除非你在做音视频。

方案典型延迟方向成本复杂度生死线(一句话)
轮询 Polling秒级(=轮询间隔)客户端拉中(请求空转烧钱)最低数据晚 5 秒没人死,且读多写少、并发几百以内
SSE100–500ms服务端→客户端单向低(HTTP 长连接)低只需要服务端往客户端推,不需要客户端上行
WebSocket50–200ms全双工中(连接常驻内存)中双向低延迟交互:协作、聊天、游戏、AI 对话
WebRTC<100ms(P2P)点对点高(信令+TURN+调试)最高音视频通话、点对点大文件,且房间规模小

轮询的出局条件(满足一条就别用):写入频繁(每次轮询都可能查到新数据)、并发上千(乘数效应先打穿你的数据库)、延迟敏感(超过 1 秒不可接受)。三条占一条,轮询就是慢性自杀。

SSE 是 2026 年最被低估的选项。AI 流式输出——大模型一个 token 一个 token 往外吐——标准答案就是 SSE(EventSource):简单、浏览器原生支持断线重连(Last-Event-ID)、走 HTTP/2 多路复用。但有两个坑:Nginx / CDN 默认会缓冲响应体,记得关 proxy_buffering 并设置 X-Accel-Buffering: no;它是单向的,客户端上行还是要走普通 HTTP 请求。

SSE 的 Nginx 配置三行,缺一行就抽风:

proxy_buffering off;
proxy_cache off;
# X-Accel-Buffering: no;  # 响应头,由应用返回

症状对照:本地好好的、一上生产 SSE 变"攒一波再吐"——99% 是某一层代理在缓冲。Cloudflare 用户注意:它的 CDN 对 SSE 友好,但别指望它替你做鉴权。

WebSocket 是双向低延迟的唯一通用答案,代价是连接常驻内存、需要自己管心跳、多实例要引入 pub/sub(第六节细讲)。一条纪律:别在 WebSocket 上跑 HTTP 能做的事——文件上传、大查询走 HTTP,WebSocket 只跑真正的实时消息。见过太多人把 WS 当万能管道,最后连接里 90% 是本可以用 REST 的流量。

WebRTC 别碰。除非你的产品就是音视频通话。P2P 听起来很美,但信令服务器、NAT 打洞、TURN 中继一个都少不了;房间超过 4 个人,P2P 的 mesh 连接数是 O(n²),该上 SFU 了——那已经是另一个专业。vibe 项目 99% 不需要 WebRTC,需要的那 1% 也不该自己写,去用现成的音视频 PaaS。

怎么把这张决策表喂给 AI?别问它"你觉得用哪个好",直接给约束:"5000 日活,客服聊天双向实时,延迟要求 500ms 内,团队 1 人,无运维——按决策表选型并给出接入代码"。约束越具体,AI 越不容易给你套轮询模板。记住:选型是你的判断,不是 AI 的。

Polling vs SSE vs WebSocket vs WebRTC comparison chart 图:四方案对照。延迟 / 成本 / 复杂度得分 1 最好、5 最差。轮询的延迟得分拉满是因为延迟恒等于轮询间隔;WebRTC 的复杂度拉满是因为信令、NAT 打洞、TURN 中继一个都少不了。选型时先看生死线:只需要服务端推送选 SSE,双向低延迟选 WebSocket。

三、频道鉴权:别让陌生人订阅别人的房间

实时系统的安全边界不在"能不能连上",而在"能订阅哪个频道"。连接是便宜的,频道是值钱的。AI 生成的代码最常见的反模式:客户端传 userId 和 roomId,服务端点点头就让订阅了——频道名还是自增的 room-1、room-2,枚举一下就能偷听所有房间。

正确的链路是三段式:服务端签发 → 握手校验 → 订阅二次校验。

  1. 服务端签发短期签名 token。用 HMAC 把 userId.频道名.过期时间 签起来,secret 只留在服务端。token 有效期 5–10 分钟,够完成握手就行,长连接不需要长 token。
  2. 握手阶段验签。WebSocket 握手时从 query 参数或 Sec-WebSocket-Protocol 取 token,验签失败直接 close(4401),连订阅的机会都不给。
  3. 订阅时二次校验频道权限。token 里声明的频道必须和实际订阅的频道一致;用户只能订阅自己有权限的房间(查一次 membership 表)。频道名用不可枚举的 room_<uuid>,别用自增 id。

Supabase Realtime 接入要点:它的频道授权走两条路——postgres_changes 靠 RLS policy(没配 RLS 等于裸奔,记得给表开 enable row level security),broadcast / presence 用私有频道 + 服务端签发逻辑。实战里最常见的错:表开了 RLS 但 policy 没覆盖 realtime 用的角色,开发环境一切正常(因为用的是 service_role),上线用 anon key 全是空数据——先查 policy,再怀疑人生。

Supabase RLS policy 示例(postgres_changes 想收到行,必须配):

alter table messages enable row level security;
create policy "room members can read"
  on messages for select
  using (
    room_id in (select room_id from memberships where user_id = auth.uid())
  );

记住:policy 是配给 realtime 用的角色(authenticated / anon)的,别只拿 service_role 测通就上线。

PartyKit 接入要点:一个 Party 就是一个房间。onConnect 里验 token(HMAC 逻辑同上,secret 放 party.env),验不过直接 connection.close()。房间内广播用 party.broadcast(),点对点用 connection.send()。它的鉴权完全交给你自己写,别指望框架兜底。

自建 WebSocket 接入要点:看第四节代码里的 verifyToken——握手验签加 timingSafeEqual 防时序攻击,一共 10 行,但 AI 永远不会主动给你写。顺手把 token 刷新做成"每次重连重新签发",别让客户端拿着一个 24 小时有效的 token 到处晃。

服务端签发 token(Node 示例,10 行):

function signChannelToken(userId, channel) {
  const exp = Math.floor(Date.now() / 1000) + 600 // 10 分钟有效
  const sig = crypto.createHmac('sha256', process.env.CHANNEL_SECRET)
    .update(`${userId}.${channel}.${exp}`).digest('hex')
  return `${userId}.${exp}.${sig}`
}
// 登录成功后,按用户可进的房间逐个签发,前端建连时取用

频道设计本身也是一门学问:频道粒度越细,鉴权越简单。按"房间"开频道(room_<uuid>),别按"功能"开全局频道(global_notifications)——全局频道意味着每条消息都要过一遍"这个订阅者能不能看"的过滤,复杂度从 O(1) 变成 O(n)。需要跨房间的系统通知?那是另一套单向推送,走 SSE 或推送服务,别塞进房间频道里。

四、断线重连与状态恢复:心跳、退避、断点续传

先立一个标准:没有重连策略的"实时",只是"偶尔在线"。地铁、电梯、WiFi 切换——断网是常态不是异常。用户能接受"断了一下",不能接受"断了一下之后消息丢了"。重连三件套,缺一不可:

1. 心跳。客户端每 25–30 秒发 ping,服务端回 pong;服务端连续两次没收到 pong 就判定死亡、剔除连接。没有心跳的连接池,三个月后就是一池僵尸连接——还占着内存,还占着你的连接数账单。WebSocket 协议自带 ping/pong 帧,别自己发明心跳消息格式。

心跳间隔为什么是 25–30 秒而不是 60 秒?因为 NAT 网关和云 LB 的空闲超时大多在 60 秒左右,移动网络下更短。30 秒 ping 一次,留足余量。App 进后台时记得主动降级:后台的 WebSocket 连接很快会被系统杀掉,别让它在后台空转耗电——进后台记 last_msg_id 并断开,回前台重连补发,这才是移动端的正确姿势。

2. 指数退避 + 抖动。重连间隔 1s → 2s → 4s → 8s……上限 30 秒,每次乘一个 0.5–1 的随机抖动。为什么加抖动?服务端重启那一刻,几千个客户端如果同时重连,就是一次自导自演的 DDoS(惊群效应)。AI 写的重连清一色是"固定 3 秒重试",上线第一天没事,第一次服务端发版就原地爆炸。

3. 断点续传。每条消息带单调递增 id,客户端持久化 last_msg_id(localStorage 就够);重连时带上 since=last_msg_id,服务端返回这之后的消息。服务端要配合:保留一个窗口——Redis Stream 保留最近 500 条,或数据库按时间查。窗口之外的数据,重连时走一次全量拉取兜底。

// 客户端重连器:指数退避 + 抖动 + 断点续传
function connectRealtime({ channel, getToken, onMessage }) {
  let ws, retry = 0, lastId = localStorage.getItem('last_msg_id') || 0
  const MAX_DELAY = 30000
  async function dial() {
    const token = await getToken(channel) // 短期签名,每次重连刷新
    ws = new WebSocket(
      `wss://api.example.com/ws?channel=${channel}&token=${token}&since=${lastId}`
    )
    ws.onopen = () => { retry = 0 }
    ws.onmessage = (e) => {
      const msg = JSON.parse(e.data)
      if (msg.id > lastId) {
        lastId = msg.id
        localStorage.setItem('last_msg_id', lastId)
      }
      onMessage(msg)
    }
    ws.onclose = () => {
      const delay = Math.min(1000 * 2 ** retry++, MAX_DELAY) * (0.5 + Math.random() / 2)
      setTimeout(dial, delay)
    }
  }
  dial()
}

服务端 since 查询(Redis Stream 版,配合第六节代码里的 xadd 落盘):

// 重连时:补发断点之后的消息
const missed = await redis.xrange(`stream:${channel}`, `(${lastId}`, '+')
for (const [, fields] of missed) ws.send(fields.msg)

Supabase 用户注意:它的 SDK 自带重连,但 postgres_changes 在断线期间发生的变更不会补发——重连成功不代表数据追上了。关键业务(订单状态、支付结果)必须自己维护游标:重连后按 updated_at > 断线时间 全量拉一次做对账。把 realtime 当"通知你去拉",而不是"数据本身",能避开 80% 的坑。

补发带来一个新问题:重复。断线瞬间服务端可能已经推送、客户端没收到,重连补发后客户端会收到两条。解法是客户端按 msg.id 去重(Set 缓存最近 1000 个 id),服务端保证 id 单调递增。顺序加去重,两件事做完,"exactly-once 的体感"才算完整——注意是体感,分布式 exactly-once 不存在,别钻牛角尖。

Reconnect and message resume sequence diagram 图:断线重连 + 消息断点续传时序。①–⑥ 正常建连与收发;⑦ 心跳超时后指数退避重连(1s→2s→4s…上限 30s,加抖动防惊群);⑧ 重连携带 last_msg_id;⑨ 服务端从 Redis Stream 补查断点后的消息;⑩ 补发;⑪ 恢复正常。记住:重连成功不等于数据追上,补发才是关键。

五、Presence 实战:谁在线、正在输入、光标协同

Presence(在线状态)是实时功能里"看起来简单、做起来全是坑"的代表:谁在线、对方正在输入、协作文档里那几个彩色光标——本质都是"带超时的状态广播"。实现上有三件套:

  1. 心跳续租。客户端每 10 秒上报一次"我还在",服务端给这条状态 30 秒 TTL。TTL 过期即下线,不需要"优雅断开"——移动端杀进程从来不跟你打招呼。
  2. 只广播 diff。状态没变就别广播。在线列表 100 人,每人每 10 秒广播一次全量,就是 10 QPS 的垃圾流量。Presence 消息只发变化的部分。
  3. 节流铁律。"正在输入" throttle 2 秒(用户停 2 秒才算输完),光标位置 100ms 节流。反模式:每个 keystroke 发一条 presence——你的频道会变成打字机噪声,账单和延迟一起爆炸。

"正在输入"还有个 UX 细节:收到 typing 状态后,如果 5 秒内没收到新消息,自动把状态清掉——否则对方打了一半字去接电话,你这边"正在输入…"能挂一整天。Presence 的每一条状态都要有"过期"的语义,这是它和普通消息最大的区别。

Supabase Presence 接入要点(四行核心):

const channel = supabase.channel('room:' + roomId, {
  config: { presence: { key: userId } }
})
channel
  .on('presence', { event: 'sync' }, () => {
    const online = channel.presenceState() // { userId: [{status, cursor}] }
    renderAvatars(online)
  })
  .subscribe(async (s) => {
    if (s === 'SUBSCRIBED') await channel.track({ status: 'online' })
  })
// 正在输入:throttle 2 秒后 channel.track({ status: 'typing' })

自建 Presence 要点(Redis 版,20 行搞定):

// 上报:presence:{room} 是 hash,field=userId,TTL 30 秒
await redis.hset(`presence:${room}`, userId,
  JSON.stringify({ status: 'typing', cursor: { x, y } }))
await redis.expire(`presence:${room}`, 30)
// 读取:hgetall 即全房在线列表;TTL 到期自动下线,无需清理任务
// 变更广播:上报后 publish(`presence:${room}`, userId) 通知各实例推 diff

一个判断:Presence 数据永远不要落库。它是瞬时状态,落库就是给自己找一致性麻烦——"数据库说他在线,但他手机已经没电了"这种灵异事件,根源都是把瞬时状态当成了持久事实。Redis TTL 是 presence 的天然归宿。

光标协同再往前一步就是协同编辑(OT / CRDT),那是另一个深度的题目,一句话划界:Presence 只同步"光标在哪",不同步"文档内容"——内容同步用 Yjs / Automerge 这类 CRDT 库,别自己发明合并算法。房间人数也有上限判断:presence 广播是 O(n²) 的(n 个人每人广播给 n-1 人),50 人以下随便玩;500 人的直播间,"谁在线"就别做全量 presence,改成"在线人数 + 抽样头像"。

六、从单机到多实例:pub/sub 横向扩展与粘性会话取舍

单机阶段,实时系统简单得不像话:一个 Map<频道, Set<连接>>,收到消息遍历推送,几千并发毫无压力。分水岭是第二个实例上线那一刻:用户 A 连在实例 1,用户 B 连在实例 2,A 发的消息 B 永远收不到——因为广播只发生在实例 1 的内存里。

解法是引入 pub/sub(架构见下图):实例只管"本机连接",跨实例的消息走 Redis(或 NATS)广播总线。发布流程变成四步:客户端发布 → 本实例写入 pub/sub → 所有实例收到 → 各实例推送给自己的本机订阅者。实例本身变成无状态的,挂掉一个只影响它上面的连接(客户端重连到别的实例即可)。

import { WebSocketServer } from 'ws'
import Redis from 'ioredis'
import crypto from 'node:crypto'

const SECRET = process.env.CHANNEL_SECRET
const pub = new Redis(process.env.REDIS_URL)
const sub = new Redis(process.env.REDIS_URL)
const wss = new WebSocketServer({ port: 8080 })
const rooms = new Map() // 频道 -> 本机连接集合(只存本机!)

// 握手鉴权:token = HMAC(userId.频道.过期时间)
function verifyToken(token, channel) {
  const [userId, exp, sig] = token.split('.')
  if (Date.now() > Number(exp) * 1000) return null
  const expect = crypto.createHmac('sha256', SECRET)
    .update(`${userId}.${channel}.${exp}`).digest('hex')
  if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect))) return null
  return userId
}

wss.on('connection', (ws, req) => {
  const q = new URL(req.url, 'http://x').searchParams
  const channel = q.get('channel')
  const userId = verifyToken(q.get('token'), channel)
  if (!userId) return ws.close(4401, 'unauthorized')
  ws.channel = channel
  if (!rooms.has(channel)) { rooms.set(channel, new Set()); sub.subscribe(channel) }
  rooms.get(channel).add(ws)
  ws.isAlive = true
  ws.on('pong', () => { ws.isAlive = true })
  ws.on('message', (raw) => {
    const msg = JSON.stringify({ id: Date.now(), from: userId, body: String(raw) })
    pub.publish(channel, msg)                                             // 跨实例广播
    pub.xadd(`stream:${channel}`, 'MAXLEN', '~', 500, '*', 'msg', msg)    // 断点续传
  })
  ws.on('close', () => rooms.get(channel)?.delete(ws))
})

// 心跳:30 秒扫一轮,剔除僵尸连接
setInterval(() => {
  wss.clients.forEach((ws) => {
    if (!ws.isAlive) return ws.terminate()
    ws.isAlive = false; ws.ping()
  })
}, 30000)

// pub/sub 到达 → 只推本机订阅者
sub.on('message', (channel, msg) => {
  rooms.get(channel)?.forEach((ws) => {
    if (ws.readyState === 1) ws.send(msg)
  })
})

粘性会话(sticky session)的取舍:让负载均衡把同一客户端始终路由到同一实例,重连恢复更简单(last_msg_id 的内存游标还在)。代价是扩缩容时连接迁移疼、实例挂了它上面的连接全断。一人团队的判断:选无状态实例 + Redis,别碰 sticky——除非你有专职运维。无状态加外部 pub/sub 是云原生时代的默认答案,sticky 是上个时代的遗产。

托管服务的价值就在这里:Supabase Realtime、PartyKit、Pusher 把"多实例广播、全球边缘、连接管理"打包卖给你,你付的是连接数和消息数的钱,省的是半个运维。日活 1 万以下,直接用托管;连接数上来之后,再按第七节的模型算自建账。

Redis 还是 NATS?一人团队无脑选 Redis:你已经有它了(缓存、session、presence 全在上面),pub/sub 只是顺手。NATS 的 JetStream 在"消息必须持久化、可回放"时更强,但那是日活十万量级才需要操心的事。另外注意:发布端也要鉴权——pub.publish 之前校验发送者确实在这个频道里,否则任何人连上 WebSocket 就能往任意频道灌垃圾消息,pub/sub 成了放大器。

还有一个新手常问的:pub/sub 本身会不会成为单点?会。Redis 挂了,所有跨实例广播都停。所以 Redis 要做高可用(主从加哨兵,或直接用云托管版),它的 SLA 就是你的实时 SLA。别在 Redis 上省钱——它挂的时候,你的"实时"会退化成"所有人只能看到自己实例里的消息",这种半残状态比全挂更难排查。

Realtime multi-instance architecture diagram 图:实时系统多实例架构。客户端经网关鉴权后连到应用实例;实例只维护本机连接表,跨实例消息经 Redis Pub/Sub 广播;断点消息落 Redis Stream 供重连续传。单机阶段可以砍掉右半部分,但从第一天起就把"发布到总线"和"推送本机连接"写成两个函数——第二个实例上线那天,你会感谢自己。

七、上线清单:连接数成本模型 + 8 项验收

实时功能上线前,先算账。先说一个反直觉的结论:实时功能最贵的部分往往不是服务器,而是"连接数"这个计费维度本身。传统 HTTP 按请求量计费,闲时不花钱;实时连接是 7×24 小时占着的——1000 个半夜不说话的挂机连接,和 1000 个正在聊天的连接,在托管账单上是一个价。设计产品时就要想:哪些连接真的需要实时?挂机用户能不能降级成 SSE 或干脆断开?

连接数成本模型分两种:

托管服务版(Supabase Realtime / Pusher / Ably 这类按量计费):

月成本 ≈ 峰值并发连接数 × 单连接单价 + 月消息量 × 单消息单价

注意是峰值不是平均——营销推送、直播开播那一小时的并发,决定了你的账单档位。先压测出峰值,再选档位,别反过来。

自建版:

单实例连接上限 ≈ 可用内存 / 每连接内存占用(以实测为准,经验值 1–5 万/实例)
月成本 = 所需实例数 × 实例单价 + Redis 费用

每连接内存占用自己压出来:空连接和带订阅、带 presence 的连接能差一个数量级。别用网上的数字,那是别人的业务。

一个判断:日活 1 万以下,优先托管或单实例自建,别提前做多实例。实时系统的复杂度 80% 来自"多实例 + 鉴权 + 重连",单实例阶段你只需要写对,另外 20% 是压测。过早引入 Redis 集群、全球边缘,是 vibe 项目最常见的"简历驱动架构"。

上线 8 项验收(一项不过,不许上线):

  1. 弱网测试。Chrome DevTools 限速 + 手机飞行模式开关 10 次,消息零丢失、顺序不错乱。
  2. 断线重连。kill 掉连接,30 秒内自动恢复,last_msg_id 续传无丢失、无重复(重复要有去重)。
  3. 连接风暴。重启服务端,N 个客户端同时重连——退避抖动生效,网关 CPU 不打满。
  4. 僵尸清理。kill -9 客户端进程,60 秒内服务端剔除连接,监控上的连接数曲线回落。
  5. 越权订阅。拿 A 用户的 token 订阅 B 的房间,必须被拒绝;枚举 room-1 到 room-100 必须全灭。
  6. 峰值压测。压到 2 倍预期峰值连接数,p99 推送延迟达标,内存增长收敛(无泄漏)。
  7. 监控告警。连接数、消息 p99 延迟、心跳超时率三个指标进告警,阈值先按压测数据设。
  8. 降级开关。一键切回 SSE / 轮询的逃生通道,演练一次——真出事那天,你没时间现写降级。

压测实时系统,k6 的 ws 模块 20 行就能跑:

import ws from 'k6/ws'
export default function () {
  ws.connect('wss://api.example.com/ws?channel=test&token=__TOKEN__', {}, (socket) => {
    socket.on('message', () => {})
    socket.setInterval(() => socket.ping(), 25000)
  })
}
// k6 run --vus 2000 --duration 5m script.js:先看 2000 并发下内存和 p99 延迟

监控阈值起步建议:连接数告警线 = 压测上限的 70%;消息 p99 延迟告警 = 500ms;心跳超时率告警 = 1%。这三个数先按压测结果设,跑两周再调——没有压测数据的告警阈值都是拍脑袋。

降级开关怎么做?一个 feature flag(realtime.enabled),前端建连失败超过阈值、或手动拨开关,就切到 SSE / 轮询兜底。关键细节:降级状态要打点上报,否则你永远不知道"昨晚有一半用户在用轮询"。逃生通道的价值不在"能用",在"出事时 10 秒内生效"。

快问快答:实时功能的五个灵魂拷问

1. "我用 Supabase / Firebase,还要管这些吗?"
托管替你管了多实例广播和连接管理,但鉴权、重连补发、presence 节流还是你的。托管不是免死金牌,是把 80% 的运维换成 20% 的业务逻辑——剩下那 20% 才是决定体验的部分。

2. "HTTP/2、HTTP/3 时代,SSE 是不是过时了?"
没有。SSE 在 HTTP/2 下多路复用反而更香。WebTransport 是未来,但 2026 年它的代理穿透和浏览器覆盖还不够稳,vibe 项目别当小白鼠。等它成为"无脑默认"再换不迟。

3. "消息顺序怎么保证?"
单频道内:服务端单调递增 id,客户端按 id 排序加去重。跨频道不保证——如果你的业务需要全局顺序,说明频道切分错了,先回去看第三节。

4. "移动端 App 进后台怎么办?"
后台的连接注定会死,别挣扎。正确姿势:进后台主动断开并记 last_msg_id,回前台重连补发;重要消息走推送通知(APNs / FCM)兜底。实时连接只在前台存在,这是设计约束,不是 bug。

5. "AI 对话的流式输出,用 WebSocket 还是 SSE?"
SSE。单向、浏览器原生重连、和大模型的 token 流天然同构。除非你的对话需要客户端实时上行(比如语音打断),否则别上 WebSocket——杀鸡不用牛刀,牛刀还要你自己磨。

最后回到开篇那个账单。如果你按这篇指南做:选型时先看生死线而不是看 AI 给了什么,鉴权卡在频道而不是连接,重连带着断点 id,presence 只广播 diff,多实例那天 pub/sub 已经就位——那么你的实时功能在地铁里断网之后,会自己把丢的消息补回来。这才是"实时"两个字该有的样子:不是"快",是"断了也能回来"。

阅读 0评论 0

评论 (0)

ME
0/1000
评论加载中...
浏览项目广场发布你的项目

相关文章

录音室麦克风特写,象征 vibe 项目的语音功能接入:采集、转写、合成与实时对话
指南
让你的产品开口说话:vibe 项目的语音功能接入实战

语音 demo 好做,能上线的语音功能是链路工程。这篇指南讲透:三种形态(STT/TTS/实时对话)对号入座、400ms 延迟预算在采集→STT→LLM→TTS→播放全链路的拆分、Whisper 系与流式 STT 取舍、TTS 音质/延迟/成本不可能三角、打断与轮次状态机、浏览器端五个坑、成本建模与防刷,以及 10 项上线验收清单。

工具技巧后端工程项目创作