Кэш планов запросов¶
hare сохраняет устройство запроса — его вид (shape), — чтобы при повторном вызове той же цепочки
.filter()/.annotate()/.order_by() с другими значениями не обходить заново дерево фильтров, не
разбирать заново каждый оператор и не строить с нуля объект запроса hare.sql. Это полностью внутренняя
оптимизация: для неё нет опции Meta и аргумента Hare.init() — единственная настройка — переменная
окружения, ограничивающая размер кэша (см. Границы и время жизни ниже). Эта
страница объясняет, что кэш покрывает и что нет, чтобы поведение запроса никогда не было загадкой.
Главное: вид и значения¶
У двух вызовов один вид, если они строят одинаковое устройство фильтров, вычисляемых значений, соединений и сортировки — те же поля, те же операторы, те же присоединённые связи — и отличаются только конкретными значениями, подставленными в это устройство:
await Book.objects.filter(title__icontains="dispossessed")
await Book.objects.filter(title__icontains="left hand") # тот же вид, другое значение
Первый вызов строит полный объект запроса hare.sql (дерево WHERE, JOIN, список SELECT, план
чтения строк), один раз выводит его в SQL и сохраняет результат как план вида под ключом из всего,
что составляет вид: текст SQL, в какой параметр идёт каждое значение запроса и что нужно для чтения
результата. Второй вызов узнаёт тот же вид и выполняет текст SQL плана со своими значениями — не разбирая
заново Q/F/вычисляемые значения, не трогая граф соединений и не выводя SQL.
Запрос вида, у которого есть план, собирается полностью, когда выполнить его по плану нельзя: запрос,
показанный через .sql()/explain() или встроенный в другой запрос, у которого нет своего плана, и
запрос со значением, меняющим текст SQL (значение, преобразующееся в другой тип, LIMIT только с одной
стороны). План остаётся для следующего запроса. Значение фильтра None и логическое значение __isnull
входят в вид: у запросов IS NULL/IS NOT NULL свои планы.
Главный практический случай — постраничный вывод: значения LIMIT/OFFSET никогда не входят в ключ
вида и привязываются при каждом вызове заново (в ключ входит только то, есть ли у запроса LIMIT и
OFFSET вообще), поэтому листание одного и того же отфильтрованного и отсортированного списка — едва ли
не самый частый запрос в приложении — попадает в кэш на каждой странице:
base = Book.objects.filter(in_print=True).order_by("-published_at")
await base.limit(20).offset(0) # строит и сохраняет вид
await base.limit(20).offset(20) # тот же вид, попадание в кэш
await base.limit(20).offset(40) # тот же вид, попадание в кэш
Границы курсора по JSON-полям тоже привязывают новые значения к сохранённому плану по тем же правилам
сортировки JSON, что и order_by(). Это относится к after_cursor() и before_cursor(), включая
запросы values(), count() и exists().
Что входит в вид (ключ кэша)¶
Класс модели, диалект, имя подключения, построитель запросов, к которому привязана модель, какие колонки
и связи выбираются (select_related/.only()/.defer()/.values()), сортировка, distinct(),
устройство дерева WHERE (вид соединения условий, отрицание, какое поле и оператор на каком месте — но
никогда не сравниваемое значение), имена и устройство выражений вычисляемых значений (никогда не их
константы) и является ли каждое из них .alias(), флаги select_for_update(), сколько граничных
значений .after_cursor()/.before_cursor() задано и какие из них None (граница None — это проверка
IS NULL/IS NOT NULL без значения), взят ли срез строк (есть ли LIMIT, есть ли OFFSET — но не их
значения), а также пояс, который запрос пишет в свой SQL: имя текущего часового пояса при use_timezone,
собственный пояс машины (как его называет tzlocal) без него — почему, см.
Правильность ниже.
Model.objects.get(**filters) при любых параметрах исключений выполняется по той же записи, что и
QuerySet, который он заменяет: она находится по ключам фильтров и типам их значений — см.
get(). Так же работают модели с Meta.soft_delete_field или
Meta.tenant_field (фильтры области по умолчанию входят в ключ, а привязывается арендатор, активный в
момент выполнения запроса) и get() после select_for_update(), include_deleted(), only_deleted(),
all_tenants() и using() — блокировка и видимые строки входят в ключ. get() с обычными значениями —
отдельный вызов; с None, булевым значением __isnull или списком __in он становится вызовом
filter(), который заменяет. Как и любой QuerySet, соединение он выбирает при await.
Queryset, полученный из Model.objects одними простыми вызовами — filter()/exclude(), в том числе с
условиями Q(...) и Exists(...) (filter(Q(pages=3) | Q(title="x")), связки и отрицания дерева входят
в ключ) и со значениями обычными, выражениями (F("other") + 1) или запросами (id__in=Subquery(...),
QuerySet), annotate()/alias() с выражениями, distinct(), order_by() по именам полей,
limit()/offset(), first()/last(), get(), only(), select_related(),
values()/values_list() по именам полей, group_by(), prefetch_related() и count()/exists()/
aggregate() в конце, — выполняется по записи, найденной по самим вызовам: его фильтры даже не строятся.
Запрос моделей без присоединённых связей и без вычисляемых значений или запрос values()/values_list()
выполняется вовсе без объекта запроса — строки читаются так, как запись хранит для их формы. Значения
каждого вызова — фильтра, литералы выражения — находятся среди значений записи по тому, откуда они взяты,
поэтому порядок значений самой записи (сначала вычисляемые значения, потом фильтры) не обязан совпадать с
порядком вызовов. Вызов prefetch_related() входит в ключ списком подгружаемых связей — колонка ключа
прямой связи выбирается для подгрузки даже после only(), — а запросы подгрузки выполняются так же, как
у любого QuerySet. Связь, прочитанная у объекта (author.books.filter(...)), — тоже цепочка вызовов,
ключ владельца привязывается как значение. Любой другой вызов (терм hare.sql, собранный вручную, как
значение) оставляет QuerySet обычному описанию выше. Запись та же самая: цепочка вызовов находит запись,
построенную её первым запросом, а запись, вытесненную из кэша, по вызовам тоже не найти.
Прямая связь, сравниваемая со значением ключа (author=3, author__gt=3), с объектом (author=obj) или
со списком ключей или объектов (author__in=[...]), сохраняет свою запись: объект заменяется своим
ключом и по типу отличается от значения ключа, поэтому у него своя запись. Связь с составным ключом
сравнивает каждую колонку ключа: объект или кортеж ключа подставляется по колонкам. Обобщённый внешний
ключ (target=obj, target__in=[...], target__type="book", target__type__in=[...],
target__isnull=True) сравнивает ветку, которую называет значение, — имя ветки входит в ключ. Фильтр по
имени связи «ко многим» (tags=obj, tags__in=[...], books=obj) и чтение связи «многие ко многим»
(obj.tags.all()) сохраняют свою запись — объект заменяется своим первичным ключом; так же сохраняют её
pk__in=[(a, b), ...] составного первичного ключа (по числу строк), __iexact, __search в PostgreSQL,
фильтры по пути JSON (data__a=, data__a__gt=, data__a__in=) и проверка вхождения JSON в обоих
диалектах.
__range с одной открытой границей или без границ ((None, 10), (None, None)) хранит запись для
каждой открытой стороны, а оставшаяся граница — значение. JSON __filter= хранит запись для ключей
своего словаря и типов его значений, а сами значения подставляются при каждом вызове; __has_keys= и
__has_any_keys= — для числа ключей. .contains(obj) хранит запись так же, как exists(), а ключ
объекта подставляется при каждом вызове.
Фильтр, значение которого — запрос (field__in=Other.objects.filter(...).values_list("id", flat=True),
голый QuerySet — сравнивается по первичному ключу, Subquery(...)), хранит запись вместе с подзапросом:
в ключ входит собственный вид подзапроса, а его значения, включая LIMIT/OFFSET, привязываются вместе
со значениями внешнего запроса. Это верно и для подзапроса с фильтром по оконной функции и для
объединения. Подзапрос, вид которого не может иметь своей
записи (см. таблицу ниже), собирается каждый раз, и вместе с ним — запрос, значением
которого он служит.
Запрос с CTE (.with_cte(name, query)) — запрос модели, .values()/.values_list(), count(),
exists(), aggregate() — хранит запись вместе с ними так же: в ключ входит вид тела каждого CTE, а
значения тела привязываются вместе со значениями самого запроса. Значение фильтра RawSQL
(документированный способ прочитать CTE, filter(id__in=RawSQL('SELECT "id" FROM "cte"'))) входит в вид
своим текстом SQL, а его parameters — значения. update() и delete() с CTE хранят запись так же.
Операция над множествами — union(), intersection(), difference() querysets или запросов
.values()/.values_list(), а также count()/exists() над ней — хранит запись всего общего запроса:
в ключ входит вид каждой ветки (включая вложенную операцию), сами операции, сортировка и взят ли срез;
значения всех веток и среза привязываются при каждом вызове. Операция с веткой, вид которой не может
иметь записи, собирается каждый раз.
Запрос, вызывающий методы QuerySet диалекта — .final() или .limit_by(3, "author_id") ClickHouse, —
хранит запись с этими вызовами, записанными в текст SQL: в ключ входят имя и аргументы каждого метода,
так что .limit_by(3, "author_id") и .limit_by(5, "author_id") — два разных вида. Вызов с аргументом,
который не может входить в ключ (список, словарь), собирает запрос каждый раз. Запрос, выполняемый по
записи, всё равно проверяет, что диалект его подключения реализует каждый вызванный метод.
count(), exists() и aggregate() над строками, которые возвращает запрос .values()/
.values_list() или операция над множествами — со срезом ([2:5].count()), с .distinct(), — хранят
запись всего запроса над этими строками: в ключ входит вид запроса строк (и устройство каждой метрики
aggregate()), а его значения и OFFSET, который пропускает exists(), привязываются при каждом
вызове. Метрика aggregate() над такими строками со своим значением (Sum("price", _filter=Q(...)),
Coalesce(Max("price"), 0)) подставляет свои значения после значений запроса строк.
aggregate() хранит запись, как бы он ни собирался: по прежним ключам .annotate()/.alias(), которые
читают метрики или фильтры, через сгруппированную производную таблицу для агрегатного вычисляемого
значения (.annotate(n=Count(...)).aggregate(Max("n"))) или .distinct(), после .group_by(), с
границей курсора. Какие вычисляемые значения разрешает каждый шаг сборки, следует из вида и хранится в
записи, поэтому следующий запрос перечисляет свои значения в том же порядке, ничего не разрешая. После
.group_by() собственные значения метрики подставляются так же.
Вычисляемое значение, ссылающееся на другое по имени, — F("bumped") * 2, Sum("bumped"), фильтр
bumped__gt=..., partition_by=/order_by= окна или поле Lag/Lead, — хранит запись: разрешение
имени разрешает то значение ещё раз, и его значения привязываются в каждом месте, где оно разрешено.
Оконная функция хранит запись, что бы она ни вычисляла, — RowNumber(), CumeDist(), PercentRank(),
NthValue(...), NTile(n), Lag/Lead со значением по умолчанию или без него, агрегат по окну
(Window(Sum("price")), над выражением или с условием _filter=). Выражение, SQL которого не берёт
значений, — Pi(), Now(), TransactionNow() и RandomUUID() PostgreSQL — тоже хранит запись.
Аргумент None — Case без default, When(then=None), Lag без значения по умолчанию, — часть
формы: в тексте SQL это NULL, значения у него нет.
Фильтр по условию Exists(...) и фильтр, значение которого — выражение (F("other"), F("other") + 1,
OuterReference("id") в связанном Exists(...)/Subquery(...)), хранят запись: литералы самого
выражения подставляются в каждом запросе. Так же хранят запись простое .annotate(x=Value(...)),
.annotate(x=Subquery(...)) и сортировка, группировка или DISTINCT ON по невыбранному вычисляемому
значению (.annotate(dist=L2Distance("embedding", vector)).order_by("dist")[:10].values_list("id")):
значения выражения, заново выведенного там, тоже подставляются.
Exists(...) над запросом .values(), над срезом, с OFFSET или над QuerySet'ом со своим
.with_cte(...) хранит запись: запрос, встроенный в условие, входит в ключ, а его значения — включая
значения его CTE и OFFSET — подставляются вместе со значениями внешнего запроса. OuterReference(...)
через связь (OuterReference("author__name")) тоже хранит запись — JOIN связи во внешнем запросе
записывает своё ограничение по умолчанию, как любой другой, — и так же OuterReference(...) по
вычисляемому значению внешнего запроса: оно разрешается заново, а его значения подставляются там, где
стоит ссылка. Связь «многие ко многим», сравниваемая с внешней строкой (~Q(tags=OuterReference("id")),
tags__not=OuterReference("pk") у связи с составным ключом), тоже хранит запись.
with_recursive() хранит запись: строки, с которых начинается обход, — подзапрос, значения которого
подставляются при каждом вызове, max_depth — значение, а каждый JOIN обхода записывает своё
ограничение по умолчанию.
Запрос .values()/.values_list() с фильтром по оконной функции, обёрнутый в производную таблицу,
хранит запись.
FilteredRelation(...) и select_related(Select(relation, extra_condition=...)), связь которого
пересекается и в другом месте (.only()/.defer() с её полями, фильтр, .order_by(),
.values()/.values_list(), group_by(), вычисляемое значение), хранят запись: условие вплетается в
каждый JOIN связи — условие, вплетённое в несколько JOIN (фильтра, сортировки, связи и строки связи
«многие-ко-многим»), подставляет свои значения в каждый, а условие, не вплетённое ни в один JOIN
(count() без пересечения его связи), не подставляет их никуда. Значения условия — значения самого
запроса, поэтому то же верно для запроса, встроенного в другой, — подзапроса, Exists(...), тела CTE,
ветки операции над множествами, — даже если он присоединяет FilteredRelation(...) под тем же именем,
что и внешний запрос.
Расстояния векторов, полнотекстовый поиск и выражения PostgreSQL хранят запись так же, как встроенные:
L2Distance/CosineDistance/InnerProduct (вектор запроса подставляется, преобразованный так, как
диалект хранит сравниваемый VectorField), SearchVector, SearchQuery (в том числе запрос из
Lexeme), SearchRank и SearchHeadline (текст поиска подставляется, на каждом диалекте),
STDistance/STDWithin (точка и радиус подставляются) и функции триграмм.
count() и exists() за границей keyset-пагинации (.after_cursor()/.before_cursor()), exists()
после OFFSET и фильтр по строкам операции над множествами (pk__in=a.union(b)) хранят запись: значения
границы, OFFSET и значения самого объединения подставляются в каждом запросе.
stream() выполняется по тексту SQL записи так же, как ожидание запроса.
Так же хранят весь свой оператор update() и delete(), чем бы ни выбирались изменяемые строки:
фильтрами через связи, фильтром по агрегату (pk IN (SELECT ... GROUP BY ... HAVING ...)),
.after_cursor(): в ключ входит вид того, что выбирает строки, и каждое присваиваемое поле с типом его
значения или с устройством присваиваемого выражения (F("price") + 1 и F("price") * 1 — два разных
вида).
Терм hare.sql, собранный вручную (Field("score") * ValueWrapper(factor)), хранит запись, где бы он ни
стоял — в вычисляемом значении, в аргументе функции, в присваивании update(): его текст SQL, где каждое
значение — параметр, входит в ключ, а значения, которые он подставляет, подставляются при каждом вызове.
Model.objects.raw(sql, parameters) хранит свою команду для каждого текста SQL, типов параметров и
подключения: текст один раз выводится с обозначениями параметров подключения, а следующий вызов
подставляет в него свои parameters. Какие колонки результата — поля модели, а какие становятся
атрибутами каждого экземпляра, хранится для каждого списка имён колонок. Параметр, который текст держит
литералом ("*"), собирает команду каждый раз.
Фильтр через связь хранит запись при любом операторе — author__name="x", author__name__in=[...],
author__name__startswith="a", author__books__rating__range=(1, 2), с отрицанием
(.exclude(author__name="x")), — а его значение переводится для связанной модели.
Фильтр по вычисляемому значению — .annotate(n=Count("books")).filter(n__gte=2),
.annotate(doubled=F("price") * 2).filter(doubled__gt=10),
.annotate(has_x=Exists(...)).filter(has_x=True) — хранит запись: значение фильтра и собственные
значения выражения привязываются при каждом вызове.
Как запрос описывает свой план¶
Каждая часть запроса, для которой может храниться план, — фильтр (Q), выражение (F, Value,
арифметика, функция или агрегат, Case/When, Window и его оконная функция, Exists, RawSQL),
запрос, встроенный в другой (подзапрос, тело CTE, ветка операции над множествами), — описывает себя сама
(hare.query.plans.description.Plannable.get_plan_description()). У описания (PlanDescription) две
половины: структура — всё, что меняет текст SQL, который строит часть, она входит в ключ плана, — и
значения, которые часть подставляет параметрами. Часть описывает свои части их описаниями, поэтому
ключ всего запроса — структура его описания.
Описания не пишутся вручную. Класс выражения объявляет, как каждый его атрибут входит в план
(plan_parts), а класс запроса — как каждая его настройка входит в ключ (plan_slots: модель,
подключение и видимость, фильтры, сортировка, срез, CTE и т. д.); описание каждого класса генерируется из
его объявления один раз, когда класс создаётся. Запуск pytest с --verify-plans проверяет, что каждый
атрибут описываемого выражения объявлен, а также сравнивает каждый запрос, выполненный по плану, с тем же
запросом, построенным полностью.
У каждого значения есть происхождение — атрибут объекта, где оно лежит: значение фильтра — ключ его
условия, литерал — атрибут его выражения, срез — его запрос. Первый запрос ключа, пока строится,
записывает каждое место, куда текст SQL подставляет значение, вместе с тем, откуда значение взято, и его
план подставляет значение следующего запроса в каждое из них — сколько бы раз построение его ни
разрешало: условие, которое читают фильтр, сортировка и .values() по одной связи, вложенный QuerySet,
встроенный в запрос дважды. Объект, который построение делает заново для каждой копии запроса (копия
запроса, встроенного в другой, условие ограничения по умолчанию, вызов фильтра, отложенный без
построения), представляет объект, из которого сделан, а условие, которое построение выводит из значения
(фильтр через связь, ключ, сравниваемый по колонкам), сохраняет происхождение этого значения.
Ключ — вся структура, сравниваемая на равенство, а не её хэш: два запроса делят план, только когда равно
всё, что определяет текст их SQL. Часть, которая не хранит план, описывает себя как None, и так же
описывается любой запрос с ней: такой запрос каждый раз строится полностью.
Класс части, который не описывает себя и не объявляет plannable = False, отклоняется уже при
определении — см. Своё
выражение.
Что считается значением (в ключ не входит и может меняться)¶
Значение каждого фильтра (хотя его тип в Python в ключ входит: int и float/Decimal для одного
фильтра строят разный SQL и никогда не делят запись), значения LIMIT/OFFSET, содержимое списка
__in/__not_in (к устройству относится то, есть ли в нём None: None добавляет через OR проверку
IS NULL, а пустой список — постоянное условие; список короче длины, с которой диалект передаёт его
одним параметром, — 20 значений в PostgreSQL и SQLite, — различается числом значений, отличных от None,
а более длинный — ничем: одна запись служит любой длине), обе границы полностью ограниченного __range,
значения границы .after_cursor(), константы вычисляемых значений (Value() внутри выражения, константы
then=/default= у Case/When, параметры RawSQL, аргументы по умолчанию
Coalesce/Concat/NTile/Lag/Lead, значения собственного фильтра Aggregate(_filter=Q(...))) и
значения условий select_related(relation, extra_condition=Q(...)) и FilteredRelation(...),
max_depth у with_recursive(), значения терма hare.sql, собранного вручную, parameters у .raw(),
собственные значения метрики aggregate(), значения, которые присваивает update() (преобразованные
так, как их преобразует запись: значение DecimalField округляется до его знаков после запятой), и
момент, который он записывает в поля auto_now.
Что по-прежнему идёт без кэша¶
Несколько видов намеренно исключены — либо потому, что их кэширование грозило бы молча неверным результатом, либо потому, что для них пока нет способа подставить значения. Каждый из них работает правильно, просто без ускорения:
| Вид | Почему исключён |
|---|---|
Lateral(...) и JsonTable(...) |
У подзапроса или табличной функции, присоединённых под именем, нет описания — их значения стоят в JOIN. |
.sample(...) |
Процент и seed пишутся в FROM, а не подставляются. |
| Метод QuerySet диалекта, вызванный с аргументом-списком или словарём | Такой аргумент не может входить в ключ. |
Оператор из register_lookup(), который преобразует значение, без binds_by_rebuild=True |
Безопасно, только если условие содержит сам результат кодировщика значения — см. ниже. |
Терм hare.sql, собранный вручную, который выводится только в своём диалекте или подставляет значение не через ValueWrapper |
Его описание — текст SQL в нейтральном диалекте, где каждое значение ValueWrapper — параметр. |
Ничего из этого не требует изменений в вашем коде — каждый такой запрос просто выполняется без кэша.
JOIN к модели с ограничением по умолчанию — Meta.tenant_field, Meta.soft_delete_field или
собственный get_queryset() в Meta.manager у связанной модели или у промежуточной модели through= —
идёт быстрым путём: план записывает значения ограничения отдельно от значений самого запроса, а запрос,
выполняемый по плану, подставляет ограничение своего контекста (текущего арендатора, то, по чему сейчас
фильтрует свой менеджер). Если фильтр ограничения устроен иначе, чем при записи плана, — другое число
арендаторов в Tenancy.scope(...), свой менеджер фильтрует по-другому, — запрос собирается полностью.
Границы и время жизни¶
StatementPlans.plans, StatementPlans.call_signature_plans (ключ цепочки вызовов -> план из plans,
по которому она выполняется; сбрасывается, когда plans сбрасывает любой план) и
StatementPlans.decode_plans (hare.query.plans.statement.statement_plans) — объекты Cache
(hare.core.caching.cache): ограниченные по размеру и разделённые по классам моделей, а не один
неограниченный общий словарь. У каждого класса модели своя корзина OrderedDict, работающая по принципу
«вытесняется давно не использованное»: попадание перемещает запись в конец (OrderedDict.move_to_end()),
а когда в корзине модели становится больше max_size записей, самая старая удаляется
(OrderedDict.popitem(last=False)). Предел на модель по умолчанию — 512; его можно изменить для всего
процесса переменной окружения HARE_STATEMENT_PLAN_CACHE_MAX_SIZE_PER_MODEL
(hare.core.caching.constants.DEFAULT_STATEMENT_PLAN_CACHE_MAX_SIZE_PER_MODEL) — целым числом от 1 до 1
000 000 (любое другое значение даёт ConfigurationError при импорте hare). Разнообразие видов одной
«шумной» модели (например, pk__in= со всё время разной длиной списка) никогда не вытеснит записи
другой, не связанной модели, как это сделал бы один общий предел.
Корзины модели хранятся в самой модели (Model._meta), а кэш знает модели со своими корзинами только
через WeakSet. Поэтому класс модели, заменённый во время работы (например, через
Hare.register_live_models(), повторно регистрирующий ту же таблицу
под новым классом), удаляется сборщиком мусора вместе со своими записями, как только на него больше ничто
не ссылается.
Отключить кэш нельзя. hare.core.caching.caches.Caches.forget_model_caches([Book, ...]) удаляет всё, что
построено для этих моделей: этот кэш, планы чтения строк (StatementPlans.decode_plans), сохранённый SQL
сохранения и удаления, обходы связей каскадного удаления и все остальные кэши, построенные для моделей, —
так делает сам hare, когда связи модели меняются после первого использования и когда живая модель
регистрируется заново или снимается с регистрации. Используйте это в тесте, которому нужен холодный кэш;
коду приложения это не нужно.
Каждый из этих кэшей зарегистрирован в Caches (hare.core.caching.caches) там, где он объявлен: кэш
значений по ключу — это Cache (регистрирует себя сам; ключ с классом модели попадает в корзину этой
модели, любой другой — в собственную корзину кэша, каждая корзина вытесняет давно не использованное), кэш
с одним значением на класс модели — это ModelCache (тоже регистрирует себя сам; с
depends_on_other_models=True он сбрасывается целиком при изменении любой модели — для значений,
описывающих граф связей), а всё остальное, что реализует forget_model(model) / forget_all(),
передаётся в Caches.register(). forget_model_caches() и forget_all_caches() (вызывается при каждом
изменении реестров — регистрации оператора, типа поля, метода диалекта) проходят по зарегистрированным
кэшам; отдельного списка кэшей нигде нет.
Кроме max_size (сколько записей держит корзина), у Cache такие параметры. holds_sql=False — для
значений без SQL (сгенерированная модель pydantic, описание ключа фильтра): записи модели тогда остаются,
к какому бы построителю запросов модель ни была привязана, и для ещё не привязанной модели.
keyed_by_model=False — для ключей, в которых класса модели не бывает: модель в них не ищется.
depends_on_other_models=True сбрасывает все записи при изменении любой модели, как у ModelCache.
У кэшей с ключами моделей перебор и len(cache) не учитывают SQL-записи другого построителя запросов,
как и чтение, которое считает их отсутствующими.
model_attribute — атрибут _meta модели, где её обычная корзина (см. ниже) хранится ещё и там,
owner_attribute — атрибут, в котором владелец держит своё единственное значение, а
dropped_with=<another cache> сбрасывает все записи этого кэша всякий раз, когда тот кэш сбрасывает
любую свою.
Там, где обращение должно стоить не дороже словаря, кэш выдаёт обычный словарь, которым по-прежнему
владеет сам: Cache.get_model_bucket(model) — словарь, который модель хранит для этого кэша (описания её
ключей фильтров и имён сортировки, разобранные пути, раскладки строк); он удаляется вместе с записями
модели, поэтому запрашивается при каждом чтении. Cache.get_owner_bucket(owner) — словарь другого
объекта (реестр типов и рендереры диалекта держат в нём найденное по базовым классам, класс запроса
RequestQuery — свою проверенную декларацию): владелец хранит его в своём cache_buckets, а кэш очищает
на месте; Cache.new_shared_bucket() — словарь, принадлежащий тем, кто его держит (набор запросов и его
клоны хранят в нём описания ключей, начинающихся с аннотации). Одно значение, которое читается при каждом
запросе, остаётся в атрибуте владельца (owner_attribute, Cache.set_owner_value() — набор запросов
менеджера); кэш возвращает туда None, когда сбрасывает его. Факт о модели — вычисляемый один раз по её
полям и связям — это функция с декоратором ModelCache.fact().
Разделение по подключению и построителю запросов
Ключ включает диалект подключения, на котором выполняется запрос, имя этого подключения и класс
построителя запросов, к которому сейчас привязана модель. Два подключения никогда не делят записи, даже
если у них один диалект и одна и та же модель указывает на разные по устройству схемы. Запрос,
построенный, пока вложенный HareContext привязал те же модели к построителю другого диалекта, никогда
не будет отдан под ключом внешнего контекста после выхода из вложенного. SQL, сохранённый для записи,
удаления и поиска вида Model.objects.get(), разделяется так же.
Правильность¶
Кэш хранит настоящее устройство запроса под ключом из ключей фильтров, а не значений, поэтому главное правило: использовать для другого вызова неверное устройство условия или устаревшее либо чужое значение — значит молча получить неверные данные, а не просто упустить ускорение. Отсюда два правила:
- Оператор из
register_lookup()не должен преобразовывать значение. Свой оператор, который вставляет в условие напрямуюfield.to_db_value(value, model)(илиvalue_encoder(value)), можно кэшировать — при попадании новое значение привязывается тем же путём. Оператор, который вычисляет из значения что-то другое (например, переворачивает строку перед сравнением), кэшировать нельзя — при попадании молча продолжало бы применяться преобразованное значение первого вызова. hare проверяет это по тождеству объектов, прежде чем привязывать значения вида, поэтому небезопасный свой оператор исключается автоматически, а не кэшируется неверно. Оператор, чейFieldLookupобъявляетbinds_by_rebuild=True, всё же получает план: запрос, выполняемый по записи, заново строит условие из своего значения и подставляет то, что в нём оказалось, — это верно, только если текст SQL зависит от значения лишь через его тип (и длину списка). - Всё, что встроено в устройство запроса, а не хранится значением, которое привязывается при попадании,
должно входить в ключ вида. Самый наглядный пример:
field__year/__monthи подобные встраивают имя текущего пояса в своё условие (EXTRACT(... AT TIME ZONE $1)) — в базу оно уходит параметром, но при попадании в кэш заново не привязывается, — поэтому имя пояса входит в ключ вида, и смена пояса во время работы (Timezone.override(), другойinit()) не продолжает молча извлекать части в старом, уже сохранённом поясе. Безuse_timezoneвыражение с датой и временем переводит значение из собственного пояса машины, и имя этого пояса входит в ключ так же.
Скорость¶
Единого официального замера «на сколько быстрее» для кэша в целом нет — его смысл в устройстве: не
обходить и не разбирать заново всё дерево фильтров, вычисляемых значений и соединений, не строить заново
объекты выражений hare.sql и не выводить SQL при каждом вызове того же вида, а только привязывать
значения. Затраты, против которых он направлен, реальны: например, создание обёртки параметра для каждого
элемента большого списка заранее стоило около 97% стоимости построения самого списка, поэтому план
привязывает аргумент-список одним параметром, а не пересобирает его поэлементно. Но это число описывает
одно внутреннее решение, а не общий выигрыш от кэша.