《游戏服务端编程实践》5.3.1 长连接优化与 KeepAlive

区分 TCP keepalive、应用层心跳与 WebSocket ping/pong 的职责,给出移动网络下的心跳间隔、超时与抖动参数,梳理读超时、写超时和空闲超时的边界;附可运行的 Go Heartbeat,覆盖心跳重置、超时驱逐与并发更新。

一、三种“活着”不是一回事

长连接的断开有两种:一种是对端明确关闭,本端立刻收到 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、不做应用心跳,半开连接可能保留数十分钟以上。
  • 心跳包走业务队列,队列一堵就批量误踢,重连后继续雪崩。
  • 超时时间等于心跳间隔,弱网下的一次抖动就被判死。
  • 心跳只更新内存、不刷新连接的读截止时间,连接仍可能被读超时先关掉。
  • 所有客户端同一相位发心跳,网关出现周期性尖峰。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist