《MMO 服务器开发》3.1 协议与超时预算

跨进程调用的第一个问题不是选框架,而是一次调用最多能花多久。本节复用卷一的 aegis 帧做跨进程载荷,把客户端 deadline 拆成每一跳的超时预算,再用真跑的 gRPC 往返说清「超时了业务算成功还是失败」,以及为什么必须有幂等键,全部在本机真跑验证。

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 会接着往下走——重试怎么退避、连续失败怎么熔断、过载怎么用背压挡回去。

阅读导航:上一节:目录 · 下一节:重试、熔断与背压 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标