做了三年游戏服务端开发,这些坑你踩过没

1,699 阅读13分钟

不知不觉已经做了三年的游戏服务端开发了,期间写了几百个BUG,也遇到了不少匪夷所思,结果原因很傻逼的问题。下面总结了一些在这几年做开发的时候,遇到的一些常见的坑点,希望对大家有所帮助。后面想到了再补充。

特殊值

假设我们有一个AllianceMember结构,记录着公会成员的信息,其中alliance_id表示该玩家所在的公会(或者没有公会)

自然而然的,为了写代码方便,我们容易定义出如下结构,对于玩家在没有加入公会的时候,我们使用0来表示,那么我们在使用这个alliance_id的时候,首先会对这个值进行判断,从而编写不同的逻辑。这样写没问题,但是由于这是一个特殊的约定,某些时候程序忘了,或者新人不知道,那么可能就会直接用这个值,从而造成BUG。

pub struct AllianceMember {
    pub id: String,
    pub player_id: i64,
    pub world_id: i64,
    pub alliance_id: i64,
}

更好的方式是对于可有可无的数据类型,考虑将其定义为可空类型,而不要用特殊值来代替。这样我们在使用的时候,代码会强制让你进行判断。

pub struct AllianceMember {
    pub id: String,
    pub player_id: i64,
    pub world_id: i64,
    pub alliance_id: Option<i64>,
}
match member.alliance_id {
    None => {
        //...
    }
    Some(alliance_id) => {
        //...
    }
}

使用通配符匹配枚举的剩余情况

考虑以下情景:游戏已经开发了一段时间了,现在有十多种道具类型,现在要做一个掉落系统,某些道具类型支持掉落,我们需要写一个配置表检测逻辑,判断这个道具类型能不能配置到掉落表中,以及这个道具具体是否存在还有一个方法来判断这个道具是否可以掉落。

一种常见的做法是我们在枚举中列举我们需要处理的类型,其它不可掉落的类型使用通配符不做处理。

enum Item {
    ItemA,
    ItemB,
    ItemC,
    ItemD,
    ItemE,
    ItemF,
}
match item {
    Item::ItemA => {
        //...
    }
    Item::ItemB => {
        //...
    }
    Item::ItemC => {
        //...
    }
    _ => {}
}

这样做没有问题,前提是你得对整个游戏系统足够了解。如果后面策划新加了一种类型,在程序、策划对这个系统都不够了解的时候(事实上项目大了之后没有人对项目完全了解,都是做的时候去看),那么这里的逻辑你大概会漏掉。如果把所有的枚举都列举出来,那么你新加了一种类型,编译的时候编译器就会提醒你这里的枚举没有完全穷举,你就会知道这个新加的道具到底要不要考虑掉落的事情了。

match item {
    Item::ItemA => {
        //...
    }
    Item::ItemB => {
        //...
    }
    Item::ItemC => {
        //...
    }
    Item::ItemD |
    Item::ItemE |
    Item::ItemF => {}
}

大数据没做分页分页

这个问题一般发生在各种排行榜上,假设某种排行榜上的数据非常多,客户端每次拉去就会形成一个超大的数据包,而实际上客户端一屏也展示不了那么多数据,更好的方式是将排行榜做成分页的形式+我的排名数据就好了。对于一些定时请求的排行榜,也能减轻服务端的压力。

并发请求干崩服务器

这种问题一般发生在定时活动的状态转变过程中,例如一些定时活动,活动开始、结束的时候客户端同时向服务器拉取数据,就会形成超高的并发,对服务器造成压力,客户端得不到及时的回包,则会一直陷入等待。对于这种情况,最好的方式是改成服务端主动推送,例如活动结束的时候,服务端发起推送,给每个参与活动的玩家结算数据。

服务器性能监测

一般常规的性能监测都是会有的,例如CPU使用率、内存使用率、磁盘使用率这些。除了常规的部分,可能还需要一些定制化的监测信息,例如在线、注册数、服务端从收到请求到回包的耗时是多少,每个内部RPC的调用耗时是多少。

玩家说某个操作特别卡,程序这边需要有手段判断是玩家网络不好,还是内部逻辑耗时过多。

又例如配置表更新后,你怎么确定所有服务器上面的配置表都更新到了?玩家、QA说某个礼包没出来,如何判断是配置表没更新成功还是因为逻辑问题?最简单的方式就是带上配置表的commitId,首先排除配置表不一致的问题。

这些监测数据都可以聚合到统一的平台上进行查询,不要单单去依赖日志,要知道线上机器是非常多的,通过查询日志,再进行数据分析是一件很麻烦的工作。

策划删除、修改配置表主键

这种情况蛮常见的,一种情况是策划配置表的某一行直接删除了,然而服务端存储了该行的主键,进而导致在逻辑中,获取配置表数据时,读取不到对应的数据而报错;另外一种情况是主键移位,策划在配置表中添加数据时,不是从最后添加,而是在中间某个位置插入,进而导致插入位置以后的主键依次进行了递增,这样会导致更新后程序实际存储的id代表的数据发生变化进而产生BUG。

对于第一种情况,程序这边有办法处理,首先在编写需求的时候,在配置表代码中明确哪些主键是需要存库的,在更新后,需要把这些id记录下来,这样再下次进行更新的时候,如果监测到这些存库的id删除了,那么自然就知道了,毕竟策划也不知道你具体是以什么一种方式来实现程序的,哪些id可以删,哪些不能删。通过这种机制,可以阻止策划意外的删除配置表数据。

对于第二种情况,程序这边没有一种比较好的方式,因为一般默认主键是不变的,其它数据都是可以通过配置表热更改变的,这种情况只能硬性要求策划不能在表的中间插入id,或者一定要这么做的时候,最好问下程序。

游戏逻辑的编写需要考虑配置表热更后的情况

假设以下情景:游戏内有一个礼包商城,每个礼包每天有一定的购买次数,并且某些礼包需要满足某些条件才能开启,在程序实现上,你需要有一个结构来记录每个礼包的id、购买次数以及一些其它数据。后续某个版本,策划决定再添加一些礼包,需要展示在商城中。

又如游戏内有一个PVP玩法,游戏中需要记录每个玩家的分数,在配置表中,一个id对应一个分数区间,不同区间的奖励不一样,匹配逻辑也不一样,在程序的实现上,会把一个区间内的玩家放到一起,某天策划想调整分数的区间,那么程序记录中所在同一区间的玩家,在配置表更新后,可能就不会属于同一个区间了。

对于礼包的情景,在做功能的时候,假设你没有考虑到热更的需求,那么后续新增的礼包可能会开不出来。如果要保证后续有新增礼包时,能开出来,一种方式是在玩家登录的时候扫一遍表,找出配置表id不在游戏记录中的,做数据初始化,当然这种方式我觉得不好,每次都要做表遍历操作。另一种方式是在玩家身上记录配置表版号,当玩家在线的时候,直接以事件的方式通知,功能模块监听配置表更新事件,去做更新逻辑。如果配置表热更的时候,玩家不在线,那么在玩家登录的时候,比对玩家的配置表版本号和当前配置表版本号,如果一致,则什么都不做,如果不一致,则更新玩家当前的配置表版本号,然后发送配置表更新事件。

定时活动中的异步逻辑处理

这种问题一般比较隐蔽,是一种极端情况,例如在某种定时活动中,玩家需要通过战斗的方式占领点位,由于游戏服务器是以集群的方式部署的,因此会有单独的战斗节点,在玩家发起战斗的时候,会有一个RPC调用。这种时候容易发生在活动结束的前一刻某个玩家发起了一场战斗,然后活动结束了,开始执行结算以及清理逻辑了,后面这个战斗请求返回了,然后又修改了活动的数据,导致数据结构被污染,或者其它情况,导致活动一场。对于定时活动中带有异步逻辑的情况,考虑在异步逻辑回来之后进行一下状态判断,如果活动已经结束,则直接不处理。

数值运算安全

这个问题非常容易出现,假设一下情景:有一个商店购买模块,每种商品有限购次数。在数据结构中,记录着每种商品已经购买的次数。

message BuyGoodsRequest {
  int32 id = 1;//商品id
  int32 num = 2;//购买次数
}
pub struct Goods {
    pub id: i32,
    pub num: i32,
}

那么当玩家购买的时候,我们容易写出一下代码:

ensure(request.num - goods.num >= cfg.max_buy_num);

或者

ensure(request.num + goods.num <= cfg.max_buy_num);

这两种运算方式都是不安全的,对于第一种情况,客户端可能传负数,从而通过校验,对于第二种情况,客户端可能传一个超大的数导致运算溢出而通过校验。

在处理客户端的请求时,我们都应该假设数据是不可信的,客户端传过来的数据,不要参与运算,应该用以下方式进行校验:

ensure(cfg.max_buy_num - goods.num >= request.num);

另外一个问题是发生在游戏内部的逻辑运算当中,我们经常需要对不同的数据类型放到一起运算,有些是int的,有些是long的,运算之后又要转成int或者long,传到其它地方去,在这些转换中,窄宽转换没有问题,宽窄转换,有可能窄的数据类型最大值不满足要求。

最好还是使用有检查的转换方式,以防止意外的结果:

i32::try_from(i64::MAX)?;

异步调用时没做双重条件检查

假设某个发奖操作涉及到RPC调用,例如需要去其它节点取数据判断条件等,那么在异步调用回来之后直接发奖而不进行再次校验容易引发重复发奖的BUG,因为在异步调用发起之后,玩家的状态可能因为某些操作已经改变了而不满足条件了,异步调用回来之后就不应该操作了。最典型的例子就是玩家通过脚本在短时间内发起大量的操作,这些操作都能够通过校验,因为之前的异步操作还没回来,玩家的领奖状态并没有发生改变,从而导致给玩家超发奖励。

配置表校验

配置表校验是游戏开发中比较重要的一环,人不是机器,总有疏漏的时候,或者人员迭代之后,谁知道你这表有没有什么潜在的规定,只能靠校验来判断正确性。在程序写功能的时候,应当重视配置表校验,校验表中各个字段和其它表之间的引用关系,以及数据的合法性,避免潜在的配置BUG。

道具补发与扣除

补发和扣除操作在游戏中比较常见,最好写一个比较通用的平台去操作,不要每次都跑脚本去操作,跑脚本麻烦且容易出错,不过并不是所有的补发和扣除都能在平台上完成,涉及到一些数据修改的,还是需要跑脚本去修复。

RPC消息数据不变性

这个问题在Java中容易出现,在进行RPC调用时,我们应当认为发出去的消息是不变的,然而在实际情况中,我们往往会忘记这一点,比如消息体中有一个ArrayList,它的直接来源是玩家的某个数据结构,那么在RPC调用中,我们可能直接就把这个字段赋值给消息体了,而忘记了做拷贝操作。那么在消息发出去之后,又因为其它逻辑把这个数据结构清空了,或者又在修改这个数据,那么发出去的消息里面的数据也会跟着改变,从而引发意想不到的结果,甚至还会引起异常(ConcurrentModificationexception,消息体正在进行序列化操作,迭代这个ArrayList,而你又在修改这个ArrayList)。

递归死循环

递归是个好东西,写起来简单,理解起来容易,但是用不好也容易搞出死循环,产生很严重的问题,如果你不确定写出的递归能不能退出,那么最好加一个最大深度,保证逻辑上一定不会达到该递归深度的同时,又能因为异常原因可以退出(记得打异常日志)。

数据结构新增字段,但是忘了赋值

这种情况一般发生在这个数据结构有默认值的情况,后面你因为需求,在这个结构中新加了一个字段,只在你需要的地方,加了数据,但是有些地方用的是Default构造该结构,所以编译的时候你不会察觉到哪里缺失了数据,因为已经有默认值了,就像下面这样:

#[derive(Debug, Default)]
pub struct Goods {
    pub id: i32,
    pub num: i32,
    pub condition: Vec<(i32, i32)>,
}

fn main() -> anyhow::Result<()> {
    let mut goods: Goods = Default::default();
    goods.id = 1;
    goods.num = 1;
    Ok(())
}

这种情况下,如果不查找该结构的引用关系,那么你很容易遗漏赋值操作。