大型游戏服务端读书笔记

第一章 引入

服务器承载量

CPU负载内存占用网络流量等多个角度考量,也需要考虑不同游戏类型的逻辑复杂度

可以做个假设,在MMORPG中,玩家平均每3秒操作一次(走路,购物),服务器平均处理一条消息花费2毫秒。从CPU角度来看,如果同一时刻只处理一个客户端请求,服务器每秒可以处理500条消息,最高承载1500人。按经验推算,最高1000多人在线的游戏,DAU大概是三五千,这种承载量对于多数独立游戏和小型手游是足够的。

上面是仅计算了CPU负载,实际上”走路”这类广播消息受网络影响很大,不同类型游戏对服务器响应速度有不同要求。如果不做进一步优化,广播的消息量与在线玩家呈指数增长关系,通常这类单进程程序只能支持几十名玩家进行广播。

强弱交互

  • 同一场景角色交互很强,比如有”走路”消息,可以在同一进程处理。

  • 不同场景角色交互较弱,只有聊天、好友、公会这些功能需要交互,可以将同一个服的玩家放在一台机器上处理,进程间通信会比同一进程共享数据慢数百倍。

  • 不同服务器的玩家交互很少,可以放到不同机器上。

一致性问题

分布式程序要处理断线重连、断线期间的消息重发,以及 断线后进程间状态不一致的问题。

在游戏业务中,开发者一般会把一致性问题抛给具体业务去处理。

分布式与单点

难以分割的业务

实现分布式程序的前提是游戏逻辑能够分割

如果游戏规则复杂,各个功能紧密相连,则不容易找到分割的方案,部分功能依然要靠单点的性能支撑,那么单点(单个进程、单个线程)的运算能力依然会限制服务端的承载量。

延迟和承载的权衡

多个程序协作意味着消息延迟,某些功能对消息即时性要求很高,比如帧同步。

Actor模型

合理分割功能是分布式模型一大难点,Actor模型既能符合游戏逻辑的表达,又能让计算机高效执行。

Actor模型中,每个Actor相互隔离,只通过消息通信,具有天然的并发性。理念是万物皆Actor,它是更进一步的面向对象,即把世间万物都当作Actor对象。Actor可以代表一个角色、一只动物,也可以代表整个游戏场景。

每个Actor都会包含自身状态(Items,HP),以及一个信箱(消息队列),Actor通过给其他Actor寄信来实现通信。至于收到信件后的反应,取决于收信Actor

第二章 Skynet入门

一些API

目录结构,Service即Actor,放在cservice和service;lib库放在luaclib和lualib。

基础API
1
2
3
4
5
6
7
8
9
10
11
12
-- 启动一个新服务
newservice(name, ...)
-- 用func初始化服务
start(func)
-- 为type类型的消息设定处理函数func
-- Skynet支持多种消息类型,Lua服务间的消息类型是"lua"
-- func形式 function(session, source, cmd, ...) session为消息的唯一ID,source为发送消息的服务地址,cmd代表消息名
dispatch(type, func)
-- 向地址为addr的服务发送一条type类型的消息,消息名为cmd
send(addr, type, cmd, ...)
-- send对应的阻塞方法,要等待对方回应
call(addr, type, cmd, ...)
网络API
1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- 返回 listenfd
socket.listen(host, port)
-- 为listenfd设置新客户端连接时的回调方法connect
socket.start(listenfd, connect)
-- 回调方法connect获得新连接后,不会立即接收它的数据,需要再次调用socket.start(fd)开始接受
-- 回调方法connect的完整写法
function connect(fd, addr)
socket.start(fd)
...
end
-- read write close
socket.read(fd)
socket.write(fd, data)
socket.close(fd)
数据库API
1
2
3
4
5
-- 连接数据库
mysql.connect(args)
-- 执行sql语句
-- "insert into msgs (text) values (\'\hello')"
db.query(sql)
集群API
1
2
3
4
5
6
7
8
9
10
-- 本节点(重新)加载节点配置
-- {node1 = "127.0.0.1:7001", node2 = "127.0.0.1:7002"}
cluster.reload(cfg)
-- 启动本地节点
cluster.open(node)
-- 跨节点推送消息
cluster.send(node, address, cmd, ...)
cluster.call(node, address, cmd, ...)
-- 为远程节点上的服务(address)创建一个本地代理服务,可以直接用skynet.send和call操作本地代理
cluster.proxy(node, address)

协程同步问题

服务当前协程挂起时,还可以接受并处理其他消息,如果多个协程改到同一份数据,就会有同步问题

解决方案,加多一个state标识和一个协程列表,操作执行时,将state置doing,其他协程判断state=doing时就将自己加到协程列表,然后skynet.wait。在操作执行完后,重置state,然后遍历协程列表依次skynet.wakeup(co),最后将协程列表置空。

第n章 进阶用法

封装易用的API

Skynet的API提供了偏底层的功能,不方便使用,通过snax框架给出了一套更简单的API。

本节在service模块封装了更简洁的API,service模块是对Skynet服务的一种封装,还封装了重复调用的方法。

GitHub的service.lua
1
2
3
4
5
6
7
8
9
10
local M = {
-- 服务类型 服务ID
name = "", -- gateway
id = 0, -- 1
-- 回调函数
exit = nil,
init = nil,
-- dispatch方法
resp = {}
}

分布式登录流程

解决以下问题

  • 角色Actor的创建和销毁

  • SSO,登录和断线重连时检查,注意同一个角色在不同类型节点都只能有一个服务协程

利用agentmgr服务裁决登录请求,根据玩家在线状态(登录中、已在线、登出中)避免临界情况

扩展单点agentmgr服务,利用对ID取模路由到多个agentmgr服务,避免竞争

游戏数据库

避免扩展数据库结构导致长时间停服,需要保证数据库结构稳定。可以采用Key-Value,将玩家数据序列化成二进制数据BLOB

Protobuf对比Json,序列化数据小,能设置默认值(proto3不支持)

分库分表,分表依据有数据更新频率,数据更新时机以及数据量大小

同步算法

游戏特征

fps游戏,精确度要求高,一局玩家数量多,同屏角色数量少

rts游戏,同屏单位数量多,一局玩家数量不多

延迟和抖动

“顿挫”和“打不中”都可以归结于网络的延迟和抖动,即使服务器设置很高的同步频率,也无法解决

TCP解决了UDP不可靠无序的问题

MMO玩家能容忍0.1秒的延迟,MOBA玩家的容忍程度很低

客户端障眼法

插值算法

对比直接设置位置,存在更大的误差,但带来更好的效果

缓存队列

收到移动协议,不立即处理,而是把数据缓存在队列里,以固定的频率取出,结合插值算法移动角色,缓解网络抖动

根据缓存队列积累的数据量,动态调整取出速率

对比插值算法,误差更大

主动方优先

对于误差敏感的游戏(例如射击),如果游戏服不具备完全的运算能力,选择优先照顾主动方感受,应对误差

同步方案

根据服务器的输入输出内容,同步方案分为三类(输入 -> 输出):

  • 指令 -> 指令,帧同步的基础
  • 指令 -> 状态,杜绝作弊,服务器工作量大,客户端可以先行表现
  • 状态 -> 状态

指令例如移动操作,状态例如坐标位置。选择方案需要从开发难度人员配置服务器硬件水平等多方面考量。

可以结合多种同步方案,例如,某些ARPG使用 状态->状态 的方案同步角色的位置,使用 指令->状态 的方案同步技能,使服务端具备一定的反作弊能力,同时又能平衡工作量。因为客户端引擎大多集成了物理模块和寻路模块,容易实现角色移动的功能;服务端实现起来则相对比较困难,也增加了服务器负载

MMO 天刀

场景部分采用 状态 -> 状态 的同步方案,辅之以服务器强校验。该游戏的场景结构、技能触发规则都很复杂,完全由服务端运算,负载太高,所以服务器用低精度的地图数据和较低的运算频率来降低负载

MOBA 王者

一般采用帧同步,误差较小来保证公平性,方便处理同步单位较多的情况,容易实现战场回放。帧同步对网络质量的要求很高(延迟要求低于50ms),为降低延迟,采用自研的可靠UDP。帧同步需要全局计算

FPS

一般采用状态同步,延迟低(无需服务器计算),更依赖客户端算力(服务器出于性能考虑,采用低精度地图,而fps对物理碰撞精度要求很高)。客户端只关注玩家周围事物,计算量小(帧同步需要全局计算),很难防止外挂

帧同步

在 指令 -> 指令 方案的基础上,增加了一些用于确保不同客户端能有相同运算结果的机制(确定性计算),再配合客户端的障眼法​、可靠UDP等技术,为玩家提供良好的游戏体验。

严格帧同步,客户端之间的误差不会超过一轮。
乐观帧同步,公网环境下采用定时不等待策略,服务器定期广播操作指令,推进游戏进程,不必等待慢客户端。广播策略简单,但是收集策略复杂。可以设定最大允许的轮数差异

确定性计算
  • 浮点数计算,不同系统浮点数精度客户端不同,可以全部转成整数计算

  • 随机数,客户端使用同一套伪随机算法,服务器同步同一个随机种子

  • 遍历顺序,避免使用foreach语句的遍历,保证顺序

  • 多线程、异步、协程

AOI 算法

九宫格算法

不仅减少广播量,也减少了物理碰撞的计算量

可靠UDP

TCP即使禁用Nagle算法,也很难达到延迟在50ms以内的要求

通信的三角制约

低延迟低带宽可靠性三者不可兼得,TCP牺牲了低延迟,UDP牺牲了可靠性

在网费降低的背景下,低带宽成为可以权衡的因素

低延迟可靠性高带宽的通信协议更符合游戏的需求,较普遍的做法是在UDP基础上,新增一些可靠的机制(例如多发几次,降低丢失概率;增加确认重传机制)

防外挂

不信任客户端

服务端向客户端发送的信息越多,外挂有机可乘的可能性就越大

fps游戏,服务器很难精准获悉玩家的视野范围,只能向客户端多发送一些冗余信息

防外挂常用措施

防变速挂

由于加速器改变的是全局时间,因此其也会改变心跳包的发送频率,从而露出马脚

防封包工具

防止重发攻击,增加校验码

帧同步投票

外挂的根源是游戏对客户端算力的依赖,帧同步很容易作弊,服务端可以通过投票机制找出作弊的玩家

服务端可以要求每个客户端每隔一定的帧数就发送一次状态协议​,协议中包含客户端当前的帧数及状态码。如果没有作弊,那么在同一帧时,各客户端应处于同样的状态,状态码也应相同

防外挂的核心要点,就是要尽可能多地让服务端做逻辑运算、尽可能多地校验客户端的运算结果,不要相信客户端的一切输入

终章

分层架构

把服务端的所有模块分为几个层,并规定每个层的模块只能调用平级或者下一级的模块

实际项目中,有些逻辑很复杂,难以百分百遵循分层规则

| —- | —- | —- | —- | —- | —- | —- | —- |
| 业务层2 | 公告 | 签到 | 邮件系统 | 排行榜 | 战斗系统 |
| 业务层1 | 道具模块 | 地图 | 角色数据 | 离线消息 | 技能 | Buff | AOI |
| 框架层 | 网络模块 | 调度模块 | 节点通信 | 登录模块 | 热更新模块 | 数据库模块 |
| 底层 | 数学库 | 字符串库 | 缓冲区数据结构 | 编码解码方法 |

代码地址

https://github.com/luopeiyu/million_game_server