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

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 callables, которые обычно являются ламбдами. Для краткости использования он переопределяет оператор +=, так что типичное построение запроса выглядит следующим образом:

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, все лямбды вызываются только один раз. Каждая лямбда никогда не вызывается во второй раз до тех пор, пока этот запрос кэшируется в пекарне.

  5. Кэширование достигается путем хранения ссылок на сами лямбда-объекты для формулирования ключа кэша; то есть тот факт, что интерпретатор Python присваивает этим функциям идентификатор in-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(). Однако при таком подходе каждый раз при вызове Select все равно регенерируется полный объект Query.all(), и дополнительно этот совершенно новый 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()

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

Исходя из вышесказанного, если мы зададим себе вопрос: «Что, если поиск должен принимать условные решения относительно структуры запроса?», то именно здесь, надеюсь, становится очевидным, почему «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()

См.также

bindparam.expanding

ColumnOperators.in_()

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

При использовании объектов 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

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