fix(sse): 心跳 30s → 10s(实测连接寿命 34-57s 就断,与代理空闲超时擦边)

用户报「每次点击按钮 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/重连
  (服务端看到的寿命 = 对方关掉的时刻)。这条改动是**正确的加固**(心跳必须明显小于
  任何合理超时),但不构成对那个症状的修复;真正定位还需要用户浏览器侧的日志。
This commit is contained in:
2026-09-15 09:02:02 +08:00
parent d81ab319e4
commit bb8201f0c3
2 changed files with 25 additions and 1 deletions

View File

@ -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 {

View File

@ -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)
}
}