《游戏服务端编程实践》11.2 RPC 框架与消息总线

比较自研二进制协议、gRPC 与 HTTP JSON 的契约成本,说明超时、重试、元数据与错误码分层,拆解主题、分区、消费组和顺序边界,并用 Go 实现带超时的进程内 RPC 与至少一次投递总线,测试超时、业务错误透传、重复消息与消费端去重。

一、RPC 选型先看契约,再看性能

选 RPC 框架时,团队最先比较的往往是吞吐和延迟,但真正决定长期成本的是契约:接口怎么描述、代码怎么生成、错误怎么表达、版本怎么兼容、线上怎么调试。一个跑分很高但 IDL 难以演进的框架,会在半年后变成发布阻塞点;一个看起来朴素的 JSON 接口,可能因为可以直接用 curl 复现问题而节省大量排查时间。

方案契约与代码生成流式能力可调试性适合场景
自研二进制协议自定义 IDL 或手写编解码可实现,成本高需要专门工具延迟极敏感、协议稳定的内部链路
gRPCProtobuf IDL,多语言生成原生支持双向流grpcurl、反射、拦截器跨语言、强契约、服务间调用
HTTP JSONOpenAPI 或手写文档通常靠轮询/SSEcurl、浏览器、抓包都直观运营后台、开放接口、低频调用

游戏服务端常见的组合是:网关到游戏服之间使用长连接自定义协议,服务之间使用 gRPC 或内部二进制 RPC,运营后台使用 HTTP JSON。不要试图用一种协议覆盖所有链路。战斗消息在意的是每帧延迟和带宽,后台查询在意的是可读性和开发效率,两者的优化目标不同。聊天频道和订阅模型可以先参考/game-server-in-action-1031/。

二、超时是调用契约的一部分

没有超时的 RPC 等于把调用方的 goroutine、连接和内存交给被调用方保管。一次下游卡死会沿着调用链向上放大,最终拖垮整个进程。超时必须在契约里写清楚:客户端等待多久、服务端处理多久、超时后是否取消下游、重试是否还有意义。

推荐三层超时:网关对客户端的请求超时、服务对上游的响应超时、服务调用下游的单次超时。三者的关系应该是逐层收紧,而不是层层相等。例如客户端允许 3 秒,服务端给自己 2.5 秒,调用下游给 800 毫秒;这样服务端还有时间做降级和错误封装,而不是在最后一刻被上游直接掐断。

服务端 handler 必须主动检查 ctx.Done()。很多实现只在客户端设置超时,服务端拿到请求后继续跑完整逻辑,结果客户端已经返回失败,服务端还在写数据库。对于写操作,这种“客户端认为失败、服务端实际成功”的分裂会直接制造重复扣款或重复发货。

三、错误分层:传输错误与业务错误

错误必须先分类,再决定是否重试。传输错误表示“这次调用没有完成”,例如连接断开、超时、限流、服务不可用;业务错误表示“调用完成了,但业务规则拒绝”,例如背包已满、金币不足、等级不够。把业务错误当传输错误重试,是最常见的放大器。

类别典型错误是否可重试前置条件
传输错误超时、连接重置、限流、服务未就绪通常可以调用必须幂等或有去重键
业务错误背包已满、余额不足、状态非法不应自动重试需要玩家或业务逻辑改变状态
编程错误空指针、越界、序列化失败不应重试修复代码或数据

错误码要稳定,错误文本只用于展示和日志。调用方按错误码分支,按文本排查;如果调用方去匹配错误文本,一次文案修改就可能改变重试行为。业务错误还应该携带足够的上下文,例如“背包已满,当前 200/200”,但不要在错误里塞入整个对象,避免日志膨胀和敏感信息泄漏。

四、消息总线:主题、分区、消费组

RPC 是“我现在就要结果”,总线是“事情发生了,相关方各自处理”。两者不是二选一:一次购买可能同步调用扣款 RPC,成功后发布“购买完成”事件,由邮件、成就、排行榜各自消费。同步链路保证一致性,异步链路隔离副作用。

主题是发布订阅的最小单位,分区决定并行度与顺序边界,消费组决定同一主题的消息如何分摊。顺序保证通常只在分区内成立:同一玩家的消息按玩家 ID 分区,就能保证该玩家的顺序;跨玩家的全局顺序既昂贵又很少真正需要。

概念作用常见误用
主题一类事件的逻辑通道按实体逐个建主题,数量爆炸
分区并行消费与顺序边界分区键随机,顺序保证失效
消费组同一组内分摊、组间广播多个业务共用组名,互相抢消息
偏移量记录消费进度先提交偏移再处理,崩溃丢消息

投递语义通常是“至少一次”,意味着重复不可避免。消费者要么按消息 ID 去重,要么把处理做成幂等。总线不应该假设“消息只来一次”;真正需要 exactly-once 时,成本会落在存储、事务和去重表上,而不是消息中间件的一个开关。

五、失败场景:库存不足被当成网络错误重试

某次秒杀活动中,购买接口返回“库存不足”,网关把非 200 响应统一归类为下游失败,触发三次重试。每次重试都重新走一遍扣减流程,前两次其实已经扣减成功但响应超时,第三次因为库存归零返回业务错误。最终玩家被扣了两次款,运营看到的是“重试提高了成功率”,财务看到的是对账差异。

根因有三个:第一,错误没有分层,业务拒绝被当成传输失败;第二,扣减接口没有幂等键,重试无法识别同一次请求;第三,客户端超时短于服务端处理时间,制造了大量“客户端失败、服务端成功”的分裂窗口。修复时先给请求加唯一业务号,扣减按业务号去重;再把业务错误映射为不可重试的错误码;最后把客户端超时调到大于服务端 P99 处理时间,并让服务端在超时后主动取消下游。

六、Go 实现:带超时的 RPC 与至少一次总线

下面的 rpcbus.go 把两件事放在一个包里:Client.Call 统一施加超时并把错误规整成 RPCError,Bus 提供多订阅、非阻塞投递和递增序号。handler 返回业务错误时使用 NewBusinessError,其他错误一律归为传输层的 internal,避免内部细节穿透到调用方。

// file: ch11/rpcbus/rpcbus.go
// tier: L1
// verify: go test ./ch11/rpcbus/

package rpcbus

import (
	"context"
	"errors"
	"fmt"
	"strings"
	"sync"
	"sync/atomic"
	"time"
)

var (
	ErrEmptyMethod     = errors.New("rpcbus: empty method")
	ErrDuplicateMethod = errors.New("rpcbus: duplicate method")
	ErrEmptyTopic      = errors.New("rpcbus: empty topic")
)

// ErrorKind 区分传输层与业务层错误,重试策略只能建立在稳定错误码上。
type ErrorKind string

const (
	Transport ErrorKind = "transport"
	Business  ErrorKind = "business"
)

type RPCError struct {
	Kind    ErrorKind
	Code    string
	Message string
}

func (e *RPCError) Error() string {
	return fmt.Sprintf("rpcbus: %s error %s: %s", e.Kind, e.Code, e.Message)
}

func NewTransportError(code, message string) *RPCError {
	return &RPCError{Kind: Transport, Code: code, Message: message}
}

func NewBusinessError(code, message string) *RPCError {
	return &RPCError{Kind: Business, Code: code, Message: message}
}

// CodeOf 提取稳定错误码;日志、告警和重试都不应依赖错误文本。
func CodeOf(err error) string {
	var rpcErr *RPCError
	if errors.As(err, &rpcErr) {
		return rpcErr.Code
	}
	return ""
}

type Handler func(ctx context.Context, payload []byte) ([]byte, error)

// Server 保存方法表;handler 必须自己检查 ctx,客户端超时只是最后一道保险。
type Server struct {
	mu       sync.RWMutex
	handlers map[string]Handler
}

func NewServer() *Server {
	return &Server{handlers: make(map[string]Handler)}
}

func (s *Server) Register(method string, handler Handler) error {
	method = strings.TrimSpace(method)
	if method == "" {
		return ErrEmptyMethod
	}
	if handler == nil {
		return fmt.Errorf("rpcbus: nil handler for %s", method)
	}
	s.mu.Lock()
	defer s.mu.Unlock()
	if _, ok := s.handlers[method]; ok {
		return fmt.Errorf("%w: %s", ErrDuplicateMethod, method)
	}
	s.handlers[method] = handler
	return nil
}

func (s *Server) invoke(ctx context.Context, method string, payload []byte) ([]byte, error) {
	s.mu.RLock()
	handler, ok := s.handlers[method]
	s.mu.RUnlock()
	if !ok {
		return nil, NewTransportError("method_not_found", method)
	}
	return handler(ctx, payload)
}

// Client 统一施加超时,并把 handler 返回的错误规整成 RPCError。
type Client struct {
	server  *Server
	timeout time.Duration
}

func NewClient(server *Server, timeout time.Duration) *Client {
	return &Client{server: server, timeout: timeout}
}

func (c *Client) Call(ctx context.Context, method string, payload []byte) ([]byte, error) {
	if c.timeout > 0 {
		var cancel context.CancelFunc
		ctx, cancel = context.WithTimeout(ctx, c.timeout)
		defer cancel()
	}
	type reply struct {
		payload []byte
		err     error
	}
	done := make(chan reply, 1)
	go func() {
		p, err := c.server.invoke(ctx, method, payload)
		done <- reply{payload: p, err: err}
	}()
	select {
	case r := <-done:
		return r.payload, normalizeError(r.err)
	case <-ctx.Done():
		return nil, NewTransportError("deadline_exceeded", ctx.Err().Error())
	}
}

func normalizeError(err error) error {
	if err == nil {
		return nil
	}
	var rpcErr *RPCError
	if errors.As(err, &rpcErr) {
		return rpcErr
	}
	if errors.Is(err, context.DeadlineExceeded) {
		return NewTransportError("deadline_exceeded", err.Error())
	}
	if errors.Is(err, context.Canceled) {
		return NewTransportError("canceled", err.Error())
	}
	return NewTransportError("internal", err.Error())
}

// Message 是总线投递单元;ID 由生产者给出,重复投递时消费者靠它去重。
type Message struct {
	ID      string
	Topic   string
	Payload []byte
	Seq     uint64
}

// Delivery 记录一次发布结果;Dropped 非零说明有慢消费者,需要重新全量对齐。
type Delivery struct {
	Seq       uint64
	Delivered int
	Dropped   int
}

type Subscription struct {
	id        uint64
	topic     string
	ch        chan Message
	bus       *Bus
	closeOnce sync.Once
}

func (s *Subscription) C() <-chan Message {
	return s.ch
}

func (s *Subscription) Close() {
	s.closeOnce.Do(func() { s.bus.unsubscribe(s) })
}

// Bus 提供至少一次投递;发布成功不等于消费成功,消费端必须按 ID 去重。
type Bus struct {
	mu      sync.RWMutex
	nextSub uint64
	seq     atomic.Uint64
	subs    map[string]map[uint64]*Subscription
}

func NewBus() *Bus {
	return &Bus{subs: make(map[string]map[uint64]*Subscription)}
}

func (b *Bus) Subscribe(topic string, buffer int) (*Subscription, error) {
	topic = strings.TrimSpace(topic)
	if topic == "" {
		return nil, ErrEmptyTopic
	}
	if buffer < 0 {
		buffer = 0
	}
	b.mu.Lock()
	defer b.mu.Unlock()
	b.nextSub++
	sub := &Subscription{id: b.nextSub, topic: topic, ch: make(chan Message, buffer), bus: b}
	if b.subs[topic] == nil {
		b.subs[topic] = make(map[uint64]*Subscription)
	}
	b.subs[topic][sub.id] = sub
	return sub, nil
}

func (b *Bus) Publish(msg Message) (Delivery, error) {
	topic := strings.TrimSpace(msg.Topic)
	if topic == "" {
		return Delivery{}, ErrEmptyTopic
	}
	b.mu.RLock()
	defer b.mu.RUnlock()
	msg.Topic = topic
	msg.Seq = b.seq.Add(1)
	delivery := Delivery{Seq: msg.Seq}
	for _, sub := range b.subs[topic] {
		select {
		case sub.ch <- msg:
			delivery.Delivered++
		default:
			delivery.Dropped++
		}
	}
	return delivery, nil
}

func (b *Bus) unsubscribe(sub *Subscription) {
	b.mu.Lock()
	defer b.mu.Unlock()
	group, ok := b.subs[sub.topic]
	if !ok {
		return
	}
	if _, ok := group[sub.id]; !ok {
		return
	}
	delete(group, sub.id)
	if len(group) == 0 {
		delete(b.subs, sub.topic)
	}
	close(sub.ch)
}

几个设计点值得说明。第一,Call 在 goroutine 里执行 handler,并用带缓冲的 channel 接收结果;即使 handler 忽略 context,调用方也能在超时后返回,不会永久阻塞。第二,normalizeError 只信任 RPCError,普通 error 被归为 internal 传输错误,迫使 handler 显式表达业务语义。第三,总线的 Publish 对每个订阅者做非阻塞发送,队列满时计入 Dropped 而不是阻塞发布者;慢消费者应该自己扩缓冲或重新全量对齐。

第四,Message.ID 由生产者提供,Seq 由总线递增。重复投递时 ID 不变、Seq 变化,消费者可以按 ID 去重,按 Seq 观察缺口。第五,订阅者关闭使用 sync.Once,重复关闭不会 panic;总线的读写锁保证发布与退订不会同时操作同一个 channel。

七、测试覆盖了哪些边界

实验包中的 rpcbus_test.go 覆盖了五类行为:

  • 正常调用返回 handler 结果,业务错误保留 Business 类型和 inventory_full 错误码。
  • handler 阻塞等待 context 时,客户端在 20 毫秒超时后返回 deadline_exceeded。
  • 未知方法返回 method_not_found,重复注册方法返回 ErrDuplicateMethod。
  • 两个订阅者都收到同一条消息,重复发布时 ID 相同、Seq 递增,消费端能看到两份。
  • 缓冲为 1 的慢订阅者在后续发布中收到 Dropped,退订后不再计入投递。

八、运行测试

cd .local/checks/gs-practice-lab && gofmt -l ch11/rpcbus && go vet ./ch11/rpcbus/ && go test ./ch11/rpcbus/ -count=1
ok  	gspractice/ch11/rpcbus	0.480s

gofmt -l 无输出,go vet 通过。超时测试使用 20 毫秒上限,并断言总耗时远小于 1 秒;耗时数字来自本机一次运行,只说明测试通过。

小结

RPC 选型要先看契约、代码生成、错误表达和可调试性,再比较性能。超时是调用契约的一部分,应该逐层收紧,服务端 handler 必须响应 context 取消。错误要分成传输错误、业务错误和编程错误,只有幂等的传输失败才适合自动重试。消息总线负责解耦副作用,主题决定逻辑通道,分区决定顺序边界,消费组决定分摊方式,至少一次投递要求消费端去重或幂等。RPC 与总线不是二选一,同步链路保一致性,异步链路隔离副作用。服务如何被发现和摘除,见/game-server-in-action-11-3/。

常见错误与边界

  • RPC 不设超时,下游卡死时调用方 goroutine 和连接一起泄漏。
  • 把业务错误当传输错误自动重试,导致重复扣款、重复发货。
  • 重试没有幂等键或去重表,只靠“应该不会重复”的假设。
  • 客户端超时短于服务端处理时间,制造大量“客户端失败、服务端成功”。
  • 每个实体建一个主题,主题数量随玩家或房间数量线性增长。
  • 分区键随机或按消息 ID 哈希,同一玩家的消息失去顺序。
  • 多个业务共用消费组,消息被其中一个业务抢走。
  • 先提交偏移量再处理消息,消费者崩溃后消息丢失。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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