《游戏服务端编程实践》5.4.2 Redis / gRPC / Akka 通信实现

把 Redis Pub/Sub、gRPC 与 Akka 放回游戏网关的后端通信位置,对照调用语义、超时、背压、顺序与运维成本;在零外部依赖的前提下用 Go net/rpc 实现可运行的整帧转发、超时丢弃与重连,并给出端到端测试和连接管理清单。

一、三种通信方式在网关里的位置

网关到后端的通信没有“最好”,只有“放在哪一层最合适”。Redis Pub/Sub 适合通知与广播,gRPC 适合强类型的一问一答,Akka 适合把状态和顺序收进 Actor。把它们混成一套“万能总线”,通常会在超时、顺序和排障上同时失控。

方式典型位置调用语义主要代价
Redis Pub/Sub跨网关广播、踢下线、公告至多一次,发后不等待无确认、无重放、断线丢消息
gRPC登录服、角色服、场景服的 RPC一问一答,可带 deadline需要 protobuf 与代码生成
Akka房间、战斗、有状态实体发消息给 Actor,邮箱排队需要集群与分片运维

二、调用语义对照

维度Redis Pub/SubgRPCAkka
超时客户端侧自行控制deadline 全链路传播ask 模式才有超时
背压无,订阅者慢了就堆积流式调用有窗口有界邮箱,满了阻塞或丢弃
顺序单频道内基本有序单连接内有序,多路复用需自证单 Actor 串行处理
重试不适合,重复会放大广播仅幂等接口可重试由消息协议与去重键决定
运维已有 Redis 时成本最低需要服务发现与证书需要集群、分片与持久化
Go 适配客户端成熟官方支持最好生态弱,通常跨语言

结论很直接:网关的请求转发主链路用 gRPC 或自定义二进制 RPC;广播类旁路用 Redis Pub/Sub;需要“每个房间一个串行执行单元”时再考虑 Akka,而不是拿它当 RPC 框架。

三、Redis Pub/Sub:轻量广播

Redis 的协议是文本命令加 RESP 回复。发布用 PUBLISH channel payload,订阅用 SUBSCRIBE channel,连接进入订阅模式后只能收推送。它没有确认、没有离线补发,也没有消费位点;网关重启期间的消息直接消失。

// 伪代码:广播踢下线,不用于关键业务
PUBLISH gw:kick  {"player_id":10086,"reason":"duplicate_login"}
SUBSCRIBE gw:kick
// 收到消息后:校验 player_id 是否在本网关,是则关闭对应 Session

适合的场景是“通知”,不是“命令”。踢下线丢一条,玩家下次登录仍会被旧会话冲突挡住;公告丢一条,客户端下次拉取能补。若业务要求必达,应该换 Stream 或带确认的消息队列,而不是给 Pub/Sub 加一层重试。

四、Akka:Actor 消息

Akka 的核心是“一个 Actor 一次处理一条消息”。网关把请求发给房间 Actor,Actor 内部串行改状态,顺序天然有保证。代价是邮箱必须有界:无界邮箱在高峰时会把内存吃光,而有界邮箱满了以后,tell 默认丢弃还是阻塞,必须显式选择。

// 伪代码:房间 Actor 的收件箱策略
room = actor("room-1001")
room ! Join(player=42)          // 入房,串行处理
room ! Move(player=42, x=10)    // 与 Join 保序
// mailbox: capacity=1024, overflow=drop-new 并回错误码

网关侧要保留请求 ID 与超时。Actor 处理慢时,不能让网关的 goroutine 无限等待;ask 的超时只结束等待,不会取消 Actor 里已经开始的工作,这一点与 gRPC deadline 不同。

五、不引入外部依赖的三种替代

实验环境往往没有 Redis、etcd 或 Akka 集群。可以先用手头就有的东西把链路跑通:

替代适用代价
net/rpc内网、Go 到 Go、快速验证gob 编码,跨语言差
自定义长度前缀已有帧协议、追求零依赖连接管理、超时、重连都要自己写
内存 hub单进程测试、模块解耦不能跨进程,重启即丢

本节选 net/rpc:它自带请求多路复用和连接复用,正好用来演示“整帧转发 + 超时丢弃 + 重连”,又不需要引入任何第三方模块。

六、net/rpc 实现

服务端只暴露一个 Gateway.Forward 方法,参数和返回值都是整帧字节;业务处理函数在服务端解帧,网关不碰 Body。客户端复用一条连接,超时或调用失败时主动关闭它,避免迟到响应污染下一条请求。

// file: ch05/backend/rpcbackend.go
// tier: L1
// verify: go test ./ch05/backend/

package backend

import (
	"bytes"
	"context"
	"errors"
	"net"
	"net/rpc"
	"sync"
	"time"
)

type ForwardArgs struct{ Frame []byte }
type ForwardReply struct{ Frame []byte }

// RPCService 运行在后端进程,把整帧字节交给业务处理函数,不拆 Body。
type RPCService struct{ Handle func([]byte) ([]byte, error) }

func (s *RPCService) Forward(args ForwardArgs, reply *ForwardReply) error {
	if s.Handle == nil {
		return errors.New("rpcbackend: no handler")
	}
	out, err := s.Handle(args.Frame)
	if err != nil {
		return err
	}
	reply.Frame = out
	return nil
}

// ServeRPC 在 listener 上注册服务,每个连接一个 goroutine。
func ServeRPC(ln net.Listener, svc *RPCService) error {
	srv := rpc.NewServer()
	if err := srv.RegisterName("Gateway", svc); err != nil {
		return err
	}
	for {
		conn, err := ln.Accept()
		if err != nil {
			return err
		}
		go srv.ServeConn(conn)
	}
}

// RPCBackend 复用一条 net/rpc 连接。超时或调用失败时关闭连接,
// 否则迟到的响应会被下一条请求读到。
type RPCBackend struct {
	mu      sync.Mutex
	addr    string
	timeout time.Duration
	client  *rpc.Client
}

func NewRPCBackend(addr string, timeout time.Duration) *RPCBackend {
	return &RPCBackend{addr: addr, timeout: timeout}
}

func (b *RPCBackend) connLocked() (*rpc.Client, error) {
	if b.client != nil {
		return b.client, nil
	}
	client, err := rpc.Dial("tcp", b.addr)
	if err != nil {
		return nil, err
	}
	b.client = client
	return client, nil
}

func (b *RPCBackend) dropLocked() {
	if b.client != nil {
		_ = b.client.Close()
		b.client = nil
	}
}

func (b *RPCBackend) RoundTrip(ctx context.Context, req Frame) (Frame, error) {
	b.mu.Lock()
	defer b.mu.Unlock()
	if err := ctx.Err(); err != nil {
		return Frame{}, err
	}
	client, err := b.connLocked()
	if err != nil {
		return Frame{}, err
	}
	call := client.Go("Gateway.Forward", ForwardArgs{Frame: req.Encode()}, &ForwardReply{}, make(chan *rpc.Call, 1))
	timeout := b.timeout
	if deadline, ok := ctx.Deadline(); ok {
		if left := time.Until(deadline); left < timeout {
			timeout = left
		}
	}
	timer := time.NewTimer(timeout)
	defer timer.Stop()
	select {
	case <-ctx.Done():
		b.dropLocked()
		return Frame{}, ctx.Err()
	case <-timer.C:
		// net/rpc 不能取消在途调用;断开连接丢弃迟到响应,防止串包。
		b.dropLocked()
		return Frame{}, context.DeadlineExceeded
	case res := <-call.Done:
		if res.Error != nil {
			b.dropLocked()
			return Frame{}, res.Error
		}
		resp, err := ReadFrame(bytes.NewReader(res.Reply.(*ForwardReply).Frame), maxFrameSize)
		if err != nil {
			b.dropLocked()
			return Frame{}, err
		}
		resp.RequestID, resp.Route, resp.Type = req.RequestID, req.Route, FrameResponse
		return resp, nil
	}
}

func (b *RPCBackend) Close() error {
	b.mu.Lock()
	defer b.mu.Unlock()
	b.dropLocked()
	return nil
}

七、连接管理

  • 复用:一条网关到后端实例的长连接可以承载大量请求,net/rpc 内部按序号多路复用,不需要每消息新建连接。
  • 健康检查:启动时拨号,失败即摘除该实例;运行中由调用错误触发重连,不在每条消息前做 ping。
  • 断线重连:调用失败后关闭旧 client,下一次 RoundTrip 惰性重拨;重拨失败直接回 50301,不无限重试。
  • 请求超时:以 context deadline 与后端配置中较小者为准;超时即关闭连接,因为 net/rpc 无法取消在途调用。
  • 背压:连接级并发由 BackendConn 的互斥锁或工作池限制,不能把无限并发压到一条连接上。

八、端到端测试

测试在 127.0.0.1:0 启动真实 listener,注册服务后通过 TCP 拨号,验证整帧字节、RequestID 改写和超时分支。超时用例让处理函数睡 200 ms,客户端只等 30 ms,确认返回 context.DeadlineExceeded。

// file: ch05/backend/rpcbackend_test.go
// tier: L1
// verify: go test ./ch05/backend/

package backend

import (
	"bytes"
	"context"
	"errors"
	"net"
	"testing"
	"time"
)

func startRPC(t *testing.T, svc *RPCService) string {
	t.Helper()
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		t.Fatal(err)
	}
	go func() { _ = ServeRPC(ln, svc) }()
	t.Cleanup(func() { _ = ln.Close() })
	return ln.Addr().String()
}

func TestRPCBackendRoundTrip(t *testing.T) {
	addr := startRPC(t, &RPCService{Handle: func(raw []byte) ([]byte, error) {
		req, err := ReadFrame(bytes.NewReader(raw), maxFrameSize)
		if err != nil {
			return nil, err
		}
		if req.RequestID != 99 || !bytes.Equal(req.Body, []byte("payload")) {
			return nil, errors.New("rpcbackend: altered frame")
		}
		return Frame{Type: FrameResponse, Route: req.Route, Body: append([]byte("echo:"), req.Body...)}.Encode(), nil
	}})
	b := NewRPCBackend(addr, time.Second)
	defer b.Close()
	resp, err := b.RoundTrip(context.Background(), Frame{Type: FrameRequest, Route: 7, RequestID: 99, Body: []byte("payload")})
	if err != nil {
		t.Fatal(err)
	}
	if resp.RequestID != 99 || resp.Route != 7 || resp.Type != FrameResponse || string(resp.Body) != "echo:payload" {
		t.Fatalf("resp = %+v", resp)
	}
}

func TestRPCBackendTimeout(t *testing.T) {
	addr := startRPC(t, &RPCService{Handle: func([]byte) ([]byte, error) {
		time.Sleep(200 * time.Millisecond)
		return nil, errors.New("rpcbackend: late")
	}})
	b := NewRPCBackend(addr, 30*time.Millisecond)
	defer b.Close()
	start := time.Now()
	_, err := b.RoundTrip(context.Background(), Frame{Type: FrameRequest, Route: 7, RequestID: 1})
	if !errors.Is(err, context.DeadlineExceeded) || time.Since(start) > 180*time.Millisecond {
		t.Fatalf("err = %v, elapsed = %v", err, time.Since(start))
	}
}

九、失败场景:超时后的迟到响应

最危险的失败不是报错,而是“报错之后又成功”。客户端 30 ms 超时返回,服务端 200 ms 后才把响应写回;如果连接继续复用,这个响应会被下一条请求读到。net/rpc 的调用按序号匹配,迟到响应本身不会错配到新调用,但旧连接上仍可能残留半帧或服务端状态。关闭连接是最简单、最可靠的隔离手段。

第二种失败是重连风暴:后端整体不可用时,每个请求都拨号、都失败。正确做法是加短退避与熔断,让网关快速回错误包,而不是把连接建立也拖进请求延迟。

十、运行

cd .local/checks/gs-practice-lab
gofmt -l ch05/backend
go vet ./ch05/backend/
go test ./ch05/backend/ -run 'RPC' -count=1 -v

本机输出:--- PASS: TestRPCBackendRoundTrip、--- PASS: TestRPCBackendTimeout,包级结果为 ok gspractice/ch05/backend。

小结

Redis Pub/Sub 解决“通知”,gRPC 解决“强类型请求”,Akka 解决“有状态串行”;三者不能互相替代。网关的主链路应选择可设超时、可复用、可观测的 RPC。net/rpc 示例用整帧字节保住了 Body 的不透明性,用连接关闭换来了超时后的确定性;真正上线时再把传输层换成 gRPC,路由与错误码设计可以原样保留。

常见错误与边界

  • 每条消息新建一次连接,握手与 TLS 成本压垮网关,端口也很快耗尽。
  • 把 Redis Pub/Sub 当可靠队列,断线期间的消息永久丢失。
  • gRPC 调用不设 deadline,后端卡住时网关 goroutine 持续堆积。
  • Actor 邮箱无界,高峰期内存随消息量线性上涨。
  • 超时后只返回错误、不关闭连接,迟到响应与后续请求互相污染。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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