Запеченные запросы

baked предоставляет альтернативный шаблон создания объектов Query, который позволяет кэшировать этапы построения объекта и компиляции строки. Это означает, что для конкретного сценария построения Query, который используется более одного раза, все вызовы функций Python, связанные с построением запроса от его начального построения до генерации строки SQL, будут происходить только один раз, а не при каждом построении и выполнении запроса.

Смысл такой системы заключается в том, чтобы значительно снизить накладные расходы интерпретатора Python на все, что происходит до выдачи SQL. Кэширование «печеной» системы ни в коей мере не уменьшает количество вызовов SQL и не кэширует результаты из базы данных. Техника, демонстрирующая кэширование самих вызовов SQL и наборов результатов, доступна в Кэширование Dogpile.

Не рекомендуется, начиная с версии 1.4: В SQLAlchemy 1.4 и 2.0 реализована совершенно новая система прямого кэширования запросов, которая избавляет от необходимости использования системы BakedQuery. Теперь кэширование прозрачно активизируется для всех запросов Core и ORM без каких-либо действий со стороны пользователя, используя систему, описанную в Кэширование компиляции SQL.

Deep Alchemy

Расширение sqlalchemy.ext.baked не для начинающих. Его правильное использование требует хорошего понимания на высоком уровне того, как взаимодействуют между собой SQLAlchemy, драйвер базы данных и внутренняя база данных. Это расширение представляет собой очень специфический вид оптимизации, который обычно не требуется. Как было отмечено выше, оно не кэширует запросы, а только строковую формулировку самого SQL.

Синопсис

Использование системы baked начинается с создания так называемой «пекарни», которая представляет собой хранилище для определенной серии объектов запроса:

from sqlalchemy.ext import baked

bakery = baked.bakery()

Приведенная выше «пекарня» будет хранить кэшированные данные в LRU-кэше, который по умолчанию составляет 200 элементов, учитывая, что ORM-запрос обычно содержит одну запись для самого ORM-запроса, а также по одной записи на каждый диалект базы данных для SQL-строки.

Пекарня позволяет построить объект Query, задав его конструкцию в виде серии Python-вызовов, которые обычно представляют собой лямбды. Для краткости использования он переопределяет оператор += таким образом, что типичное построение запроса выглядит следующим образом:

from sqlalchemy import bindparam


def search_for_user(session, username, email=None):
    baked_query = bakery(lambda session: session.query(User))
    baked_query += lambda q: q.filter(User.name == bindparam("username"))

    baked_query += lambda q: q.order_by(User.id)

    if email:
        baked_query += lambda q: q.filter(User.email == bindparam("email"))

    result = baked_query(session).params(username=username, email=email).all()

    return result

Ниже приведены некоторые замечания по поводу приведенного выше кода:

  1. Объект baked_query является экземпляром BakedQuery. Этот объект по сути является «конструктором» для реального объекта Query, но сам он не является актуальным объектом Query.

  2. Собственно объект Query не строится вообще, до самого конца функции, когда вызывается Result.all().

  3. Все шаги, добавляемые к объекту baked_query, выражаются в виде функций Python, обычно лямбд. Первая лямбда, передаваемая функции bakery(), принимает в качестве аргумента число Session. Остальные лямбды принимают по одному аргументу Query.

  4. В приведенном выше коде, несмотря на то, что наше приложение может обращаться к search_for_user() много раз, и несмотря на то, что в каждом обращении мы создаем совершенно новый объект BakedQuery, все лямбды вызываются только один раз. Каждая лямбда никогда не вызывается второй раз до тех пор, пока этот запрос кэшируется в bakery.

  5. Кэширование достигается за счет хранения ссылок на сами лямбда-объекты для формулирования ключа кэша; то есть тот факт, что интерпретатор Python присваивает этим функциям внутрипитоновский идентификатор, определяет, как идентифицировать запрос при последующих запусках. Для тех вызовов search_for_user(), где указан параметр email, вызываемая переменная lambda q: q.filter(User.email == bindparam('email')) будет частью извлекаемого ключа кэша; когда email является None, эта вызываемая переменная не является частью ключа кэша.

  6. Поскольку все лямбды вызываются только один раз, очень важно, чтобы в них не было ссылок на переменные, которые могут изменяться при разных вызовах; вместо этого, предполагая, что это значения, которые должны быть связаны в строке SQL, мы используем bindparam() для создания именованных параметров, к которым впоследствии применяем их фактические значения с помощью Result.params().

Производительность

Возможно, «запеченный» запрос выглядит немного странно, немного неуклюже и немного многословно. Однако экономия производительности в Python для запроса, который многократно вызывается в приложении, весьма значительна. В примере short_selects, показанном в Производительность, приводится сравнение запросов, каждый из которых возвращает только одну строку, например, следующий обычный запрос:

session = Session(bind=engine)
for id_ in random.sample(ids, n):
    session.query(Customer).filter(Customer.id == id_).one()

по сравнению с эквивалентным «печеным» запросом:

bakery = baked.bakery()
s = Session(bind=engine)
for id_ in random.sample(ids, n):
    q = bakery(lambda s: s.query(Customer))
    q += lambda q: q.filter(Customer.id == bindparam("id"))
    q(s).params(id=id_).one()

Разница в количестве вызовов функций Python для итерации из 10000 обращений к каждому блоку составляет:

test_baked_query : test a baked query of the full entity.
                   (10000 iterations); total fn calls 1951294

test_orm_query :   test a straight ORM query of the full entity.
                   (10000 iterations); total fn calls 7900535

В пересчете на количество секунд на мощном ноутбуке это выглядит так:

test_baked_query : test a baked query of the full entity.
                   (10000 iterations); total time 2.174126 sec

test_orm_query :   test a straight ORM query of the full entity.
                   (10000 iterations); total time 7.958516 sec

Обратите внимание, что в этом тесте намеренно используются запросы, возвращающие только одну строку. Для запросов, возвращающих много строк, преимущество в производительности «запеченного» запроса будет все меньше и меньше, пропорционально времени, затрачиваемому на получение строк. Важно помнить, что функция «запеченных» запросов относится только к построению самого запроса, но не к получению результатов. Использование функции «запеченных» запросов ни в коем случае не гарантирует значительно более быстрой работы приложения; это лишь потенциально полезная функция для тех приложений, которые, по результатам измерений, подвержены влиянию этой конкретной формы накладных расходов.

Обоснование

Приведенный выше «лямбда»-подход является надмножеством более традиционного «параметризованного» подхода. Предположим, что мы хотим построить простую систему, в которой Query создается только один раз, а затем хранится в словаре для повторного использования. Это можно сделать уже сейчас, просто построив запрос и удалив его Session вызовом my_cached_query = query.with_session(None):

my_simple_cache = {}


def lookup(session, id_argument):
    if "my_key" not in my_simple_cache:
        query = session.query(Model).filter(Model.id == bindparam("id"))
        my_simple_cache["my_key"] = query.with_session(None)
    else:
        query = my_simple_cache["my_key"].with_session(session)

    return query.params(id=id_argument).all()

Приведенный подход дает очень минимальный выигрыш в производительности. Повторно используя Query, мы экономим на работе Python в конструкторе session.query(Model), а также на вызове filter(Model.id == bindparam('id')), который пропустит создание выражения Core и передачу его в Query.filter(). Однако при таком подходе каждый раз при вызове Query.all() происходит регенерация полного объекта Select и, кроме того, каждый раз этот новый Select отправляется на шаг компиляции строки, что для простого случая, как описано выше, составляет около 70% накладных расходов.

Чтобы уменьшить дополнительные накладные расходы, нам нужна более специализированная логика, способ мемоизации построения объекта select и построения SQL. Пример такого способа есть на вики в разделе BakedQuery, который является предшественником этой возможности, однако в этой системе мы не кэшируем конструкцию запроса. Для того чтобы убрать все накладные расходы, нам необходимо кэшировать как построение запроса, так и компиляцию SQL. Предположим, что мы адаптировали рецепт таким образом и сделали себе метод .bake(), который предварительно компилирует SQL для запроса, создавая новый объект, который может быть вызван с минимальными накладными расходами. Наш пример приобретает следующий вид:

my_simple_cache = {}


def lookup(session, id_argument):
    if "my_key" not in my_simple_cache:
        query = session.query(Model).filter(Model.id == bindparam("id"))
        my_simple_cache["my_key"] = query.with_session(None).bake()
    else:
        query = my_simple_cache["my_key"].with_session(session)

    return query.params(id=id_argument).all()

Выше мы исправили ситуацию с производительностью, но нам по-прежнему приходится иметь дело с ключом кэша строк.

Мы можем использовать подход «пекарни», чтобы переформулировать вышеизложенное таким образом, чтобы это выглядело менее необычно, чем подход «наращивания лямбд», и больше походило на простое улучшение простого подхода «повторного использования запроса»:

bakery = baked.bakery()


def lookup(session, id_argument):
    def create_model_query(session):
        return session.query(Model).filter(Model.id == bindparam("id"))

    parameterized_query = bakery.bake(create_model_query)
    return parameterized_query(session).params(id=id_argument).all()

Выше мы использовали систему «запекания», которая очень похожа на упрощенную систему «кэширования запроса». Однако она использует на две строки меньше кода, не требует изготовления ключа кэша «my_key», а также включает в себя ту же функцию, что и наша пользовательская функция «запекания», которая кэширует 100% работы по вызову Python от конструктора запроса, вызова фильтра, создания объекта Select до этапа компиляции строки.

Исходя из вышесказанного, если мы зададимся вопросом: «А что, если lookup должен принимать условные решения относительно структуры запроса?», то здесь, надеюсь, становится очевидным, почему «baked» является таким, какой он есть. Вместо того, чтобы параметризованный запрос строился на основе одной функции (а именно так, по нашему мнению, изначально должен был работать baked), мы можем строить его на основе любого количества функций. Рассмотрим наш наивный пример, если бы нам потребовалось включить в запрос дополнительный пункт на условной основе:

my_simple_cache = {}


def lookup(session, id_argument, include_frobnizzle=False):
    if include_frobnizzle:
        cache_key = "my_key_with_frobnizzle"
    else:
        cache_key = "my_key_without_frobnizzle"

    if cache_key not in my_simple_cache:
        query = session.query(Model).filter(Model.id == bindparam("id"))
        if include_frobnizzle:
            query = query.filter(Model.frobnizzle == True)

        my_simple_cache[cache_key] = query.with_session(None).bake()
    else:
        query = my_simple_cache[cache_key].with_session(session)

    return query.params(id=id_argument).all()

Теперь наша «простая» параметризованная система должна генерировать ключи кэша с учетом того, был ли передан флаг «include_frobnizzle», поскольку наличие этого флага означает, что генерируемый SQL будет совершенно другим. Должно быть очевидно, что при увеличении сложности построения запросов задача их кэширования быстро становится обременительной. Мы можем преобразовать приведенный выше пример в прямое использование «bakery» следующим образом:

bakery = baked.bakery()


def lookup(session, id_argument, include_frobnizzle=False):
    def create_model_query(session):
        return session.query(Model).filter(Model.id == bindparam("id"))

    parameterized_query = bakery.bake(create_model_query)

    if include_frobnizzle:

        def include_frobnizzle_in_query(query):
            return query.filter(Model.frobnizzle == True)

        parameterized_query = parameterized_query.with_criteria(
            include_frobnizzle_in_query
        )

    return parameterized_query(session).params(id=id_argument).all()

Выше мы снова кэшируем не только объект запроса, но и всю работу, которую он должен выполнить для генерации SQL. Нам также больше не нужно заботиться о том, чтобы сгенерировать ключ кэша, точно учитывающий все произведенные нами структурные модификации; теперь это делается автоматически и без возможности ошибки.

Этот пример кода на несколько строк короче наивного примера, избавлен от необходимости работать с ключами кэша и обладает огромными преимуществами в производительности, так называемыми «запеченными» возможностями. Но все же он немного многословен! Поэтому мы берем такие методы, как BakedQuery.add_criteria() и BakedQuery.with_criteria(), и сокращаем их до операторов, а также поощряем (хотя, конечно, не требуем!) использование простых лямбд, только в качестве средства уменьшения многословности:

bakery = baked.bakery()


def lookup(session, id_argument, include_frobnizzle=False):
    parameterized_query = bakery.bake(
        lambda s: s.query(Model).filter(Model.id == bindparam("id"))
    )

    if include_frobnizzle:
        parameterized_query += lambda q: q.filter(Model.frobnizzle == True)

    return parameterized_query(session).params(id=id_argument).all()

Там, где указано выше, подход более прост в реализации и гораздо более схож по кодовому потоку с тем, как выглядела бы функция запроса без кэширования, что облегчает перенос кода.

Приведенное выше описание, по сути, является кратким изложением процесса разработки, в результате которого мы пришли к текущему «запеченному» подходу. Начиная с «обычных» подходов, необходимо было решить дополнительные вопросы построения и управления ключами кэша, устранения избыточного выполнения Python и построения запросов с условиями, что и привело к окончательному варианту.

Специальные техники запросов

В этом разделе будут описаны некоторые приемы для конкретных ситуаций запроса.

Использование выражений IN

Метод ColumnOperators.in_() в SQLAlchemy исторически выдает переменный набор связанных параметров в зависимости от списка элементов, переданных в метод. Это не подходит для запекаемых запросов, поскольку длина списка может меняться при разных вызовах. Для решения этой проблемы параметр bindparam.expanding поддерживает поздний рендеринг выражения IN, которое безопасно для кэширования внутри запеченного запроса. Фактический список элементов отображается во время выполнения запроса, а не во время компиляции запроса:

bakery = baked.bakery()

baked_query = bakery(lambda session: session.query(User))
baked_query += lambda q: q.filter(User.name.in_(bindparam("username", expanding=True)))

result = baked_query.with_session(session).params(username=["ed", "fred"]).all()

Использование подзапросов

При использовании объектов Query часто возникает необходимость, чтобы один объект Query использовался для формирования подзапроса внутри другого. В случае, когда объект Query в данный момент находится в запеченном виде, для получения объекта Query может быть использован промежуточный метод BakedQuery.to_query(). Этому методу передается Session или Query, который является аргументом лямбда-вызова, используемого для генерации определенного шага запеченного запроса:

bakery = baked.bakery()

# a baked query that will end up being used as a subquery
my_subq = bakery(lambda s: s.query(User.id))
my_subq += lambda q: q.filter(User.id == Address.user_id)

# select a correlated subquery in the top columns list,
# we have the "session" argument, pass that
my_q = bakery(lambda s: s.query(Address.id, my_subq.to_query(s).as_scalar()))

# use a correlated subquery in some of the criteria, we have
# the "query" argument, pass that.
my_q += lambda q: q.filter(my_subq.to_query(q).exists())

Добавлено в версии 1.3.

Использование события before_compile

Начиная с версии SQLAlchemy 1.3.11, использование события QueryEvents.before_compile() против определенного Query запрещает системе кэширования запросов, если хук события возвращает новый объект Query, отличный от переданного. Это делается для того, чтобы хук QueryEvents.before_compile() мог вызываться против конкретного Query каждый раз, когда он используется, с учетом хуков, которые каждый раз изменяют запрос по-разному. Чтобы позволить QueryEvents.before_compile() изменять объект sqlalchemy.orm.Query(), но при этом обеспечить кэширование результата, событие можно зарегистрировать, передав флаг bake_ok=True:

@event.listens_for(Query, "before_compile", retval=True, bake_ok=True)
def my_event(query):
    for desc in query.column_descriptions:
        if desc["type"] is User:
            entity = desc["entity"]
            query = query.filter(entity.deleted == False)
    return query

Приведенная выше стратегия подходит для события, которое каждый раз будет модифицировать заданную Query совершенно одинаковым образом, не зависящим от конкретных параметров или внешнего состояния, которое изменяется.

Добавлено в версии 1.3.11: - added the «bake_ok» flag to the QueryEvents.before_compile() event and disallowed caching via the «baked» extension from occurring for event handlers that return a new Query object if this flag is not set.

Отключение запеченных запросов в рамках всей сессии

Флаг Session.enable_baked_queries может быть установлен в значение False, в результате чего все запеченные запросы не будут использовать кэш при работе с данным Session:

session = Session(engine, enable_baked_queries=False)

Как и все флаги сессий, он также принимается объектами-фабриками типа sessionmaker и методами типа sessionmaker.configure().

Непосредственное обоснование этого флага заключается в том, что приложение, в котором наблюдаются проблемы, потенциально связанные с конфликтами ключей кэша в результате пользовательских запеченных запросов или других проблем с запеченными запросами, может отключить это поведение, чтобы определить или устранить запеченные запросы как причину проблемы.

Добавлено в версии 1.2.

Интеграция ленивой загрузки

Изменено в версии 1.4: Начиная с версии SQLAlchemy 1.4, система «запеченных запросов» больше не является частью системы загрузки отношений. Вместо нее используется система native caching.

Документация по API

Object Name Description

BakedQuery

Объект построителя для объектов Query.

bakery

Построить новую пекарню.

Bakery

Вызываемый элемент, возвращающий значение BakedQuery.

function sqlalchemy.ext.baked.bakery(size=200, _size_alert=None)

Построить новую пекарню.

Результат:

экземпляр Bakery

class sqlalchemy.ext.baked.BakedQuery

Объект построителя для объектов Query.

method sqlalchemy.ext.baked.BakedQuery.add_criteria(fn, *args)

Добавьте к этому BakedQuery функцию критериев.

Это эквивалентно использованию оператора += для модификации BakedQuery in-place.

classmethod sqlalchemy.ext.baked.BakedQuery.bakery(size=200, _size_alert=None)

Построить новую пекарню.

Результат:

экземпляр Bakery

method sqlalchemy.ext.baked.BakedQuery.for_session(session)

Возвращает объект Result для данного BakedQuery.

Это эквивалентно вызову BakedQuery в качестве вызываемого элемента Python, например, result = my_baked_query(session).

method sqlalchemy.ext.baked.BakedQuery.spoil(full=False)

Отменяет любое кэширование запросов, которое будет происходить для данного объекта BakedQuery.

Запеченный запрос можно продолжать использовать в обычном режиме, однако дополнительные функции создания не будут кэшироваться, они будут вызываться при каждом обращении.

Это делается для того, чтобы поддержать случай, когда определенный шаг в построении запеченного запроса не позволяет ему быть кэшируемым, например, вариант, который полагается на некоторое некэшируемое значение.

Параметры:

full – Если False, то некэшируемыми будут только функции, добавленные к данному объекту BakedQuery после шага spoil; состояние объекта BakedQuery до этого момента будет браться из кэша. Если True, то весь объект Query каждый раз строится с нуля, при этом все создающие функции вызываются при каждом обращении.

method sqlalchemy.ext.baked.BakedQuery.to_query(query_or_session)

Возвращает объект Query для использования в качестве подзапроса.

Этот метод должен использоваться внутри вызываемой лямбды, которая используется для генерации шага вложенного BakedQuery. Параметром обычно должен быть объект Query, который передается в лямбду:

sub_bq = self.bakery(lambda s: s.query(User.name))
sub_bq += lambda q: q.filter(
    User.id == Address.user_id).correlate(Address)

main_bq = self.bakery(lambda s: s.query(Address))
main_bq += lambda q: q.filter(
    sub_bq.to_query(q).exists())

В случае, когда подзапрос используется в первом вызываемом запросе против Session, принимается также Session:

sub_bq = self.bakery(lambda s: s.query(User.name))
sub_bq += lambda q: q.filter(
    User.id == Address.user_id).correlate(Address)

main_bq = self.bakery(
    lambda s: s.query(
    Address.id, sub_bq.to_query(q).scalar_subquery())
)
Параметры:

query_or_session – объект Query или объект класса Session, который, как предполагается, находится в контексте вложенного вызываемого объекта BakedQuery. … versionadded:: 1.3

method sqlalchemy.ext.baked.BakedQuery.with_criteria(fn, *args)

Добавить функцию критерия в BakedQuery, клонированный из данного.

Это эквивалентно использованию оператора + для получения нового BakedQuery с модификациями.

class sqlalchemy.ext.baked.Bakery

Вызываемый элемент, возвращающий значение BakedQuery.

Этот объект возвращается методом класса BakedQuery.bakery(). Он существует как объект для того, чтобы «кэш» можно было легко осмотреть.

Добавлено в версии 1.2.

class sqlalchemy.ext.baked.Result

Вызывает BakedQuery против Session.

Объект Result - это место, где создается или извлекается из кэша реальный объект Query по отношению к целевому объекту Session, а затем вызывается для получения результатов.

method sqlalchemy.ext.baked.Result.all()

Вернуть все строки.

Эквивалентно Query.all().

method sqlalchemy.ext.baked.Result.count()

возвращают „count“.

Эквивалентно Query.count().

Обратите внимание, что здесь используется подзапрос, обеспечивающий точный подсчет независимо от структуры исходного оператора.

method sqlalchemy.ext.baked.Result.first()

Возвращает первый ряд.

Эквивалентно Query.first().

method sqlalchemy.ext.baked.Result.get(ident)

Получение объекта на основе идентификации.

Эквивалентно Query.get().

method sqlalchemy.ext.baked.Result.one()

Вернуть ровно один результат или выдать исключение.

Эквивалентно Query.one().

method sqlalchemy.ext.baked.Result.one_or_none()

Возвращает один или ноль результатов, или выдает исключение для нескольких строк.

Эквивалентно Query.one_or_none().

method sqlalchemy.ext.baked.Result.params(*args, **kw)

Укажите параметры, которые должны быть заменены в строковом SQL-операторе.

method sqlalchemy.ext.baked.Result.scalar()

Возвращает первый элемент первого результата или None, если строк нет. Если возвращается несколько строк, то возникает ошибка MultipleResultsFound.

Эквивалентно Query.scalar().

method sqlalchemy.ext.baked.Result.with_post_criteria(fn)

Добавьте функцию критериев, которая будет применяться после кэширования.

Это добавляет функцию, которая будет выполняться над объектом Query после его извлечения из кэша. В настоящее время она включает только методы Query.params() и Query.execution_options().

Предупреждение

Функции Result.with_post_criteria() применяются к объекту Query после того, как объект SQL-оператора запроса был извлечен из кэша. Следует использовать только методы Query.params() и Query.execution_options().

Добавлено в версии 1.2.

Вернуться на верх