一、先把三种交互形态分开
很多协议栈出问题,不是编解码写错了,而是一开始就把所有消息都塞进了“请求-响应”模型。游戏连接上的消息至少有三类,它们的生命周期完全不同。
| 形态 | 典型例子 | 是否有返回值 | 关联方式 | 超时语义 |
|---|---|---|---|---|
| 请求-响应 | 登录、查询背包、进入副本 | 有,且必须对应 | seq / request_id | 超时即失败,可有限重试 |
| 推送 | 其他玩家移动、广播、系统公告 | 无 | 不需要关联 | 没有“超时”,只有丢弃或补偿 |
| 双向流 | 帧同步输入流、语音、观战 | 持续产生 | 流 ID + 帧号 | 断流检测,不是单次超时 |
判断标准很简单:发送方是否需要在时间上等到一个明确答复,并据此继续自己的状态机。只有满足这个条件才值得引入关联字段。如果只是“服务器有事告诉你”,硬加一个 seq 再让服务器回一个空响应,等于把单向通知改成了两次网络往返。
推送也不应该被当作响应。客户端收到 S>C cmd=0x2001 时,不能因为它的 seq 恰好等于某个挂起请求就交给等待者。协议里应当用 flags 或命令号空间把“响应”和“推送”分开,关联表只接受响应。
双向流更特殊:它天然是多次请求共享一个通道。此时 seq 表示“流内第几帧”,而不是“第几次调用”。把流的帧号直接塞进请求关联表,会让挂起表随着流长度无限增长。
二、seq 不是流水号装饰
request_id 或 seq 的唯一职责,是让异步到达的响应找到当初的等待者。它必须满足三个条件:同一连接内唯一、在请求存活期内不被复用、能被双方原样回显。
2.1 分配
常见做法是每条连接一个自增计数器。分配和登记挂起表必须在同一把锁内完成,否则两个并发调用可能拿到同一个号,后登记的覆盖先登记的,先到的响应被投递给错误的调用者。这个 bug 在低并发下几乎不出现,压测时才表现为“偶发串包”。
2.2 零值与推送
建议把 seq = 0 保留给“无需响应”的消息。这样服务端收到 0 时可以直接按推送处理,客户端也不会为一个永远不会到来的响应建挂起项。保留值让“忘记赋值”的结构体零值自然落在安全的一侧。
2.3 溢出与复用
32 位计数器在每秒十万请求下大约 12 小时回绕一次,不能假设“永远用不完”。回绕本身不是问题,问题是回绕后撞上一个仍然挂起的旧请求。
正确的做法是:分配时跳过 0,并在挂起表中检查候选号是否仍被占用;如果占用就继续取下一个。极端情况下(挂起数接近号空间)分配会失败,应返回明确错误,而不是覆盖。
还应注意,超时之后不能立刻无条件复用。如果连接上仍可能有旧响应在途,复用同一个号会让旧响应被新请求误收。工程上通常把“号空间足够大 + 超时后丢弃”一起使用,而不是只靠号空间。
2.4 迟到响应
超时发生时,调用方返回错误,但网络上那个响应可能几毫秒后才到。此时挂起表里已经没有对应项,接收线程应当:
- 查表失败,不创建新项;
- 计入“迟到丢弃”指标;
- 继续处理后续消息,绝不阻塞。
迟到计数很有价值。它持续上升,说明超时阈值对当前网络太激进,或者服务端某个命令确实慢,而不是“客户端代码有 bug”。
三、客户端的三种等待模型
| 模型 | 写法 | 优点 | 风险 |
|---|---|---|---|
| 同步等待 | resp, err := caller.Call(...) | 逻辑直观,顺序清晰 | 阻塞调用线程;在 IO 线程上使用会卡住整条连接 |
| 回调 | Call(cmd, body, func(resp, err){...}) | 不占线程,天然异步 | 回调地狱;异常路径容易漏处理 |
| Future/Promise | future := Call(...); future.Then(...) | 可组合、可超时、可取消 | 需要运行时或库支持;生命周期管理复杂 |
选择建议:
- 登录、切场景这类必须按顺序完成的流程,用同步等待最不容易写错,但必须放在逻辑线程或独立 goroutine 中,不能放在网络读线程。
- 高频且允许乱序的查询(查排行、查邮件摘要),用回调或 Future,避免为每个请求占一个 goroutine。
- 同一连接上大量并发请求,无论哪种模型,底层都应该只有一张挂起表和一个接收循环,而不是“每个请求一个读 goroutine”。
同步等待并不等于“服务端同步阻塞”。前者是客户端调用者的写法,后者是把下游调用串行化,二者要分开讨论。
四、服务端不要同步阻塞等待下游
客户端可以等,服务端不应该在一个连接的处理协程里同步等另一个服务。原因不是“性能不好”这么笼统,而是三点具体后果:
- 队头阻塞:该连接后续的请求全部排在后面,哪怕它们只需要查内存。
- 超时叠加:客户端超时 1 秒,服务端等下游 2 秒,客户端早已放弃,服务端还在占资源。
- 故障放大:下游抖动时,等待中的协程堆积,最终把网关的内存和文件描述符一起吃满。
正确做法是“请求登记 + 异步回包”:服务端收到请求后记录上下文(连接、seq、命令号),向下游发起带 deadline 的调用,拿到结果再回到该连接的发送队列。等待发生在下游客户端内部,不发生在连接读循环里。
如果业务确实需要顺序(例如同一玩家的背包操作),应当用玩家维度的串行队列,而不是阻塞网络线程。串行的是业务状态,不是 socket。
五、超时、重试与幂等的边界
超时不是“等久一点”的参数,而是协议语义的一部分。客户端超时后,服务端可能仍在执行;因此超时只代表调用方放弃等待,不代表操作没有发生。
| 请求类型 | 例子 | 超时后能否重试 | 原因 |
|---|---|---|---|
| 幂等查询 | 查背包、查排行、拉配置 | 可以,需限次退避 | 重复执行不改变状态 |
| 幂等写入(带版本号) | 设置已读、覆盖式保存 | 可以,带条件版本 | 旧版本写入会被拒绝 |
| 非幂等写入 | 扣钻石、发邮件、抽卡 | 不能盲目重试 | 可能重复扣费或重复发放 |
| 有业务单号的写入 | 下单、领奖 | 可以,服务端按单号去重 | 幂等键由业务提供 |
| 长事务 | 跨服迁移、批量结算 | 只能查询状态后补偿 | 重试会制造第二个执行者 |
工程上最稳妥的组合是:可重试请求携带业务幂等键,服务端按 (玩家, 幂等键) 去重;不可重试请求在超时后进入“未知结果”状态,由客户端主动查询或由服务端对账。
重试还要限制次数和退避。立即重试三次只会把一次抖动放大成三次流量;指数退避加抖动,才能避免大量客户端在同一毫秒同时重连。
六、实现:一个可验证的 Caller
下面的实现把“分配 seq、登记挂起、发送、等待、清理”放在一条清晰的路径上。关键点是 register 在同一把锁内完成“取号 + 查重 + 登记”,Dispatch 查不到挂起项时计入迟到,Close 让所有在途调用立即失败。
// file: ch03/rpc/call.go
// tier: L1
// verify: go test ./ch03/rpc/
package rpc
import (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
"time"
)
var (
ErrTimeout = errors.New("rpc: call timeout")
ErrClosed = errors.New("rpc: caller closed")
ErrSeqExhausted = errors.New("rpc: no free request id")
)
// Response is the reply the transport hands back to the Caller.
type Response struct {
Seq uint32
Code int32
Body []byte
}
// Sender writes one request to the connection. It must not block on the reply:
// the reply arrives later through Dispatch.
type Sender func(seq uint32, cmd uint16, body []byte) error
type result struct {
resp Response
err error
}
type pendingCall struct {
ch chan result
}
// Caller correlates responses with requests through a sequence number. One
// Caller owns one connection; multiplexing several connections through one
// Caller would make sequence reuse ambiguous.
type Caller struct {
seq atomic.Uint32
mu sync.Mutex
pending map[uint32]*pendingCall
send Sender
timeout time.Duration
closed atomic.Bool
late atomic.Uint64
}
func NewCaller(send Sender, timeout time.Duration) *Caller {
if timeout <= 0 {
timeout = 3 * time.Second
}
return &Caller{
pending: make(map[uint32]*pendingCall),
send: send,
timeout: timeout,
}
}
// register allocates a sequence number and inserts the pending entry under one
// lock, so two concurrent calls can never observe or share the same id.
func (c *Caller) register() (uint32, *pendingCall, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed.Load() {
return 0, nil, ErrClosed
}
for i := 0; i < 1<<16; i++ {
seq := c.seq.Add(1)
if seq == 0 {
continue // 0 is reserved for unsolicited pushes
}
if _, busy := c.pending[seq]; busy {
continue // wrap-around landed on a live request
}
pc := &pendingCall{ch: make(chan result, 1)}
c.pending[seq] = pc
return seq, pc, nil
}
return 0, nil, ErrSeqExhausted
}
func (c *Caller) remove(seq uint32) {
c.mu.Lock()
delete(c.pending, seq)
c.mu.Unlock()
}
// Call sends one request and waits for the response carrying the same seq.
// A timeout removes the pending entry before returning, so a response that
// arrives later has nothing to attach to and is counted as late.
func (c *Caller) Call(ctx context.Context, cmd uint16, body []byte) (Response, error) {
seq, pc, err := c.register()
if err != nil {
return Response{}, err
}
defer c.remove(seq)
if err := c.send(seq, cmd, body); err != nil {
return Response{}, fmt.Errorf("rpc: send cmd=%d seq=%d: %w", cmd, seq, err)
}
timer := time.NewTimer(c.timeout)
defer timer.Stop()
select {
case r := <-pc.ch:
return r.resp, r.err
case <-timer.C:
return Response{}, fmt.Errorf("%w: cmd=%d seq=%d", ErrTimeout, cmd, seq)
case <-ctx.Done():
return Response{}, ctx.Err()
}
}
// Dispatch delivers a response to the waiting call. It reports false when no
// call is waiting, which is the normal fate of a response that misses its
// deadline. Counting these events is how you detect a latent timeout that is
// too aggressive for the current network.
func (c *Caller) Dispatch(resp Response) bool {
if resp.Seq == 0 {
c.late.Add(1)
return false
}
c.mu.Lock()
pc, ok := c.pending[resp.Seq]
if ok {
delete(c.pending, resp.Seq)
}
c.mu.Unlock()
if !ok {
c.late.Add(1)
return false
}
select {
case pc.ch <- result{resp: resp}:
return true
default:
// The waiter left between delete and send; do not block the reader.
c.late.Add(1)
return false
}
}
// Close fails every in-flight call and rejects new ones. It does not close the
// underlying connection; the owner of the connection does that after Close.
func (c *Caller) Close() {
if !c.closed.CompareAndSwap(false, true) {
return
}
c.mu.Lock()
pending := c.pending
c.pending = make(map[uint32]*pendingCall)
c.mu.Unlock()
for _, pc := range pending {
select {
case pc.ch <- result{err: ErrClosed}:
default:
}
}
}
// Pending reports how many calls are currently waiting for a response.
func (c *Caller) Pending() int {
c.mu.Lock()
defer c.mu.Unlock()
return len(c.pending)
}
// LateDiscarded reports how many responses arrived after their call had
// already given up.
func (c *Caller) LateDiscarded() uint64 {
return c.late.Load()
}
接收循环只需要做一件事:把解析出的响应交给 Dispatch。它不关心业务,也不会因为某个响应迟到而阻塞。
七、失败场景逐个拆
| 场景 | 现象 | 正确处置 |
|---|---|---|
| 请求超时 | Call 返回 ErrTimeout | 删除挂起项;记录命令号与耗时;不要自动无限重试 |
| 响应迟到 | Dispatch 返回 false | 计入迟到指标后丢弃;不能按 seq 猜一个等待者 |
| 并发拿号冲突 | 两个调用共享 seq | 取号与登记同锁;测试中构造并发调用验证不串包 |
| 连接断开 | 所有在途调用永久等待 | Close 唤醒所有等待者并拒绝新调用 |
| 发送失败 | 请求根本没出去 | 立即清理挂起项并返回错误,不进入等待 |
| 下游慢但未超时 | 客户端继续等 | 由服务端自己的 deadline 控制,不能无限透传 |
其中“发送失败”最容易被忽略。如果发送函数返回错误但挂起项没删,调用者虽然拿到了错误,表里却留下一个永远不会被满足的项;跑得久了,表会缓慢泄漏。
八、验证
实验代码位于 ch03/rpc,测试覆盖了往返、超时清理、迟到丢弃、64 路并发不串包、发送失败清理、关闭唤醒,以及序号回绕时跳过 0 和被占用号。
go test -race ./ch03/rpc/
在本机的执行结果是三个包共 24 个测试全部通过,其中 rpc 包覆盖上述七类路径。测试里刻意让响应逆序返回,因为顺序返回无法证明“关联正确”,只能证明“碰巧按顺序取到了”。
常见错误与边界
- 用同一个 seq 并发发请求:分配器必须原子且查重,不能“先取号、稍后登记”。
- 超时不清理挂起表:每次超时泄漏一个 map 项和一个 channel,长时间运行后内存与查找成本一起上升。
- 把推送当响应:推送没有等待者,必须从协议层区分,而不是靠“seq 看起来像”。
- 在 IO 读线程里同步
Call:读线程被阻塞后,响应永远读不上来,形成自锁。 - 超时后立即重用同一个 seq:旧响应可能被新请求误收;应依赖大号空间加迟到丢弃。
- 对非幂等操作自动重试:超时只说明“不知道结果”,重试可能造成重复扣费。
小结
请求-响应模型的核心不是“发一个包再收一个包”,而是用关联字段把异步到达的响应绑定回等待者。这要求 seq 唯一、分配与登记原子、超时必清理、迟到必丢弃。推送和双向流应当使用各自的语义,不要为了统一而塞进同一张挂起表。服务端则要把“等待”留在下游客户端内部,绝不在连接处理路径上同步阻塞。把这些边界写进代码和测试,协议栈才算真正可运维。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。