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