笔者近期对 Python 版本的 Raven 进行了性能优化,本文是对优化过程的复盘和总结。

背景

收集异常和错误(Exception & Error,后面简称EE)信息是修复 bug 的重要基础,我们项目采用 Raven 和 Sentry 实现 EE 的收集和管理。

最近测试中发现,当出现某个频发的 EE 时,我们的游戏画面会出现卡顿,怀疑是 Raven 过渡耗时导致的;经测试发现,每一个 EE 收集操作会消耗好几毫秒,这对于每秒60帧的游戏来说太奢侈了,每一帧只有16毫秒,一个 EE 消耗几毫秒,四五个就可以消耗完所有的时间,势必造成卡顿。

通过分析 Raven 的性能热点,对热点优化,最终将每一个收集操作的时间控制在一毫米之内,游戏体验显著提升。

注:本文中被优化的 Raven 版本并不是最新版,我对比 github 上最新的版本发现主要逻辑基本一致,并未发生大的变化。

Sentry & Raven

Sentry

Sentry is a modern error logging and aggregation platform.

Raven

Raven is a Python client for Sentry.

性能分析

分析 Python 代码的时间消耗可以用 rkern/line_profiler 工具,在某些情况下不能使用外部库时,就需要自己用 time 库记录时间来分析了。

注:分析内存也有类似的兄弟工具:memory_profiler

下图是出现 EE 时 Raven 的调用过程图,其中红色背景的代码段是性能热点,后文主要对这些部分进行优化。

注:此图用 Visio 2016 绘制,原文件可在 github/tanchao90 /evolution/Python/Raven 下载。

raven\base.py send()

该函数消耗主要在于 encode() 函数,发送本身采用异步方案,耗时可忽略;

raven\base.py encode()

该函数消耗取决于要 encode 的 data 的大小,所以通过控制 data 的大小来保证 encode 的效率;

raven\base.py build_msg()

该函数耗时非常多,主要取决于 handler.capture(**kwargs) 内部收集 EE 数据的耗时;

该函数中通过 processor.process(data) 对收集的 EE 数据进行过滤,保证安全性;

utils\stacks.py get_lines_from_file()

该函数用于获取 EE 所在行前后五行的代码段,消耗一般;

utils\stacks.py get_frame_locals()

该函数遍历 EE 栈,并获取每一个栈帧中的局部对象,包括变量、函数、类等,并对这些对象数据进行序列化;

该函数非常耗时,并且耗时不确定,主要取决于下面几个因素:

  • EE 栈的深度
  • EE 栈帧中局部对象的数量及其复杂程度,Raven 默认限制每个栈帧中对象序列化之后的数据大小不超过 4096 bytes,从而一定程度上保证产生的数据量;但 Raven 是对每一个对象序列化之后才能计算产生的数据的大小,因此该机制并不能消除序列化对象带来的性能消耗,而在我们的游戏中,有些局部变量是非常庞大的 dict 或者 list,序列化会造成极大的性能消耗;
  • 序列化每一个对象非常耗时,具体说明见后文 transform() 函数;

raven\utils\serializer\manager.py class Serializer(object): transform()

该函数用于序列化每一个对象,非常耗时,主要是因为下面几个方面:

  • 对于任何对象的序列化,即使最简单的 Int,都需要实例化所有的序列化类(raven\utils\serializer\base.py class XXSerializer);
  • 函数内部包含过多的 try catch,每个对象至少被 try catch 一次,类似 list、dict 的复杂对象被 try catch 的次数不少于其中的基本元素数目;

优化方案

方案 1.0

由于项目近期要对外测试,所以和老大沟通之后,选择了修改代码量最小的方案,目的是在最低风险的情况下极大可能的优化性能。

  • 由于我们的数据不涉及敏感信息,所以我们直接注释掉 processor.process(data)
  • get_lines_from_file() 函数进行修改,只获取 EE 所在行的代码,从而优化获取消耗并减少产生的数据量,提升 encode 效率;
  • 关闭 get_frame_locals() 函数调用,放弃收集每一个栈帧中的对象数据,既舍弃了序列化耗时,又大大减少了产生的数据量,提升 encode 效率;

通过上面三点的优化,目前我的测试 case 已经可以在一毫秒之内完成发送(优化之前基本在3-5毫秒),效果显著;
不好的一点是,收集的信息量会大大减少,也许在一些特殊情况下会对分析和处理 EE 造成困难;

方案 2.0

本方案是对上述 1.0 方案的改进,更精细化的进行优化,有待于实践验证。

  • 开启 get_frame_locals() 函数调用,但设置规则过滤当前栈帧中的对象,只对满足条件的对象进行序列号,这样就能在一定程度上保留现场信息,同时大大提高序列化效率,控制产生的数据量;
  • 优化序列化类的实现(class Serializer(object)),大大减少其中的 try catch 次数,这个可有效提高序列化效率;
  • 可以考虑只收集 EE 栈中部分栈帧的数据;比如控制栈帧层数,最多只收集离 EE 触发栈帧最近的5个栈帧的数据,这样既保留了最有效的数据,又控制了数据量;

相比方案 1.0,本方案有比较灵活的策略,可以更多的保留 EE 相关数据,同时也提高性能,但性能肯定会比方案 1.0 有所降低,算是数据和性能的一个权衡。

优化感悟

回顾同事之前对项目客户端的优化和我本次对 Raven 优化的经历,我大概有以下几点感悟,希望能在以后继续提升自己的理解:

  • 准确定位性能热点,积极借助于性能分析工具,阅读和分析源码,发现热点的真正原因;
  • 纯代码的优化提升会比较有限,一般不会有质变,除非之前的代码非常烂;
  • 性能优化要有明显的提升,往往需要做出一些取舍,比如降低客户端的渲染效果(画质、特效等),对渲染效果分级等;再如本文中直接放弃收集每一个栈帧的数据,对修复 EE 影响不大,但是可以带来质变的提升。