一、RPC 选型先看契约,再看性能
选 RPC 框架时,团队最先比较的往往是吞吐和延迟,但真正决定长期成本的是契约:接口怎么描述、代码怎么生成、错误怎么表达、版本怎么兼容、线上怎么调试。一个跑分很高但 IDL 难以演进的框架,会在半年后变成发布阻塞点;一个看起来朴素的 JSON 接口,可能因为可以直接用 curl 复现问题而节省大量排查时间。
| 方案 | 契约与代码生成 | 流式能力 | 可调试性 | 适合场景 |
|---|---|---|---|---|
| 自研二进制协议 | 自定义 IDL 或手写编解码 | 可实现,成本高 | 需要专门工具 | 延迟极敏感、协议稳定的内部链路 |
| gRPC | Protobuf IDL,多语言生成 | 原生支持双向流 | grpcurl、反射、拦截器 | 跨语言、强契约、服务间调用 |
| HTTP JSON | OpenAPI 或手写文档 | 通常靠轮询/SSE | curl、浏览器、抓包都直观 | 运营后台、开放接口、低频调用 |
游戏服务端常见的组合是:网关到游戏服之间使用长连接自定义协议,服务之间使用 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 哈希,同一玩家的消息失去顺序。
- 多个业务共用消费组,消息被其中一个业务抢走。
- 先提交偏移量再处理消息,消费者崩溃后消息丢失。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。