Ключ кэша Python зависит от служебного поля: настроить dataclass

Два одинаковых запроса к каталогу попадают в разные записи кэша. Ключ оформлен frozen dataclass, все поля хешируемы, ошибок нет. Но вместе с фильтрами в объект записали идентификатор трассировки, и теперь каждый HTTP-запрос создаёт новый ключ.

Ключ выражает эквивалентность вычислений

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

Сначала перечислите поля, способные изменить результат: набор фильтров, идентификатор пользователя при персонализации, язык, версия исходных данных. Отсутствующее значимое поле опаснее лишнего служебного: можно вернуть ответ другого пользователя. Только после такого разбора решайте, где хранить служебные сведения. Часто правильнее оставить trace_id в отдельном объекте контекста запроса.

Если служебное поле всё же нужно в одном объекте, field(compare=False) исключит его из сгенерированного равенства. При обычной настройке hash=None участие поля в хеше следует за compare. Два равных ключа должны иметь одинаковый хеш; разные ключи могут иметь совпавший хеш, это допустимо. Поэтому тесту не следует требовать разных чисел hash для любых разных фильтров.

Убрать шум, сохранить значимые различия

Пример описывает запрос каталога. Фильтры хранятся кортежем пар, поэтому порядок нормализуется сортировкой перед созданием ключа. Версия каталога участвует в сравнении: после обновления данных прежний ответ уже не подходит. trace_id исключён из сравнения и хеша, но остаётся доступен для диагностики текущего объекта.

Проверка помещает первый ключ в словарь и читает ответ через второй, созданный с другой трассировкой. Отдельно проверяется смена версии. Это важнее просмотра repr: строковое представление может отличаться, хотя ключи равны. Не исключайте поле только потому, что оно мешает попасть в кэш; сначала подтвердите, что его изменение действительно не меняет семантику результата.

from dataclasses import dataclass, field

@dataclass(frozen=True)
class CatalogKey:
    filters: tuple[tuple[str, str], ...]
    revision: int
    trace_id: str = field(compare=False)

def make_key(filters, revision, trace_id):
    return CatalogKey(tuple(sorted(filters.items())), revision, trace_id)

first = make_key({"city": "Москва", "kind": "book"}, 4, "request-a")
second = make_key({"kind": "book", "city": "Москва"}, 4, "request-b")
assert first == second
assert hash(first) == hash(second)
cache = {first: ("book-17", "book-29")}
assert cache[second] == ("book-17", "book-29")
changed = make_key({"kind": "book", "city": "Москва"}, 5, "request-c")
assert changed != first
assert changed not in cache
print(len(cache), cache[second])

Где заканчивается нормализация

Сортировка подходит словарю независимых фильтров, но не произвольной последовательности. Если пользователь задаёт приоритеты сортировки, перестановка элементов меняет результат. Аналогично нижний регистр подходит только полям с соответствующим правилом сравнения. Преобразование входа в ключ должно повторять смысл вычисления, иначе экономия запросов превратится в подмену результата.

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

В боевом кэше дополнительно требуется срок хранения и ограничение объёма. Удаление trace_id из ключа уменьшает число случайных записей, но не ограничивает множество настоящих сочетаний фильтров. Метрики попаданий и размер хранилища помогут проверить эффект отдельно от корректности. Регрессионный тест при этом остаётся небольшим: эквивалентные запросы читают одну запись, значимые изменения создают другой ключ.

Отдельный вопрос возникает при нескольких пользователях и арендаторах сервиса. Если фильтры совпадают, но права доступа различаются, идентификатор области доступа становится частью ключа либо кэши физически разделяются. Одного trace_id для этого недостаточно: он идентифицирует обращение, а не набор разрешённых данных. Запишите рядом с моделью ключа, от каких входов зависит вычисление ответа, и сверьте этот список с полями dataclass. При добавлении нового параметра функции такой список помогает заметить необходимость обновить ключ. Тест полезно строить парой: изменение служебного значения сохраняет попадание, изменение области данных исключает его. Это проверяет правило повторного использования, которое остаётся важным при любой последующей замене реализации кэша и способа хранения записей.

Следующий шаг в практике

Откройте бесплатный урок о Dataclass, NamedTuple и обычном классе и сравните способы представить объект данных. В полной программе продвинутого Python продолжите проверкой поведения классов и pytest. Профессиональный Python добавляет специальные возможности объектов и механизмы интерпретатора для более глубокого разбора модели данных.

Продолжить на практике

  • Python для продвинутых: ООП, типизация и тестирование ↗

    Практический курс-мост от базового Python к разработке поддерживаемых приложений. Вы разберётесь со структурой пакетов, объектной моделью, современной типизацией и автоматическими тестами, а затем соедините эти инструменты в небольшом приложении с хранением данных и логированием.

  • Python для профессионалов ↗

    Python для профессионалов — курс для людей, стремящихся углубить свои знания языка. Вы изучите итераторы, генераторы, декораторы, современные возможности Python (3.8+), освоите метапрограммирование, асинхронность, внутреннее устройство интерпретатора, инструменты оптимизации и профилирования, а также получите уверенность в использовании продвинутых возможностей стандартной библиотеки.