《游戏服务端架构·品类、状态与拓扑》5.1 长连接、短连接、WebSocket 与 QUIC 的取舍

《裂隙战场》《群岛纪元》《泡泡工坊》对延迟、推送与运维的要求完全不同。本节把短连接、长轮询、WebSocket 与 QUIC 放进同一张选型矩阵,给出硬约束筛选、加权打分、握手与重连预算,并说明团队只有两人或延迟要求宽松时,为什么不该自建长连接。

三款产品,三种“连接”问题

北辰工作室的三款产品在同一个平台部门下运行,但接入层的诉求几乎相反。

《裂隙战场》是 5v5 竞技。一局十分钟,服务端每 33ms 推进一次,玩家的移动、技能和命中判定都依赖持续下行。它需要低延迟、服务端主动推送,并且玩家会在 WiFi 与移动网络之间切换。团队有八个人,能承担一套 UDP 接入的运维。

《群岛纪元》是 SLG。大地图上的行军、建造和联盟消息是事件驱动,玩家不要求每帧同步,但服务端必须能主动推送“你的城被打了”。它的用户大量在中东和东南亚的弱网环境,延迟预算在 200ms 以上。团队六个人,不想再维护一套 UDP 网关。

《泡泡工坊》是休闲小游戏。两个人维护后端,目标是两周上线一个新玩法。它只需要房间内的事件广播,请求与响应占绝大多数,延迟 100ms 到 200ms 都能接受。

把三件事放在一起,连接选型就不是“哪个协议更先进”,而是在给定延迟预算、网络环境、团队规模和上线速度下,哪一种代价可以接受。

候选协议的边界

先把四种常见形态的工程边界摆出来。表中的延迟门槛是保守的工程值,不是 benchmark 峰值;真实数字必须由目标市场的现网测量决定。

形态最小单向延迟门槛(示例值)服务端推送弱网迁移过传统 CDN主要代价
短连接 HTTP250ms不支持天然无状态支持每次请求重新握手,不适合实时
长轮询120ms有限推送断线后重开请求支持连接占用高,消息延迟受轮询周期影响
WebSocket40ms持续推送断线重连,不能迁移多数支持需要心跳、重连风暴治理
QUIC 流25ms持续推送连接迁移支持有限UDP 运维、降级链路、团队能力
QUIC 数据报20ms不可靠连接迁移基本不支持丢包处理全部上移业务层

两个容易被忽略的边界:

短连接不是“低端方案”。 对《泡泡工坊》的商城、邮件、排行榜,短连接配合 CDN 和缓存,成本和可靠性都优于长连接。把长连接用在请求-响应场景,只会增加心跳和重连负担。

QUIC 不是“WebSocket 的升级版”。 QUIC 的优势在连接迁移、0-RTT 恢复和可选的不可靠传输;代价是 UDP 在部分运营商和企业网络中被阻断,必须有 TCP 或 WebSocket 降级路径。没有降级路径的 QUIC 方案,在真实用户群里一定会有打不开的投诉。

选型矩阵

把三款产品放进同一张表,结论会清楚很多。

产品延迟预算推送需求网络特征团队规模选定形态明确不选
《裂隙战场》30ms 到 50ms每帧持续下行移动网络切换频繁8 人QUIC 流承载关键帧,数据报承载可丢状态短连接、长轮询
《群岛纪元》200ms 到 500ms事件级推送弱网、跨地域6 人WebSocket + CDN 前置QUIC 数据报、纯短连接
《泡泡工坊》100ms 到 200ms房间事件广播普通家庭网络2 人托管 WebSocket 或长轮询自建 UDP、复杂网关

《裂隙战场》选 QUIC 流,不是因为它“新”,而是因为玩家在移动网络切换时,连接迁移能避免一次完整重连;关键帧仍走可靠流,避免业务层处理乱序。可丢的位置快照才走数据报,且必须允许客户端插值。

《群岛纪元》选 WebSocket,是因为它的推送是事件级而不是帧级,200ms 的延迟预算完全够用;CDN 前置能吸收跨地域握手成本,团队也不需要碰 UDP。

《泡泡工坊》直接用托管 WebSocket。两个人维护后端时,任何自建长连接都是在给未来埋运维债。

什么时候不值得自建长连接

以下条件满足任意两条,就应该优先考虑托管 WebSocket、长轮询或纯 HTTP,而不是自建 TCP/UDP 接入:

  • 单向延迟预算大于 200ms,且没有逐帧同步需求;
  • 团队少于三人,没有专职 SRE 或网络方向的人;
  • 业务以请求-响应为主,服务端推送可以用事件表或轮询兜底;
  • 目标市场存在 UDP 阻断,且团队没有能力维护完整降级链路;
  • 已有 CDN 或边缘平台能终止 WebSocket,并提供连接数、流量和错误率指标。

自建长连接的真实成本不在“把连接维持住”,而在四件持续发生的事:心跳与半开连接清理、断线重连风暴、发布时的连接排空、以及跨可用区的连接可观测性。前三件任何一件做错,都会表现为“玩家掉线”或“登录排队”,而不是一个漂亮的延迟数字。

把选型规则写成代码

下面的实现把协议画像、硬约束、加权打分和握手/重连预算放在同一个包里。它不是“自动选型器”,而是把评审会上口头说的规则固定下来,让下一次争论有可执行的依据。

// file: internal/conn/profile.go
// tier: L1
// verify: go test ./internal/conn/
package conn

import (
	"errors"
	"sort"
	"time"
)

// Transport is a wire shape a product ships, not a library choice.
type Transport string

const (
	ShortHTTP    Transport = "http-short"
	LongPoll     Transport = "http-longpoll"
	WebSocket    Transport = "websocket"
	QUICStream   Transport = "quic-stream"
	QUICDatagram Transport = "quic-datagram"
)

// Profile records decision-relevant properties. LatencyFloorMS is the lowest
// one-way latency the shape can sustain, not a benchmark result.
type Profile struct {
	Transport      Transport
	LatencyFloorMS int
	ServerPush     bool
	PersistentPush bool
	CDNSafe        bool
	Migration      bool
	ResumeFriendly bool
	HandshakeRTTs  int
	OpsCost        int
	RunsOverUDP    bool
	LossTolerant   bool
}

var catalog = []Profile{
	{ShortHTTP, 250, false, false, true, false, true, 2, 0, false, false},
	{LongPoll, 120, true, false, true, false, true, 2, 1, false, false},
	{WebSocket, 40, true, true, true, false, true, 2, 1, false, false},
	{QUICStream, 25, true, true, false, true, true, 1, 2, true, false},
	{QUICDatagram, 20, false, false, false, true, false, 1, 3, true, true},
}

// Requirements are constraints from genre, network and team shape.
type Requirements struct {
	MaxOneWayMS  int
	ServerPush   bool
	WeakMobile   bool
	MustPassCDN  bool
	LossTolerant bool
	TeamSize     int
}

// ErrNoFit means every catalog entry violates a hard requirement.
var ErrNoFit = errors.New("conn: no transport fits the requirements")

// Decision carries the winner and the eligible ranking for an ADR appendix.
type Decision struct {
	Profile Profile
	Score   int
	Ranked  []Profile
}

func eligible(p Profile, req Requirements) bool {
	if req.MaxOneWayMS < p.LatencyFloorMS {
		return false
	}
	if req.ServerPush && !p.ServerPush {
		return false
	}
	if req.MustPassCDN && !p.CDNSafe {
		return false
	}
	if req.LossTolerant && !p.LossTolerant {
		return false
	}
	// A two-person team should not operate its own UDP fleet for a product
	// that already tolerates 200ms.
	if req.TeamSize <= 2 && req.MaxOneWayMS >= 200 && p.RunsOverUDP {
		return false
	}
	return true
}

func score(p Profile, req Requirements) int {
	s := 100 - p.HandshakeRTTs*3
	if req.WeakMobile {
		if p.Migration {
			s += 12
		}
		if p.ResumeFriendly {
			s += 4
		}
	}
	if req.ServerPush && p.PersistentPush {
		s += 8
	}
	if req.TeamSize <= 3 {
		s -= p.OpsCost * 6
	}
	if p.RunsOverUDP {
		s -= p.OpsCost * 2
	}
	if req.LossTolerant && p.LossTolerant {
		s += 10
	}
	return s
}

// Recommend scores every eligible profile and breaks ties by lower latency
// floor, then by transport name, so the result is reproducible in an ADR.
func Recommend(req Requirements) (Decision, error) {
	type scored struct {
		p Profile
		s int
	}
	var list []scored
	for _, p := range catalog {
		if eligible(p, req) {
			list = append(list, scored{p, score(p, req)})
		}
	}
	if len(list) == 0 {
		return Decision{}, ErrNoFit
	}
	sort.Slice(list, func(i, j int) bool {
		if list[i].s != list[j].s {
			return list[i].s > list[j].s
		}
		if list[i].p.LatencyFloorMS != list[j].p.LatencyFloorMS {
			return list[i].p.LatencyFloorMS < list[j].p.LatencyFloorMS
		}
		return list[i].p.Transport < list[j].p.Transport
	})
	out := Decision{Profile: list[0].p, Score: list[0].s}
	for _, item := range list {
		out.Ranked = append(out.Ranked, item.p)
	}
	return out, nil
}

// HandshakePlan describes what must happen before the first game byte.
type HandshakePlan struct {
	TLS      bool
	Resume   bool
	AuthRTTs int
}

// Handshake is the connection-setup cost in round trips and milliseconds.
type Handshake struct {
	RoundTrips int
	Millis     int
	Resumed    bool
}

// EstimateHandshake uses TLS 1.3 (one round trip after the TCP handshake).
// A resumed session is modelled as one round trip regardless of authRTTs.
func EstimateHandshake(rttMS int, plan HandshakePlan) Handshake {
	if rttMS <= 0 {
		rttMS = 1
	}
	rt := 1 + plan.AuthRTTs
	if plan.TLS && !plan.Resume {
		rt++
	}
	if plan.Resume {
		rt = 1
	}
	return Handshake{RoundTrips: rt, Millis: rt * rttMS, Resumed: plan.Resume}
}

// ReconnectPolicy is exponential backoff plus a resumable-session window.
type ReconnectPolicy struct {
	Base         time.Duration
	Cap          time.Duration
	MaxAttempts  int
	ResumeWindow time.Duration
	JitterRatio  float64
}

// Delay returns the wait before attempt (1-based). unit in [0,1] is supplied
// by the caller; unit=0 is the jitter-free control used by tests.
func (p ReconnectPolicy) Delay(attempt int, unit float64) (time.Duration, bool) {
	if attempt < 1 || attempt > p.MaxAttempts || p.Base <= 0 {
		return 0, false
	}
	d := p.Base
	for i := 1; i < attempt && d < p.Cap; i++ {
		d *= 2
	}
	if p.Cap > 0 && d > p.Cap {
		d = p.Cap
	}
	if unit < 0 {
		unit = 0
	}
	if unit >= 1 {
		unit = 1
	}
	ratio := p.JitterRatio
	if ratio < 0 {
		ratio = 0
	}
	if ratio > 1 {
		ratio = 1
	}
	return time.Duration(float64(d) * (1 - ratio*unit)), true
}

// CanResume reports whether a gap still falls inside the resume window.
func (p ReconnectPolicy) CanResume(gap time.Duration) bool {
	return gap >= 0 && gap <= p.ResumeWindow
}

规则怎么读

  • LatencyFloorMS 是硬门槛:请求预算低于门槛,直接淘汰,不参与打分。
  • MustPassCDN 会淘汰 QUIC;LossTolerant 会淘汰可靠流;TeamSize <= 2 且延迟预算大于 200ms 时,淘汰所有 UDP 形态。这些是否决项,不能被分数抵消。
  • 通过筛选后,分数只表达偏好:弱网迁移、持续推送、运维成本、握手轮次。权重是团队政策,不是物理定律,换团队就应该改权重。
  • 排序在分数相同时先比延迟门槛,再比协议名,保证同一份配置在任何机器上得到同一个结论。

这套规则最重要的输出不是 Profile,而是 Ranked。评审时真正要解释的是“为什么第二名输了”,而不是“为什么第一名赢了”。

握手与重连预算

登录预算是连接选型里最容易被低估的一项。玩家点击“进入游戏”后,客户端要先建连、握手、认证,再进入房间。以 40ms 的 RTT 为例:

场景轮次预计耗时(示例值)说明
TLS 1.3 + 认证3120msTCP 1 轮 + TLS 1 轮 + 认证 1 轮
明文 TCP + 认证280ms内网或已由边缘终止 TLS
恢复会话140ms用 resume token 跳过完整握手
弱网 RTT 120ms3360ms还没算丢包重传

这些数字由 EstimateHandshake 按公式算出,是预期值,不是本机实测。真实预算是“握手耗时 + 资源加载 + 场景切换”,连接层通常只占三分之一;如果连接层就吃掉了 300ms,说明应该先做 CDN 前置或会话恢复。

重连策略同样要写死。示例策略是:首次等待 100ms,每次翻倍,上限 800ms,最多 5 次,抖动比例 0.2,恢复窗口 30 秒。

尝试次数无抖动等待(示例值)抖动后范围
1100ms80ms 到 100ms
2200ms160ms 到 200ms
3400ms320ms 到 400ms
4800ms640ms 到 800ms
5800ms640ms 到 800ms

抖动不是“让数字好看”,而是把同一批掉线玩家打散。没有抖动时,一次网关发布会让所有客户端在同一毫秒重连,形成第二轮雪崩。

恢复窗口要和业务对齐。30 秒是示例值:如果一局战斗最长 10 分钟,窗口太短会让玩家在关键团战时被迫重开;如果窗口太长,旧连接的状态会占用资源,也增加顶号复杂度。这个值应该由“可接受的最长断线”和“服务端保留会话的成本”共同决定。

本机验证

$ cd .local/checks/gsarch-lab
$ gofmt -l internal/conn
$ go vet ./internal/conn/
$ go test -count=1 ./internal/conn/ -v
...
PASS
ok  	gsarchlab/internal/conn	0.308s

gofmt -l 与 go vet 均无输出。5 个测试函数、20 个子用例,共 25 个 PASS,覆盖选型矩阵、无解场景、握手轮次、退避上限和恢复窗口边界。耗时来自本机一次运行,随负载变化。

失败模式与兜底

失败表现兜底
UDP 被运营商阻断QUIC 建连超时同一客户端降级到 WebSocket,并记录降级率
CDN 不支持 UpgradeWebSocket 返回 4xx/5xx健康检查覆盖 Upgrade,发布前拨测
NAT 重绑定连接迁移失败按普通断线走 resume,不假设迁移一定成功
登录洪峰重连风暴、队列积压抖动退避 + 服务端排队令牌,禁止客户端同时重试
会话已过期客户端静默新建会话返回可区分错误码,让客户端回到登录页
客户端时钟异常恢复窗口判断错误窗口只用服务端时间,客户端时间不参与

常见错误与边界

第一,把实验室的 P99 当成对玩家的承诺。实验室没有弱网、没有跨洋、没有运营商劫持。

第二,只比较延迟,不比较运维。QUIC 少 20ms,但团队没有 UDP 排障能力,故障恢复时间可能多出两小时。

第三,没有降级路径。任何依赖 UDP 的方案都必须能在同一版本内退回 WebSocket。

第四,把心跳当业务消息。心跳应该独立于业务通道,不能被大包阻塞,也不能触发业务逻辑。

第五,重连没有抖动。发布或机房故障时,所有客户端会在同一时刻回来。

第六,在网关里做业务规则。连接层只负责连接,业务校验放到业务服;否则每次规则变更都要发网关。

第七,用短连接承载实时状态。HTTP 轮询可以做事件通知,但不能做逐帧同步。

小结

连接选型不是协议优劣排序,而是把延迟预算、网络环境、团队规模和上线速度放在一起做减法。硬约束先淘汰,分数只表达偏好,最终结论要能解释第二名为什么输。

internal/conn 把这三件事写成可测试的规则:画像、筛选、打分、握手预算和退避策略。下一节进入网关:连接终结之后,认证、路由、限流和灰度标签应该放在哪里,以及节点摘除时如何让重分配最小化。

阅读导航:上一节:4.3 离线推进与时间旅行:SLG 的补算 · 下一节:5.2 网关的职责边界与横向扩展 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查