Redis для кэширования. Ускоряем взаимодействие с основной базой
Учимся ускорять нашу реляционную базу и измерять эффект от кэширования.


скриншот из игры marvel vs capcom: infinite / capcom, marvel
Сегодня мы создадим простое приложение, которое взаимодействует с базой данных MySQL, и применим механизм кэширования Redis. Приложение и обе базы развернём с помощью docker-контейнеров.
Если вы ещё не знакомы с Redis — начните с этой статьи, а о работе с Docker читайте здесь.
Какое приложение разработаем
Это будет spring-boot-приложение для хранения книг в базе данных (книжный онлайн-магазин).
Из-за частых запросов в базу подобные приложения работают медленно. Поэтому мы задействуем механизм кэширования — стратегию, позволяющую сохранять результаты запросов в оперативной памяти, что повысит скорость работы при повторном выполнении тех же запросов.
Иными словами, если данные есть в кэше — берём их оттуда; иначе выполняем более тяжёлый запрос — из постоянного хранилища.
Подготовка
- Устанавливаем Docker по инструкции с официального сайта.
- Генерируем наш проект с помощью инструмента Spring Initializr. Выбираем нужные зависимости (компоненты Spring, подключаемые к проекту):
- Spring Web,
- Spring Data JPA,
- MySQL Driver,
- Spring Data Redis
- и Lombok (по желанию).
3. Скачиваем и распаковываем полученный архив, открываем его в нашей среде разработки.
Делаем приложение
В открывшемся проекте создаём такую структуру каталогов (готовый код тут):

Начнём разработку с модели, а именно с класса Book (сущность, хранимая в базе данных):
@Data
@Entity
public class Book implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String description;
private BigDecimal price;
}
@Data — lombok-аннотация, генерирующая шаблонный код (конструкторы, геттеры, сеттеры и так далее.
public interface BookRepository extends JpaRepository<Book, Long> {
}
BookRepository — интерфейс, расширяющий интерфейс JpaRepository. Spring Data JPA также избавляет нас от необходимости реализовывать CRUD-операции.
Наибольший интерес представляет класс сервиса. Сервис — промежуточный слой между контроллером, обрабатывающим HTTP-запросы, и репозиторием. Каждый метод помечен аннотациями, которые обеспечивают кэширование.
@Service
@CacheConfig(cacheNames = "bc")
public class BookService {
private final BookRepository bookRepository;
@Autowired
public BookService(BookRepository bookRepository) {
this.bookRepository = bookRepository;
}
@Cacheable
public List<Book> findAll() {
return bookRepository.findAll();
}
@Cacheable(key = "#id")
public Optional<Book> findById(Long id) {
return bookRepository.findById(id);
}
@CachePut(key = "#book.id")
public Book save(Book book) {
return bookRepository.save(book);
}
@CacheEvict(key = "#id")
public void deleteById(Long id) {
bookRepository.deleteById(id);
}
}
@CacheConfig — аннотация конфигурирует все кэш-операции данного класса.
@Cacheable — говорит, что результат работы метода попадает в кэш и при последующем вызове берётся оттуда (по ключу, указанному в параметре).
@CachePut — позволяет обновить запись в кэше.
@CacheEvict — удаляет запись из кэша.
Класс BookController содержит конечные точки для всех вызовов разрабатываемого API.
@RestController
@RequestMapping("/api/v1/books")
public class BookController {
private final static Logger logger = LoggerFactory.getLogger(BookController.class);
private final BookService bookService;
@Autowired
public BookController(BookService bookService) {
this.bookService = bookService;
}
@GetMapping
public ResponseEntity<List<Book>> findAll() {
long startTime = System.currentTimeMillis();
List<Book> books = bookService.findAll();
long endTime = System.currentTimeMillis() - startTime;
logger.info("Duration = {}", endTime);
return ResponseEntity.status(HttpStatus.OK)
.body(books);
}
@GetMapping("/{id}")
public ResponseEntity<Book> findById(@PathVariable Long id) {
return ResponseEntity.status(HttpStatus.OK)
.body(bookService.findById(id).get());
}
@PostMapping
public ResponseEntity<Book> create(@RequestBody Book book) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(bookService.save(book));
}
@PutMapping("/{id}")
public ResponseEntity<Book> update(@PathVariable Long id, @RequestBody Book book) {
return ResponseEntity.status(HttpStatus.ACCEPTED)
.body(bookService.save(book));
}
public ResponseEntity delete(@PathVariable Long id) {
bookService.deleteById(id);
return ResponseEntity.status(HttpStatus.ACCEPTED).build();
}
}
Аннотации @GetMapping, @PostMapping и @PutMapping обозначают вызов соответствующего http-метода по пути, указанному в параметре.
Обратите внимание на операцию получения списка всех книг findAll () — мы рассчитываем время её выполнения и результат пишем в лог.
Ещё нам нужно добавить аннотацию @EnableCaching в главный класс приложения. Это позволит запускать постпроцессор для обработки других аннотаций и обработки кэширования.
@EnableCaching
@SpringBootApplication
public class RedisCacheApplication {
public static void main(String[] args) {
SpringApplication.run(RedisCacheApplication.class, args);
}
}
Развёртываем приложение и базы
Воспользуемся Docker. Нам нужно подготовить развёртывание трёх наших сервисов: MySQL, Redis и самого приложения.
Создадим Dockerfile для описания образа нашего приложения:
FROM adoptopenjdk/openjdk11:alpine-jre
COPY /target/redis-cache-0.0.1-SNAPSHOT.jar redis-cache-0.0.1-SNAPSHOT.jar
ENTRYPOINT ["java","-jar","redis-cache-0.0.1-SNAPSHOT.jar"]
Затем создадим файл docker-compose (описывает несколько связанных между собой контейнеров):
version: '3'
services:
rc-mysql:
container_name: rc-mysql
image: mysql/mysql-server:5.7
environment:
MYSQL_DATABASE: rc
MYSQL_ROOT_PASSWORD: root
MYSQL_ROOT_HOST: '%'
ports:
- "3306:3306"
restart: always
rc-redis:
container_name: rc-redis
image: redis:5
ports:
- "6379:6379"
restart: always
redis-cache:
build: ./
ports:
- "8080:8080"
depends_on:
- "rc-mysql"
- "rc-redis"
В нашем случае файл содержит описание трёх сервисов с именами контейнеров, образов и обозначением портов. Параметром environment задаются переменные среды.
Настраиваем подключение к базе
Это делается в файле application.properties:
spring.datasource.url=jdbc:mysql://rc-mysql:3306/rc?useSSL=false
spring.datasource.username=root
spring.datasource.password=root
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.jpa.hibernate.ddl-auto=create
spring.jpa.database-platform=org.hibernate.dialect.MySQL57Dialect
spring.jpa.generate-ddl=true
spring.jpa.show-sql=true
spring.redis.host=rc-redis
spring.redis.timeout=2000
spring.cache.redis.time-to-live=100000
spring.data.redis.repositories.enabled=false
Обратите внимание, что в spring.datasource.url и spring.redis.host задаются хосты контейнеров.
spring.cache.redis.time-to-live задаёт время существования параметра в кэше.
Собираем проект
Это делается командами:
./mvnw clean package -Dmaven.test.skip=true — собираем проект в JAR-файл.
docker-compose up — инициируем выполнение файла docker-compose.yml — а именно сборку трёх образов и создание контейнеров.
Результат отразится в терминале:
MBP-Maksim:redis-cache mikheev$ docker-compose up
Creating network "redis-cache_default" with the default driver
Building redis-cache
Step 1/3 : FROM adoptopenjdk/openjdk11:alpine-jre
---> a7b99112d065
Step 2/3 : COPY /target/redis-cache-0.0.1-SNAPSHOT.jar redis-cache-0.0.1-SNAPSHOT.jar
---> cd9351197743
Step 3/3 : ENTRYPOINT ["java","-jar","redis-cache-0.0.1-SNAPSHOT.jar"]
---> Running in c0b7fe2de08a
Removing intermediate container c0b7fe2de08a
---> d74e79bfc5eb
Successfully built d74e79bfc5eb
Successfully tagged redis-cache_redis-cache:latest
Creating rc-mysql ... done
Creating rc-redis ... done
Creating redis-cache_redis-cache_1 ... done
Attaching to rc-mysql, rc-redis, redis-cache_redis-cache_1
Последние четыре строки означают успешные сборки.
Проверяем эффект от кэширования
Чтобы протестировать работу сервисов, несколько раз выполним post-запрос, добавляющий новую книгу:
curl --location --request POST 'localhost:8080/api/v1/books' \
--header 'Content-Type: application/json' \
--data-raw '{
"name": "book1",
"description": "blabla",
"price": 100500
}'
Это можно сделать через терминал или в Postman.
Далее в отдельном терминале выполняем команды:
MBP-Maksim:~ mikheev$ docker exec -it rc-redis sh
# redis-cli
127.0.0.1:6379> KEYS *
1) "bc::2"
2) "bc::1"
127.0.0.1:6379> FLUSHALL
OK
127.0.0.1:6379>
Здесь мы подключаемся к контейнеру Redis и проверяем, что добавленные в базу книги попали и в кэш. Для чистоты эксперимента — очищаем кэш.
Далее подключаемся к контейнеру основного приложения в режиме чтения логов с помощью команды:
docker logs -f 6f767bc19768
Флаг -f означает чтение логов в режиме реального времени (новые логи будут последовательно выводиться в консоль в порядке их появления), а 6f767bc19768 — идентификатор контейнера (его можно получить командой docker ps).
Затем несколько раз выполняем запрос списка книг:
curl --location --request GET 'localhost:8080/api/v1/books'
и в логах получаем результат:
^[[1;2DHibernate: select book0_.id as id1_0_, book0_.description as descript2_0_, book0_.name as name3_0_, book0_.price as price4_0_ from book book0_
2020-10-02 19:45:51.666 INFO 1 --- [nio-8080-exec-6] c.m.r.controller.BookController : Duration = 676
Hibernate: select book0_.id as id1_0_, book0_.description as descript2_0_, book0_.name as name3_0_, book0_.price as price4_0_ from book book0_
2020-10-02 19:46:00.329 INFO 1 --- [nio-8080-exec-2] c.m.r.controller.BookController : Duration = 12
Hibernate: select book0_.id as id1_0_, book0_.description as descript2_0_, book0_.name as name3_0_, book0_.price as price4_0_ from book book0_
2020-10-02 19:46:02.238 INFO 1 --- [nio-8080-exec-3] c.m.r.controller.BookController : Duration = 10
Видим, что первый запрос длился 676 мс (Duration) — его результаты выбирались из базы MySQL. А вот результаты последующих двух брались уже из кэша — и эти запросы выполнились в 60 раз быстрее.
Повторная проверка после очистки кэша подтверждает это:
^[[1;Hibernate: select book0_.id as id1_0_, book0_.description as descript2_0_, book0_.name as name3_0_, book0_.price as price4_0_ from book book0_
2020-10-02 19:49:30.350 INFO 1 --- [nio-8080-exec-3] c.m.r.controller.BookController : Duration = 96
Hibernate: select book0_.id as id1_0_, book0_.description as descript2_0_, book0_.name as name3_0_, book0_.price as price4_0_ from book book0_
2020-10-02 19:49:34.582 INFO 1 --- [io-8080-exec-10] c.m.r.controller.BookController : Duration = 6
Hibernate: select book0_.id as id1_0_, book0_.description as descript2_0_, book0_.name as name3_0_, book0_.price as price4_0_ from book book0_
2020-10-02 19:49:54.696 INFO 1 --- [nio-8080-exec-1] c.m.r.controller.BookController : Duration = 14
Вот мы и доказали эффективность Redis для кэширования данных — ускорили взаимодействие с реляционной базой.