一个基于Golang和Grpc的游戏后端框架。
A GameServer framework built using Golang and GRPC
M3Game是一个采用Golang构建游戏后端的尝试,期望能探索出一条Golang游戏后台的开发方案。
框架分为GameLogic,Frame-Runtime,Custom-Plugin三层。Frame-Runtime为框架驱动层,负责消息驱动,服务网格,插件管理等核心驱动工作。Custom-Plugin为自定义插件层,框架层将第三方服务抽象为多种插件接口,插件层根据实际的基础设施来进行实现。GameLogic为游戏逻辑层,用于承载实际的业务逻辑。框架使用protobuf来生成脚手架,可以通过在pb中添加Option的方式将业务层接口自动注入到框架层。
当前外围服务的框架已经基本稳定,下一阶段会做一个简单玩法服务的框架
优势:
1,更加贴近实际业务。
2、自动化的逻辑注入。借助pb的自定义选项,业务逻辑只需要很少的代码,就可以自动的注入到框架层
3、更通用的技术和更低的门槛。M3基于golang主流的protobuf和grpc进行构建,没有繁琐的代码生成工具,上手门槛低。
4、这里有一个很有意思的数据管理模块,只需要在pb中定义好数据和标记,就可以轻松实现自动置脏&批量写回&视图过滤功能。
5、使用Nats替换了Grpc底层的http2传输协议,使Grpc支持广播和消息缓存。
Mutil,Async,Actor-Server: 游戏后台常见的业务模式,分别对应并发,单线程异步,Actor模式
App: 用于承载业务逻辑的服务实体,是服务网格中的独立个体,由“环境ID.区服ID.功能ID.实例ID”唯一标识。一个App可以承载一个或多个Server
Client:RPC客户端,由服务提供方编写,包含一些参数校验,和路由规则
ResourceLoader: 可线上热更新的资源加载器,一般用于GameLogic Config的管理
Runtime: 框架驱动器
Transport: 提供服务之间Req-Rsp式RPC调用能力,采用tcp/GrpcSer实现一对一传输
BroekerSer:提供服务之间单向Ntify式RPC调用能力,采用Broker-plugin实现一对多传输
Mesh:服务网格,内含一组路由规则,以及规则对应的选路逻辑。采用Router-Plugin实现服务发现和服务注册
ResourceMgr: 资源管理器
PluginMgr:插件管理器
Router-Plugin: 路由组件,提供服务注册和服务发现的能力。当前有一个Consul实现
DB-Plugin: 存储组件,提供数据存储能力,当前有内存数据库,redis,mongo实现
Broker-Plugin:消息队列组件,提供针对主题的发布和订阅功能,当前有一个Nats实现
Log-Plugin: 日志组件,当前有一个Zap实现。
Trace-Plugin: 链路追踪组件,当前接入opentelemetry标准。
Metric-Plugin: 监控组件,当前有一个prometheus实现
Shape-Plugin:流量治理组件,当前有一个sentinel实现
Gate-Plugin:服务网关组件,当前有一个grpc-stream实现
Lease-Plugin:租约管理组件,当前有一个etcd实现
Transport-Plugin:Grpc传输层组件,当前有一个http2(原生) 和 一个Nats的实现。
GamePlay: 一组走状态同步的大地图Gameplay框架,当前有一个单机版的World实现
在GPT的帮助下,对runtime做了一轮优化
以example/simpleapp为例
Step1、定义服务 proto,生成pb文件
// example/proto/simpleapp.proto
syntax = "proto3";
package proto;
import "options.proto"; // 框架文件
option go_package = "proto/pb";
// 定义SimpleSer服务
service SimpleSer {
rpc HelloWorld(HelloWorld.Req) returns (HelloWorld.Rsp); // 定义接口
}
// 定义RPC
message HelloWorld {
option (rpc_option).route_key = "";
message Req {
string Req = 1;
}
message Rsp {
string Rsp = 1;
}
}
Step2、编写App代码
// example/simpleapp/simpleapp.go
package simpleapp
import (
"m3game/example/proto"
"m3game/example/simpleapp/simpleser"
_ "m3game/plugins/transport/tcptrans"
"m3game/runtime"
"m3game/runtime/app"
"m3game/runtime/server"
)
// 创建App实体
func newApp() *SimpleApp {
return &SimpleApp{
App: app.New(proto.SimpleAppFuncID), // 指定App的FuncID
}
}
type SimpleApp struct {
app.App
}
// 健康检测
func (d *SimpleApp) HealthCheck() bool {
return true
}
func Run() error {
// 启动一个 包含了simpleser的SimpleApp
runtime.Run(newApp(), []server.Server{simpleser.New()})
return nil
}
Step3、定义服务实体simpleser
// example/simpleapp/simpleser
package simpleser
import (
"context"
"fmt"
"m3game/example/proto/pb"
"m3game/runtime/rpc"
"m3game/runtime/server/mutil"
"google.golang.org/grpc"
)
func init() {
// 注册RPC信息到框架层
if err := rpc.RegisterRPCSvc(pb.File_simple_proto.Services().Get(0)); err != nil {
panic(fmt.Sprintf("RegisterRPCSvc SimpleSer %s", err.Error()))
}
}
func New() *SimpleSer {
return &SimpleSer{
Server: mutil.New("SimpleSer"), // 以MutilSer为基础构建SimpleSer
}
}
type SimpleSer struct {
*mutil.Server
pb.UnimplementedSimpleSerServer
}
// 实现HelloWorld接口
func (d *SimpleSer) HelloWorld(ctx context.Context, in *pb.HelloWorld_Req) (*pb.HelloWorld_Rsp, error) {
out := new(pb.HelloWorld_Rsp)
out.Rsp = fmt.Sprintf("HelloWorld , %s", in.Req)
return out, nil
}
// 将SimpleSer注册到grpcser
func (s *SimpleSer) TransportRegister() func(grpc.ServiceRegistrar) error {
return func(t grpc.ServiceRegistrar) error {
pb.RegisterSimpleSerServer(t, s)
return nil
}
}
step4 制作配置文件
[Plugin]
[[Plugin.Trans.trans_tcp]] // 采用http2传输层
Host = "127.0.0.1"
Port = 20051
Step5 编译运行
go build .
./main -idstr example.world1.simple.1 -conf ../../config/simpleapp.toml
1、重新梳理第三方包依赖
2、GamePlay实现
在M3中所有的跨服务调用都依托RPC进行,RPC接口通过pb-grpc生成。M3框架的附加信息都存储在RPC的metadata中。
如下是一个RPC定义的proto。
// 定义SimpleSer服务
service SimpleSer {
rpc HelloWorld(HelloWorld.Req) returns (HelloWorld.Rsp); // 定义接口
}
// 定义RPC
message HelloWorld {
option (rpc_option).route_key = "";
message Req {
string Req = 1;
}
message Rsp {
string Rsp = 1;
}
}
业务层通过编写rpc_option将RPC接口注入框架层,解析相关逻辑参看runtime/rpc。rpc_option定义如下
message M3GRPCOption {
string route_key = 1; // Hash路由时的key字段名
bool ntf = 2; // 是否是单向Nty
bool trace = 3; // 是否开启链路追踪
bool cs = 4; // 是否支持客户端访问
}
M3框架通过rpc注入和泛型编程,大大简化了业务层进行RPC调用时的操作,如下是对hello接口进行"随机选址"的RPCCall调用
func Hello(ctx context.Context, hellostr string, opts ...grpc.CallOption) (string, error) {
var in pb.Hello_Req
in.Req = hellostr
// RPCCallRandom 接受泛型参数in,返回泛型参数out。
// RPCCall通过入参in获取到对应的rpc_option,自动填充选址参数,并对常见RPC异常进行前置处理。
out, err := client.RPCCallRandom(_client, _client.Hello, ctx, &in, opts...)
if err != nil {
return "", err
} else {
return out.Rsp, nil
}
}
M3的服务之间的RPC调用采用Grpc框架,Grpc底层采用http2,不支持广播,不支持消息缓存。
M3使用Tranport组件来处理Grpc的传输协议,除了基于原生http2的tcptrans,M3还是实现了一个基于Nats的natstrans,使Grpc支持广播与消息缓存。相关实现参看plugins/transport/natstrans.
游戏后台服务常见的业务模型有 Mutil 多线程,Async 单线程异步,Actor 模式 三种(暂时没见过更复杂的模型)
Mutil 多线程模型,主要用于无状态服务,M3采用原生Grpc服务实现。参考实现 example/mutilapp/mutilser
Async 单线程异步,使用这类模型的服务不允许并发的执行RPC调用。参考实现 example/asyncapp/asyncser
M3在Async服务的RPC驱动链中加入了资源锁。通过资源锁确保同一时间只有一个RPC调用再执行
Actor模型。使用这类模型的服务将RPC调用和游戏实体绑定,实体内部串行,实体之间并发。参考实现 example/actorapp/actorser
M3为每个Actor分配一个执行Goroutine,并引入ActorRuntime和ActorMgr对Actor进行管理,前者用于管理单个Actor的执行Goroutine,后者用于管理整个Actor池。
M3在Actor服务的RPC调用链中加入了Actor管理逻辑。对于Actor的RPC调用都在Actor自己的Goroutine中执行。
引入Lease-plugin可以保证一个Actor在分布式环境下至多只会在一个App上运行。参看rumtime/server/actor
Mesh使用Router插件进行服务注册和服务发现,Router插件是M3的必要插件,plugins/router/consul是一个基于Consul的Rotuer实现。
M3使用Grpc的Resolver & Picker方式将Mesh与RPC路由相关联,相关逻辑参看runtime/mesh/resolver.go,balance.go
当前支持 P2P,Random,Hash,BroadCast,Single路由模式
路由模式 | 选路参数 | 选路规则 |
---|---|---|
P2P | 目标实例ID | 直接寻路 |
Random | 目标服务ID | 在目标服务中随机 |
Hash | 目标服务ID & 哈希Key | 在目标服务中按哈希key,一致性哈希映射寻路 |
BroadCast | 目标服务ID | 对目标服务所有实例广播 |
Single | 目标服务ID | 对目标服务中ID最小的实例寻路 |
M3中的资源指由GameLogic定义,在服务运行过程中需要实时热更新的资源文件。一般用于GameLogic的配置管理。
ResourceMgr使用双缓冲区模型,一主一备,主缓冲区用于资源访问,备缓冲区用于资源更新,每次热更新后主备缓冲区交换。相关逻辑参看resource/resourcemgr.go
M3对于资源的访问需要附带上下文context用于确认是资源访问还是资源更新
M3对于资源文件格式没有要求,只要求资源管理器提供Load接口,example/loader/titlecfgloader.go是一个对于json配置文件的资源加载器样例。
M3采用pb来定义游戏实体的DB存储结构。如下是一个简单实体的结构定义。相关实现参看example/actorapp/actor
message ActorDB {
string ActorID = 1 [(dbfield_option) = { flag: "FActorID", primary: true }]; // 主键
string Name = 2 [(dbfield_option) = { flag: "FActorName" }];
int32 Level = 3 [(dbfield_option) = { flag: "FActorLevel" }];
}
enum AcFlag {
FActorMin = 0;
FActorID = 1;
FActorName = 2;
FActorLevel = 3;
}
M3使用DB插件来对实体数据进行落地,M3根据实体的PB结构生成对应的dbmeta,DB插件不用感知业务数据的具体类型,直接根据Meta就可以对实体数据进行CRUD操作。
DBMeta的生成逻辑参看 db/dbmeta.go
type DBMetaInter interface {
Setter(msg proto.Message, flag int32, data interface{}) // 赋值
Getter(msg proto.Message, flag int32) interface{} // 读取
FlagKind(flag int32) protoreflect.Kind // 获取字段类型
FlagName(flag int32) string // 获取字段类型
KeyFlag() int32 // 主键字段
AllFlags() []int32 // 所有字段名
New() proto.Message
Table() string
}
type DB interface {
plugin.PluginIns
Read(ctx context.Context, meta DBMetaInter, key interface{}, flags ...int32) (proto.Message, error)
Update(ctx context.Context, meta DBMetaInter, key interface{}, obj proto.Message, flags ...int32) error
Create(ctx context.Context, meta DBMetaInter, key interface{}, obj proto.Message) error
Delete(ctx context.Context, meta DBMetaInter, key interface{}) error
ReadMany(ctx context.Context, meta DBMetaInter, filters interface{}, flags ...int32) ([]proto.Message, error)
}
数据管理指对游戏实体数据的管理功能,M3的Wraper和Viewer提供了自动置脏 和 视图过滤功能。实现了一套类似重返帝国的属性系统。
https://mp.weixin.qq.com/s/rKpHb9WNkYh7rN_DNqC5xw 天美干货分享:怎么解决大地图SLG的技术痛点?
Wraper,对数据的ORM级封装,采用反射&泛型极大的简化了DB操作,同时封装了一套自动化的置脏管理。example/actorapp/actor是一个基于Wraper的实体样例
如下是Wraper定义
type Wraper[TM proto.Message, TF Flag] struct {
meta *WraperMeta[TM, TF] // Meta
key interface{} // 主键值
obj TM // 原始数据
dirtys map[TF]bool // 脏标记
}
func (w *Wraper[TM, TF]) Set(flag TF, value interface{})
func (w *Wraper[TM, TF]) Get(flag TF) interface{}
func (w *Wraper[TM, TF]) Update(db db.DB) error // CRUD操作
func (w *Wraper[TM, TF]) Create(db db.DB) error
func (w *Wraper[TM, TF]) Delete(db db.DB) error
func (w *Wraper[TM, TF]) Read(db db.DB) error
使用方式如下,以前述ActorDB为例
pb定义
message ActorDB {
string ActorID = 1 [(dbfield_option) = { flag: "FActorID", primary: true }]; // 主键
string Name = 2 [(dbfield_option) = { flag: "FActorName" }];
int32 Level = 3 [(dbfield_option) = { flag: "FActorLevel" }];
}
enum AcFlag {
FActorMin = 0;
FActorID = 1;
FActorName = 2;
FActorLevel = 3;
}
dbmeta := db.NewMeta[*pb.ActorDB]("actor_table")
wrapermeata := db.NewWraperMeta[*pb.ActorDB, pb.AcFlag](db,eta)
wp := wrapermeata.New("ActorID123") // 构建Wraper
// 读数据
dbplugin := plugin.GetDBPlugin()
wp.Read(ctx, dbplugin)
// 修改用户名
wp.Setter(pb.AcFlag_FActorName, "小明")
// 脏字段写回
if wp.IsDirty() {
wp.Update(ctx, dbplugin)
}
Viewer是一个按视图级别的数据过滤器。Viewer会自动读取pb结构体的视图标记字段,然后按照视图对实体数据进行过滤展示。demo/roleapp/role实现了Viewer视图管理
使用方式如下,以前述ActorDB为例
pb定义
message ActorDB {
string ActorID = 1 [
(viewfield_option) = { wflag: "ViewDetail,ViewBrief,ViewCard" }
]; // 所有视图都展示
string Name = 2 [
(viewfield_option) = { wflag: "ViewDetail,ViewBrief,ViewCard" }
]; // 所有视图都展示
int32 Level = 3 [
(viewfield_option) = { wflag: "ViewDetail,ViewBrief" }
]; // 只在详情和简要视图展示,其他视图为默认值0
ActorFight Fight = 4; // 没有视图标记,直接递归检查子结构体
}
message ActorFight {
ActorFightBase Base = 1 [
(viewfield_option) = { wflag: "ViewDetail,ViewBrief" }
]; // 只在详情和简要视图展示
ActorFightPlus Plus = 2
[(viewfield_option) = { wflag: "ViewDetail" }]; // 只在详情视图展示
}
message ActorFightBase {
int32 Atk = 1;
int32 Def = 2;
}
message ActorFightPlus {
int32 Hp = 1;
int32 Mp = 2;
}
enum ViewFlag {
ViewMin = 0;
ViewDetail = 1; // 详情
ViewBrief = 2; // 简要
ViewCard = 3; // 极简
}
actor := &pb.ActorDB{
ActorID: "1001",
Name: "小明",
Level: 1,
Fight: &pb.ActorFight{
Base: &pb.ActorFightBase{
Atk: 1,
Def: 2,
},
Plus: &pb.ActorFightPlus{
Hp: 3,
Mp: 4,
},
},
}
v := db.NewViewer[*pb.ActorDB, pb.ViewFlag]()
fmt.Println(v.Filter(pb.ViewFlag_ViewCard, actor)) // 极简视图
fmt.Println(v.Filter(pb.ViewFlag_ViewBrief, actor)) // 简要视图
fmt.Println(v.Filter(pb.ViewFlag_ViewDetail, actor)) // 详情视图
M3采用Shape组件进行流量管理,Shape组件采用Interceptor方式注入RPC调用链。shape/Sentinel是一个基于Sentinel实现的shape组件。
流量管理针对RPC进行,规则分为限流规则 FlowRule 和 熔断规则 BreakRule。
如下是对example/mutilapp的BreakHello的流量管理配置
[Rules]
Method = "/proto.MutilSer/BreakHello" // RPC方法
[[Rules.FlowRules]] // 限流规则
Threshold = 2 // 限流阈值
StatIntervalMs = 1000 // 统计周期
MaxQueueWaitMs = 0 // 限流最大等待时长
[[Rules.BreakRules]] // 熔断规则
Threshold = 1 // 熔断阈值
Strategy = "ErrorCount" // 熔断规则,错误请求数
StatIntervalMs = 1000 // 统计周期
RetryTimeOutMs = 2000 // 熔断恢复市场
MinRequestNum = 2 // 熔断生效最小请求次数
M3采用Metric组件来进行监控统计,对于统计项分为Counter,Guage,Histogram,Summary四类。
metric/prometheus 是一个基于prometheu 实现的Metric
type StatCounter interface { // 计数器
Add(float64)
Inc()
}
type StatGauge interface { // 测量器
Set(float64)
Sub(float64)
Dec()
Add(float64)
Inc()
}
type StatHistogram interface { // 直方图
Observe(float64)
}
type StatSummary interface { // 点分数
Observe(float64)
}
M3的链路追踪采用Open-Telemetry方案,trace/stdout是一个直接向控制台打印的trace。
对于RPC是否启用Trace,采用pb.Option的方式进行定义。如下是一个开启Trace的TraceHello RPC的定义
message TraceHello {
option (rpc_option).route_key = "";
option (rpc_option).trace = true; // 打开Trace
message Req {
RouteHead RouteHead = 1;
string Req = 2;
}
message Rsp {
RouteHead RouteHead = 1;
string Rsp = 2;
}
}
M3采用Log组件进行本地日志管理,日志分为DEBUG,INFO,WARN,ERROR,FATAL 五个级别,log/zap 是一个基于zap实现的Log组件样例。
type Logger interface {
Output(depth Depth, lv LogLv, plus LogPlus, format string, v ...interface{})
SetLevel(level LogLv)
GetLevel() LogLv
}
func Debug(format string, v ...interface{}) // 调试日志,只在开发环境开启
func Info(format string, v ...interface{}) // 重要行为日志,生产环境开启
func Warn(format string, v ...interface{}) // 警告日志,如果遇到问题,用于辅助检查
func Error(format string, v ...interface{}) // 错误日志,明确的逻辑异常,高度关注
func Fatal(format string, v ...interface{}) // 致命错误,必须立机告警处理
func DebugP(plus LogPlus, format string, v ...interface{})
func InfoP(plus LogPlus, format string, v ...interface{})
func WarnP(plus LogPlus, format string, v ...interface{})
func ErrorP(plus LogPlus, format string, v ...interface{})
func FatalP(plus LogPlus, format string, v ...interface{})
服务网关用于管理与客户端的连接,并将客户端请求转化为Grpc-Reply请求。plugins/gate/grpcgate是一个 基于Grpc-Stream建立的Gate组件。
example/gateapp实现了一套将客户端请求转化为Grpc-Reply请求的通用方案。
type Gate interface {
GetConn(playerid string) CSConn
}
type CSConn interface {
Send(ctx context.Context, msg *metapb.CSMsg) error
Kick()
}
type GateReciver interface {
AuthCall(*metapb.AuthReq) (*metapb.AuthRsp, error) // 建立连接时的鉴权接口
LogicCall(*metapb.CSMsg) (*metapb.CSMsg, error) // 将客户端请求转化为Grpc-Reply请求
}
为了解决分布式系统下的数据一致性问题,M3引入了租约(悲观锁),Lease-plugin,plugins/lease/etcd 是一个基于etcd的实现。
使用租约来保护数据的所有权,可以保证在同一时间,整个分布式系统中最多只会有一个App可以操作该数据。
type LeaseMoveOutFunc func(context.Context) ([]byte, error) // 租约退出回调
type Lease interface {
plugin.PluginIns
AllocLease(ctx context.Context, id string, f LeaseMoveOutFunc) error // 获取租约
FreeLease(ctx context.Context, id string) error // 释放租约
KickLease(ctx context.Context, id string) ([]byte, error) // 要求释放租约
RecvKickLease(ctx context.Context, id string) ([]byte, error) // 接受释放租约消息
GetLease(ctx context.Context, id string) ([]byte, error) // 获取租约内容
}
type LeaseReciver interface {
SendKickLease(ctx context.Context, id string, app string) ([]byte, error) // 发送释放租约消息
}
这里说的热更新指的是在不影响服务能力前提下,对线上程序的逻辑代码进行更新。对于使用编译型语言进行业务逻辑开发的程序,常见的热更新方式有
1、脚本虚拟机。在编译型语言中引入脚本语言,比如Lua,Python。在合理的位置“打桩”,当程序执行到“桩”点时,就会进入脚本虚拟机中,这时可以利用脚本语言的特性来实现程序热更新。
2、动态链接库。大部分的编译型语言都支持生成、链接动态库文件。可以通过线上更新挂载的动态库文件,实现程序热更新。
3、直接莽。线上更新程序文件,并重启程序,以实现业务逻辑更新的效果。这种方式必然会带来服务能力的损失,但是可以通过一些方式降低损失,比如滚动更新,共享内存等。
热更新方式的选择更新基于技术选型(比如你用skynet,那就没啥好说的了)。M3本身并不提供热更新能力,但是在demo/roleapp/roleser中,给出一个使用Lua & “打桩”的方式,实现拒绝RoleId最后一位数为“1”的玩家登陆的热更新实现。
example 是一组简单服务的样例,用来展示M3框架的单实例开发方案。
example/simpleapp 是一个HelloWorld服务。
example/mutilapp 是一个并发服务,提供Hello,TraceHello(链路追踪),BreakHello(熔断限流) 接口
example/asyncapp 是一个单线程异步服务,提供PostChannel(广播处理),SSPullChannel(单线程阻塞) 接口
example/actorapp 是一个Actor模型服务,提供 Register(一个App部署多个Server),Login(DB数据加载),ModifyName(自动置脏标记),LvUp(自动置脏标记),GetInfo(资源配置),PostChannel(广播发送),PullChannel(服务间RPC调用)。ActorApp加入了租约插件,确保每个Actor最多只会在一个ActorApp上存在
example/gateapp 是一个网关服务,客户端可以通过gprc-stream方式与网关建立长连接。
example/test 是一个模拟客户端发包程序,内置多个测试用例。
example使用方式
1、修改example/config中 nats和router接口地址
2、依次启动mutilapp,asyncapp,actorapp,gateapp的main/start.sh
3、到test/main目录下执行测试用例命令
./main -testmode Hello -agenturl 127.0.0.1:22000 // helloworld用例
./main -testmode Trace -agenturl 127.0.0.1:22000 // helloworld链路追踪
./main -testmode Break -agenturl 127.0.0.1:22000 // helloworld流量治理
./main -testmode ActorCommon -agenturl 127.0.0.1:22000 // 注册,登陆,改名,升级,服务端到客户端主动通知
./main -testmode ActorBroadCast -agenturl 127.0.0.1:22000 // 注册,登陆,广播
./main -testmode ActorMove -agenturl 127.0.0.1:22000 // 数据一致性,一个Actor两个ActorApp之间进行服务迁移(需要启动ActorApp1 和 ActorApp2)
游戏后端服务的核心功能就是对业务数据进行增删改查。当服务采用多机部署时,就会引入对同一份数据的并发操作问题。而集群化部署所要处理的问题就是在多机环境下,如何分配数据管理权。
无状态服务,其每次请求的处理结果不依赖上下文,不需要长期占有数据的管理权,一般采用加锁 或者 CompareAndSwap来处理并发问题
有状态服务,其每次请求的处理结果依赖上下文,需要长期占有数据的管理权。根据其管理的数据不同,分为三种类型:
1、元数据。这类数据的总量小,数据管理权可以集中在一台机器上处理。集群部署时,一般采用主从模式,当主备宕机时,数据管理权整体迁移到备机。
2、轻量级数据。这类数据的总量大,且数据管理权的跨机迁移成本低(时间成本,资源成本),数据管理权分散在多台机器。集群部署时,一般采用对等部署,基于一致性哈希进行寻址,当机器增减时,数据管理权会动态调整。
3、重量级数据。这类数据的总量大,且数据管理权的跨机迁移成本高,数据管理权分散在多台机器。集群部署时,一般会专门指定一个管理进程(管理进程采用元数据方式部署),用于处理数据管理权的调度,尽量减少跨机的管理权迁移
集群部署方式与具体的业务行态,实际条件相关,这里只是提供一个思路。M3的demo采用K8s部署,详情参看"部署"
为了更好的暴露问题,并验证解决方案,M3构建了一个重度游戏后端Demo作为集群化解决方案的载体。
Demo是一个全服互通游戏,玩家(Role)可以自由组建社团(Club),核心玩法采用匹配(Match)开单局(Fight)方式进行。
在部署上,所有的服务都采用集群化部署以支持容灾恢复和弹性伸缩能力。
游戏实体分为 玩家(Role),社团(Club),单局(Fight)。玩家(Role)实体只有对应玩家在线时才会激活,社团(Club)实体一经创建常驻激活,直到被解散,单局(Fight)实体在玩家开启单局期间才会激活且激活期间不易发生服务迁移。
服务实例包括GateApp(网关服务), UidApp(id管理服务),OnlineApp(在线管理服务),RoleApp(玩家服务),ClubApp(社团服务),MatchApp(匹配服务),ZoneApp(战斗集群服务),FightApp(战斗服务)
游戏后台服务一般分为玩法服务和外围服务。
玩法服务指与客户端表现直接关联的状态同步类服务,比如MMO的地图服务,Moba的单局服务等。这类服务同质化高,易于抽象,经常与客户端共用逻辑代码,甚至可以由unity,ue等客户端游戏引擎直接生成。
外围服务指的是玩法服务以外用于承载游戏逻辑的服务,比如吃鸡的大厅服务,好友服务,战队服务等。这类服务以数据管理为核心,与具体的业务逻辑相关比如数值成长,运营活动,很难抽象为通用架构,是游戏后端开发的主要工作。
在本demo中外围服务包括GateApp, UidApp, OnlineApp, RoleApp, ClubApp,ClubMgrApp组成的部分,管理玩家(Role) 和 社团(Club)数据。
GateApp: 网关服务,无状态服务,客户端任意链接
UidApp: Id管理服务,包括玩家Openid到RoleId的映射,ClubId的分配
RoleApp:玩家服务,以Role为单位的Actor服务
OnlineApp:在线管理,维护Role在线状态,提供大量级租约服务。
ClubApp:社团服务,将Club划分为有限个Slot,以Slot为单位的Actor服务。
ClubRoleApp:社团玩家服务,管理社团和玩家的关联关系。
首先编写服务接口协议
# GateApp
service GateSer {
rpc SendToCli(SendToCli.Req) returns (SendToCli.Rsp); // 向客户端主动推送
}
# UidApp
service UidSer {
rpc AllocRoleId(AllocRoleId.Req) returns (AllocRoleId.Rsp); // 分配RoleID
rpc AllocClubId(AllocClubId.Req) returns (AllocClubId.Rsp); // 分配ClubID
}
# RoleApp
service RoleSer {
rpc RoleLogin(RoleLogin.Req) returns (RoleLogin.Rsp); // 登陆注册
rpc RoleGetInfo(RoleGetInfo.Req) returns (RoleGetInfo.Rsp); // 获取详情
rpc RoleModifyName(RoleModifyName.Req) returns (RoleModifyName.Rsp); // 改名
rpc RolePowerUp(RolePowerUp.Req) returns (RolePowerUp.Rsp); // 战力提升
rpc RoleKick(RoleKick.Req) returns (RoleKick.Rsp); // 服务迁移
rpc RoleGetClubInfo(RoleGetClubInfo.Req) returns (RoleGetClubInfo.Rsp); // 获取社团信息
rpc RoleGetClubList(RoleGetClubList.Req) returns (RoleGetClubList.Rsp); // 获取社团列表
rpc RoleGetClubRoleInfo(RoleGetClubRoleInfo.Req) returns (RoleGetClubRoleInfo.Rsp); // 获取玩家社团信息
rpc RoleCreateClub(RoleCreateClub.Req) returns (RoleCreateClub.Rsp); // 创建社团
rpc RoleJoinClub(RoleJoinClub.Req) returns (RoleJoinClub.Rsp); // 加入社团
rpc RoleExitClub(RoleExitClub.Req) returns (RoleExitClub.Rsp); // 退出社团
rpc RoleCancelClub(RoleCancelClub.Req) returns (RoleCancelClub.Rsp); // 解散社团
}
# OnlineApp
service OnlineSer {
rpc OnlineCreate(OnlineCreate.Req) returns (OnlineCreate.Rsp); // 创建在线状态
rpc OnlineRead(OnlineRead.Req) returns (OnlineRead.Rsp); // 获取在线情况
rpc OnlineDelete(OnlineDelete.Req) returns (OnlineDelete.Rsp); // 删除在线状态
}
# ClubApp
service ClubSer {
rpc ClubCreate(ClubCreate.Req) returns (ClubCreate.Rsp); // 创建社团
rpc ClubGetInfo(ClubGetInfo.Req) returns (ClubGetInfo.Rsp); // 创建社团
rpc ClubJoin(ClubJoin.Req) returns (ClubJoin.Rsp); // 加入社团
rpc ClubExit(ClubExit.Req) returns (ClubExit.Rsp); // 退出社团
rpc ClubCancel(ClubCancel.Req) returns (ClubCancel.Rsp); // 解散社团
}
service ClubDaemonSer {
rpc ClubKick(ClubKick.Req) returns (ClubKick.Rsp); // 服务迁移
}
# ClubRoleApp
service ClubRoleSer {
rpc ClubRoleRead(ClubRoleRead.Req) returns (ClubRoleRead.Rsp); // 查询Role归属Club
rpc ClubRoleCreate(ClubRoleCreate.Req) returns (ClubRoleCreate.Rsp); // 创建Role-Club关系
rpc ClubRoleDelete(ClubRoleDelete.Req) returns (ClubRoleDelete.Rsp); // 删除Role-Club关系
}
UidApp 管理玩家OpenId(社交账户Id)到RoleId(游戏角色Id)的映射关系,以及ClubId(社团Id)的生成。其负载与单位时间内玩家登陆次数和创建社团次数相关,署于小负载服务。类比元数据类服务,这里采用主从部署,使用Lease(租约)保证数据一致性,使用Single(最小ID)方式选主。参看 demo/uidapp
OnlineApp 管理玩家的在线状态(玩家所处的RoleApp信息),其提供了一种大规模Lease服务,用来保证玩家数据在RoleApp上的一致性,当玩家登陆时RoleApp会先在OnlineApp申请Lease后再提供服务(如果Lease冲突,则把原Lease踢下线)。其负载与单位时间内玩家登陆次数相关,署于小负载服务。类比元数据类服务,这里采用主从部署,使用Lease(租约)保证数据一致性,使用Single(最小ID)方式选主。参看 demo/onlineapp
RoleApp 持有玩家RoleDB的数据管理权,负责RoleDB层面的游戏逻辑,其负载与单位时间内所有在线玩家的总操作次数相关,署于大负载服务,且数据迁移成本低。类比轻量数据服务,这里采用对等部署,使用OnlineApp提供的Lease服务保证数据一致性,使用Hash(一致性哈希)方式寻址。参看demo/roleapp
GateApp 管理玩家链接,本质上是个代理服务,没有数据管理权需求,署于无状态服务。这里采用对等部署。参看demo/gateapp
ClubRoleApp 持有玩家与社团的映射关系,没有数据管理权,实时查询ClubApp的相关数据表。这里采用对等部署,随机路由。参看demo/clubroleapp
ClubApp 管理社团数据,社团数量动态变化,ClubApp将社团按照Slot划分,以Slot为服务单位,采用Actor+Lease模式构建,对等部署。参看demo/clubapp
Test 测试客户端
sh test.sh Test1 // 单次 关键路径(登陆,修改Role数据,拉取Role数据)测试
sh test.sh MutilTest1 // 100TPS 10000次 关键路径(登陆,修改Role数据,拉取Role数据)测试
sh test.sh Test2 // 单次 社团路径(登陆,创建社团,退出社团)测试
Demo采用Docker方式进行交付。采用Helm编排服务架构。采用K8s进行程序部署。
使用demo/dockerbuild.sh脚本即可Docker容器构建。如下是使用的dockerfile
# 基础镜像,包含一套dev环境
FROM golang:1.20-rc
# Transport 40001 Metric 40002 Gate 40003
EXPOSE 40001/tcp 40002/tcp 40003/tcp
#
COPY uidapp/main/main /go/bin/demo/uidapp/main/main
COPY roleapp/main/main /go/bin/demo/roleapp/main/main
COPY onlineapp/main/main /go/bin/demo/onlineapp/main/main
COPY gateapp/main/main /go/bin/demo/gateapp/main/main
COPY clubapp/main/main /go/bin/demo/clubapp/main/main
COPY clubroleapp/main/main /go/bin/demo/clubroleapp/main/main
COPY test/main/main /go/bin/demo/test/main/main
COPY resource /go/bin/demo/resource
COPY deploy /go/bin/demo/deploy
Helm相关配置在demo/helm/m3demo。部署前需要修改demo/helm/m3demo/values.yaml中的image字段
m3demoimage: m3demo:latest