Статьи

Кэширование запросов по запросу в Django

Django ORM — замечательная вещь. Это делает доступ к базе данных настолько простым, что иногда вы забываете, что это даже происходит. То есть до тех пор, пока вы не откроете django-debug-toolbar и не увидите, что внезапно запускаете сотни запросов! Мало того, но, глядя на реальные запросы, многие из них являются дубликатами! Вы думаете: «Откуда взялись все эти запросы? Глупые коллеги, не пишущие эффективный код! » Тогда вы неизбежно понимаете, что половина дополнительных запросов была написана вами. Как это произошло?

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

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

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

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

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

from myapp.utils import query_cache


class QueryCacheMiddleware:
    def process_request(self, request):
        query_cache.patch()

Затем вы должны включить это промежуточное ПО в settings.py:

 MIDDLEWARE_CLASSES = (
    ...
    'myapp.middleware.QueryCacheMiddleware',

Наконец, вот сам query_cacheпатч.

'''
Hack to cache SELECT statements inside a single Django request. The patch() method replaces
the Django internal execute_sql method with a stand-in called execute_sql_cache. That method
looks at the sql to be run, and if it's a select statement, it checks a thread-local cache first.
Only if it's not found in the cache does it proceed to execute the SQL. On any other type of
sql statement, it blows away the cache. There is some logic to not cache large result sets,
meaning anything over 100 records. This is to preserve Django's lazy query set evaluation.
'''
from threading import local
import itertools
from django.db.models.sql.constants import MULTI
from django.db.models.sql.compiler import SQLCompiler
from django.db.models.sql.datastructures import EmptyResultSet
from django.db.models.sql.constants import GET_ITERATOR_CHUNK_SIZE


_thread_locals = local()


def get_sql(compiler):
    ''' get a tuple of the SQL query and the arguments '''
    try:
        return compiler.as_sql()
    except EmptyResultSet:
        pass
    return ('', [])


def execute_sql_cache(self, result_type=MULTI):

    if hasattr(_thread_locals, 'query_cache'):

        sql = get_sql(self)  # ('SELECT * FROM ...', (50)) <= sql string, args tuple
        if sql[0][:6].upper() == 'SELECT':

            # uses the tuple of sql + args as the cache key
            if sql in _thread_locals.query_cache:
                return _thread_locals.query_cache[sql]

            result = self._execute_sql(result_type)
            if hasattr(result, 'next'):

                # only cache if this is not a full first page of a chunked set
                peek = result.next()
                result = list(itertools.chain([peek], result))

                if len(peek) == GET_ITERATOR_CHUNK_SIZE:
                    return result

            _thread_locals.query_cache[sql] = result

            return result

        else:
            # the database has been updated; throw away the cache
            _thread_locals.query_cache = {}

    return self._execute_sql(result_type)


def patch():
    ''' patch the django query runner to use our own method to execute sql '''
    _thread_locals.query_cache = {}
    if not hasattr(SQLCompiler, '_execute_sql'):
        SQLCompiler._execute_sql = SQLCompiler.execute_sql
        SQLCompiler.execute_sql = execute_sql_cache

Здесь происходит то, что я заменяю внутренний execute_sqlметод Django оболочкой, которая кэширует результаты в локальном словаре потока. Он кэширует только небольшие наборы результатов. При любом результате, превышающем 100 строк, Django запустит курсор базы данных и генератор. Кэшировать их без активных запросов ко всему набору данных было бы довольно сложно , поэтому в этом случае я помогу. Я заметил, что в моей кодовой базе большинство повторных вызовов относятся к одной записи или небольшому набору записей.

Чтобы не иметь дело с какими-либо хитрыми случаями аннулирования, я просто удаляю кеш, если выполняется какой-либо оператор UPDATE, INSERT или DELETE.

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