一、三种“活着”不是一回事
长连接的断开有两种:一种是对端明确关闭,本端立刻收到 FIN;另一种是中间设备已经丢弃了连接状态,两端却都以为连接还在。后者在移动网络上非常常见:手机切换 Wi-Fi 与蜂窝、电梯和地铁里信号短暂消失、运营商 NAT 回收空闲映射,都会留下半开连接。只靠 TCP 自身的机制,服务端可能几十分钟甚至几小时都发现不了。
| 机制 | 工作层 | 谁发探测 | 能发现什么 | 局限 |
|---|---|---|---|---|
| TCP keepalive | 内核 | 操作系统 | 对端主机无响应、路由中断 | 默认 idle 通常 2 小时;NAT 映射可能先失效;应用卡死但内核仍回 ACK |
| 应用层心跳 | 业务协议 | 客户端或服务端 | 对端进程能读能写、业务协程未卡死 | 需要自己定义间隔、超时和重连语义 |
| WebSocket ping/pong | 协议控制帧 | 任意一端 | 对端协议栈能回控制帧 | 仍属于应用层;代理可能不透传,不能替代业务鉴权 |
三者的关系是叠加而不是替代。TCP keepalive 把“机器失联”交给内核,应用心跳把“进程或连接不可用”暴露给业务,WebSocket ping/pong 则在协议层面提供标准化的探测帧。游戏网关通常同时开启:内核参数把 tcp_keepalive_time 调到分钟级,业务层再跑 15~30 秒一次的心跳。
移动网络的 NAT 超时没有统一标准。运营商级 NAT 的 UDP 映射常见 30 秒到 5 分钟,TCP 空闲映射也可能在几分钟内被回收;玩家切网后 IP 变化,旧四元组直接失效。心跳间隔必须小于这条链路的最短空闲回收时间,否则服务端会持续向一个已经不存在的映射写数据。
二、心跳参数:间隔、超时、抖动
心跳不是越频繁越好。间隔太短会让手机基带频繁唤醒,耗电和流量都上升;间隔太长则死连接发现慢,幽灵玩家占着队伍位、战斗房间和内存。经验值如下:
| 参数 | 建议值 | 作用 | 代价 |
|---|---|---|---|
| 心跳间隔 | 15~30 秒 | 维持 NAT 映射、证明进程存活 | 太短耗电、增加小包 |
| 超时阈值 | 3 倍间隔,45~90 秒 | 连续丢失多个心跳才判死 | 太短会误踢弱网玩家 |
| 抖动 | 间隔的 ±10%~20% | 打散全服同时发包 | 实现要记录下一次发送时间 |
| 扫描周期 | 1~5 秒 | 驱逐超时连接的粒度 | 太密增加定时器唤醒 |
超时不能等于心跳间隔。网络中一个包晚到几百毫秒很常见,超时等于间隔会把偶发抖动直接判成断线。至少留出两到三次重试:发送端在间隔内没收到 pong,下一次继续发;服务端连续一个完整超时窗口没更新 last_seen,才关闭连接并通知业务层。
抖动同样重要。十万玩家如果都在整分钟发心跳,网关会周期性出现尖峰,其他时刻又几乎空闲。让客户端在首次连接时随机偏移,之后按固定相位发送,可以把负载摊平,同时不增加额外的包。
三、读超时、写超时、空闲超时各防什么
很多实现只设置了一个“连接超时”,结果该断的不断、不该断的乱断。三类超时的职责不同:
| 超时 | 从哪里算 | 防的问题 | 典型值 |
|---|---|---|---|
| 读超时 | 每次成功读到完整帧后重置 | 半包、慢速攻击、对端只发一半帧 | 5~15 秒 |
| 写超时 | 每次写调用前设置 | 慢客户端导致发送协程永久阻塞、内存堆积 | 3~10 秒 |
| 空闲超时 | 最近一次任何有效流量 | 长期无业务也无心跳的连接 | 略大于心跳超时 |
读超时防的是“帧永远读不完”。攻击者发一个长度字段很大的包头,然后一个字节一个字节地挤,服务端如果阻塞在 io.ReadFull 上,协程和缓冲区就被长期占用。写超时防的是“对端不读”。TCP 发送缓冲区满了以后,Write 会阻塞;如果不设截止时间,广播协程会被最慢的客户端拖住,进而拖垮整个房间。
空闲超时和心跳超时不要共用一个计时器。心跳是业务语义,空闲是资源语义:一个玩家可能长时间在副本里挂机,但心跳正常;也可能心跳包在发,业务消息全被丢弃。前者不该被空闲超时误杀,后者需要业务层自己的健康检查。
四、连接数优化:少包、合并写、多路复用
单机连接数上去以后,瓶颈往往不是 CPU,而是每个连接的固定开销:读写缓冲区、goroutine 栈、定时器、心跳表项。优化方向是减少“每连接每消息”的成本。
- 单连接多路复用:同一玩家的大厅、聊天、战斗消息走一条连接,用
channel_id或消息号区分,避免为每个功能各建一条 TCP。 - 减少小包:把同一帧内的多条业务消息合并成一个批次,协议头只写一次;移动端尤其要避免“每点一次按钮发一个 20 字节包”。
- 合并写:使用
bufio.Writer时明确Flush边界,不要把每条消息都 Flush;需要一次写多个切片时用net.Buffers,让运行时走writev。 - 控制缓冲区:读缓冲按最大帧长分配,不要按连接数乘一个大常数;空闲连接可以缩容,但频繁扩缩容会带来分配压力。
- 慎用压缩:文本协议压缩收益明显,二进制小包压缩后可能更大,还会增加每连接的 CPU 和内存。
需要提醒的是,Nagle 算法与延迟 ACK 叠加可能带来约 40 毫秒的额外延迟。实时性要求高的帧应设置 TCP_NODELAY,同时仍然在应用层做批量,而不是靠内核攒包。
五、失败场景:只开了 TCP keepalive
某款手游的网关只设置了 SetKeepAlive(true),没有应用层心跳。玩家从 Wi-Fi 切到 4G 后,旧连接的 NAT 映射失效,客户端其实已经用新连接重新登录,服务端却仍保留旧连接。由于内核 keepalive 的 idle 是两小时,旧连接在心跳表里一直“活着”,队伍里出现两个同名角色,战斗结算时同一账号被扣两次体力。
更隐蔽的一种情况是心跳包走了业务队列。网关把所有上行消息塞进同一个有界队列,某个副本广播把队列堵住,心跳包排在几百条业务消息后面。服务端的心跳扫描看不到更新,把一整批健康连接踢掉;客户端重连后又挤进同一个队列,形成雪崩。心跳必须有独立通道或至少独立优先级,不能在业务队列里排队。
修复方案分三层:内核 keepalive 调到 60~120 秒,应用心跳 20 秒一次、60 秒超时,心跳处理走独立入口且只更新 last_seen,不参与业务排队。三层各司其职,任何一层失效都还有兜底。
六、Go 实现:Heartbeat
下面的 Heartbeat 只做一件事:记录每条连接最近一次有效心跳,并在扫描时返回超时连接。Evict 在同一个锁内完成扫描与删除,避免“扫描完、删除前刚好来了一个新心跳”导致误删。时间函数可注入,测试不需要真的等待。
// file: ch05/scale/keepalive.go
// tier: L1
// verify: go test ./ch05/scale/
package scale
import (
"errors"
"sort"
"sync"
"time"
)
// Heartbeat 维护每条连接最近一次有效心跳的时间。
// TCP keepalive 由内核负责探活,应用层心跳负责确认对端进程仍能处理业务。
type Heartbeat struct {
mu sync.Mutex
interval time.Duration
timeout time.Duration
now func() time.Time
lastSeen map[string]time.Time
}
var ErrInvalidHeartbeatConfig = errors.New("heartbeat timeout must be greater than interval")
func NewHeartbeat(interval, timeout time.Duration) (*Heartbeat, error) {
if interval <= 0 || timeout <= interval {
return nil, ErrInvalidHeartbeatConfig
}
return &Heartbeat{
interval: interval,
timeout: timeout,
now: time.Now,
lastSeen: make(map[string]time.Time),
}, nil
}
func (h *Heartbeat) Interval() time.Duration { return h.interval }
func (h *Heartbeat) Timeout() time.Duration { return h.timeout }
// Beat 记录一次有效心跳。同一 id 重复登录时直接覆盖,旧截止时间随之失效。
func (h *Heartbeat) Beat(id string) {
h.mu.Lock()
h.lastSeen[id] = h.now()
h.mu.Unlock()
}
func (h *Heartbeat) LastSeen(id string) (time.Time, bool) {
h.mu.Lock()
defer h.mu.Unlock()
t, ok := h.lastSeen[id]
return t, ok
}
// Overdue 返回 now 时刻已超过 timeout 未心跳的连接,按 id 排序以便稳定驱逐。
func (h *Heartbeat) Overdue(now time.Time) []string {
h.mu.Lock()
defer h.mu.Unlock()
out := make([]string, 0, len(h.lastSeen))
for id, t := range h.lastSeen {
if now.Sub(t) >= h.timeout {
out = append(out, id)
}
}
sort.Strings(out)
return out
}
// Evict 在一个锁内完成扫描与删除,避免扫描后到来的心跳被误删。
// 返回被驱逐的连接 id,调用方负责关闭对应的 socket。
func (h *Heartbeat) Evict(now time.Time) []string {
h.mu.Lock()
defer h.mu.Unlock()
var evicted []string
for id, t := range h.lastSeen {
if now.Sub(t) >= h.timeout {
evicted = append(evicted, id)
}
}
sort.Strings(evicted)
for _, id := range evicted {
delete(h.lastSeen, id)
}
return evicted
}
func (h *Heartbeat) Len() int {
h.mu.Lock()
defer h.mu.Unlock()
return len(h.lastSeen)
}
测试覆盖三个关键行为:心跳必须重置截止时间、驱逐只影响超时连接、并发心跳不会丢更新也不会误删。
// file: ch05/scale/keepalive_test.go
// tier: L1
// verify: go test ./ch05/scale/
package scale
import (
"fmt"
"sync"
"testing"
"time"
)
func newTestHeartbeat(t *testing.T) (*Heartbeat, *time.Time) {
t.Helper()
h, err := NewHeartbeat(15*time.Second, 45*time.Second)
if err != nil {
t.Fatal(err)
}
now := time.Unix(1_800_000_000, 0)
h.now = func() time.Time { return now }
return h, &now
}
func TestHeartbeatResetsDeadline(t *testing.T) {
h, now := newTestHeartbeat(t)
h.Beat("conn-1")
*now = now.Add(40 * time.Second)
if got := h.Overdue(*now); len(got) != 0 {
t.Fatalf("overdue=%v before timeout", got)
}
h.Beat("conn-1") // 心跳必须把截止时间推后
*now = now.Add(40 * time.Second)
if got := h.Overdue(*now); len(got) != 0 {
t.Fatalf("overdue=%v after fresh beat", got)
}
*now = now.Add(5 * time.Second)
if got := h.Overdue(*now); len(got) != 1 || got[0] != "conn-1" {
t.Fatalf("overdue=%v at timeout", got)
}
}
func TestEvictRemovesOnlyTimedOut(t *testing.T) {
h, now := newTestHeartbeat(t)
h.Beat("fresh")
*now = now.Add(30 * time.Second)
h.Beat("stale")
*now = now.Add(20 * time.Second)
evicted := h.Evict(*now)
if len(evicted) != 1 || evicted[0] != "fresh" {
t.Fatalf("evicted=%v, want [fresh]", evicted)
}
if _, ok := h.LastSeen("fresh"); ok {
t.Fatal("fresh connection still tracked after eviction")
}
if _, ok := h.LastSeen("stale"); !ok {
t.Fatal("stale connection should still be tracked")
}
if h.Len() != 1 {
t.Fatalf("tracked=%d, want 1", h.Len())
}
}
func TestConcurrentBeat(t *testing.T) {
h, now := newTestHeartbeat(t)
const workers, rounds = 64, 200
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
id := fmt.Sprintf("conn-%d", i%8)
for j := 0; j < rounds; j++ {
h.Beat(id)
}
}(i)
}
wg.Wait()
if h.Len() != 8 {
t.Fatalf("tracked=%d, want 8", h.Len())
}
if got := h.Evict(*now); len(got) != 0 {
t.Fatalf("evicted fresh connections: %v", got)
}
}
func TestHeartbeatRejectsTimeoutNotGreaterThanInterval(t *testing.T) {
if _, err := NewHeartbeat(30*time.Second, 30*time.Second); err == nil {
t.Fatal("timeout equal to interval should fail")
}
if _, err := NewHeartbeat(0, 45*time.Second); err == nil {
t.Fatal("zero interval should fail")
}
}
七、运行与验证
cd .local/checks/gs-practice-lab && gofmt -l ch05/scale && go vet ./ch05/scale/ && go test ./ch05/scale/ -count=1
ok gspractice/ch05/scale 0.311s
并发测试建议再跑一次竞态检测:
cd .local/checks/gs-practice-lab && go test ./ch05/scale/ -race -count=1
ok gspractice/ch05/scale 1.516s
小结
长连接保活要分层:TCP keepalive 处理主机和路由失联,应用心跳处理进程与连接可用性,WebSocket ping/pong 提供协议级探测。心跳间隔要小于移动网络 NAT 的最短回收时间,超时至少是间隔的两到三倍,并加入抖动打散尖峰。读、写、空闲三类超时分别防半包、慢客户端和资源泄漏。连接数优化靠多路复用、合并写和受控缓冲区,而不是单纯调大参数。
常见错误与边界
- 只开 TCP keepalive、不做应用心跳,半开连接可能保留数十分钟以上。
- 心跳包走业务队列,队列一堵就批量误踢,重连后继续雪崩。
- 超时时间等于心跳间隔,弱网下的一次抖动就被判死。
- 心跳只更新内存、不刷新连接的读截止时间,连接仍可能被读超时先关掉。
- 所有客户端同一相位发心跳,网关出现周期性尖峰。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。