断开是常态,缺口才是事故
一句话总结
SSE 的重连由浏览器免费完成,但填补缺口是服务器的工作。
连接两者的纽带,只有 id 字段和 Last-Event-ID 请求头这两样。
为什么需要它
在地铁里打开通知页面,连接每隔几分钟就会断一次。浏览器会自己重新连上——到这一步为止,是 EventSource 替你做的。问题在于断开期间服务器发送的内容。
如果只是重新连上,那一段就永远消失了。三条通知就像没有发生过,订单状态会从“已支付”突然跳到“配送中”。反过来,如果服务器总是从头重新发送,同一条通知就会弹出两次。这两种都是用户马上就能察觉的故障。
规范用一个非常小的机制解决了这个问题。服务器给每个事件加上 id,浏览器就会记住最后看到的 id,在重新连接时,通过 Last-Event-ID 请求头还回去。服务器只需要从那之后开始发送。
工作原理
规范的规定比想象的更细密
HTML 标准 9.2 的解析规则是一行一行地读取并这样运转的。需要记住的有三个——data 缓冲区、event type 缓冲区,以及 last event ID 缓冲区。
빈 줄 → 이벤트를 내보낸다
콜론으로 시작 → 그 줄은 무시 (주석 · 하트비트)
콜론이 있다 → 앞이 필드 이름, 뒤가 값. 값이 공백으로 시작하면 하나만 뗀다
콜론이 없다 → 줄 전체가 필드 이름, 값은 빈 문자열
该代码块中的韩文说明依次为:空行表示派发事件;以冒号开头的行会被忽略(注释、心跳);有冒号时,冒号前是字段名,冒号后是值,如果值以空格开头,只去掉一个空格;没有冒号时,整行就是字段名,值为空字符串。
各个字段的处理,规范中也有明确写出。
| 字段 | 规则 |
|---|---|
data |
把值追加到缓冲区,再追加一个换行 |
event |
把事件类型缓冲区改成这个值 |
id |
只有值中没有 U+0000 NULL 时,才修改最后的 id 缓冲区 |
retry |
只有值完全由 ASCII 数字组成时,才修改重连时间 |
| 其他 | 忽略 |
在这里,人们最常弄错的有两点。第一,最后的 id 缓冲区在派发事件之后也不会被清空。 只有 data 缓冲区和 event type 缓冲区会被清空。所以即使连续出现没有 id 的事件,用于重连的编号也仍然保留。第二,如果 data 缓冲区是空字符串,就不会派发事件。 只写了 retry: 的块,或者只有注释的块,是配置而不是事件。
末尾被截断的事件要丢弃
规范明确规定——如果文件在最后一个空行之前就结束,那个不完整的事件就不会被派发。 这条规则是为了在连接于事件中间断开时,不把只有一半的 JSON 放到界面上,同时也让 Last-Event-ID 只指向实际完整收到的部分。续传之所以准确,原因就在这里。
重连由浏览器负责,缺口由服务器负责
重连的时机在规范中也有规定。浏览器会抛出错误,把 readyState 改为 CONNECTING,然后等待重连时间再重新连接。这个时间的初始值交由实现决定(规范只写了“几秒左右”),服务器可以用 retry: 来修改。如果上一次尝试失败了,规范允许浏览器再加上指数退避。
而且在重新连接时,只有最后的 id 字符串不是空字符串时,才会携带 Last-Event-ID 请求头。服务器只要分两种情况处理就行:没有这个请求头就“从头开始”,有就“从它之后开始”。
服务器一侧的搭档是可以回放的窗口。在环形缓冲区中保存最近的 N 个事件,当收到请求时,返回所请求 id 之后的内容。如果 id 超出了窗口,就不应该发送剩余的内容,而要告知“无法续传”,让客户端重新接收全部内容。如果只发送在未知 id 之后的内容,中间就会出现缺口,而且没有人知道这件事。
续传只能做到服务器记得的程度
即使客户端正确地带上了请求头,如果服务器什么都不记得,续传就无法成立。回放窗口的大小,归根结底是决定可以断开多久的值。如果窗口是 100 个,而流每秒发送 50 个,那就只是一份 2 秒钟的保险。必须先计算窗口是按时间还是按数量来设定,以及超出之后整体重发的规模是否承受得起,再来确定数字。
在现场相遇的样子
在 LLM 令牌流式传输中,有一种常见事故。没有给每个令牌加上 id,导致重连总是从头开始,用户会看到同一句话被打印两次。反过来,也有给了 id,服务器却什么都不记得,对续传请求总是返回空流的情况——这种情况会以“偶尔回答会在中途停下”的形式被受理。
还有团队把 id 解析成了整数。在规范中,id 是只要不含 NULL、LF、CR 的字符串,由于分片的缘故,团队开始使用像 b7-1042 这样的 id 的那一天,续传整个失效了。异常只留在服务器日志里,界面却悄悄地从头重新接收。
下一项实验要做什么
严格按照规范实现解析规则。被拆成片段的输入、三种行结束符、注释、没有冒号的行、含有 NULL 的 id、不是数字的 retry,以及被截断的最后一个事件。在此基础上,再加上服务器一侧的回放窗口和重连请求头。