——我们是如何将序列化时间减少到原来的99%的!
当开发人员选择Python、Django或Django Rest框架时,通常并不是因为它们的性能非常快。Python一直是“舒适”的选择,当你更关心人体工程学而不是略去某些过程的几微秒时,你就会选择Python。
人体工程学没有什么问题。大多数项目并不真正需要那微秒级别的性能提升,但是它们确实需要快速交付高质量的代码。
所有这些并不意味着性能不重要。正如这个故事告诉我们的那样,只需稍加注意并进行一些小的改变,就可以显著提高性能。
"mip mip"
模型序列化器性能
不久前,我们注意到一个主要API端点的性能非常差。该端点从一个非常大的表中获取数据,因此我们自然而然地假设问题一定在数据库中。
当我们注意到即使是很小的数据集也会有很差的性能时,我们开始查看应用程序的其他部分。这个旅程最终将我们带向了Django Rest框架(DRF)序列化器。
版本
在基准测试中,我们使用Python 3.7、Django 2.1.1和Django Rest框架3.9.4。
简单的函数
序列化器用于将数据转换为对象,以及将对象转换为数据。这是一个简单的函数,因此我们来编写一个接受一个User实例并返回一个字典的函数:
创建一个用户以便在基准测试中使用:
对于我们的基准测试,我们将使用 cProfile。为了消除数据库等外部影响,我们提前获取一个用户,并对其进行5000次序列化:
这个简单的函数花了0.034秒来序列化一个用户对象5000次。
ModelSerializer
Django Rest框架(DRF)附带了一些实用程序类,即ModelSerializer。
内置User模型的一个ModelSerializer可能是这样的:
和之前一样运行相同的基准测试:
DRF序列化一个用户5000次需要12.8秒,或者说,仅序列化一个用户需要390毫秒。这比普通的函数慢377倍。
我们可以看到在functional.py中花费了大量的时间。ModelSerializer使用了django.utils.functional中的lazy函数来评估验证情况。Django的verbose name等也使用到了lazy,DRF也对它进行了评估。这个函数似乎在拖累序列化器。
只读 ModelSerializer
ModelSerializer仅为可写字段添加字段验证。为了度量验证的效果,我们创建了一个ModelSerializer,并将所有字段标记为只读:
当所有字段是只读时,则不能使用序列化器创建新的实例。
我们来运行这个只读序列化器的基准测试:
只有7.4秒。与可写的ModelSerializer相比,提升了40%。
在基准测试的输出中,我们可以看到在field_mapping.py和fields.py中花费了大量时间。这些都与ModelSerializer的内部工作方式有关。在序列化和初始化过程中,ModelSerializer使用大量元数据来构造和验证序列化器字段,当然这是有代价的。
“一般”Serializer
在下一个基准测试中,我们希望准确地测量ModelSerializer“花费”了我们多少时间。我们先为User模型创建一个“一般”Serializer:
对这个"一般" 序列化器运行同样的基准测试:
这就是我们期待已久的飞跃!
“一般”序列化器只花了2.1秒。这比只读的ModelSerializer快60%,比可写的ModelSerializer惊人地快85%。
此时,我们可以很明显地看到ModelSerializer并不“便宜”!
只读“一般”Serializer
在可写的ModelSerializer中,验证过程花费了大量的时间。通过将所有字段标记为只读,我们可以使它更快。“一般”序列化器并不定义任何的验证,因此将字段标记为只读并不会使它更快。我们要确保:
并对一个用户实例运行基准测试:
和预期的一样,与“一般”序列化器相比,将字段标记为只读并没有带来太大区别。这就再一次肯定了时间主要花在从模型的字段定义派生的验证部分上。
结果摘要
以下是迄今为止的运行结果的摘要:
之前的工作
目前,人们写了很多关于Python中的序列化性能的文章。正如预期的那样,大多数文章都关注于使用select_related和prefetch_related等技术来改进DB访问。虽然这两种方法都可以有效地提高API请求的总体响应时间,但它们并没有解决序列化本身的问题。我怀疑这是因为没有人想到序列化会很慢。
其他只关注序列化的文章通常会避免修复DRF,而是去激发新的序列化框架,如marshmallow和serpy。甚至有一个站点专门比较Python中的序列化格式。为了节省你的点击,DRF总是排在最后。
2013年年末,Django Rest框架的创建者Tom Christie写了一篇文章,讨论了DRF的一些缺点。在他的基准测试中,序列化过程占处理单个请求总时间的12%。在总结中,Tom建议不要总是使用序列化:
4.你不需要总是使用序列化器。
对于性能关键的视图,你可以考虑完全删除序列化器,并在数据库查询中简单地使用.values()。
正如我们在前面看到的,这是一个可靠的建议。
为什么会这样?
在第一个使用ModelSerializer的基准测试中,我们看到大量的时间花费在functional.py中,更具体地说是在lazy函数中。
修复Django中的lazy
Django在内部使用lazy函数来处理许多事情,比如verbose name(冗长的名称)、模板等。其源代码中将lazy描述如下:
对一个函数调用进行封装,并将其作为一个在该函数的结果上进行调用的方法的代理。在调用结果上的一个方法之前,不会对函数进行计算。
lazy函数通过创建一个结果类的代理来实现它的魔力。要创建这个代理,lazy函数会遍历这个结果类(及其超类)的所有属性和函数,并创建一个包装器类,该类仅在实际使用函数结果时才会对函数进行计算。
对于大型结果类,创建代理可能需要一些时间。因此,为了加快速度,lazy会缓存该代理。但事实证明,代码中的一个小疏忽会完全破坏这个缓存机制,使得lazy函数非常非常慢。
为了了解在没有适当缓存的情况下,lazy函数有多慢,让我们使用一个简单的函数,它返回一个str (结果类),比如upper。我们选择str是因为它有很多方法,所以为它设置一个代理需要一段时间。
为了建立一个基线,我们直接使用str.upper进行基准测试,不使用lazy函数:
现在就是惊人的部分,完全相同的函数,但这次使用lazy进行了包装:
没有任何错误! 使用lazy时,将5000个字符串转换为大写需要1.139秒,而直接使用相同的函数只需要0.034秒。快将近33.5倍。
这显然是一个疏忽。开发人员清楚地意识到缓存代理的重要性。因此,他们发布了一个PR,并在不久后进行了合并(有关不同之处请看这里)。一旦发布,这个补丁将使Django的整体性能更好。
修复 Django Rest 框架
DRF对验证和字段冗长名称使用了lazy函数。当所有这些惰性评估结果放在一起时,你会明显感觉运行要慢。
Django中对lazy的修复在进行微小修复后本来也可以解决DRF的这个问题,但尽管如此,开发人员还是对DRF进行了一个单独的修复,用更有效的东西替代lazy。
要查看更改的效果,请安装Django和DRF的最新版本:
在应用了这两个补丁之后,我们再一次运行同样的基准测试。这些是并列的结果:
我们来总结一下Django和DRF的变化结果:
可写 ModelSerializer的序列化时间被降低了一半。
只读 ModelSerializer的序列化时间被降低了三分之一。
和预期的一样,在其它的序列化方法中没有明显的差异。
结论
我们从这个实验中得出的结论是:
1.一旦这些补丁正式发布,就升级DRF和Django。
两个PR的补丁都已合并,但尚未发布。
2.在性能关键的端点中,使用“一般”序列化器,或者根本不使用。
我们有几个地方的客户端正在使用API来获取大量数据。API只用于从服务器读取数据,因此我们决定根本不使用Serializer,而是使用内联序列化进行替代。
3.不用于写入或验证的Serializer字段应该是只读的。
正如我们在基准测试中所看到的,验证的实现方式使它们变得昂贵,而将字段标记为只读可以消除不必要的额外成本。
福利: 强制形成好习惯
为了确保开发人员不会忘记设置只读字段,我们添加了一个Django检查,以确保所有的ModelSerializer都设置了read_only_fields:
有了这个检查,当开发人员添加一个序列化器时,她还必须设置read_only_field。如果这个序列化器是可写的,read_only_fields可以设置为一个空元组。如果开发人员忘记设置read_only_fields,她将得到以下错误:
我们经常使用Django检查,以确保没有遗漏任何内容。你可以在《我们如何使用Django系统检查框架》这篇文章中找到更多的其他有用的检查。
英文原文:https://hakibenita.com/django-rest-framework-slow
译者:野生大熊猫