Server-Sent Events 在一条持续的 HTTP 响应中传递服务端到客户端的更新。它不把 HTTP 变成双向套接字,自动重连也不会让投递自动具备持久性。事件分帧、连接寿命、历史保留和业务确认,是四个需要分别定义的契约,不能因为都发生在一条流里,就用一个“推送成功”概括。

一份响应包含有边界的文本事件

SSE 响应使用 text/event-stream 类型和 UTF-8 编码。字段按行组织,空行结束一个块;同一事件中的多行 data 会用换行符拼接。以冒号开头的注释行不作为事件数据处理,因此可以承载心跳,而不触发一次业务消息。缺少正确的空行边界,字节到达也可能暂时看不到事件。

event 字段指定事件类型;没有该字段时,默认类型是 message。命名为 update 的事件需要注册 update 监听器,不能只依赖 onmessage。流在事件尚未结束时到达 EOF,也不会仅仅因为连接关闭就派发这份不完整事件。因此,网络层确实收到字节,不等于事件分帧和监听器选择已经正确。

id 字段是字符串,不是协议强制要求单调递增的整数。应用可以选择序号,但排序方式与保留策略仍要自己定义。空 id 会重置保留的编号,而不是确认此前所有业务已经完成。不要把一个用于续传定位的字段,直接解释成数据库提交记录或可靠消息队列的消费确认。

重连仍然依赖历史记录

浏览器重连时可以通过 Last-Event-ID 发送保留的事件编号。这个值是游标提示,不是事务确认。服务端需要一份经过权限约束、仍然保留的历史,以及“从游标之后继续”的明确规则。重新建立连接,无法恢复从未记录的事件,也无法凭空找回已经过期删除的内容。

应定义游标失效后的行为,例如重新获取快照、拒绝续传,或遵循其他明确业务约定。悄悄从最新事件继续,会隐藏中间的缺口。消费者可能还需要去重;收到一个事件,并不能证明后续动作已经恰好执行一次,也不能保证重试后不会再次触发相同副作用。

EventSource 会对可恢复的断连执行重连,HTTP 204 则明确要求停止重连;其他终止条件也存在,并非每种错误都会无限重试。retry 字段表达以毫秒计的重连间隔,但重连调度不是业务投递截止时间,不能拿它充当消息在多少毫秒内必达的承诺。

坏掉的样子

处理函数或中间代理可能缓冲输出,让应用已经生成的事件迟迟不到客户端。源站需要刷新输出,还要沿真实代理路径观察交付。心跳能够帮助维持某些空闲计时边界,却不能推翻固定的请求总期限。订阅队列也要有界,并规定消费者跟不上时是断开、丢弃还是要求重新同步。

认证和 CORS 规则仍然适用。原生 EventSource 提供凭据模式,而不是任意请求头配置,不能假设它接受与 fetch 完全相同的认证参数。多个页面同时订阅时,还要考虑 HTTP/1.x 连接上限与 HTTP/2 流上限;多路复用不会取消连接级资源预算,也不会替服务端解决慢消费者问题。

快速验证

保存为 sse_probe.go,使用 Go 1.23 或更新版本运行 go run sse_probe.go。本地服务刷新一段注释和一个完整的命名事件,然后让响应继续保持打开。客户端核对精确字节后取消本次请求;后续检查开始之前,服务端必须已经观察到这次断连,避免只检查客户端配置就认定清理成功。

package main

import (
	"context"
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"runtime"
	"sync/atomic"
	"time"
)

func must(err error) {
	if err != nil {
		panic(err)
	}
}

func check(ok bool, message string) {
	if !ok {
		panic(message)
	}
}

func main() {
	const firstFrame = ": heartbeat\n\nid: cursor-A\nevent: update\ndata: hello\ndata: world\n\n"
	const replayFrame = "id: cursor-B\nevent: update\ndata: resumed\n\n"
	disconnected, stop := make(chan struct{}), make(chan struct{})
	var handled atomic.Int64
	var firstEnded atomic.Bool
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		handled.Add(1)
		cursor := r.Header.Get("Last-Event-ID")
		w.Header().Set("X-Received-Cursor", cursor)
		switch cursor {
		case "cursor-B":
			w.WriteHeader(http.StatusNoContent)
			return
		case "", "cursor-A":
		default:
			http.Error(w, "cursor expired; fetch a fresh snapshot", http.StatusGone)
			return
		}
		w.Header().Set("Content-Type", "text/event-stream")
		w.Header().Set("Cache-Control", "no-cache")
		frame := replayFrame
		if cursor == "" {
			frame = firstFrame
		}
		_, err := io.WriteString(w, frame)
		must(err)
		must(http.NewResponseController(w).Flush())
		if cursor == "" {
			defer firstEnded.Store(true)
			select {
			case <-r.Context().Done():
				close(disconnected)
			case <-stop:
			}
		}
	}))
	defer server.Close()
	defer close(stop)
	transport := &http.Transport{}
	defer transport.CloseIdleConnections()
	client := &http.Client{Transport: transport, Timeout: 2 * time.Second}
	open := func(ctx context.Context, cursor string) *http.Response {
		request, err := http.NewRequestWithContext(ctx, http.MethodGet, server.URL, nil)
		must(err)
		if cursor != "" {
			request.Header.Set("Last-Event-ID", cursor)
		}
		response, err := client.Do(request)
		must(err)
		check(response.Header.Get("X-Received-Cursor") == cursor, "server must receive the exact cursor")
		return response
	}
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	first := open(ctx, "")
	check(first.StatusCode == 200 && first.Header.Get("Content-Type") == "text/event-stream", "SSE response required")
	buffer := make([]byte, len(firstFrame))
	_, err := io.ReadFull(first.Body, buffer)
	must(err)
	check(string(buffer) == firstFrame && !firstEnded.Load(), "frame must arrive before handler completion")
	cancel()
	must(first.Body.Close())
	select {
	case <-disconnected:
	case <-time.After(2 * time.Second):
		panic("server did not observe disconnect")
	}
	fmt.Println("initial: comment and complete cursor-A frame arrive while response remains open")
	complete := func(cursor string, wantStatus int, wantBody string) {
		response := open(context.Background(), cursor)
		body, readError := io.ReadAll(response.Body)
		closeError := response.Body.Close()
		must(readError)
		must(closeError)
		check(response.StatusCode == wantStatus && string(body) == wantBody, "wrong replay/status response")
		if wantStatus == 200 {
			check(response.Header.Get("Content-Type") == "text/event-stream", "replay must keep the SSE media type")
		}
		fmt.Printf("cursor=%s status=%d\n", cursor, response.StatusCode)
	}
	complete("cursor-A", 200, replayFrame)
	complete("expired", 410, "cursor expired; fetch a fresh snapshot\n")
	complete("cursor-B", 204, "")
	check(handled.Load() == 4, "expected four HTTP requests")
	fmt.Println("explicit replay requests; no browser reconnection or durability claim")
	fmt.Println(runtime.Version())
}

第一个事件含有 cursor-A、update 类型和两行 data。符合规范的 SSE 客户端会派发一个 update,数据为 hello、换行符和 world;前面的注释不是另一个事件。实验核对完整的线上文本块,并确认它们在初始处理函数仍然等待时已经到达,而不是直到 EOF 才一起读到。

第二次请求显式发送 Last-Event-ID: cursor-A,并收到 cursor-B 事件。服务端回显实际收到的游标,以便核对传输值。过期游标得到 410 和重新取得快照的说明,这是本应用选择的策略,不是 SSE 规范规定的游标过期状态。携带 cursor-B 的请求则得到 204 和空正文。

已核验的 macOS、Go 1.27.0 运行中,四次请求全部通过。每次客户端请求限时两秒,等待服务端观察断连也有独立上限。这些短期限是实验保护,不是长期生产订阅的通用配置。响应正文、等待中的处理函数和本地资源都会清理,不会为了展示持续响应而遗留一个无限等待的进程。

Go 程序检查的是字面分帧,并自行构造续传请求,不是浏览器 EventSource 的实现。它没有验证浏览器自动重连、代理缓冲、认证、CORS、历史持久保留、恰好一次业务效果或慢消费者负载。这些能力需要各自额外验收,不能由一次本地流式响应成功来代替。

延伸阅读

来源