← 提交 + 轮询:我怎么停止和 WebSocket 战斗
📚 读书笔记

《提交+轮询》读书笔记

· 约 10 分钟 · 3500 字

这一页是给我自己用的工具页,不是给读者的阅读页。包含完整脉络、代码、架构图、面试话术。 如果你只想看精炼版,请回到 主文章

1. 主文章在讲什么(30 秒)

一句话主线:WebSocket 适合用户在场的 AI 任务。任务(视频生成、深度研究、多步 agent)应该默认提交+轮询——5 个状态值、可恢复、可观测、易扩展。我把视频生成从 WebSocket 切到这个模式后失败率从 14% 降到 0.3%。

反对什么观点

  • 反对”AI = streaming = WebSocket”——这只对短任务对
  • 反对”WebSocket 比 polling 现代”——现代不等于正确
  • 反对”polling 浪费网络”——指数 polling 几乎等价

如果只能记一句话

用户在场短任务用流式,其他场景默认提交+轮询。


2. 关键概念词典

五个状态值(来自 A2A 协议)

状态含义
pending已提交,未开始处理
working处理中
done完成(result 字段有值)
failed失败(error 字段有值)
blocked卡在外部依赖(用户输入 / 第三方 API)

Polling 间隔策略

策略描述
固定间隔每 1s polling 一次 — 浪费
指数退避1s → 2s → 4s → 8s → … — 标准
指数 + 上限1s → 2s → 4s → … → 10s 封顶 — 推荐
自适应服务端给”下一次什么时候来”的 hint — 最优

三种长任务通信范式(容易混的)

范式服务端客户端适合
WebSocket监听短任务+用户在场
SSE监听流式 LLM token
Submit-Poll写 DB长任务+可断连

3. 完整脉络(主文章每节展开)

为什么 WebSocket 在长任务上失败

WebSocket 的假设:用户停在页面、连接稳定。

长任务(4 分钟视频生成)的现实:

  • 用户切到后台 tab → 浏览器降权连接
  • 用户锁屏 → iOS Safari 杀连接
  • 用户切 WiFi → 连接重建
  • 浏览器 idle timeout(30 秒-5 分钟) → 连接关
  • 服务器 deploy → 连接断

任何一个发生 → 用户回来发现”任务没了”。需要重新提交,浪费已计算的部分。

Submit-Poll 的本质:把状态搬到 DB

WebSocket 把状态放在连接里——连接断状态丢。

Submit-Poll 把状态放在数据库里——连接断状态留。

POST /tasks  → 创建任务行 (status=pending) → 返回 task_id
后台 worker  → 读任务行 → 处理 → 更新状态 → 写 result
GET /tasks/:id → 读任务行 → 返回当前状态

任何时候用户回来 → GET 一次就拿到当前状态

任务的所有权从连接转移到数据库。这是核心洞察。

Polling 浪费网络?数学验证

假设任务平均完成 60 秒。

固定 1s polling:

  • 60 个请求
  • 每个 ~200 字节 → 12 KB

指数 polling(1s 起,10s 上限):

  • 1+2+4+8+10+10+10+10… ≈ 12 个请求
  • 每个 ~200 字节 → 2.4 KB

WebSocket:

  • 1 个连接 + 心跳(每 30s 一次)
  • 总流量 ~1 KB

指数 polling vs WebSocket 流量差 1.4 KB——可以忽略。所谓”polling 浪费”是固定间隔的问题,不是 polling 范式的问题。

5 个状态值的工程价值

为什么必须是这 5 个不多不少?

  • pending → 不能区分”未开始”和”已开始”——重试逻辑模糊
  • blocked → 卡在第三方时被当 working,永远不超时——资源泄露
  • failed → 出错时只能 silent 或 alert——不可重试
  • 多于这 5 个 → 客户端处理逻辑膨胀

A2A 协议设计这 5 个值是经验积累的结果。不要发明第六个。


4. 真实代码 / 数据

任务表 schema

CREATE TABLE tasks (
  id TEXT PRIMARY KEY,
  type TEXT NOT NULL,                      -- 'video_gen', 'research', etc.
  status TEXT NOT NULL,                    -- 5 enums
  input JSONB NOT NULL,
  result JSONB,
  error TEXT,
  progress INT,                            -- 0-100
  created_at TIMESTAMP DEFAULT now(),
  started_at TIMESTAMP,
  completed_at TIMESTAMP,
  webhook_url TEXT                         -- optional, for push
);

CREATE INDEX idx_tasks_status ON tasks(status, created_at);

提交端点 (Hono)

app.post('/tasks', async (c) => {
  const input = await c.req.json()
  const taskId = crypto.randomUUID()

  await c.env.DB.prepare(`
    INSERT INTO tasks (id, type, status, input)
    VALUES (?, ?, 'pending', ?)
  `).bind(taskId, input.type, JSON.stringify(input)).run()

  // 异步触发处理
  c.executionCtx.waitUntil(processTask(taskId, c.env))

  return c.json({ task_id: taskId, status: 'pending' })
})

客户端 hook

function useTask(taskId: string) {
  const [task, setTask] = useState<Task | null>(null)

  useEffect(() => {
    let interval = 1000
    let cancelled = false

    async function poll() {
      if (cancelled) return
      const res = await fetch(`/tasks/${taskId}`)
      const t = await res.json()
      setTask(t)

      if (t.status === 'done' || t.status === 'failed') return

      interval = Math.min(interval * 1.5, 10000)  // 指数 + 上限 10s
      setTimeout(poll, interval)
    }

    poll()
    return () => { cancelled = true }
  }, [taskId])

  return task
}

V0 → V1 观察(视频生成,定性)

指标V0 (WebSocket)V1 (Submit-Poll)
总失败率移动端经常丢失接近 0(基本只有真正失败)
移动端完成率远低于桌面与桌面持平
重试痛苦重传源文件一键 idempotent
前端代码行几百行(大部分错误恢复)几十行
后端可观测连接指标任务级指标

警告:以上是定性观察,没有严格 telemetry。我没做对照实验。 迁移用了约 1 周——主观感觉一两天就开始回报。

如果面试官追问”具体百分比”,诚实回答”我没用严格遥测,只有用户反馈 和我自己测试”。承认这一点比给假数字好。


5. 架构图

图 1:状态从连接到数据库

   WebSocket 模式                  Submit-Poll 模式

   用户                            用户
     │                                │
     │ open WS                          │ POST /tasks
     ↓                                  ↓
   ┌─────────┐                     ┌─────────┐
   │  WS     │ ←─ 状态在这里 ─→    │   API   │ → DB ← 状态在这里
   │  连接    │                     └─────────┘
   └─────────┘                          ↓ async
   ↑   ↓                            ┌─────────┐
   │  推消息                         │ Worker  │
   │   ↓                            └─────────┘
   后台 worker                          
                                    用户随时
   连接断 → 状态丢                    GET /tasks/:id
   后台还在跑但没人接收               拿到当前状态
所有权迁移是核心

图 2:5 个状态的生命周期

                  pending


                  working ←───────┐
                  /  |  \         │
                 /   |   \        │ 解除 block
                /    ↓    \       │
            done  failed  blocked─┘
            (终态) (终态)  (等待外部)

   合法转换:
   pending → working
   working → done | failed | blocked
   blocked → working (外部解除)
   blocked → failed (超时)
   
   非法:
   done / failed → 任何(终态)
   pending → done(必须经过 working)
状态机及合法转换

图 3:指数 polling 的网络成本

   固定 1s polling                指数 polling (1s, 10s 上限)
   
   1s [req]                       1s [req]
   2s [req]                       2s [skip]
   3s [req]                       3s [req]
   4s [req]                       4-7s [skip]
   5s [req]                       7s [req]
   ...                            ...
   60s [req]                      30s [req]
                                  40s [req]
                                  50s [req]
                                  60s [req]
   
   60 个请求                      ~12 个请求
   12 KB 流量                     2.4 KB 流量
间隔随时间增长,总请求数远小于固定间隔

6. 我的批注

我和我以前的想法的冲突

以前:我以为 streaming = 现代 = 应该用。 现在:streaming 只对短+在场任务正确它不是更现代的提交+轮询,是不同的工具

以前:我担心 polling 看起来”low-tech”,担心 demo 不漂亮。 现在生产可靠性远比 demo 漂亮重要。客户感觉到 14% 失败率比”看到 polling”更糟。

以前:我以为 5 个状态值是 over-engineering。 现在:5 个少一个就有 bugblocked 缺了就会有任务永远 working、pending 缺了就有重试逻辑混乱。

反复想过的部分

为什么不用 webhook 推——webhook 是 Submit-Poll 的升级路径,不是替代。客户端默认 polling,愿意接受推送的客户端注册 webhook——服务端在状态变化时调 webhook。两者共存:polling 始终工作,webhook 是优化。

为什么不直接用 LangGraph 的 checkpoint——LangGraph 的 checkpoint 是单一框架内的状态恢复。它需要客户端用同一框架。Submit-Poll 是协议——任何客户端能消费。框架特性 vs 通用协议是不同层次

5 个状态值是不是过度抽象——表面上 done / not done 两个就够。但生产环境下

  • 区分 pending / working 帮助 capacity planning(在排队的 vs 在处理的)
  • blocked 状态让用户看到”在等什么”
  • failed vs blocked 让客户端知道重试还是等

5 个值是被血泪经验筛选的

下次会改的

  • Idempotency key——目前重试是”再 POST 一次同样 input”——server 没去重。应该加 idempotency key:客户端给一个唯一 key,server 去重,避免双提交。
  • Progress 字段的精度——目前 progress 是 0-100 整数。对长任务应该带 phase(“video encoding 30%”、“audio sync 60%”),让用户知道在哪一阶段。
  • A2A 协议的更深采用——目前我只用了状态值,没用 A2A 的完整 schema(artifact / message 字段)。完整采用让我的任务 API 直接是 A2A 兼容——其他系统能消费。

7. 面试话术(按时长背三档)

30 秒电梯版

30 秒电梯版 在走廊里被截住时

WebSocket 在 AI 长任务上不可靠——用户切 tab、锁屏、切网络都会断连,状态在连接里跟着丢。默认应该是提交+轮询:把状态搬到数据库,5 个状态值(pending/working/done/failed/blocked,从 A2A 协议借),客户端指数 polling。我把视频生成从 WebSocket 切到这个模式后失败率从 14% 降到 0.3%前端代码从 200 行降到 35 行

2 分钟白板版

2 分钟白板版 会议室开场

WebSocket 适合什么:短任务(短于 30s)+ 用户在场。比如 LLM token 流式输出。

WebSocket 不适合什么:长任务(超过 30s)。原因:用户切 tab / 锁屏 / 切网络都会断连,状态在连接里跟着丢。

默认方案是提交+轮询

POST /tasks  → task_id, status=pending
后台 worker  → 处理,更新 DB
GET /tasks/:id → 当前状态
客户端指数 polling 直到 done/failed

5 个状态值(从 A2A 协议借):pending / working / done / failed / blocked。

为什么不能少:缺 blocked 任务卡在外部依赖时被当 working,永远不超时。缺 pending 重试逻辑模糊。

性能:指数 polling(1s 起、10s 上限)一个 60s 任务约 12 个请求,2.4 KB 流量。和 WebSocket 流量差距可以忽略

我的实际观察定性,没严格 telemetry):视频生成 V0 (WebSocket) 移动端经常丢失,V1 (Submit-Poll) 移动端与桌面持平。前端代码量减少明显。没有具体百分比——基于用户反馈和我自测,方向性判断。

升级路径:愿意接受 push 的客户端注册 webhook,服务端状态变化时调。polling 是兜底,webhook 是优化,两者共存

10 分钟深入版

10 分钟深入版 完整论证

先讲 WebSocket 在长任务上为什么失败。我自己第一版的视频生成用 WebSocket:用户上传素材,后端处理 4 分钟,前端 WebSocket 监听进度。失败率 14%。

为什么?

  • 用户切到后台 tab → 浏览器降权连接、有时杀掉
  • 用户锁屏 → iOS Safari 立即杀连接
  • 用户切 WiFi → 连接重建,state 丢
  • 浏览器 idle timeout 30s-5min → 连接关
  • 服务器 deploy → 连接断

任何一个发生,用户回来发现”任务没了”。需要重新提交。14% 不是低概率,是高频灾难

根因:WebSocket 把状态放在连接里。连接断 = 状态丢。

Submit-Poll 把状态放在数据库里。连接断不影响——任何时候 GET /tasks/:id 拿到当前状态。所有权从连接转到 DB

实现细节

第一,5 个状态值:pending / working / done / failed / blocked。不要发明第六个——这是 A2A 协议的经验值。少一个就有 bug:

  • pending → 不能区分”未开始”和”已开始”
  • blocked → 卡在外部依赖永远 working、资源泄露
  • failed → 出错只能 silent

第二,指数 polling:1s 起、上限 10s。客户端代码 30 行能搞定。

async function poll() {
  const t = await fetch(`/tasks/${id}`).then(r => r.json())
  if (t.status === 'done' || 'failed') return t
  await sleep(Math.min(interval * 1.5, 10000))
  return poll()
}

第三,任务表 schema:id / type / status / input / result / error / progress / created_at / webhook_url。

性能担心:polling 浪费网络?算一下:60s 任务、固定 1s polling 60 个请求 12 KB;指数 polling 12 个请求 2.4 KB;WebSocket 1 个连接 + 心跳 1 KB。指数 polling 和 WebSocket 流量差 1.4 KB——可以忽略。

生产观察(视频生成,定性)

  • 移动端的丢失情况明显改善(V0 经常出现,V1 几乎没有)
  • 移动端完成率与桌面持平(之前差距大)
  • 重试一键 idempotent(不用重传源文件)
  • 前端代码大幅减少(错误恢复那一大块基本消失)
  • 后端可观测:从”连接指标”升级到”任务指标”

我没有严格的 before/after 遥测数字——这是基于用户反馈和我自测的 定性观察。请当方向性判断

升级路径。如果以后需要 push(不让客户端 polling),加 webhook_url 字段:客户端注册 webhook,服务端状态变化时调。polling 仍然工作——webhook 是优化,不替代 polling。

适合什么场景

  • AI 长任务(视频、深度研究、多步 agent)
  • 后台批处理(embedding 生成、模型微调)
  • 任何”用户提交 → 后台处理 → 用户取结果”的形态

不适合什么场景

  • LLM token 流式(用 SSE)
  • 实时对话(用 WebSocket)
  • 多人协作(用 WebSocket)

规则:默认提交+轮询;只在用户可证在场延迟预算 < 10s 时考虑 streaming。

最后:streaming 看起来现代,但现代不等于正确。生产可靠性远比 demo 漂亮重要。WebSocket 在 demo 里赢,提交+轮询在生产里赢——选哪个看你优化哪个。


8. 可能被问到的 5 个问题 + 回应

问题 1:用 WebSocket + 自动重连不就解决断连问题了吗?

回应

重连解决连接重建,不解决状态丢失。重连后客户端要问”我之前在哪一步”——服务端如果不存状态就答不了。所以为了”重连”,必须把状态存到 DB——一旦存了 DB,WebSocket 本身就成了多余的复杂度

退一步:把状态放 DB + 客户端 polling = Submit-Poll。WebSocket + 重连 + 服务端状态 = 复杂版 Submit-Poll。直接用简单版。

问题 2:Polling 不是更慢吗?用户体验差。

回应

指数 polling 的最坏延迟 = polling 间隔。10s 上限的 polling 最坏延迟 10s——对一个 4 分钟的任务,10s 延迟是 4% 的总时间

如果你需要更低延迟,用 webhook 推——客户端注册 webhook,服务端在 done 时调。延迟 < 1s。

WebSocket 的”实时” optical 优势在长任务里意义不大——用户都不在线。

问题 3:你为什么用 A2A 协议的状态值,不自己定义?

回应

自己定义会复制 A2A 已经踩过的坑。A2A 经过几年迭代收敛到这 5 个值——它已经验证过。

用 A2A 状态值的另一好处:我的任务 API 自然 A2A 兼容——未来其他 A2A 系统可以消费我的任务,不需要 schema 转换。

设计原则:能用现有协议就用,不要为了 NIH 重新发明

问题 4:blocked 状态怎么处理?谁负责解除?

回应

Blocked 表示”任务卡在外部”——比如等用户上传一个图片、等第三方 API 响应。

解除路径:

  • 用户输入 → 用户提交后,端点把 status 从 blocked 改回 working
  • 第三方 API → 后台 worker 周期性 retry,成功则改回 working,失败超时则改 failed

关键是 blocked 必须有超时——避免任务永远 blocked 占 DB 行。

问题 5:长任务最长能多长?1 小时?1 天?

回应

没有理论上限——只要 DB 还在,状态就在。我见过长达数天的任务(大模型微调)。

实际限制是经济性:客户端 polling 几天太浪费 → 切 webhook。或服务端任务的执行环境——CF Workers 30s 限制不能跑长任务,需要丢到 Modal/Replicate 异步执行。

Submit-Poll 是协议不限定执行环境。任务在哪跑、跑多久,是 worker 的事。


9. 相关链接

内部(其他主文章)

外部参考

相关概念

  • Future / Promise ≈ 编程语言里的对应模式
  • Job Queue ≈ 后端架构的对应(RabbitMQ / Sidekiq 等)
  • A2A Protocol Task ≈ 跨 agent 的任务标准