- Tutorial

В данном посте я постараюсь объяснить о кэшировании так же просто, как это сейчас понимаю я. Вы узнаете о том, как внедрить кэширование за 1 минуту, как кэшировать по ключу, устанавливать время жизни кэша, и многие прочие штуки, которые необходимо знать, если Вам поручили закэшировать что-то в вашем рабочем проекте, и Вы не хотите ударить в грязь лицом.
Почему я говорю «поручили»? Потому что кэширование, как правило, есть смысл применять в больших, высоконагруженных проектах, с десятками тысяч запросов в минуту. В таких проектах, чтобы не перегружать базу, как правило, кэшируют обращения к репозиторию. Особенно если известно, что данные из какой-нибудь мастер-системы обновляются с некоторой периодичностью. Сами мы такие проекты не пишем, мы на них работаем. Если же проект маленький и перегрузки ему не грозят, тогда, конечно, лучше ничего не кэшировать — всегда свежие данные всегда лучше периодически обновляемых.
Обычно в обучающих постах докладчик сначала лезет под капот, начинает копаться в кишочках технологии, чем немало утомляет читателя, а уж потом, когда тот пролистал без дела добрую половину статьи и ничего не понял, повествует, как это работает. У нас всё будет иначе. Сначала мы делаем так, чтобы заработало, и желательно, с приложением наименьших усилий, а уж потом, если интересно, Вы сможете заглянуть под капот кэширования, посмотреть изнутри сам бин и тонко настроить кэширование. Но даже если Вы этого не сделаете (а это начинается с 6 пункта), Ваше кэширование будет работать и так.
Мы создадим проект, в котором разберём все те аспекты кэширования, которые я обещал. В конце, как обычно, будет ссылка на сам проект.
0. Создание проекта.
Мы создадим очень простой проект, в котором мы сможем брать сущность из базы данных. Я добавил в проект Lombok, Spring Cache, Spring Data JPA и H2. Хотя, вполне можно обойтись только Spring Cache.
plugins {
id 'org.springframework.boot' version '2.1.7.RELEASE'
id 'io.spring.dependency-management' version '1.0.8.RELEASE'
id 'java'
}
group = 'ru.xpendence'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '1.8'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
У нас будет только одна сущность, назовём её User.
@Entity
@Table(name = "users")
@Data
@NoArgsConstructor
@ToString
public class User implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "name")
private String name;
@Column(name = "email")
private String email;
public User(String name, String email) {
this.name = name;
this.email = email;
}
}
Добавим репозиторий и сервис:
public interface UserRepository extends JpaRepository<User, Long> {
}
@Slf4j
@Service
public class UserServiceImpl implements UserService {
private final UserRepository repository;
public UserServiceImpl(UserRepository repository) {
this.repository = repository;
}
@Override
public User create(User user) {
return repository.save(user);
}
@Override
public User get(Long id) {
log.info("getting user by id: {}", id);
return repository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("User not found by id " + id));
}
}
Когда мы заходим в сервисный метод get(), мы пишем об этом в лог.
Подключим к проекту Spring Cache.
@SpringBootApplication
@EnableCaching //подключение Spring Cache
public class CacheApplication {
public static void main(String[] args) {
SpringApplication.run(CacheApplication.class, args);
}
}
Проект готов.
1. Кэширование возвращаемого результата.
Что делает Spring Cache? Spring Cache просто кэширует возвращаемый результат для определённых входных параметров. Давайте это проверим. Мы поставим аннотацию @Cacheable над сервисным методом get(), чтобы кэшировать возвращаемые данные. Дадим этой аннотации название «users» (далее мы разберём, зачем это делается, отдельно).
@Override
@Cacheable("users")
public User get(Long id) {
log.info("getting user by id: {}", id);
return repository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("User not found by id " + id));
}
Для того, чтобы проверить, как это работает, напишем простой тест.
@RunWith(SpringRunner.class)
@SpringBootTest
public abstract class AbstractTest {
}
@Slf4j
public class UserServiceTest extends AbstractTest {
@Autowired
private UserService service;
@Test
public void get() {
User user1 = service.create(new User("Vasya", "vasya@mail.ru"));
User user2 = service.create(new User("Kolya", "kolya@mail.ru"));
getAndPrint(user1.getId());
getAndPrint(user2.getId());
getAndPrint(user1.getId());
getAndPrint(user2.getId());
}
private void getAndPrint(Long id) {
log.info("user found: {}", service.get(id));
}
}
Поэтому я предпочитаю сокращать количество поднимаемых контекстов при тестировании/сборке, если это возможно.
Что делает наш тест? Он создаёт двоих юзеров и потом по 2 раза вытаскивает их из базы. Как мы помним, мы поместили аннотацию @Cacheable, которая будет кэшировать возвращаемые значения. После получения объекта из метода get() мы выводим объект в лог. Также, мы выводим в лог информацию о каждом посещении приложением метода get().
Запустим тест. Вот что мы получаем в консоль.
getting user by id: 1
user found: User(id=1, name=Vasya, email=vasya@mail.ru)
getting user by id: 2
user found: User(id=2, name=Kolya, email=kolya@mail.ru)
user found: User(id=1, name=Vasya, email=vasya@mail.ru)
user found: User(id=2, name=Kolya, email=kolya@mail.ru)
Как мы видим, первые два раза мы действительно сходили в метод get() и реально получили юзера из базы. Во всех остальных случаях, реального захода в метод не было, приложение брало закэшированные данные по ключу (в данном случае, это id).
2. Объявление ключа для кэширования.
Бывают ситуации, когда в кэшируемый метод приходит несколько параметров. В таком случае, бывает нужно определить параметр, по которому будет происходить кэширование. Добавим в пример метод, который будет сохранять в базу сущность, собранную по параметрам, но если сущность с таким именем уже есть, мы не будем её сохранять. Для этого мы определим параметр name как ключ для кэширования. Выглядеть это будет так:
@Override
@Cacheable(value = "users", key = "#name")
public User create(String name, String email) {
log.info("creating user with parameters: {}, {}", name, email);
return repository.save(new User(name, email));
}
Напишем соответствующий тест:
@Test
public void create() {
createAndPrint("Ivan", "ivan@mail.ru");
createAndPrint("Ivan", "ivan1122@mail.ru");
createAndPrint("Sergey", "ivan@mail.ru");
log.info("all entries are below:");
service.getAll().forEach(u -> log.info("{}", u.toString()));
}
private void createAndPrint(String name, String email) {
log.info("created user: {}", service.create(name, email));
}
Мы попытаемся создать троих пользователей, для двоих из которых будет совпадать имя
createAndPrint("Ivan", "ivan@mail.ru");
createAndPrint("Ivan", "ivan1122@mail.ru");
и для двоих из которых будет совпадать email
createAndPrint("Ivan", "ivan@mail.ru");
createAndPrint("Sergey", "ivan@mail.ru");
В методе создания мы логируем каждый факт обращения к методу, а также, мы будем логировать все сущности, которые этот метод нам вернул. Результат будет таким:
creating user with parameters: Ivan, ivan@mail.ru
created user: User(id=1, name=Ivan, email=ivan@mail.ru)
created user: User(id=1, name=Ivan, email=ivan@mail.ru)
creating user with parameters: Sergey, ivan@mail.ru
created user: User(id=2, name=Sergey, email=ivan@mail.ru)
all entries are below:
User(id=1, name=Ivan, email=ivan@mail.ru)
User(id=2, name=Sergey, email=ivan@mail.ru)
Мы видим, что фактически приложение вызывало метод 3 раза, а заходило в него только два раза. Один раз для метода совпадал ключ, и он просто возвращал закэшированное значение.
3. Принудительное кэширование. @CachePut
Бывают ситуации, когда мы хотим кэшировать возвращаемое значение для какой-то сущности, но в то же время, нам нужно обновить кэш. Для таких нужд существует аннотация @CachePut. Оно пропускает приложение в метод, при этом, обновляя кэш для возвращаемого значения, даже если оно уже закэшировано.
Добавим пару методов, в которых мы будем сохранять юзера. Один из них мы пометим обычной аннотацией @Cacheable, второй — @CachePut.
@Override
@Cacheable(value = "users", key = "#user.name")
public User createOrReturnCached(User user) {
log.info("creating user: {}", user);
return repository.save(user);
}
@Override
@CachePut(value = "users", key = "#user.name")
public User createAndRefreshCache(User user) {
log.info("creating user: {}", user);
return repository.save(user);
}
Первый метод будет просто возвращать закэшированные значения, второй — принудительно обновлять кэш. Кэширование будет осуществляться по ключу #user.name. Напишем соответствующий тест.
@Test
public void createAndRefresh() {
User user1 = service.createOrReturnCached(new User("Vasya", "vasya@mail.ru"));
log.info("created user1: {}", user1);
User user2 = service.createOrReturnCached(new User("Vasya", "misha@mail.ru"));
log.info("created user2: {}", user2);
User user3 = service.createAndRefreshCache(new User("Vasya", "kolya@mail.ru"));
log.info("created user3: {}", user3);
User user4 = service.createOrReturnCached(new User("Vasya", "petya@mail.ru"));
log.info("created user4: {}", user4);
}
По той логике, которая уже описывалась, при первом сохранении пользователя с именем «Vasya» через метод createOrReturnCached() далее мы будем получать кэшированную сущность, при этом, в сам метод приложение заходить не будет. Если же мы вызовем метод createAndRefreshCache(), кэшированная сущность для ключа с именем «Vasya» перезапишется в кэше. Выполним тест и посмотрим, что будет выведено в консоль.
creating user: User(id=null, name=Vasya, email=vasya@mail.ru)
created user1: User(id=1, name=Vasya, email=vasya@mail.ru)
created user2: User(id=1, name=Vasya, email=vasya@mail.ru)
creating user: User(id=null, name=Vasya, email=kolya@mail.ru)
created user3: User(id=2, name=Vasya, email=kolya@mail.ru)
created user4: User(id=2, name=Vasya, email=kolya@mail.ru)
Мы видим, что user1 благополучно записался в базу и кэш. При повторной попытке записать юзера с таким же именем мы получаем закэшированный результат выполнения первого обращения (user2, для которого id такой же, как у user1, что говорит нам о том, что юзер не был записан, и это просто кэш). Далее, мы пишем третьего пользователя через второй метод, который даже при имеющемся закэшированном результате всё равно вызвал метод и записал в кэш новый результат. Это user3. Как мы видим, у него уже новый id. После чего, мы вызываем первый метод, который берёт новый кэш, добавленный user3.
4. Удаление из кэша. @CacheEvict
Иногда возникает необходимость жёстко обновить какие-то данные в кэше. Например, сущность уже удалена из базы, но она по-прежнему доступна из кэша. Для сохранения консистентности данных, нам необходимо хотя бы не хранить в кэше удалённые данные.
Добавим в сервис ещё пару методов.
@Override
public void delete(Long id) {
log.info("deleting user by id: {}", id);
repository.deleteById(id);
}
@Override
@CacheEvict("users")
public void deleteAndEvict(Long id) {
log.info("deleting user by id: {}", id);
repository.deleteById(id);
}
Первый будет просто удалять пользователя, второй тоже будет его удалять, но мы пометим его аннотацией @CacheEvict. Добавим тест, который будет создавать двух юзеров, после чего, одного будет удалять через простой метод, а второго — через аннотируемый метод. После чего, мы достанем этих юзеров через метод get().
@Test
public void delete() {
User user1 = service.create(new User("Vasya", "vasya@mail.ru"));
log.info("{}", service.get(user1.getId()));
User user2 = service.create(new User("Vasya", "vasya@mail.ru"));
log.info("{}", service.get(user2.getId()));
service.delete(user1.getId());
service.deleteAndEvict(user2.getId());
log.info("{}", service.get(user1.getId()));
log.info("{}", service.get(user2.getId()));
}
Логично, что раз наш юзер уже закэширован, удаление не помешает нам его как бы получить — ведь он закэширован. Посмотрим логи.
getting user by id: 1
User(id=1, name=Vasya, email=vasya@mail.ru)
getting user by id: 2
User(id=2, name=Vasya, email=vasya@mail.ru)
deleting user by id: 1
deleting user by id: 2
User(id=1, name=Vasya, email=vasya@mail.ru)
getting user by id: 2
javax.persistence.EntityNotFoundException: User not found by id 2
Мы видим, что приложение благополучно сходило оба раза в метод get() и Spring закэшировал эти сущности. Далее, мы удалили их через разные методы. Первый мы удалили обычным путём, и закэшированное значение осталось, поэтому когда мы попытались получить юзера под id 1, нам это удалось. Когда же мы попытались получить юзера 2, метод вернул нам EntityNotFoundException — такого юзера в кэше не оказалось.
5. Группировка настроек. @Caching
Иногда один метод требует нескольких настроек кэширования. Для этих целей используется аннотация @Caching. Выглядеть это может приблизительно так:
@Caching(
cacheable = {
@Cacheable("users"),
@Cacheable("contacts")
},
put = {
@CachePut("tables"),
@CachePut("chairs"),
@CachePut(value = "meals", key = "#user.email")
},
evict = {
@CacheEvict(value = "services", key = "#user.name")
}
)
void cacheExample(User user) {
}
Это единственный способ группировать аннотации. Если Вы попытаетесь нагородить что-то вроде
@CacheEvict("users")
@CacheEvict("meals")
@CacheEvict("contacts")
@CacheEvict("tables")
void cacheExample(User user) {
}
то IDEA сообщит Вам, что так нельзя.
6. Гибкая настройка. CacheManager.
Наконец-то мы разобрались с кэшем, и он перестал быть для нас чем-то непонятным и страшным. Теперь давайте заглянем под капот и посмотрим, как мы можем настроить кэширование в целом.
Для таких задач существует CacheManager. Он существует везде, где есть Spring Cache. Когда мы добавили аннотацию @EnableCache, такой кэш менеджер автоматически будет создан Spring. Мы можем убедиться в этом, если заавтовайрим ApplicationContext и вскроем его на брейкпоинте. Среди прочих бинов, будет и бин «cacheManager».

Я остановил приложение на этапе, когда уже два юзера были созданы и помещены в кэш. Если мы вызовем нужный нам бин через Evaluate Expression, то мы увидим, что такой бин действительно есть, в нём есть ConcurentMapCache с ключом «users» и значением ConcurrentHashMap, в которой уже лежат закэшированные юзеры.

Мы, в свою очередь, можем создать свой кэш-менеджер, с хабром и программистами, после чего, тонко настроить его на наш вкус.
@Bean("habrCacheManager")
public CacheManager cacheManager() {
return null;
}
Осталось только выбрать, какой именно кэш-менеджер мы будем использовать, потому что их предостаточно. Я не буду перечислять все кэш-менеджеры, достаточно будет знать, что есть такие:
- SimpleCacheManager — самый простой кэш-менеджер, удобный для изучения и тестирования.
- ConcurrentMapCacheManager — лениво инициализирует возвращаемые экземпляры для каждого запроса. Также рекомендуется для тестирования и изучения работы с кэшем, а также, для каких-то простых действий, вроде наших. Для серьёзной работы с кэшем рекомендуются имплементации ниже.
- JCacheCacheManager, EhCacheCacheManager, CaffeineCacheManager — серьёзные кэш-менеджеры «от партнёров», гибко настраиваемые и выполняющие задачи очень широкого спектра действия.
В рамках своего скромного поста я не буду описывать кэш-менеджеры из последней тройки. Вместо этого, мы разберём несколько аспектов настройки кэш-менеджера на примере ConcurrentMapCacheManager.
Итак, досоздадим наш кэш-менеджер.
@Bean("habrCacheManager")
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager();
}
Наш кэш-менеджер готов.
7. Настройка кэша. Время жизни, максимальный размер и проч.
Для этого нам потребуется довольно популярная библиотека Google Guava. Я взял последнюю.
compile group: 'com.google.guava', name: 'guava', version: '28.1-jre'
При создании кэш-менеджера переопределим метод createConcurrentMapCache, в котором вызовем CacheBuilder от Guava. В процессе нам будет предложено настроить кэш-менеджер при помощи инициализации следующих методов:
- maximumSize — максимальный размер значений, которые может содержать кэш. При помощи этого параметра можно найти попытаться найти компромисс между нагрузкой на базу данных и на оперативную память JVM.
- refreshAfterWrite — время после записи значения в кэш, после которого оно автоматически обновится.
- expireAfterAccess — время жизни значения после последнего обращения к нему.
- expireAfterWrite — время жизни значения после записи в кэш. Именно этот параметр мы определим.
и прочих.
Определим в менеджере время жизни записи. Чтобы долго не ждать, выставим 1 секунду.
@Bean("habrCacheManager")
public CacheManager cacheManager() {
return new ConcurrentMapCacheManager() {
@Override
protected Cache createConcurrentMapCache(String name) {
return new ConcurrentMapCache(
name,
CacheBuilder.newBuilder()
.expireAfterWrite(1, TimeUnit.SECONDS)
.build().asMap(),
false);
}
};
}
Напишем соответствующий такому случаю тест.
@Test
public void checkSettings() throws InterruptedException {
User user1 = service.createOrReturnCached(new User("Vasya", "vasya@mail.ru"));
log.info("{}", service.get(user1.getId()));
User user2 = service.createOrReturnCached(new User("Vasya", "vasya@mail.ru"));
log.info("{}", service.get(user2.getId()));
Thread.sleep(1000L);
User user3 = service.createOrReturnCached(new User("Vasya", "vasya@mail.ru"));
log.info("{}", service.get(user3.getId()));
}
Мы сохраняем несколько значений в базу данных, причём, если данные закэшированы, мы ничего не сохраняем. Сначала мы сохраняем два значения, потом ожидаем 1 секунду, пока кэш не протухнет, после чего, сохраняем ещё одно значение.
creating user: User(id=null, name=Vasya, email=vasya@mail.ru)
getting user by id: 1
User(id=1, name=Vasya, email=vasya@mail.ru)
User(id=1, name=Vasya, email=vasya@mail.ru)
creating user: User(id=null, name=Vasya, email=vasya@mail.ru)
getting user by id: 2
User(id=2, name=Vasya, email=vasya@mail.ru)
Логи показывают, что сначала мы создали юзера, потом попытались ещё одного, но поскольку данные были закэшированы, мы получили их из кэша (в обоих случаях — при сохранении и при получении из базы). Потом протух кэш, о чём сообщает нам запись о фактическом сохранении и фактическом получении юзера.
8. Подведу итог.
Рано или поздно, разработчик сталкивается с необходимостью реализации кэширования в проекте. Я надеюсь, что эта статья поможет Вам разобраться в предмете и смотреть на вопросы кэширования смелее.
Гитхаб проекта тут: https://github.com/promoscow/cache