TT Lab
开始
学习 学习路径 课程

SSE — 服务端先说话的方式

第一个字节什么时候到

在 TT Lab 中继续学习

目标

亲手实现使用 SSE 时实际碰到的问题——代码是对的,界面上却一下子全部出来——并用时间来确认。

规则

启动服务器

cd /root/work/sse
uvicorn app:app --host 127.0.0.1 --port 8000 > /tmp/uv.log 2>&1 &
curl -N -s localhost:8000/stream

如果去掉 -N,curl 会在自己这一侧缓冲,所以即使服务器流式发送得很好,看起来也像是一下子全部出来的。诊断时最先该怀疑的是测量工具本身。

测量首字节

curl -N -s -o /dev/null -w '%{time_starttransfer}
' localhost:8000/stream

步骤

  1. /stream——间隔 0.3 秒的 3 个事件
  2. 线路格式 → 02-wire.txt
  3. id:、retry:
  4. Last-Event-ID 续传 → 04-resume.txt
  5. /idle——注释心跳
  6. 复现 /buffered → 06-timing.txt
  7. /long + finally → closed.log
  8. 总结 → 08-notes.md

参考

如果不用空行(即连续两个换行符)结束事件,客户端就会认为事件还没有结束,并一直等待。这是“什么都收不到”的第 1 号原因。

创建永不结束的响应

在 /root/work/sse/app.py 中创建 FastAPI 应用,让 GET /stream 以 text/event-stream 发送 3 个事件,间隔为 0.3 秒。

mkdir -p /root/work/sse。应用名称必须是 app。StreamingResponse(gen(), media_type="text/event-stream") 就足够了(也安装了 sse-starlette)。请在生成器内部执行 await asyncio.sleep(0.3)。用 curl -N localhost:8000/stream 来确认——-N 会关闭 curl 的缓冲。

对齐线路格式

给每个事件加上 event: token 和 data:,并用空行结束事件。 把收到的原文原样保存为 02-wire.txt。

一个事件是 event: token\ndata: 안녕\n\n(韩文,意为“你好”)。漏掉最后的空行,是“什么都收不到”的第 1 号原因。保存方法:curl -N -s localhost:8000/stream > 02-wire.txt。

提供 id 和 retry

给每个事件从 1 开始加上 id:,并在流的最前面发送一次 retry:。

id 是浏览器记住之后,在重连时通过 Last-Event-ID 请求头还回来的值。retry: 3000 是重连等待时间(毫秒)。

从漏掉的地方接着发

如果请求中有 Last-Event-ID 请求头,就从下一个 id 开始发送。把总数增加到发送 5 个,并把以 Last-Event-ID: 3 收到的结果保存为 04-resume.txt。

request.headers.get("last-event-id")(请用小写查询)。没有就从 1 开始,有就从该值加 1 开始。确认方法:curl -N -s -H 'Last-Event-ID: 3' localhost:8000/stream > 04-resume.txt——必须只出现 id 4、5。这就是 SSE 的运维成本比 WebSocket 低的原因。

让空闲连接保持存活

创建 GET /idle,让它不发送任何事件,只以 0.2 秒的间隔发送 5 次注释心跳。

: ping\n\n——以冒号开头就是注释,所以客户端不会把它看作事件。只有字节在流动,这样就能越过负载均衡器的空闲超时(通常是 60 秒)。

复现缓冲

创建 GET /buffered。让它等待与 /stream 完全相同的量(0.3 秒 × 5 次),但要在全部生成完之后一次性返回。请在两条路径上测量首字节的到达时刻,并保存为 06-timing.txt。

不用生成器,而是创建一个列表,用 Response(...) 返回即可。测量:比较 curl -N -s -o /dev/null -w '%{time_starttransfer}\n' localhost:8000/stream 与 /buffered。流式传输在 0.3 秒以内,缓冲大约是 1.5 秒——总时间相同,首字节却不同。 这就是“代码是对的,界面上却一下子全部出来”这种症状的真面目。

察觉到连接断了

创建 GET /long,让它长时间流动,并在生成器中加入 finally,使其在断开时向 /root/work/sse/closed.log 写入一行。

即使客户端断开,生成器在察觉到之前也会一直运行。如果不用 try: ... finally: 放入清理代码,每关闭一次标签页,服务器上就会多积累一个僵尸任务。确认方法:curl -N -s --max-time 1 localhost:8000/long > /dev/null; cat closed.log。

什么时候用 SSE,什么时候用 WebSocket

在 08-notes.md 中写三行以上。第 6 步的两个数字意味着什么,Last-Event-ID 替你做了什么,以及必须选择 WebSocket 的一种情况。

正文中必须包含 버퍼링(韩文,意为“缓冲”)、Last-Event-ID 和 WebSocket。只需记住一条判断标准——连接断开时,什么会被自动恢复。