性能

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

从 1.4 版本开始,SQLAlchemy 包含一个 SQL 编译缓存机制,它允许 Core 和 ORM SQL 构造缓存其字符串化形式,以及用于从语句中获取结果的其他结构信息,从而允许在下次使用结构等效的构造时跳过相对昂贵的字符串编译过程。此系统依赖于为所有 SQL 构造实现的功能,包括诸如 Column, select()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.

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

缓解缺乏缓存的步骤包括

  • 查看并为所有从 TypeDecorator, UserDefinedType 扩展的自定义类型,以及这些类型的子类(如 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 属性可以为不包含任何影响 SQL 编译的子类特定状态信息的简单子类设置为 True

    另请参阅

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

另请参阅

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

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

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

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

查询分析

有时,仅仅是普通的 SQL 日志记录(通过 python 的 logging 模块或通过 create_engine() 上的 echo=True 参数启用)就可以了解事情花费的时间。例如,如果您在 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 Profiling Module。以下是一个简单的配方,可将分析工作集成到上下文管理器中

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 等系统。

结果获取缓慢 - Core

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

非常慢的 fetchall() DBAPI 级别的调用将表明行数异常大

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

意外的大量行,即使最终结果看起来没有很多行,也可能是笛卡尔积的结果 - 当多个行集合组合在一起而没有适当地连接表时。如果复杂的查询中使用了错误的 Column 对象,拉入意外的附加 FROM 子句,则通常很容易使用 SQLAlchemy Core 或 ORM 查询产生此行为。

另一方面,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 起都将 RETURNING 与 insertmanyvalues 支持一起使用。有关详细信息,请参阅 针对 MySQL 以外的所有后端实现的优化 ORM 批量插入 部分。

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

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