Код
#статьи

Redis для кэширования. Ускоряем взаимодействие с основной базой

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

скриншот из игры marvel vs capcom: infinite / capcom, marvel

Сегодня мы создадим простое приложение, которое взаимодействует с базой данных MySQL, и применим механизм кэширования Redis. Приложение и обе базы развернём с помощью docker-контейнеров.

Если вы ещё не знакомы с Redis — начните с этой статьи, а о работе с Docker читайте здесь.

Какое приложение разработаем

Это будет spring-boot-приложение для хранения книг в базе данных (книжный онлайн-магазин).

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

Иными словами, если данные есть в кэше — берём их оттуда; иначе выполняем более тяжёлый запрос — из постоянного хранилища.

Подготовка

  1. Устанавливаем Docker по инструкции с официального сайта.
  2. Генерируем наш проект с помощью инструмента 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 для кэширования данных — ускорили взаимодействие с реляционной базой.

Научитесь: Базы данных для разработчиков Узнать больше
Понравилась статья?
Да

Пользуясь нашим сайтом, вы соглашаетесь с тем, что мы используем cookies 🍪

Ссылка скопирована