一、三种通信方式在网关里的位置
网关到后端的通信没有“最好”,只有“放在哪一层最合适”。Redis Pub/Sub 适合通知与广播,gRPC 适合强类型的一问一答,Akka 适合把状态和顺序收进 Actor。把它们混成一套“万能总线”,通常会在超时、顺序和排障上同时失控。
| 方式 | 典型位置 | 调用语义 | 主要代价 |
|---|---|---|---|
| Redis Pub/Sub | 跨网关广播、踢下线、公告 | 至多一次,发后不等待 | 无确认、无重放、断线丢消息 |
| gRPC | 登录服、角色服、场景服的 RPC | 一问一答,可带 deadline | 需要 protobuf 与代码生成 |
| Akka | 房间、战斗、有状态实体 | 发消息给 Actor,邮箱排队 | 需要集群与分片运维 |
二、调用语义对照
| 维度 | Redis Pub/Sub | gRPC | Akka |
|---|---|---|---|
| 超时 | 客户端侧自行控制 | 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,不无限重试。 - 请求超时:以
contextdeadline 与后端配置中较小者为准;超时即关闭连接,因为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 邮箱无界,高峰期内存随消息量线性上涨。
- 超时后只返回错误、不关闭连接,迟到响应与后续请求互相污染。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。