3.1 协议与超时预算
跨进程调用一旦发生,第一个要回答的问题不是「用哪个 RPC 框架」,而是「一次调用最多能花多久」。在单进程里,函数调用没有超时——它要么返回,要么栈溢出。跨进程之后,任何一次调用都可能永远不返回:对端进程挂了、网线被拔了、对端卡在 GC 里。MMO 里这条链路还特别长:客户端 → 网关 → 世界服 → 场景服 → 数据服,任意一跳变慢,都会把整条链路上游的资源一起占住。
这一节做三件事。第一,复用卷一已经定稿的 aegis 帧作为跨进程载荷——跨服 RPC 不重新发明一套 wire format。第二,把客户端的一个 deadline 拆成每一跳的 timeout,也就是超时预算。第三,说清最容易被忽略、也最容易出事故的一个问题:超时了,业务到底算成功还是失败。
第三点不是文字游戏。它直接决定了一个跨服发奖、一次跨服交易、一次战斗结算在「网关超时」之后该不该重试、该不该回滚。
与站内既有文章的分工
这一节不重复站内已有架构文对 RPC 治理的全局讨论,只落在 aegis 的实现上。分工如下:
| 站内既有文章(根级路径) | 它负责讲什么 | 本节负责讲什么 |
|---|---|---|
/game-server-rpc-timeout-budget-architecture/ | 超时预算的架构层设计、跨服务链路预算分配的原则 | 把预算落成 aegis 里可 go test 的 Budget 类型 |
/game-server-protocol-gateway-architecture/ | 协议网关的整体架构、协议选型与转发职责 | 复用卷一的 aegis 帧做跨进程载荷,不引入 IDL |
想先建立判断力,可以先读这两篇;想直接看实现,往下走。
一帧跨进程:复用 aegis 帧
跨服 RPC 的载荷不重新设计。卷一定稿的 aegis 固定头 11 字节直接复用:length(4, BE) | cmd(2, BE) | seq(4, BE) | flags(1),length = 7 + N,帧总长 = 11 + N。好处是客户端帧、网关转发帧、跨服 RPC 帧是同一种东西,抓包、日志、回放都只需要一套解析器。
下面这段是 L1,go test 可以直接跑:
// file: internal/rpc/frame.go
// tier: L1
// verify: go test ./internal/rpc/
package rpc
import (
"encoding/binary"
"errors"
"fmt"
)
// aegis 固定头 11 字节,与卷一 / 卷二定稿一致:
// length(4, BE) | cmd(2, BE) | seq(4, BE) | flags(1)
// length = 7 + N(7 = cmd+seq+flags),帧总长 = 11 + N。
const (
HeaderSize = 11
MaxPayload = 1 << 20
)
var (
ErrShortFrame = errors.New("rpc: short frame")
ErrTooLarge = errors.New("rpc: payload too large")
)
// Frame 与卷一 / 卷二同名同义,跨服 RPC 直接复用同一份帧。
type Frame struct {
Cmd uint16
Seq uint32
Flags uint8
Payload []byte
}
// Encode 序列化一帧。
func (f Frame) Encode() ([]byte, error) {
n := len(f.Payload)
if n > MaxPayload {
return nil, fmt.Errorf("%w: %d > %d", ErrTooLarge, n, MaxPayload)
}
buf := make([]byte, HeaderSize+n)
binary.BigEndian.PutUint32(buf[0:4], uint32(7+n))
binary.BigEndian.PutUint16(buf[4:6], f.Cmd)
binary.BigEndian.PutUint32(buf[6:10], f.Seq)
buf[10] = f.Flags
copy(buf[HeaderSize:], f.Payload)
return buf, nil
}
// Decode 反序列化一帧,并校验 length 与真实长度一致。
func Decode(raw []byte) (Frame, error) {
if len(raw) < HeaderSize {
return Frame{}, ErrShortFrame
}
length := binary.BigEndian.Uint32(raw[0:4])
if int(length) != len(raw)-4 {
return Frame{}, fmt.Errorf("rpc: length %d mismatch frame %d", length, len(raw)-4)
}
f := Frame{
Cmd: binary.BigEndian.Uint16(raw[4:6]),
Seq: binary.BigEndian.Uint32(raw[6:10]),
Flags: raw[10],
}
if length > 7 {
f.Payload = make([]byte, length-7)
copy(f.Payload, raw[HeaderSize:])
}
return f, nil
}
Decode 里那句 length 校验不是多余的:卷二的线上事故里有一类就是「半包被当成整包」,length 与真实长度对不上必须立刻报错,而不是拿一段错位的 payload 去反序列化。
超时预算:把 deadline 拆成每一跳
客户端只知道自己给了一个 deadline,比如「跨服报名 800ms 内返回」。这个 deadline 不是平均分给每一跳的:网关转发几乎不耗时,真正花时间的是场景服查路由和数据服落库。超时预算就是把总 deadline 按职责拆开,并预留一段「链路开销」不参与分配。
下面这段是 L1:
// file: internal/rpc/budget.go
// tier: L1
// verify: go test ./internal/rpc/
package rpc
import (
"fmt"
"time"
)
// Hop 是调用链上的一跳。
type Hop struct {
Name string
Share float64 // 这一跳在可用预算里的占比,所有跳之和应为 1。
}
// Budget 把客户端的总 deadline 拆成每一跳的 timeout。
// safety 是预留的链路开销比例(序列化、排队、网络),不参与分配。
type Budget struct {
Total time.Duration
Hops []Hop
safety float64
}
func NewBudget(total time.Duration, safety float64, hops ...Hop) *Budget {
return &Budget{Total: total, Hops: hops, safety: safety}
}
// Allocate 返回每一跳的 timeout,所有跳之和 = Total*(1-safety)。
func (b *Budget) Allocate() map[string]time.Duration {
usable := time.Duration(float64(b.Total) * (1 - b.safety))
out := make(map[string]time.Duration, len(b.Hops))
for _, h := range b.Hops {
out[h.Name] = time.Duration(float64(usable) * h.Share)
}
return out
}
// Remaining 计算「从 start 起已经花了 now-start,还剩多少给当前跳」。
// 返回 0 与 false 表示预算已耗尽,应当立刻放弃而不是继续往下调。
func (b *Budget) Remaining(start, now time.Time) (time.Duration, bool) {
left := b.Total - now.Sub(start)
if left <= 0 {
return 0, false
}
return left, true
}
func (b *Budget) String() string {
return fmt.Sprintf("budget(total=%s safety=%.2f hops=%d)", b.Total, b.safety, len(b.Hops))
}
关键在 Remaining:每一跳往下调之前,先算「总共还剩多少」。剩 5ms 还去调一个平均要 40ms 的下游,等于把这次调用注定的超时推迟到下游才发生——上游白白占着连接和协程。预算耗尽的正确动作是立刻放弃,让错误在离用户最近的地方暴露。
超时了到底算成功还是失败
这是本节的核心问题。客户端超时只说明「我没等到响应」,不等于「服务端没执行」。请求可能已经送达、已经写库、只是响应在回来的路上丢了。所以:
- 超时的调用,状态是未知,不是失败。
- 对「未知」的处理只有两条路:要么不重试(把不确定性交给业务),要么带幂等键重试。
- 没有幂等键就重试,等于允许同一个跨服发奖执行两次。
下面这段是 L2,起一个真的 gRPC 服务端(127.0.0.1 随机端口),跑两个真往返:一个验证超时语义,一个验证幂等键。go test 直接可跑:
// file: internal/rpc/live_timeout_test.go
// tier: L2
// verify: go test ./internal/rpc/ -run 'TestTimeoutLive|TestIdempotencyKeyLive' -v
package rpc
import (
"context"
"fmt"
"net"
"sync"
"testing"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/status"
)
// startPing 在 127.0.0.1 的随机端口起一个真 gRPC 服务端。
func startPing(t *testing.T, h PingHandler) (addr string, stop func()) {
t.Helper()
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
s := grpc.NewServer(grpc.ForceServerCodec(rawCodec{}))
s.RegisterService(&PingServiceDesc, h)
go func() { _ = s.Serve(lis) }()
return lis.Addr().String(), s.Stop
}
func dialPing(t *testing.T, addr string) *grpc.ClientConn {
t.Helper()
cc, err := grpc.NewClient(addr,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithDefaultCallOptions(grpc.ForceCodec(rawCodec{})),
)
if err != nil {
t.Fatal(err)
}
return cc
}
func TestTimeoutLive(t *testing.T) {
addr, stop := startPing(t, func(ctx context.Context, in []byte) ([]byte, error) {
select {
case <-time.After(300 * time.Millisecond): // 模拟慢下游
f, _ := Decode(in)
return Frame{Cmd: f.Cmd + 1, Seq: f.Seq, Payload: f.Payload}.Encode()
case <-ctx.Done():
return nil, ctx.Err()
}
})
defer stop()
cc := dialPing(t, addr)
defer cc.Close()
req, _ := Frame{Cmd: 0x0E01, Seq: 7, Payload: []byte("dawn-realm|hello")}.Encode()
// 预算 100ms < 下游 300ms:必须超时
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
var out []byte
err := cc.Invoke(ctx, "/aegis.Ping/Call", req, &out)
cancel()
if status.Code(err) != codes.DeadlineExceeded {
t.Fatalf("want DeadlineExceeded, got %v", err)
}
t.Logf("预算 100ms:%s", status.Code(err))
// 预算 800ms > 下游 300ms:必须成功
ctx2, cancel2 := context.WithTimeout(context.Background(), 800*time.Millisecond)
defer cancel2()
out = nil
if err := cc.Invoke(ctx2, "/aegis.Ping/Call", req, &out); err != nil {
t.Fatalf("want success, got %v", err)
}
resp, _ := Decode(out)
if resp.Cmd != 0x0E02 || resp.Seq != 7 {
t.Fatalf("bad resp %+v", resp)
}
}
func TestIdempotencyKeyLive(t *testing.T) {
var mu sync.Mutex
applied := 0
store := map[string]int{} // 幂等键 -> 效果编号
addr, stop := startPing(t, func(ctx context.Context, in []byte) ([]byte, error) {
f, err := Decode(in)
if err != nil {
return nil, err
}
key := string(f.Payload)
time.Sleep(200 * time.Millisecond) // 慢到超过客户端预算,但效果照常提交
mu.Lock()
if _, ok := store[key]; !ok {
applied++
store[key] = applied
}
v := store[key]
mu.Unlock()
return Frame{Cmd: f.Cmd + 1, Seq: f.Seq, Payload: []byte(fmt.Sprintf("applied=%d", v))}.Encode()
})
defer stop()
cc := dialPing(t, addr)
defer cc.Close()
req, _ := Frame{Cmd: 0x0E01, Seq: 9, Payload: []byte("idem-cross-chat-9")}.Encode()
// 第一次:预算 50ms,必然超时;但服务端其实已把效果提交了
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
_ = cc.Invoke(ctx, "/aegis.Ping/Call", req, new([]byte))
cancel()
// 客户端不知道成没成功,用同一幂等键重试
ctx2, cancel2 := context.WithTimeout(context.Background(), 800*time.Millisecond)
var out []byte
if err := cc.Invoke(ctx2, "/aegis.Ping/Call", req, &out); err != nil {
t.Fatalf("retry failed: %v", err)
}
cancel2()
mu.Lock()
n := applied
mu.Unlock()
if n != 1 {
t.Fatalf("effect applied %d times, want 1", n)
}
t.Logf("幂等键生效:同一 key 重试后效果只应用了 %d 次", n)
}
这段 L2 依赖 internal/rpc/grpc.go 里的自定义 Codec(rawCodec)和手写服务描述(PingServiceDesc)——它们让 gRPC 直接收发 aegis 帧、不引入任何 IDL,3.3 会完整贴出。
本节小结 / 实测结论
本机实跑结果:
- 下游固定 300ms,客户端预算 100ms:返回
DeadlineExceeded,如预期。 - 客户端预算 800ms:同一调用成功,响应帧
cmd=0x0E02、seq原样返回。 - 第一次调用预算 50ms 必然超时,但服务端效果已提交;用同一幂等键重试后,效果只应用了 1 次。
三条合起来就是本节结论:超时是「未知」而不是「失败」;对未知的处理要么不重试,要么带幂等键重试;而预算的分配必须给链路留开销、给下游留真实时间,耗尽了就立刻放弃。这三件事在 3.2 会接着往下走——重试怎么退避、连续失败怎么熔断、过载怎么用背压挡回去。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。