性能

性能分析

性能分析是指分析程序的执行情况并测量聚合数据。这些数据可以是每个函数的耗时、已执行的 SQL 查询等…

虽然性能分析本身并不能提升程序的性能,但它对于发现性能问题以及找出程序中导致这些问题的部分非常有帮助。

Odoo 提供了一个集成的性能分析工具,可以记录执行期间所有已执行的查询和堆栈跟踪。它可用于分析用户会话的一组请求,或某段特定的代码。分析结果既可以通过集成的 speedscope 可视化火焰图的开源应用 视图查看,也可以先将其保存为 JSON 文件或存入数据库,再使用自定义工具进行分析。

启用分析器

分析器既可以从用户界面启用——这是最简单的方式,但只能分析 Web 请求;也可以从 Python 代码启用——这样可以分析包括测试在内的任何代码片段。

  1. 启用开发者模式。

  2. 在开始分析会话之前,必须先在数据库上全局启用分析器。这可以通过两种方式完成:

    • 打开 开发者模式工具,然后切换 启用性能分析 按钮。向导会提供一组分析的有效期供选择。点击 ENABLE PROFILING 以全局启用分析器。

      ../../../_images/enable_profiling_wizard.png
    • 前往 设置 –> 常规设置 –> 性能,并在 启用性能分析至 字段中设置所需时间。

  3. 在数据库上启用分析器后,用户可以在自己的会话中启用它。为此,请再次切换 开发者模式工具 中的 启用性能分析 按钮。默认情况下,推荐选项 记录 SQL 和 记录跟踪 处于启用状态。要了解不同选项的更多信息,请前往 收集器。

    ../../../_images/profiling_debug_menu.png

启用分析器后,发送到服务器的所有请求都会被分析并保存到一条 ir.profile 记录中。这些记录会归入当前的分析会话,会话从启用分析器开始,到禁用分析器结束。

Note

Odoo Online 数据库无法进行性能分析。

分析结果

要浏览性能分析结果,请确保 已在数据库上全局启用分析器,然后打开 开发者模式工具 并点击性能分析部分右上角的按钮。此时会打开按分析会话分组的 ir.profile 记录列表视图。

../../../_images/profiling_web.png

每条记录都有一个可点击的链接,可在新标签页中打开 speedscope 结果。

../../../_images/flamegraph_example.png

Speedscope 不在本文档的讨论范围内,但有许多工具可供尝试:搜索、相似帧高亮、帧缩放、时间线、左重视图(left heavy)、三明治视图(sandwich view)等……

根据启用的分析选项,Odoo 会生成不同的视图模式,您可以从顶部菜单访问它们。

../../../_images/speedscope_modes.png
  • 组合 视图显示合并在一起的所有 SQL 查询和跟踪。

  • 组合(无上下文) 视图显示相同的结果,但会忽略已保存的执行上下文 <performance/profiling/enable>`。

  • sql (no gap) 视图显示所有 SQL 查询,就如同它们被依次执行一样,不包含任何 Python 逻辑。这适用于仅优化 SQL 的场景。

  • sql (density) 视图仅显示所有 SQL 查询,并保留它们之间的间隔。这有助于判断问题出在 SQL 还是 Python 代码上,并可识别出许多小查询可以合并的区域。

  • frames 视图仅显示 周期性收集器 的结果。

Important

尽管分析器的设计已尽可能轻量,它仍可能影响性能,尤其是在使用 同步收集器 时。分析 speedscope 结果时请记住这一点。

收集器

分析器关注的是分析的 时机 ,而收集器负责的是分析的 内容 。

每个收集器都以各自的格式和方式专门收集性能分析数据。可以在用户界面中通过 开发者模式工具 里各自专用的切换按钮单独启用它们,也可以在 Python 代码中通过其键或类来启用。

Odoo 目前提供四种收集器:

名称

切换按钮

Python 键

Python 类

SQL 收集器

记录 sql

sql

SqlCollector

周期性收集器

记录 traces

traces_async

PeriodicCollector

QWeb 收集器

记录 qweb

qweb

QwebCollector

同步收集器

无

traces_sync

SyncCollector

默认情况下,无论从用户界面还是从 Python 代码启用分析器,它都会启用 SQL 收集器和周期性收集器。

SQL 收集器

SQL 收集器会保存当前线程(所有游标)对数据库发出的所有 SQL 查询,以及堆栈跟踪。该收集器的开销会加到被分析线程的每个查询上,这意味着在大量小查询上使用它可能会影响执行时间和其他分析器。

它特别适合用于调试查询数量,或在合并的 speedscope 视图中为 周期性收集器 补充信息。

周期性收集器

该收集器在单独的线程中运行,并按每个时间间隔保存被分析线程的堆栈跟踪。时间间隔(默认 10 毫秒)可以通过用户界面中的 Interval 选项或 Python 代码中的 interval 参数设置。

Warning

如果时间间隔设置得过低,分析长请求将产生内存问题。如果设置得过高,则会丢失短函数执行的相关信息。

它是分析性能的最佳方式之一,因为它运行在单独的线程中,对执行时间的影响应当非常小。

QWeb 收集器

该收集器会保存所有指令的 Python 执行时间和查询。与 SQL 收集器 一样,在执行大量小指令时其开销可能较大。其结果在收集的数据方面与其他收集器不同,可以通过 ir.profile 表单视图使用自定义部件进行分析。

它主要用于优化视图。

同步收集器

该收集器会为每次函数的调用和返回保存堆栈,并在同一线程上运行,这会极大地影响性能。

它可用于调试和理解复杂流程,并在代码中跟踪其执行。但由于开销较大,不建议将其用于性能分析。

性能陷阱

  • 注意随机性。多次执行可能产生不同的结果。例如,垃圾回收恰好在执行期间被触发。

  • 注意阻塞调用。在某些情况下,外部的 c_call 在释放 GIL 之前可能耗时较长,从而导致 周期性收集器 出现意外的长帧。分析器应能检测到这种情况并给出警告。如有需要,可以在这类调用之前手动触发分析器。

  • 注意缓存。在 view/assets/… 尚未进入缓存之前进行性能分析可能导致不同的结果。

  • 注意分析器的开销。当执行大量小查询时,SQL 收集器 的开销可能很大。性能分析适合用来发现问题,但您可能需要禁用分析器,以便衡量代码变更的实际影响。

  • 性能分析结果可能占用大量内存。在某些情况下(例如分析安装过程或长请求时),您可能会触及内存上限,尤其是在渲染 speedscope 结果时,这可能导致 HTTP 500 错误。此时,您可能需要以更高的内存上限启动服务器:--limit-memory-hard $((8*1024**3))。

数据库数据填充

Odoo CLI 通过 odoo-bin populate 命令提供 数据库填充 功能。

无需再以手动或编程的方式繁琐地指定测试数据,可以使用此功能按需向数据库填充所需数量的测试数据,用于检测被测流程中的各类缺陷或性能问题。

要为某个模型填充数据,可以定义以下方法和属性。

Note

必须在该模型上至少定义 _populate() 或 _populate_factories() 才能启用数据库填充。

Example

from odoo.tools import populate

class CustomModel(models.Model)
    _inherit = "custom.some_model"
    _populate_sizes = {"small": 100, "medium": 2000, "large": 10000}
    _populate_dependencies = ["custom.some_other_model"]

    def _populate_factories(self):
        # Record ids of previously populated models are accessible in the registry
        some_other_ids = self.env.registry.populated_models["custom.some_other_model"]

        def get_some_field(values=None, random=None, **kwargs):
            """ Choose a value for some_field depending on other fields values.

            :param dict values:
            :param random: seeded :class:`random.Random` object
            """
            field_1 = values['field_1']
            if field_1 in [value2, value3]:
                return random.choice(some_field_values)
            return False

        return [
            ("field_1", populate.randomize([value1, value2, value3])),
            ("field_2", populate.randomize([value_a, value_b], [0.5, 0.5])),
            ("some_other_id", populate.randomize(some_other_ids)),
            ("some_field", populate.compute(get_some_field, seed="some_field")),
            ('active', populate.cartesian([True, False])),
        ]

    def _populate(self, size):
        records = super()._populate(size)

        # If you want to update the generated records
        # E.g setting the parent-child relationships
        records.do_something()

        return records

数据填充工具

有多种数据填充工具,可以轻松创建所需的数据生成器。

良好实践

批量操作

在处理记录集时,批量执行操作几乎总是更好的做法。

Example

不要在遍历记录集时调用会执行 SQL 查询的方法,因为该查询会针对集合中的每条记录执行。

def _compute_count(self):
    for record in self:
        domain = [('related_id', '=', record.id)]
        record.count = other_model.search_count(domain)

取而代之的是,将 search_count 替换为 read_group,对整批记录只执行一条 SQL 查询。

def _compute_count(self):
    if self.ids:
        domain = [('related_id', 'in', self.ids)]
        counts_data = other_model.read_group(domain, ['related_id'], ['related_id'])
        mapped_data = {
            count['related_id'][0]: count['related_id_count'] for count in counts_data
        }
    else:
        mapped_data = {}
    for record in self:
        record.count = mapped_data.get(record.id, 0)

Note

这个示例并非在所有情况下都最优或正确,它只是 search_count 的一种替代方案。另一种解决方案是预取并统计反向 One2many 字段。

Example

不要逐条创建记录。

for name in ['foo', 'bar']:
    model.create({'name': name})

相反,应累积创建值,并在整批记录上调用 create 方法。这样做几乎没有影响,还能帮助框架优化字段计算。

create_values = []
for name in ['foo', 'bar']:
    create_values.append({'name': name})
records = model.create(create_values)

Example

在循环内浏览单条记录,导致未能预取记录集的字段。

for record_id in record_ids:
    model.browse(record_id)
    record.foo  # One query is executed per record.

相反,应先浏览整个记录集。

records = model.browse(record_ids)
for record in records:
    record.foo  # One query is executed for the entire recordset.

通过读取包含每条记录 id 的 prefetch_ids 字段,我们可以验证记录是否被批量预取。将所有记录一起浏览并不实际,

如有需要,可以使用 with_prefetch 方法禁用批量预取:

for values in values_list:
    message = self.browse(values['id']).with_prefetch(self.ids)

降低算法复杂度

算法复杂度衡量的是算法相对于输入规模 n 完成所需的时间。当复杂度较高时,随着输入变大,执行时间可能快速增长。在某些情况下,可以通过正确地预处理输入数据来降低算法复杂度。

Example

针对给定问题,假设我们有一个用两层嵌套循环编写的朴素算法,其复杂度为 O(n²)。

for record in self:
    for result in results:
        if results['id'] == record.id:
            record.foo = results['foo']
            break

假设所有结果都有不同的 id,我们可以预处理数据以降低复杂度。

mapped_result = {result['id']: result['foo'] for result in results}
for record in self:
    record.foo = mapped_result.get(record.id)

Example

选择不合适的数据结构来保存输入可能导致二次方复杂度。

invalid_ids = self.search(domain).ids
for record in self:
    if record.id in invalid_ids:
        ...

如果 invalid_ids 是类列表的数据结构,算法的复杂度可能是二次方的。

相反,应优先使用集合操作,例如将 invalid_ids 转换为集合。

invalid_ids = set(invalid_ids)
for record in self:
    if record.id in invalid_ids:
        ...

根据输入情况,也可以使用记录集操作。

invalid_ids = self.search(domain)
for record in self - invalid_ids:
    ...

使用索引

数据库索引有助于加快搜索操作,无论是代码中的 search 还是通过用户界面进行的搜索。

name = fields.Char(string="Name", index=True)

Warning

注意不要对所有字段都建立索引,因为索引会占用空间,并且在执行 INSERT、UPDATE 和 DELETE 时会影响性能。