游戏测试 聊一聊游戏的压测

不愧是暗影大人 · 2020年04月18日 · 最后由 skottZy 回复于 2021年04月20日 · 4919 次阅读

目前在做的业务,需要为很多的游戏项目写压测脚本。因为没有项目愿意直接提供客户端的网络协议代码,大部分还是愿意提供部分的协议文件的。所以基本上需要从这部分协议文件凭空变出一份压测脚本。

目前的情况是,基于 locustio 做了一个压测客户端。当然 locustio 也只提供了 http 的通信,socket 通信这块还是得自己写。基本上用到 locustio 本身功能的地方就是 taskset 的创建、管理,还有内置的事件机制,用来统计一些消息的收发间隔时间。

socket 通信

基本的需求是异步 IO,否则并发量上没法保证。用 python2 比较顺手,所以这块儿就得靠老伙计 gevent 了。

gevent.monkey.patch_all()

monkey_patch 一下,基本的异步 IO 就完事儿了。至少 send、recv 不阻塞,但是咱是需要同时创建一堆 socket 连接互相无阻塞的收发消息的。反正发送是靠 locustio 的 taskset 去往下调用的,这块 locustio 原始功能已经够用了。剩下接收就不能靠 locustio 来解决,只能自己想办法了。

_watcher = gevent.get_hub().loop.io(conn.fileno(), 1)

创建一个监听 socket 读事件的监听器,参考 API:
io(fd, events, ref=True, priority=None)¶
Create and return a new IO watcher for the given fd. events is a bitmask specifying which events to watch for. 1 means read, and 2 means write.

每一个 socket 连接成功后,都往 gevent 的主循环里注册这么一个监听器。等有消息发送过来的时候,它就能调用你注册的回调函数。这时候就该你调 conn.recv,收数据包啦。

题外话,因为每一个游戏都有自己的数据包封包方式,所以回调函数这块我都是单独一个文件存放的。来活儿了之后,开个分支,把封包、拆包函数写好扔进去。单独的文件比较好管理。

tcp 数据包

一般的数据包都是固定长度的包头,然后里面带有数据包长度。我这边的习惯是分成两次去读,反正需要注意接收到的数据检查一下长度就行了。如果读的数据少了一截,那基本上在压测中算比较常见了,先扔 buffer 里,等下次再读拼接上去就是了。

数据包接收完毕,就需要解析数据了。得先找到对应的协议定义,一般包头里会带协议号。那就要求咱维护一份协议号跟协议定义的映射表啦。

又该体外话了,该吐槽吐槽有些时候协议号的定义方式千奇百怪。其中最好处理的还是用 protobuf 的,把协议号写在 message 的第一个 option 字段里。其他的有些写在单独的文件里,有些放在注释里。朋友们,最完犊子的是协议号定义文件里写的不是协议类名而是函数名的,函数当然是跟协议定义对得上的啊。可,我只能靠推理得出函数跟协议类的对应关系。

有了映射表,你就可以正常解析数据包了。通常,还需要写一个 dispatch 函数,用来按照协议号分发给不同的回调函数。

封包也差不多,不过是先创建协议对象,然后把协议号扔到包头里去。

事件分发

都是为了写收协议的回调函数方便,所以统一把接收的协议通过 dispatch 函数按协议号分发出去。

@tcp_callback(RSPID)
def _callback_RSPID(self, response):
    pass

所以写回调的时候,用个装饰器,函数定义完直接注册进去监听对应协议号的事件。

同步请求

压测还需要统计协议收发间隔时间呢,除了协议内容带有时间戳的情况外。一般都需要在发送的时候看下表,然后接收的时候再看下表,然后在纸上算出时间......才怪。

如果不想每次接收消息的时候,都去查上一条协议是啥时候发的,就只能采用异步改同步的写法了。gevent 的写法是通过 AsyncResult。

self.async_results[MSGID] = gevent.event.AsyncResult()

当然懒得去打理这个创建的过程,就统统写在装饰器内部咯。回调函数 return 什么值,就用这个值去 set 对应的 AsyncResult。

self.async_results[MSGID].set(value)

当然是写在装饰器内部啦。

def login(self, account, password):
    self.entity_msg(account, password)
    t = time.time()
    response = self.async_results[RSPID].get(block=True, timeout=5)
    self.async_results[RSPID].clear()
    locust.events.request_success.fire(request_type="TCP", name='login', response_time=time.time()-t, response_length=len(response))

@tcp_callback(RSPID)
def _callback_login(self, response):
    return response

上面的 self 当然就是咱的机器人对象啦。机器人类的其他部分略略略咯,也就是在init函数里写一些实例化管理 Socket 的类的操作。其他部分跟上面这段都差不多的。

这样的写法还算是可以接受吧。嗯,其实 login 函数的后三行,都是封装了函数的,所以写起来还要更简短一些。

Locust

嗯,有关 locust 的内容就略略略吧,最近已经想换掉它了。所以,有啥好用的替代方案吗?

共收到 13 条回复 时间 点赞

楼主一般做一个项目的压测大概会花多少时间?

递归思念 回复

从对接到完成至少要一个星期吧

游戏行走这一块是怎么避免服务器位置判定的?

那相当强了,完成编解码,然后确定场景时序图,编写场景,还要做一些容错啊什么的,做起来都很麻烦。之前也是觉得编解码那块比较烦,各个游戏千差万别,甚至有私有加密,验签什么的。但是后来写顺手了以后,还是觉得场景那块最恶心,又是体力活。

鸽男 回复

用客户端录下来回播

递归思念 回复

确实是体力活,说到这,我以前还用过一种很狂野的写法压缩代码量。都是黑历史了,不过可以贴出来供大家乐一乐
def use_item_42413(response):
locals().update(response)
try:
bag_type != 2 and get_bag(2) and 1/0
bag_type == 2 and item_list[42413] < 100 and add_item(2, 42413, 100) and 1/0
use_item(2, 42413, 100)
except:
return False
简单解释一下就是:1.通过把字典塞到 locals() 里,然后就能直接把字典的 key 当局部变量取值 2.用 and/or 组成的条件链省去写 if/else 和调整缩进的时间 3.条件链里面不能通过 return 跳过后面几行的语句执行,用了 try/catch 和主动抛异常的取巧办法。

游戏压测和互联网 API 压测难度等级根本不是一个层次。
要考虑协议、解码、加密、签名、密钥,和互联网一个 HTTP 请求带个 cookie 完全不一样。游戏一般是长连接,还要考虑心跳包,考虑异步接收。
做过游戏压测的做互联网压测,简直就是大叔撩小妹

汇荔君 回复

确实是这样的,api 式的压测,是基于应用层来进行的,http 也好 rpc 也好,应用层的事情你不用关心了,只用关注会话(场景)层,甚至有的时候就压单个接口,那更简单了,同时成熟的工具一抓一大把。这些都让压起来比较容易。但是在性能分析,优化这些方面呢,两者还是颇多相同的,这也是压测最有价值的地方。游戏压测实际上是一个不怎么讨好的工作,花很多的力气在让压测跑起来上,而实际上这部分并不能带来产出,毕竟压测的目标不是写个压测工程,而是优化和修复可能的问题。但是对个人来说,确实是比较锻炼人,一直在传输层上行走,所在的层次(OSI 模型)越低,能看到的就越多。

匿名 #9 · 2020年09月02日

👍

汇荔君 回复

我就说么,咋感觉互联网的协议啊、压测啥的都是张口就来。
做游戏的接口、压测感觉挺费劲的。而且之前都是程序自己帮忙写了一套框架,我们测试在对应场景写压测的 json 脚本,然后执行看结果。

有几个问题想咨询下大佬:
1.压测的并发数量级如何确定?
2.压测相关的服务器数据标准如何确定?比如服务器的 cpu、网络带宽、内存之类的?还有就是,压测一般关注哪些数据?

JarvanRookie 回复
  1. 游戏服务器的承载力似乎都是转换到最大承载玩家 PCU 估值。并发数量级可以按照上行 tcp 包每秒数量计算,单接口的压测这样计算是没有问题的。不过游戏跟 http 压测不同的部分,很多功能要考虑能够承载同时对多少个客户端进行消息广播,n 个上行并发可能导致 n*n 个下行广播。我这边交报告的时候,1)估算一个整体的 PCU,2)单接口给一个最大 TPS,3)广播消息给实际 PCU。
  2. 数据标准参考一下大厂就好了。CPU 整体不超过 80%,单核不要到 100%(大概率是死循环)。内存除了关注整体占用,以及压测结束后能否按预定释放。儒雅一点的办法是找程序确认内存释放的预定是怎样,然后针对性的找突破点,做了内存缓存数据的就测到最高限制,有些内存释放后并不直接释放给系统可以用多轮压测不断塞数据清数据。粗暴的办法,就是跑长时间的压测了。还有流量、文件操作符、慢查询、日志错误数量、进程状态,这些要看一下监控。要想保障不出问题,CPU、内存是需要主动去尝试触发故障的,而不仅仅是记录压测脚本执行时的数值。

处理完协议、登录、就差不多了,做这个东西确实麻烦,不改确实能跑出很多问题,内存泄漏,redis、mysql 储存过长这些

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册