SQL и Qt

Кеширование запросов и инвалидация кеша в слое доступа к данным

Введение

Некоторые данные запрашиваются значительно чаще, чем изменяются — справочник категорий товаров, список стран, настройки приложения. Повторное обращение к базе данных за такими данными при каждом запросе — лишняя нагрузка, особенно для удалённой базы данных с заметной сетевой задержкой. В статье разберём, как добавить кеширование на уровень repository-слоя, используя LruCache из основного цикла статей про готовые методы, и как правильно инвалидировать кеш при изменении данных.

Концепция

Кеширование на уровне репозитория означает, что метод чтения сначала проверяет наличие данных в кеше, и только при их отсутствии обращается к базе данных, сохраняя результат в кеш для последующих обращений. Критичная часть — инвалидация: при любой операции изменения (save, remove) соответствующие записи должны быть удалены из кеша (или кеш обновлён новым значением), иначе после изменения данные в базе и в кеше расходятся, и приложение работает с устаревшей информацией.

Пример кода: Кеширование запросов и

// cachedcategoryrepository.h — кеширующая обёртка над обычным репозиторием
#pragma once
#include "icategoryrepository.h"
#include <QHash>
#include <QMutex>
#include <QMutexLocker>

class CachedCategoryRepository : public ICategoryRepository
{
public:
    explicit CachedCategoryRepository(std::unique_ptr<ICategoryRepository> innerRepository)
        : m_inner(std::move(innerRepository)) {}

    std::optional<Category> findById(int id) override
    {
        {
            QMutexLocker locker(&m_mutex);
            if (m_cacheById.contains(id)) {
                return m_cacheById.value(id);
            }
        }

        auto result = m_inner->findById(id);
        if (result) {
            QMutexLocker locker(&m_mutex);
            m_cacheById.insert(id, *result);
        }
        return result;
    }

    QVector<Category> findAll() override
    {
        {
            QMutexLocker locker(&m_mutex);
            if (m_allCached) {
                return m_allCachedValue;
            }
        }

        const auto result = m_inner->findAll();

        QMutexLocker locker(&m_mutex);
        m_allCachedValue = result;
        m_allCached = true;
        for (const auto &category : result) {
            m_cacheById.insert(category.id, category);
        }
        return result;
    }

    bool save(Category &category) override
    {
        const bool success = m_inner->save(category);
        if (success) {
            invalidateAll(); // изменение данных делает весь кеш потенциально неактуальным
        }
        return success;
    }

    bool remove(int id) override
    {
        const bool success = m_inner->remove(id);
        if (success) {
            invalidateAll();
        }
        return success;
    }

private:
    void invalidateAll()
    {
        QMutexLocker locker(&m_mutex);
        m_cacheById.clear();
        m_allCached = false;
        m_allCachedValue.clear();
    }

    std::unique_ptr<ICategoryRepository> m_inner;
    QHash<int, Category> m_cacheById;
    bool m_allCached = false;
    QVector<Category> m_allCachedValue;
    mutable QMutex m_mutex;
};
// Использование: оборачиваем реальный SQL-репозиторий кеширующей обёрткой
auto sqlRepository = std::make_unique<SqlCategoryRepository>(db);
auto cachedRepository = std::make_unique<CachedCategoryRepository>(std::move(sqlRepository));

// Первый вызов обращается к базе данных, последующие — берутся из кеша
auto category1 = cachedRepository->findById(5); // обращение к БД
auto category2 = cachedRepository->findById(5); // взято из кеша, без обращения к БД

Пояснения к коду

CachedCategoryRepository реализует тот же интерфейс ICategoryRepository, что и обычный SQL-репозиторий, оборачивая его (паттерн Декоратор) — вызывающий код не видит разницы между кешированной и не кешированной версией, поскольку оба реализуют один и тот же контракт. findById() сначала проверяет m_cacheById, и только при отсутствии запрошенного ID обращается к m_inner->findById(), сохраняя результат для последующих обращений.

Критичная часть — save()/remove() вызывают invalidateAll() после успешного изменения через внутренний репозиторий: вместо попытки точно вычислить, какие именно записи кеша стали неактуальными (что было бы сложнее и более подвержено ошибкам), весь кеш просто полностью очищается — следующее обращение заново загрузит актуальные данные из базы. Это особенно важно для findAll() — изменение одной записи делает потенциально неактуальным весь закешированный список, поэтому инвалидация затрагивает и индивидуальный кеш по ID, и общий кеш списка.

Подводные камни

  • Полная инвалидация всего кеша при любом изменении — простой и надёжный, но не самый эффективный подход: если изменилась только одна категория из тысячи, весь кеш findAll() всё равно полностью сбрасывается, и следующий вызов снова обратится к базе данных за всеми записями — более тонкая, частичная инвалидация (обновление только изменённой записи в m_cacheById, и инвалидация только общего списка m_allCached) возможна, но усложняет логику и увеличивает риск рассинхронизации, если где-то забыть инвалидировать конкретный кусок кеша.
  • Кеширование данных, которые меняются другим процессом или другим экземпляром приложения, не проходящим через этот же кеш. Если несколько экземпляров приложения работают с одной базой данных, и один из них изменяет данные напрямую (или через свой собственный, отдельный кеш), кеш в других экземплярах не узнает об этом изменении и продолжит отдавать устаревшие данные — кеширование на уровне отдельного процесса приложения эффективно только для данных, которые либо меняются исключительно через тот же процесс, либо для которых приемлема некоторая задержка согласованности (TTL-инвалидация, разобранная как альтернатива).
  • Отсутствие ограничения размера кеша для findById, в отличие от LruCache из основного цикла, использующего ограниченный размер — если приложение работает с очень большим числом уникальных категорий (что не очень типично для справочника категорий, но возможно для других видов сущностей), неограниченный QHash-кеш может неконтролируемо расти, потребляя память; для сущностей с потенциально большим числом уникальных записей стоит использовать именно ограниченный по размеру LruCache, а не простой неограниченный QHash.
  • Race condition между чтением и инвалидацией кеша из разных потоков, если CachedCategoryRepository используется из нескольких потоков одновременно — хотя в примере используется QMutex для защиты доступа к структурам кеша, есть более тонкая возможная гонка: поток A читает из БД (вне мьютекса) устаревшее значение, в это время поток B успевает изменить и инвалидировать кеш, а затем поток A записывает в кеш уже устаревшее значение, которое он успел прочитать раньше инвалидации — для полностью корректного решения этой гонки в высоконагруженных многопоточных сценариях нужна более тщательная синхронизация, чем показанная в упрощённом примере.