最近出现一个比较严重的性能问题。造成的问题是客户端卡顿,点什么都没有响应,服务器这边消息队列过载,导致内存暴涨和CPU几个核都是100%,什么事情都干不了。
因为之前很长时间没有出现过类似的问题,我负责的玩法引起之前老代码没有注意到的性能问题。没有合服前多个跨服点分摊压力没怎么看出来,人数不多,虽然是某个时间点的活动,会聚集大量玩家同时做某件事情,后来就再次合服就剩下两个跨服点,某个跨服点一次性上七八百个人在战斗。由于之前在本地战斗和机器人,有些行为无法模拟出来,且本地战斗不经过网络和消息队列的中转,所以有些问题很难发现,直到线上出现问题。
324 int overload = skynet_mq_overload(q);
325 if (overload) {
326 skynet_error(ctx, "May overload, message queue length = %d", overload);
327 }
第一次出现问题的时候,前一分钟,消息队列中消息达到五十万来不及处理,再过一分钟,四五百万没有处理,再过一分钟,近八百万,第四分钟末尾近一千六百万条。一个消息结构体不算消息内容,大概占20字节:
7 struct skynet_message {
8 uint32_t source;
9 int session;
10 void * data;
11 size_t sz;
12 };
这样大概光消息结构占用320MB,不包括内容。
后来进行些相关后,前一分钟,消息队列中消息达到二十五万来不及处理,再过一分钟,二百万没有处理,再过一分钟,近四百万,第四分钟末尾近八百万条,和上次比较降低一半。但这次也是上机器人,且测试机器和线上一致,没有出现overload问题和卡顿,以为问题解决了。
后来线上再次出现卡顿,如果调整架构,可能时间不够和测试不充分。后来就仔细分析代码,觉得这跟具体玩法没关系,而是跟之前的实现有关系,后来先统计每个包的数量及时间,然后在一些战斗、位置等地方加上日志,最后发现有两三处实现的不合理,一次释放技能要同步两三次位置,哪怕玩家站着不动,且和服务器位置一致,由于这块没有做些过滤,所以会导致广播给周围玩家。而且不应该这样,客户端代码调整之前貌似是定时每几百毫秒在释放技能时上传位置等一些问题。
这次优化后,前一分钟,消息队列中消息达到六万来不及处理,再过一分钟,五十多万条消息没有处理,再过一分钟,近二百万条消息,后面再也没有增长。相比之前几次,整体已经降到八分之一,因为广播包太多,占用主要逻辑业务,造成雪崩,那些超时的消息没有被丢弃掉,导致消息队列过长,占用过多内存:
174 static void
175 expand_queue(struct message_queue *q) {
176 struct skynet_message *new_queue = skynet_malloc(sizeof(struct skynet_message) * q->cap * 2);
177 int i;
178 for (i=0;icap;i++) {
179 new_queue[i] = q->queue[(q->head + i) % q->cap];
180 }
181 q->head = 0;
182 q->tail = q->cap;
183 q->cap *= 2;
184
185 skynet_free(q->queue);
186 q->queue = new_queue;
187 }
消息入队列时,可能会增长数组内存,但在消息出队列时,当消息数远远小于队列容量时并未及时收回,导致在上一次扩大时,并未在后面进行收缩把内存归还系统。
由于某些设计原因比如当玩家死亡后,弹个复活界面待选择时,此时如果负载过大,会存在多种表现。比如请求复活时,请求达到服务器队列,此时客户端由于超时或其他实现再次点相同的请求复活,两个请求相继被处理,由于复活要扣费,此时是异步过程,那么可能造成两次扣费。另一种表现是请求复活,服务器处理成功后给回包,此时回包还未到达客户端并进行复活表现,又被其他玩家击杀,那么对于玩家来说,扣费了但没见到复活,这中间的序列:死亡->复活请求->扣费->死亡->复活响应中多夹杂着死亡过程。当然这里可以做成请求等待,只能点一次,必须等响应或超时,并带上相同的消息序列号,这样服务器认为是重复包,只处理一次,不然造成多扣钱或其他问题。
另外发现当负载过大时,出现:A message from [ :%08x ] to [ :%08x ] maybe in an endless loop (version = %d)
表示处理到某条消息时可能存在死循环,这里并未具体打印是哪条消息,因为有些时间处理消息会随机死循环一段时间,比如随机几个不同的数,如果实现不好会出现一直随机。但检测时:
93 static void *
94 thread_monitor(void *p) {
95 struct monitor * m = p;
96 int i;
97 int n = m->count;
98 skynet_initthread(THREAD_MONITOR);
99 for (;;) {
100 CHECK_ABORT
101 for (i=0;im[i]);
103 }
104 for (i=0;i<5;i++) {
105 CHECK_ABORT
106 sleep(1);
107 }
108 }
109
110 return NULL;
111 }
37 void
38 skynet_monitor_check(struct skynet_monitor *sm) {
39 if (sm->version == sm->check_version) {
40 if (sm->destination) {
41 skynet_context_endless(sm->destination);
42 skynet_error(NULL, "A message from [ :%08x ] to [ :%08x ] maybe in an endless loop (version = %d )", sm->source , sm->destination, sm->version);
43 }
44 } else {
45 sm->check_version = sm->version;
46 }
47 }
30 void
31 skynet_monitor_trigger(struct skynet_monitor *sm, uint32_t source, uint32_t destination) {
32 sm->source = source;
33 sm->destination = destination;
34 ATOM_INC(&sm->version);
35 }
329 skynet_monitor_trigger(sm, msg.source , handle);
331 if (ctx->cb == NULL) {
332 skynet_free(msg.data);
333 } else {
334 dispatch_message(ctx, &msg);
335 }
337 skynet_monitor_trigger(sm, 0,0);
以上工作线程开始和结束处理每条消息都会设置skynet_monitor_trigger
,然后monitor线程会sleep几秒,再检查skynet_monitor_check
。
总结下就是,尽量把实现简单化,耗时太久的消息会拖慢工作线程,那么其他消息队列的调度就可能慢下来。当skynet底层出现负载时,上层业务如何感知到并作相应的处理。虽然经过几次优化,还存在一定的May overload
,只能花些时间去改进原有的设计方案,调整底层框架。有时候,机器人很难模拟出真实玩家的行为,那么没有测试出的问题会在线上暴露出来,引起重大问题。
这几天在优化性能分析工具,又踩了坑,即lua中存在尾调用时,hook不到函数的返回事件,被调用函数返回时,直接复用调用函数的栈,需要特殊处理,又重新整理之前写的逻辑,因为无python图形基础,相比做出图形可视化界面,花更少的时间用控制台命令代替先。