《游戏服务端编程实践》3.4.1 客户端请求与响应模型

从请求-响应、推送、双向流三种交互形态出发,讲清 request_id 与 seq 的关联、分配、溢出与复用,以及客户端同步等待、回调与 Future 模型的取舍,最后讨论超时、重试和幂等边界,并给出一个可测试的 Go Caller 实现。

一、先把三种交互形态分开

很多协议栈出问题,不是编解码写错了,而是一开始就把所有消息都塞进了“请求-响应”模型。游戏连接上的消息至少有三类,它们的生命周期完全不同。

形态典型例子是否有返回值关联方式超时语义
请求-响应登录、查询背包、进入副本有,且必须对应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 迟到响应

超时发生时,调用方返回错误,但网络上那个响应可能几毫秒后才到。此时挂起表里已经没有对应项,接收线程应当:

  1. 查表失败,不创建新项;
  2. 计入“迟到丢弃”指标;
  3. 继续处理后续消息,绝不阻塞。

迟到计数很有价值。它持续上升,说明超时阈值对当前网络太激进,或者服务端某个命令确实慢,而不是“客户端代码有 bug”。

三、客户端的三种等待模型

模型写法优点风险
同步等待resp, err := caller.Call(...)逻辑直观,顺序清晰阻塞调用线程;在 IO 线程上使用会卡住整条连接
回调Call(cmd, body, func(resp, err){...})不占线程,天然异步回调地狱;异常路径容易漏处理
Future/Promisefuture := Call(...); future.Then(...)可组合、可超时、可取消需要运行时或库支持;生命周期管理复杂

选择建议:

  • 登录、切场景这类必须按顺序完成的流程,用同步等待最不容易写错,但必须放在逻辑线程或独立 goroutine 中,不能放在网络读线程。
  • 高频且允许乱序的查询(查排行、查邮件摘要),用回调或 Future,避免为每个请求占一个 goroutine。
  • 同一连接上大量并发请求,无论哪种模型,底层都应该只有一张挂起表和一个接收循环,而不是“每个请求一个读 goroutine”。

同步等待并不等于“服务端同步阻塞”。前者是客户端调用者的写法,后者是把下游调用串行化,二者要分开讨论。

四、服务端不要同步阻塞等待下游

客户端可以等,服务端不应该在一个连接的处理协程里同步等另一个服务。原因不是“性能不好”这么笼统,而是三点具体后果:

  1. 队头阻塞:该连接后续的请求全部排在后面,哪怕它们只需要查内存。
  2. 超时叠加:客户端超时 1 秒,服务端等下游 2 秒,客户端早已放弃,服务端还在占资源。
  3. 故障放大:下游抖动时,等待中的协程堆积,最终把网关的内存和文件描述符一起吃满。

正确做法是“请求登记 + 异步回包”:服务端收到请求后记录上下文(连接、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 唯一、分配与登记原子、超时必清理、迟到必丢弃。推送和双向流应当使用各自的语义,不要为了统一而塞进同一张挂起表。服务端则要把“等待”留在下游客户端内部,绝不在连接处理路径上同步阻塞。把这些边界写进代码和测试,协议栈才算真正可运维。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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