三款产品,三种“连接”问题
北辰工作室的三款产品在同一个平台部门下运行,但接入层的诉求几乎相反。
《裂隙战场》是 5v5 竞技。一局十分钟,服务端每 33ms 推进一次,玩家的移动、技能和命中判定都依赖持续下行。它需要低延迟、服务端主动推送,并且玩家会在 WiFi 与移动网络之间切换。团队有八个人,能承担一套 UDP 接入的运维。
《群岛纪元》是 SLG。大地图上的行军、建造和联盟消息是事件驱动,玩家不要求每帧同步,但服务端必须能主动推送“你的城被打了”。它的用户大量在中东和东南亚的弱网环境,延迟预算在 200ms 以上。团队六个人,不想再维护一套 UDP 网关。
《泡泡工坊》是休闲小游戏。两个人维护后端,目标是两周上线一个新玩法。它只需要房间内的事件广播,请求与响应占绝大多数,延迟 100ms 到 200ms 都能接受。
把三件事放在一起,连接选型就不是“哪个协议更先进”,而是在给定延迟预算、网络环境、团队规模和上线速度下,哪一种代价可以接受。
候选协议的边界
先把四种常见形态的工程边界摆出来。表中的延迟门槛是保守的工程值,不是 benchmark 峰值;真实数字必须由目标市场的现网测量决定。
| 形态 | 最小单向延迟门槛(示例值) | 服务端推送 | 弱网迁移 | 过传统 CDN | 主要代价 |
|---|---|---|---|---|---|
| 短连接 HTTP | 250ms | 不支持 | 天然无状态 | 支持 | 每次请求重新握手,不适合实时 |
| 长轮询 | 120ms | 有限推送 | 断线后重开请求 | 支持 | 连接占用高,消息延迟受轮询周期影响 |
| WebSocket | 40ms | 持续推送 | 断线重连,不能迁移 | 多数支持 | 需要心跳、重连风暴治理 |
| 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 + 认证 | 3 | 120ms | TCP 1 轮 + TLS 1 轮 + 认证 1 轮 |
| 明文 TCP + 认证 | 2 | 80ms | 内网或已由边缘终止 TLS |
| 恢复会话 | 1 | 40ms | 用 resume token 跳过完整握手 |
| 弱网 RTT 120ms | 3 | 360ms | 还没算丢包重传 |
这些数字由 EstimateHandshake 按公式算出,是预期值,不是本机实测。真实预算是“握手耗时 + 资源加载 + 场景切换”,连接层通常只占三分之一;如果连接层就吃掉了 300ms,说明应该先做 CDN 前置或会话恢复。
重连策略同样要写死。示例策略是:首次等待 100ms,每次翻倍,上限 800ms,最多 5 次,抖动比例 0.2,恢复窗口 30 秒。
| 尝试次数 | 无抖动等待(示例值) | 抖动后范围 |
|---|---|---|
| 1 | 100ms | 80ms 到 100ms |
| 2 | 200ms | 160ms 到 200ms |
| 3 | 400ms | 320ms 到 400ms |
| 4 | 800ms | 640ms 到 800ms |
| 5 | 800ms | 640ms 到 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 不支持 Upgrade | WebSocket 返回 4xx/5xx | 健康检查覆盖 Upgrade,发布前拨测 |
| NAT 重绑定 | 连接迁移失败 | 按普通断线走 resume,不假设迁移一定成功 |
| 登录洪峰 | 重连风暴、队列积压 | 抖动退避 + 服务端排队令牌,禁止客户端同时重试 |
| 会话已过期 | 客户端静默新建会话 | 返回可区分错误码,让客户端回到登录页 |
| 客户端时钟异常 | 恢复窗口判断错误 | 窗口只用服务端时间,客户端时间不参与 |
常见错误与边界
第一,把实验室的 P99 当成对玩家的承诺。实验室没有弱网、没有跨洋、没有运营商劫持。
第二,只比较延迟,不比较运维。QUIC 少 20ms,但团队没有 UDP 排障能力,故障恢复时间可能多出两小时。
第三,没有降级路径。任何依赖 UDP 的方案都必须能在同一版本内退回 WebSocket。
第四,把心跳当业务消息。心跳应该独立于业务通道,不能被大包阻塞,也不能触发业务逻辑。
第五,重连没有抖动。发布或机房故障时,所有客户端会在同一时刻回来。
第六,在网关里做业务规则。连接层只负责连接,业务校验放到业务服;否则每次规则变更都要发网关。
第七,用短连接承载实时状态。HTTP 轮询可以做事件通知,但不能做逐帧同步。
小结
连接选型不是协议优劣排序,而是把延迟预算、网络环境、团队规模和上线速度放在一起做减法。硬约束先淘汰,分数只表达偏好,最终结论要能解释第二名为什么输。
internal/conn 把这三件事写成可测试的规则:画像、筛选、打分、握手预算和退避策略。下一节进入网关:连接终结之后,认证、路由、限流和灰度标签应该放在哪里,以及节点摘除时如何让重分配最小化。
阅读导航:上一节:4.3 离线推进与时间旅行:SLG 的补算 · 下一节:5.2 网关的职责边界与横向扩展 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。