性能

为什么我的应用程序在升级到 1.4 和/或 2.x 后变慢了?

从 1.4 版本开始,SQLAlchemy 包含一个 SQL 编译缓存功能,它允许 Core 和 ORM SQL 构造缓存其字符串形式,以及用于从语句中获取结果的其他结构信息,从而在下次使用结构上等效的构造时跳过相对昂贵的字符串编译过程。此系统依赖于为所有 SQL 构造实现的功能,包括对象,如 Columnselect()TypeEngine 对象,以生成一个 **缓存键**,它完全代表了它们的状态,其程度影响了 SQL 编译过程。

缓存系统使 SQLAlchemy 1.4 及更高版本在将 SQL 构造转换为字符串方面的性能优于 SQLAlchemy 1.3。但是,这只有在为所使用的方言和 SQL 构造启用了缓存时才有效;如果没有,字符串编译通常与 SQLAlchemy 1.3 相似,在某些情况下速度略有下降。

然而,存在一种情况,如果 SQLAlchemy 的新缓存系统被禁用(原因如下),ORM 的性能实际上可能比 1.3 或其他先前版本差得多,这是因为 ORM 延迟加载器和对象刷新查询中缺乏缓存,在 1.3 和更早版本中使用的是现已过时的 BakedQuery 系统。如果应用程序在切换到 1.4 时看到性能显着下降(完成操作的时间降低 30% 或更高),则很可能是此问题造成的,以下介绍了缓解措施。

另请参阅

SQL 编译缓存 - 缓存系统的概述

对象不会生成缓存键,性能影响 - 有关针对无法启用缓存的元素发出的警告的更多信息。

第一步 - 打开 SQL 日志并确认缓存是否正常工作

这里,我们想使用 引擎日志记录 中描述的技术,查找带有 [no key] 指示符甚至 [dialect does not support caching] 的语句。我们会在成功参与缓存系统的 SQL 语句中看到的指示符是 [generated in Xs](当语句第一次被调用时)和 [cached since Xs ago](对于绝大多数后续语句)。如果 [no key] 特别是在 SELECT 语句中很常见,或者如果由于 [dialect does not support caching] 缓存完全被禁用,这可能是性能显着下降的原因。

第二步 - 确定哪些构造阻止了缓存的启用

假设语句没有被缓存,应用程序日志(仅限 SQLAlchemy 1.4.28 及更高版本)应该会发出早期警告,指示方言、TypeEngine 对象和未参与缓存的 SQL 构造。

对于用户定义的数据类型,例如扩展 TypeDecoratorUserDefinedType 的数据类型,警告将如下所示:

sqlalchemy.ext.SAWarning: MyType will not produce a cache key because the
``cache_ok`` attribute is not set to True. This can have significant
performance implications including some performance degradations in
comparison to prior SQLAlchemy versions. Set this attribute to True if this
type object's state is safe to use in a cache key, or False to disable this
warning.

对于自定义和第三方 SQL 元素,例如使用 自定义 SQL 构造和编译扩展 中描述的技术构建的元素,这些警告将如下所示:

sqlalchemy.exc.SAWarning: Class MyClass will not make use of SQL
compilation caching as it does not set the 'inherit_cache' attribute to
``True``. This can have significant performance implications including some
performance degradations in comparison to prior SQLAlchemy versions. Set
this attribute to True if this object can make use of the cache key
generated by the superclass. Alternatively, this attribute may be set to
False which will disable this warning.

对于使用 Dialect 类层次结构的自定义和第三方方言,警告将如下所示:

sqlalchemy.exc.SAWarning: Dialect database:driver will not make use of SQL
compilation caching as it does not set the 'supports_statement_cache'
attribute to ``True``. This can have significant performance implications
including some performance degradations in comparison to prior SQLAlchemy
versions. Dialect maintainers should seek to set this attribute to True
after appropriate development and testing for SQLAlchemy 1.4 caching
support. Alternatively, this attribute may be set to False which will
disable this warning.

第三步 - 为给定对象启用缓存和/或寻求替代方案

缓解缓存不足的步骤包括

  • 查看并为所有扩展自 TypeDecoratorUserDefinedType 以及这些子类的子类(例如 PickleType)的自定义类型设置 ExternalType.cache_okTrue。仅当自定义类型不包含任何影响其呈现 SQL 的额外状态属性时才设置此属性。

    class MyCustomType(TypeDecorator):
        cache_ok = True
        impl = String

    如果使用的类型来自第三方库,请咨询该库的维护人员,以便对其进行调整和发布。

    另请参阅

    ExternalType.cache_ok - 有关启用自定义数据类型缓存要求的背景信息。

  • 确保第三方方言将 Dialect.supports_statement_cache 设置为 True。这意味着第三方方言的维护人员已确保其方言与 SQLAlchemy 1.4 或更高版本兼容,并且其方言不包含任何可能妨碍缓存的编译功能。由于存在一些常见的编译模式实际上可能干扰缓存,因此方言维护人员必须仔细检查和测试这一点,并针对可能无法与缓存配合使用的任何遗留模式进行调整。

    另请参阅

    第三方方言的缓存 - 有关第三方方言参与 SQL 语句缓存的背景信息和示例。

  • 自定义 SQL 类,包括使用 自定义 SQL 构造和编译扩展 创建的所有 DQL / DML 构造,以及 ColumnTable 等对象的临时子类。可以将 HasCacheKey.inherit_cache 属性设置为 True,用于简单的子类,这些子类不包含任何影响 SQL 编译的子类特定状态信息。

    另请参阅

    启用自定义构造的缓存支持 - 有关应用 HasCacheKey.inherit_cache 属性的指南。

另请参阅

SQL 编译缓存 - 缓存系统概述

对象不会生成缓存键,性能影响 - 有关针对特定构造和/或方言未启用缓存时发出的警告的背景信息。

如何分析 SQLAlchemy 驱动的应用程序?

查找性能问题通常涉及两种策略。一个是查询分析,另一个是代码分析。

查询分析

有时,简单的 SQL 日志(通过 Python 的日志模块或通过 echo=True 参数在 create_engine() 上启用)可以提供一些关于事情花费了多长时间的线索。例如,如果您在 SQL 操作之后立即记录一些内容,您将在日志中看到类似这样的内容

17:37:48,325 INFO  [sqlalchemy.engine.base.Engine.0x...048c] SELECT ...
17:37:48,326 INFO  [sqlalchemy.engine.base.Engine.0x...048c] {<params>}
17:37:48,660 DEBUG [myapp.somemessage]

如果您在操作之后立即记录 myapp.somemessage,您就知道完成 SQL 部分需要 334 毫秒。

记录 SQL 也会说明是否发出了数十/数百个查询,这些查询可以更好地组织成更少的查询。在使用 SQLAlchemy ORM 时,提供了“急切加载”功能来部分地 (contains_eager()) 或完全地 (joinedload()subqueryload()) 自动执行此活动,但如果没有 ORM 的“急切加载”,通常意味着使用联接,以便可以在一个结果集中加载多个表的结果,而不是随着深度增加而增加查询次数(即 r + r*r2 + r*r2*r3 …)。

要对查询进行更长时间的分析,或实现应用程序端的“慢查询”监控,可以使用事件来拦截游标执行,使用以下配方

from sqlalchemy import event
from sqlalchemy.engine import Engine
import time
import logging

logging.basicConfig()
logger = logging.getLogger("myapp.sqltime")
logger.setLevel(logging.DEBUG)


@event.listens_for(Engine, "before_cursor_execute")
def before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
    conn.info.setdefault("query_start_time", []).append(time.time())
    logger.debug("Start Query: %s", statement)


@event.listens_for(Engine, "after_cursor_execute")
def after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
    total = time.time() - conn.info["query_start_time"].pop(-1)
    logger.debug("Query Complete!")
    logger.debug("Total Time: %f", total)

上面,我们使用 ConnectionEvents.before_cursor_execute()ConnectionEvents.after_cursor_execute() 事件在语句执行之前和之后建立拦截点。我们将计时器附加到连接上,使用 info 字典;我们在此处使用堆栈来处理游标执行事件可能嵌套的情况。

代码分析

如果日志显示单个查询花费的时间过长,您需要详细了解在数据库中处理查询、通过网络发送结果、由 DBAPI 处理以及最后被 SQLAlchemy 的结果集和/或 ORM 层接收所花费的时间。这些阶段中的每一个都可能呈现出自己的瓶颈,具体取决于具体情况。

为此,您需要使用 Python 分析模块。以下是一个将分析集成到上下文管理器中的简单配方

import cProfile
import io
import pstats
import contextlib


@contextlib.contextmanager
def profiled():
    pr = cProfile.Profile()
    pr.enable()
    yield
    pr.disable()
    s = io.StringIO()
    ps = pstats.Stats(pr, stream=s).sort_stats("cumulative")
    ps.print_stats()
    # uncomment this to see who's calling what
    # ps.print_callers()
    print(s.getvalue())

要分析代码段

with profiled():
    session.scalars(select(FooClass).where(FooClass.somevalue == 8)).all()

分析的输出可以用来了解时间花在了哪里。分析输出的一部分如下所示

13726 function calls (13042 primitive calls) in 0.014 seconds

Ordered by: cumulative time

ncalls  tottime  percall  cumtime  percall filename:lineno(function)
222/21    0.001    0.000    0.011    0.001 lib/sqlalchemy/orm/loading.py:26(instances)
220/20    0.002    0.000    0.010    0.001 lib/sqlalchemy/orm/loading.py:327(_instance)
220/20    0.000    0.000    0.010    0.000 lib/sqlalchemy/orm/loading.py:284(populate_state)
   20    0.000    0.000    0.010    0.000 lib/sqlalchemy/orm/strategies.py:987(load_collection_from_subq)
   20    0.000    0.000    0.009    0.000 lib/sqlalchemy/orm/strategies.py:935(get)
    1    0.000    0.000    0.009    0.009 lib/sqlalchemy/orm/strategies.py:940(_load)
   21    0.000    0.000    0.008    0.000 lib/sqlalchemy/orm/strategies.py:942(<genexpr>)
    2    0.000    0.000    0.004    0.002 lib/sqlalchemy/orm/query.py:2400(__iter__)
    2    0.000    0.000    0.002    0.001 lib/sqlalchemy/orm/query.py:2414(_execute_and_instances)
    2    0.000    0.000    0.002    0.001 lib/sqlalchemy/engine/base.py:659(execute)
    2    0.000    0.000    0.002    0.001 lib/sqlalchemy/sql/elements.py:321(_execute_on_connection)
    2    0.000    0.000    0.002    0.001 lib/sqlalchemy/engine/base.py:788(_execute_clauseelement)

...

上面,我们可以看到 instances() SQLAlchemy 函数被调用了 222 次(递归地,从外部调用了 21 次),所有调用的总时间为 0.011 秒。

执行缓慢

这些调用的具体情况可以告诉我们时间花在了哪里。例如,如果您看到时间花在了 cursor.execute() 中,例如针对 DBAPI

2    0.102    0.102    0.204    0.102 {method 'execute' of 'sqlite3.Cursor' objects}

这表明数据库需要很长时间才能开始返回结果,这意味着您的查询应该进行优化,方法是添加索引或重构查询和/或底层架构。为此,需要使用数据库后端提供的 EXPLAIN、SHOW PLAN 等系统分析查询计划。

结果获取缓慢 - 核心

另一方面,如果您看到与获取行相关的数千次调用,或者对 fetchall() 的调用非常长,这可能意味着您的查询返回的行数超出了预期,或者获取行本身很慢。ORM 本身通常使用 fetchall() 来获取行(或 fetchmany() 如果使用 Query.yield_per() 选项)。

行数过多将由 DBAPI 级别对 fetchall() 的调用非常缓慢来指示

2    0.300    0.600    0.300    0.600 {method 'fetchall' of 'sqlite3.Cursor' objects}

即使最终结果似乎没有很多行,意外的大量行也可能是笛卡尔积的结果 - 当多个行集组合在一起而没有适当地联接表时。使用 SQLAlchemy Core 或 ORM 查询很容易产生这种行为,如果在复杂查询中使用了错误的 Column 对象,会导致意外地拉入额外的 FROM 子句。

另一方面,如果 DBAPI 级别对 fetchall() 的调用很快,但当 SQLAlchemy 的 CursorResult 被要求执行 fetchall() 时很慢,这可能表明在处理数据类型时很慢,例如 Unicode 转换等

# the DBAPI cursor is fast...
2    0.020    0.040    0.020    0.040 {method 'fetchall' of 'sqlite3.Cursor' objects}

...

# but SQLAlchemy's result proxy is slow, this is type-level processing
2    0.100    0.200    0.100    0.200 lib/sqlalchemy/engine/result.py:778(fetchall)

在某些情况下,后端可能会进行不需要的类型级处理。更具体地说,在类型 API 中看到缓慢的调用是更好的指标 - 下面是使用此类类型时的样子

from sqlalchemy import TypeDecorator
import time


class Foo(TypeDecorator):
    impl = String

    def process_result_value(self, value, thing):
        # intentionally add slowness for illustration purposes
        time.sleep(0.001)
        return value

此故意缓慢操作的分析输出可以这样看到

200    0.001    0.000    0.237    0.001 lib/sqlalchemy/sql/type_api.py:911(process)
200    0.001    0.000    0.236    0.001 test.py:28(process_result_value)
200    0.235    0.001    0.235    0.001 {time.sleep}

也就是说,我们看到 type_api 系统中有很多昂贵的调用,而真正耗时的操作是 time.sleep() 调用。

请务必查看 方言文档,了解此级别的已知性能调整建议,尤其是对于 Oracle 等数据库。可能存在与确保数值精度或字符串处理相关的系统,这些系统可能并非在所有情况下都需要。

可能还有更多底层点导致行获取性能下降;例如,如果花费的时间似乎集中在类似 socket.receive() 的调用上,这可能表明除了实际的网络连接外,所有内容都很快,并且花费了过多的时间来移动网络上的数据。

结果获取缓慢 - ORM

要检测 ORM 获取行的速度缓慢(这是性能问题最常见的区域),类似 populate_state()_instance() 的调用将说明单个 ORM 对象的填充情况

# the ORM calls _instance for each ORM-loaded row it sees, and
# populate_state for each ORM-loaded row that results in the population
# of an object's attributes
220/20    0.001    0.000    0.010    0.000 lib/sqlalchemy/orm/loading.py:327(_instance)
220/20    0.000    0.000    0.009    0.000 lib/sqlalchemy/orm/loading.py:284(populate_state)

ORM 在将行转换为 ORM 映射对象的缓慢性是此操作的复杂性与 cPython 开销相结合的产物。缓解此问题的常见策略包括

  • 获取单个列而不是完整的实体,也就是说

    select(User.id, User.name)

    而不是

    select(User)
  • 使用 Bundle 对象来组织基于列的结果

    u_b = Bundle("user", User.id, User.name)
    a_b = Bundle("address", Address.id, Address.email)
    
    for user, address in session.execute(select(u_b, a_b).join(User.addresses)):
        ...
  • 使用结果缓存 - 请参阅 Dogpile 缓存 以了解此示例的详细说明。

  • 考虑使用速度更快的解释器,例如 PyPy 的解释器。

分析的输出可能有点令人望而生畏,但在练习几次之后,它们就非常容易阅读。

另请参阅

性能 - 具有捆绑分析功能的一套性能演示。

我使用 ORM 插入了 400,000 行,速度非常慢!

ORM 插入的性质已经发生了变化,因为大多数包含的驱动程序从 SQLAlchemy 2.0 开始使用 insertmanyvalues 支持与 RETURNING 一起使用。有关详细信息,请参阅部分 为除 MySQL 之外的所有后端实现了优化的 ORM 批量插入

总的来说,除了 MySQL 的驱动程序之外,SQLAlchemy 内置驱动程序现在应该提供非常快的 ORM 批量插入性能。

第三方驱动程序也可以通过一些小的代码更改选择加入新的批量基础设施,假设其后端支持必要的语法。SQLAlchemy 开发人员鼓励第三方方言的用户发布有关这些驱动程序的问题,以便他们可以联系 SQLAlchemy 开发人员寻求帮助。