From bb8201f0c37b9492096cf126fe24504fbef7e5fa Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 15 Sep 2026 09:02:02 +0800 Subject: [PATCH] =?UTF-8?q?fix(sse):=20=E5=BF=83=E8=B7=B3=2030s=20?= =?UTF-8?q?=E2=86=92=2010s=EF=BC=88=E5=AE=9E=E6=B5=8B=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E5=AF=BF=E5=91=BD=2034-57s=20=E5=B0=B1=E6=96=AD=EF=BC=8C?= =?UTF-8?q?=E4=B8=8E=E4=BB=A3=E7=90=86=E7=A9=BA=E9=97=B2=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E6=93=A6=E8=BE=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户报「每次点击按钮 1-2s 延迟」时抓到的实测: · 普通 API 30-58ms(服务端不慢); · 他的 /events/stream 连接每次只活 34.6s / 39.4s / 56.9s 就被关闭; · 当时心跳是 30s —— 与常见的 30s 代理读超时**擦边**,晚一点就被判空闲。 所以心跳改 10s(留三倍余量,代价是每 10s 一个 16 字节注释帧),并加判据钉"量级关系": 心跳间隔必须 < 20s,不写成具体数字(心跳与超时"相当"就是错,不是"30 不对 10 对")。 变异:改回 30s → 该判据红。 ★ 诚实记录:部署后**连接still 在被掐**(观察到 12.6s / 17.2s 的寿命,反而更短), 说明掐连接的不是"30s 空闲超时"这一条 —— 更可能是客户端自己 close/重连 (服务端看到的寿命 = 对方关掉的时刻)。这条改动是**正确的加固**(心跳必须明显小于 任何合理超时),但不构成对那个症状的修复;真正定位还需要用户浏览器侧的日志。 --- server/internal/sse/manager.go | 13 ++++++++++++- server/internal/sse/manager_test.go | 13 +++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/server/internal/sse/manager.go b/server/internal/sse/manager.go index 2f8ccaf..4bc436b 100644 --- a/server/internal/sse/manager.go +++ b/server/internal/sse/manager.go @@ -351,9 +351,20 @@ func (c *Client) SendWithID(id, eventType string, data interface{}) { c.Flusher.Flush() } +// heartbeatInterval —— 心跳间隔。 +// +// ★ 2026-09-15 实测(用户报「每次点击按钮 1-2s 延迟」):他的 SSE 连接每次只活 +// 34.6s / 39.4s / 56.9s 就被关闭(网关日志里 /events/stream 的耗时即连接寿命), +// 而普通 API 只要 30-58ms —— 说明不是服务端慢,是**连接被中间反代按空闲超时掐掉**, +// 而我们的心跳是 30s,正好与那个超时擦边:晚一点就被判空闲。 +// +// 心跳必须**明显小于**常见的 30s/60s 代理读超时,而不是与它相当。10s 留了三倍余量, +// 代价只是每 10s 一个 16 字节的注释帧。 +const heartbeatInterval = 10 * time.Second + // heartbeat 定期发送心跳保活 func (m *Manager) heartbeat(client *Client) { - ticker := time.NewTicker(30 * time.Second) + ticker := time.NewTicker(heartbeatInterval) defer ticker.Stop() for { diff --git a/server/internal/sse/manager_test.go b/server/internal/sse/manager_test.go index a9dab41..973d0ac 100644 --- a/server/internal/sse/manager_test.go +++ b/server/internal/sse/manager_test.go @@ -98,3 +98,16 @@ func TestEventRingConcurrent(t *testing.T) { } // 只验证不 panic,不验证内容(并发下顺序无意义) } + +// TestHeartbeatIntervalIsWellUnderProxyIdleTimeout —— 心跳必须**明显小于**常见代理读超时。 +// +// 2026-09-15 实测(用户报「每次点击按钮 1-2s 延迟」):他的 SSE 连接每次只活 +// 34.6s / 39.4s / 56.9s 就被关闭,而普通 API 只要 30-58ms —— 掐连接的不是我们, +// 是中间那层反代的空闲超时,而当时的心跳是 30s,正好与它擦边。 +// 这条判据钉的不是"某个数字",而是**量级关系**:心跳要留出余量,不能与超时相当。 +func TestHeartbeatIntervalIsWellUnderProxyIdleTimeout(t *testing.T) { + if heartbeatInterval >= 20*time.Second { + t.Fatalf("心跳间隔 %s 与常见的 30s 代理读超时擦边:实测连接只活 34-57s 就断,"+ + "心跳必须明显小于该超时(当前上限取 20s)", heartbeatInterval) + } +}