将 EngineFacade 变成一个 Facade

https://blueprints.launchpad.net/oslo.db/+spec/make-enginefacade-a-facade

问题描述

oslo.db.sqlalchemy.session.EngineFacade 类在许多 OpenStack 项目(包括 Ceilometer、Glance、Heat、Ironic、Keystone、Neutron、Nova 和 Sahara)中充当 SQLAlchemy Engine 和 Session 对象的网关。然而,该对象功能严重不足;虽然它提供了一个最终调用 create_engine()sessionmaker() 的函数调用,但消费项目无法从该对象获得任何其他实用程序,并且为了解决它们共同面临的密切相关问题,每个项目都发明了自己的系统,这些系统各不相同、冗长且容易出错,并存在各种性能、稳定性和可扩展性问题。

注册功能

在第一种情况下,项目使用的 EngineFacade 需要充当线程安全的注册表,而它并不提供此功能,并且每个消费项目都必须直接发明它。这些发明冗长且不一致。

例如,在 Keystone 中,EngineFacade 在 keystone/common/sql/core.py 中创建如下

_engine_facade = None

def _get_engine_facade():
    global _engine_facade

    if not _engine_facade:
        _engine_facade = db_session.EngineFacade.from_config(CONF)

    return _engine_facade

在 Ironic 中,我们有这个;Sahara 包含类似的东西

_FACADE = None

def _create_facade_lazily():
    global _FACADE
    if _FACADE is None:
        _FACADE = db_session.EngineFacade(
            CONF.database.connection,
            **dict(CONF.database.iteritems())
        )
    return _FACADE

然而在 Nova 中,我们得到了类似的模式,但有一个非常关键的转折

_ENGINE_FACADE = None
_LOCK = threading.Lock()

def _create_facade_lazily():
    global _LOCK, _ENGINE_FACADE
    if _ENGINE_FACADE is None:
        with _LOCK:
            if _ENGINE_FACADE is None:
                _ENGINE_FACADE = db_session.EngineFacade.from_config(CONF)
    return _ENGINE_FACADE

每个库都发明了自己的将 EngineFacade 作为单例建立起来并向其中提供 CONF 的系统,而且这些系统都不相同。Nova 恰好发现这种单例模式不是线程安全的,并添加了一个互斥锁,但是缺乏这一关键改进仍然是所有其他系统中的一个错误。

事务资源功能

添加一个功能齐全的创建模式是一个简单的胜利,但问题不仅仅在于此。EngineFacade 在 get_engine()get_session() 处结束其工作;前者返回一个 SQLAlchemy Engine 对象,它本身只是连接的工厂,而后者返回一个 SQLAlchemy Session 对象,准备好使用但与任何特定连接或事务上下文无关。

“Facade”的定义是一个隐藏底层细粒度 API 使用的层,并提供针对手头用例进行粗粒化和定制的层。根据这个定义,EngineFacade 目前只是一个工厂,而不是一个 Facade。

EngineFacade 缺乏这种指导所造成的危害是广泛的。未能提供足够的创建模式导致每个 Openstack 项目都发明自己的解决方法,而未能提供关于连接或事务范围的任何指导,则导致所有 Openstack 项目都出现大量不良实现模式。观察到的每个项目都说明了对引擎、会话和事务的误用,程度或轻重,而且通常会对性能、稳定性和可维护性产生直接影响。

所有项目的共同主题是,虽然它们都呈现为 Web 服务,但没有结构来建立服务方法作为一个整体的连接性和事务范围。各个方法包括显式的样板代码,用于建立某种连接性,无论是在事务中还是不在事务中。这种样板代码的格式不仅在项目之间不一致,甚至在单个项目甚至单个模块中也不一致,有时是故意为之,有时不是。同样重要的是,单个 API 方法经常跨越多个连接和非连接事务的范围,在单个操作中,并且在某些情况下,多个事务甚至嵌套。首先,使用多个连接和事务是主要的性能障碍,其次,它削弱了事务逻辑本身的效用,因为 API 方法实际上并不具有原子性。当事务确实嵌套时,死锁的风险显著增加。

事务范围示例

本节将详细介绍一些刚刚描述的问题的具体示例,供好奇的人参考。

我们首先展示 Neutron 的系统,该系统组织得最好,可能也是这类问题最少的一个。Neutron 有一个系统,所有数据库操作都通过 neutron.context.Context 对象进行;Context 对象充当从 EngineFacade 获取的 SQLAlchemy Session 的主基。一个方法摘录如下

def add_resource_association(self, context, service_type, provider_name,
                             resource_id):

    # ...

    with context.session.begin(subtransactions=True):
        assoc = ProviderResourceAssociation(provider_name=provider_name,
                                            resource_id=resource_id)
        context.session.add(assoc)

我们看到,虽然 Context 对象至少允许所有操作访问相同的 Session,但该方法仍然必须声明它希望开始一个事务,并且需要支持 Session 可能已经处于事务中的事实。Neutron 的系统有点冗长,并且存在一个问题,即连续调用的各个方法可能会在每次调用时在新的连接上启动它们的工作,但至少确保在给定的 API 方法从开始到结束时只使用一个 Session;这可以防止意外的多个事务嵌套,因为 Session 的 begin() 方法将不允许从打开新连接开始嵌套调用。

接下来,我们来看 Keystone。Keystone 有一些数据库相关的辅助函数,但它们没有任何功能,而是一些命名抽象。Keystone 有很多短“查找”方法,其中许多如下所示

@sql.handle_conflicts(conflict_type='trust')
def list_trusts(self):
    session = sql.get_session()
    trusts = session.query(TrustModel).filter_by(deleted_at=None)
    return [trust_ref.to_dict() for trust_ref in trusts]

以上,sql.get_session() 调用只是另一个对 EngineFacade.get_session() 的调用,而连接就在那里建立。 sql.handle_conflicts() 调用在建立此会话中没有任何作用。

以上调用使用 SQLAlchemy Session 在“autocommit”模式下;在这种模式下,SQLAlchemy 基本上会为每个查询创建一个连接/事务上下文,并在查询完成后将其丢弃;使用 Python 数据库 API (DBAPI),没有跨平台的选项可以防止最终存在事务;因此,“autocommit”并不意味着“无事务”。

在绝大多数情况下,以“autocommit”模式使用 Session 不是一个好的方法,并且在 SQLAlchemy 自己的文档中也不鼓励这样做(参见 https://docs.sqlalchemy.org.cn/en/rel_0_9/orm/session.html#autocommit-mode),因为它意味着一系列查询将每个查询都在一个全新的连接和事务上进行,浪费数据库资源,并产生昂贵的回滚,甚至在轻微负载下为每个查询创建一个新的数据库连接,此时连接池处于溢出模式。oslo.db 本身也会在每个连接上发出“悲观 ping”,其中会发出“SELECT 1”以确保连接处于活动状态,因此以“autocommit”模式发出三个查询意味着实际上会发出 个查询。

诚然,对于像上面那样只发出一个 SELECT 并且绝对没有其他内容的这种方法,在 Python 开销方面,Session 不会为事务构建内部状态对象,这略有优化,但这只是一个微小的优化;如果需要这种规模的优化,可以使用其他方法使上述系统大大提高性能(例如,使用烘焙查询、基于列的查询或 Core 查询)。

虽然 Keystone 和 Neutron 都有隐式使用“autocommit”模式的问题,但 Nova 存在更严重的问题,既是因为它在数据库级别更复杂,也是因为它在持久性方面对性能要求更高。

在 Nova 中,连接系统大致等同于 Keystone;许多显式调用 get_session() 以及大量使用 session 在“autocommit”模式下,最常见的是通过 model_query() 函数。但更重要的是,Nova API 的复杂性以及缺乏维护事务范围的可靠系统,导致了 API 调用中广泛使用多个事务,在某些情况下甚至并发使用,这确实对稳定性和性能有影响。

一个典型的 Nova 方法如下所示

@require_admin_context
def cell_update(context, cell_name, values):
    session = get_session()
    with session.begin():
        cell_query = _cell_get_by_name_query(context, cell_name,
                                             session=session)
        if not cell_query.update(values):
            raise exception.CellNotFound(cell_name=cell_name)
        cell = cell_query.first()
    return cell

在上面的调用中,get_session() 调用返回一个全新的会话,该会话开始一个事务;然后该方法调用 _cell_get_by_name_query,将 Session 传递进去,以确保该子方法使用相同的事务。这里的意图是好的,即 cell_update() 方法知道它应该与子方法共享其事务上下文。

然而,这是一种繁琐且不一致应用的编码模式。在未能应用它的区域,最终结果是单个操作会调用几个新的连接和事务,有时是在嵌套调用中;这既浪费又缓慢,并且是死锁的主要风险因素。可以在单个调用中找到非嵌套的多个连接/会话使用的示例。真正嵌套的事务不太常见;一个例子是 nova/db/api.py -> floating_ip_bulk_destroy。在这个方法中,我们看到

@require_context
def floating_ip_bulk_destroy(context, ips):
    session = get_session()
    with session.begin():
        project_id_to_quota_count = collections.defaultdict(int)
        for ip_block in _ip_range_splitter(ips):
            query = model_query(context, models.FloatingIp).\
                filter(models.FloatingIp.address.in_(ip_block)).\
                filter_by(auto_assigned=False)
            rows = query.all()
            for row in rows:
                project_id_to_quota_count[row['project_id']] -= 1
            model_query(context, models.FloatingIp).\
                filter(models.FloatingIp.address.in_(ip_block)).\
                soft_delete(synchronize_session='fetch')
        for project_id, count in project_id_to_quota_count.iteritems():
            try:
                reservations = quota.QUOTAS.reserve(context,
                                                    project_id=project_id,
                                                    floating_ips=count)
                quota.QUOTAS.commit(context,
                                    reservations,
                                    project_id=project_id)
            except Exception:
                with excutils.save_and_reraise_exception():
                    LOG.exception(_("Failed to update usages bulk "
                                    "deallocating floating IP"))

整个方法都在 session.begin() 中。但是,首先我们看到两次调用 model_query(),每个调用都忘记传递会话,因此 model_query() 为每个调用创建自己的会话和事务。但更严重的是,get_session() 再次被调用很多次,没有任何方法传递会话,在 quota.QUOTAS.commit 在循环中被调用时。该接口从数据库 API 外部开始,在 nova/quota.py 中,没有可用的 session 参数

def commit(self, context, reservations, project_id=None, user_id=None):
    """Commit reservations."""
    if project_id is None:
        project_id = context.project_id
    # If user_id is None, then we use the user_id in context
    if user_id is None:
        user_id = context.user_id

    db.reservation_commit(context, reservations, project_id=project_id,
                          user_id=user_id)

db.reservation_commit 在 nova/db/api.py 中,我们看到对 get_session() 的全新调用,begin(),并再次调用 @_retry_on_deadlock 装饰器,该装饰器也最了解如何管理最顶层范围。

@require_context
@_retry_on_deadlock
def reservation_commit(context, reservations, project_id=None, user_id=None):
    session = get_session()
    with session.begin():
        _project_usages, user_usages = _get_project_user_quota_usages(
                context, session, project_id, user_id)
        reservation_query = _quota_reservations_query(session, context,
                                                      reservations)
        for reservation in reservation_query.all():
            usage = user_usages[reservation.resource]
            if reservation.delta >= 0:
                usage.reserved -= reservation.delta
            usage.in_use += reservation.delta
        reservation_query.soft_delete(synchronize_session=False)

在上面的示例中,我们首先看到“临时会话”系统和“不止一种方法”方法 model_query() 导致被默默掩盖的编码错误,但在 reservation_commit() 的情况下,架构本身不允许甚至可以纠正此编码错误。

可以在测试套件中使用断言来找到一个 API 调用中非嵌套的多个会话和事务的示例。当前代码中发生这种情况的两个主要区域是

  • instance_create() 调用 get_session(),然后 ec2_instance_create() -> models.save() -> get_session()

  • aggregate_create() 调用 get_session(),然后 aggregate_get() -> model_query() -> get_session()

可以手动修复上述示例,但与其添加更多的样板代码、装饰器和命令式参数来解决问题,因为识别出单个案例,不如用一个纯声明式 Facade 来替换使用 get_session()、get_engine() 和特殊上下文管理器的所有命令式数据库代码,该 Facade 以一致且感知上下文的方式处理所有项目的连接性、事务范围和相关功能(例如方法重试)。

提议的变更

更改是将 get_session()、get_engine() 和特殊上下文管理器的使用替换为一组新的装饰器和上下文管理器,这些装饰器和上下文管理器本身是从替换通常的 EngineFacade 逻辑的简单导入中调用的。

该导入将基本上允许一个符号来处理 EngineFacadeCONF 在幕后的工作

from oslo.db import enginefacade as sql

该符号将提供两个关键装饰器,reader()writer(),以及镜像其行为的上下文管理器,using_reader()using_writer()。装饰器将 SQLAlchemy Session 对象传递给现有 API 方法的 context 参数

@sql.reader
def some_api_method(context):
    # work with context.session

@sql.writer
def some_other_api_method(context):
    # work with context.session

而上下文管理器则在本地接收此 context 参数

def some_api_method(context):
    with sql.using_reader(context) as session:
        # work with session

def some_other_api_method(context):
    with sql.using_writer(context) as session:
        # work with session

事务范围

这些装饰器和上下文管理器将在没有作用域时使用类似于当前 get_session() 函数的方法获取新的 Session,或者如果已经存在作用域,则返回该现有的 Session。然后 Session 将使用 begin() 无条件地在事务中,或者我们最好切换到 Session 的默认模式,即“autocommit=False”。此事务的状态将保持打开状态,直到方法结束,无论是引发异常(无条件回滚)还是完成(根据 reader/writer 语义进行提交() 或关闭())。

目标是任何级别的嵌套调用都可以调用 reader()writer() 并参与一个已经进行的事务。只有最外层的调用在作用域内才会实际结束事务,除非发生异常;writer() 方法将发出 commit(),而 reader() 方法将 close() 会话,确保底层连接以轻量级的方式回滚。

上下文和线程局部变量

目前,该提案期望存在一个“context”对象,该对象可以是任何 Python 对象,以便提供某种将调用堆栈的所有元素连接在一起的对象。大多数 API 都包含一个 context 参数,但 Keystone 是一个例外。

为了支持不包含“context”参数的模式,唯一的替代方法是使用线程局部变量。与社区的讨论中,使用线程局部变量存在两个问题:1. 它需要在 eventlet 级别进行早期修补,并且 2. 线程局部变量被视为“远程操作”,比“显式”更“隐式”。

此处所述的提案可以使用以下配方使用线程局部变量

# at the top of the api module
GLOBAL_CONTEXT = threading.local()


def some_api_method():
    with sql.using_writer(GLOBAL_CONTEXT) as session:
        # work with session

无论我们构建上述模式,还是让 Keystone 使用显式 context 对象,尚未决定。有关各种选项,请参阅“替代方案”。

Reader 与 Writer

起初,`reader()` 与 `writer()` 仅旨在允许一段功能将其自身标记为仅需要只读访问权限,或涉及写入访问权限。至少,它可以指示最外层块是否需要关注提交事务。除此之外,此声明可用于确定特定方法或块是否适合“重试死锁”,并允许尝试在“读取器”和“写入器”数据库链接之间拆分逻辑的系统提前知道应该将哪些块路由到哪里。

虽然对开放式支持多个数据库的完整规范描述超出了本规范的范围,但作为这里的实现的一部分,我们必然会实现至少当前已存在的内容。现有的 EngineFacade 具有“slave_engine”属性以及在 `get_session()` 和 `get_engine()` 上的“use_slave”标志;至少 Nova 项目和其他项目可能当前正在使用此标志。因此,我们将把同等水平的功能移植到 `reader()` 和 `writer()` 中作为起点。

除了维护现有功能外,在推出此规范后,将更容易指定和实现更全面和潜在的复杂的多数据库支持系统。这是因为消耗项目会将它们的冗长程度大大降低到简单的声明级别,从而使 oslo.db 能够扩展底层机制,而无需对项目进行额外的全面更改(因此“facades”被使用的主要原因之一)。

读者和写入器的嵌套行为如下

  1. 最终调用随后调用 `writer()` 的方法的 `reader()` 块应引发异常;这意味着这个 `reader()` 实际上根本不是 `reader()`。

  2. 最终调用调用 `reader()` 的方法的 `writer()` 块应成功通过;这些 `reader()` 块将在 `writer()` 块的上下文中被强制充当 `writer()`。

核心连接方法

对于仅使用 Core 的方法,提供了相应的 `reader_connection()` 和 `writer_connection()` 方法,它们不返回 `sqlalchemy.orm.Session`,而是返回 `sqlalchemy.engine.Connection`

@sql.writer_connection
def some_core_api_method(context):
    context.connection.execute(<statement>)

def some_core_api_method(context):
    with sql.using_writer_connection(context) as conn:
        conn.execute(<statement>)

`reader_connection()` 和 `writer_connection()` 将与 `reader()` 和 `writer()` 集成,以便最外层上下文将建立要在整个上下文期间使用的 `sqlalchemy.engine.Connection`,无论它是否与 `Session` 关联。这意味着以下几点:

  1. 如果首先调用 `reader_connection()` 或 `writer_connection()` 管理器,则将 `sqlalchemy.engine.Connection` 与上下文关联,而不是 `Session`。

  2. 如果首先调用 `reader()` 或 `writer()` 管理器,则将 `Session` 与上下文关联,其中将包含 `sqlalchemy.engine.Connection`。

  3. 如果调用 `reader_connection()` 或 `writer_connection()` 管理器并且已经存在 `Session`,则使用该 `Session` 的 `Session.connection()` 方法来获取 `Connection`。

  4. 如果调用 `reader()` 或 `writer()` 管理器并且已经存在 `Connection`,则创建新的 `Session`,并将其直接绑定到此现有的 `Connection`。

与配置/启动的集成

`reader()`、`writer()` 和其他方法将调用 oslo.db 中当前 `get_session()` 和 `get_engine()` 方法的功能等效项,以及处理当前由调用 `EngineFacade` 和组合 `CONF` 组成的逻辑。也就是说,消耗应用程序不引用 `EngineFacade` 或 `CONF`;与 `CONF` 的交互以类似于当前在 oslo.db 中执行的方式进行,并且在互斥锁下完成,以确保它是线程安全的,就像 Nova 执行此任务一样。

对于当前具有将键添加到 `CONF` 或 `EngineFacade` 的特殊逻辑的应用程序,将提供额外的 API 方法。例如,Sahara 希望确保将 `sqlite_fk` 标志设置为 `True`。模式如下:

from oslo.db import enginefacade as sql

sql.configure(sqlite_fk=True)

def some_api_method():
    with sql.reader() as session:
        # work with session

重试死锁/其他故障

Oslo.db 提供了 `@wrap_db_retry()` 装饰器,它允许 API 方法在失败时重播自身。根据 https://review.openstack.org/#/c/109549/,我们将向此装饰器添加特异性,允许它显式指示应在发生死锁条件时重试该方法。我们也可以考虑将此功能集成到 `reader()` 和 `writer()` 装饰器中。

备选方案

这里的关键决策是装饰器与上下文管理器以及线程局部变量的使用。

示例形式

  1. 装饰器,使用上下文

    @sql.reader
    def some_api_method(context):
        # work with context.session
    
    @sql.writer
    def some_other_api_method(context):
        # work with context.session
    
  2. 装饰器,使用线程局部变量;这里,`session` 参数被注入到装饰器范围内 API 方法的参数列表中,它不存在于 API 方法的外部调用中

    @sql.reader
    def some_api_method(session):
        # work with session
    
    @sql.writer
    def some_other_api_method(session):
        # work with session
    
  3. 上下文管理器,使用上下文

    def some_api_method(context):
        with sql.using_reader(context) as session:
            # work with session
    
    def some_other_api_method(context):
        with sql.using_writer(context) as session:
            # work with session
    
  4. 上下文管理器,使用隐式线程局部变量

    def some_api_method():
        with sql.using_reader() as session:
            # work with session
    
    def some_other_api_method():
        with sql.using_writer() as session:
            # work with session
    
  5. 上下文管理器,使用显式线程局部变量

    def some_api_method():
        with sql.using_reader(GLOBAL_CONTEXT) as session:
            # work with session
    
    def some_other_api_method():
        with sql.using_writer(GLOBAL_CONTEXT) as session:
            # work with session
    

作者倾向于方法 #1。应该注意的是,如果项目无法达成一致,所有上述方法都可以同时支持。

仅使用显式上下文的装饰器的优点是

  1. 消除了对线程局部变量或 eventlet 的需求。

  2. “重试死锁”和其他“重试”功能可以集成到 `reader()` / `writer()` 装饰器中,以便所有 API 方法自动获得此功能。目前,应用程序需要不断推出新的更改,每次在野外检测到不可避免的死锁情况时,将他们的 `@_retry_on_deadlock()` 装饰器添加到越来越多的 API 方法中。

  3. 与上下文管理器相比,装饰器减少了嵌套深度,最终也更简洁,但需要一个“context”参数。

  4. 装饰器消除了这种已经存在的反模式

    def some_api_method():
        with sql.writer() as session:
            # do something with session
    
        # transaction completes here
    
        for element in stuff:
            # new transaction per element
            some_other_api_method_with_db(element)
    

在上面的例子中,我们无意中执行了许多不同的事务,首先使用 `sql.writer()`,然后使用对某些其他具有数据库的 api_method() 的每次调用。这种反模式已经出现在 Nova 的 `instance_create()` 方法等方法中,如下所示:

@require_context
def instance_create(context, values):

    # ...  about halfway through

    session = get_session()

    # session / connection / transaction #1
    with session.begin():
        # does some things with instnace_ref

    # session / connection / transaction #2
    ec2_instance_create(context, instance_ref['uuid'])

    # session / connection / transaction #3
    _instance_extra_create(context, {'instance_uuid': instance_ref['uuid']})

    return instance_ref

由于上下文管理器允许在方法中随意选择事务何时开始和结束,我们就有可能做出错误的决定,就像当前代码中发生的那样。使用装饰器,这种反模式是不可能的

@sql.writer()
def some_api_method(context):
    # do something with context.session

    for element in stuff:
        # uses same session / transaction guaranteed
        some_other_api_method_with_db(context, element)

    # transaction completes here

使用隐式“线程局部”上下文的一个优点是,不可能在调用链的中间无意中切换上下文,这再次会导致嵌套事务问题。

使用具有隐式线程局部变量的上下文管理器的优点是,Keystone 将更容易迁移到此系统。

Impact on Existing APIs

现有项目需要集成到某种形式的给定模式中。

安全影响

性能影响

性能将得到显著提高,因为当前许多冗余和不连接的会话和事务将被合并在一起。

Configuration Impact

无。

开发人员影响

开发人员需要了解的新模式。

Testing Impact

由于大多数测试套件当前都简单地决定使用 SQLite 并允许 API 方法在没有任何更改或注入的情况下使用其通常的 get_session() / get_engine() 逻辑,因此最初不需要进行太多更改。在 oslo.db 中,将“机会主义”fixtures 以及 DbTestCase 系统与新的上下文管理器/装饰器系统集成。

实现

负责人

Mike Bayer

里程碑

完成目标里程碑

工作项

孵化

采用

预计 API 稳定

文档影响

依赖项

参考资料

注意

本作品采用知识共享署名 3.0 非移植许可协议授权。 http://creativecommons.org/licenses/by/3.0/legalcode