Сегодня будем делать финальный проект по четвертому модулю JRU. Что это будет? Попробуем поработать с разными технологиями: MySQL, Hibernate, Redis, Docker. Теперь предметнее.
Задание: у нас есть реляционная БД MySQL с схемой (страна-город, язык по стране). И есть частый запрос города, который тормозит. Мы придумали решение – вынести все данные, которые запрашиваются часто, в Redis (in memory storage типа ключ-значение).
И нужны нам, не все данные, которые хранятся в MySQL, а только выбранный набор полей. Проект будет в виде туториала. То есть, здесь будем поднимать проблему и сразу же ее решать.
Итак, начнем с того, какой софт нам будет нужен:
- IDEA Ultimate (у кого закончился ключ – пишите в слаке Роману)
- Workbench (или любой другой клиент для MySQL)
- Docker
- redis-insight – опционально
Наш план действий:
- Настроить докер (в туториале это не буду делать, т.к. для каждой ОС будут свои особенности и в интернете куча ответов на вопросы типа «how to install docker on windows»), проверить что все работает.
- Запустить MySQL сервер как докер-контейнер.
- Развернуть дамп.
- Создать проект в Идее, добавить зависимости maven.
- Сделать слой domain.
- Написать метод получения всех данных из MySQL.
- Написать метод трансформации данных (в Redis будем писать только те данные, который запрашиваются часто).
- Запустить Redis сервер как докер-контейнер.
- Записать данные в Redis.
- Опционально: установить redis-insight, посмотреть на данные, которые хранятся в Redis.
- Написать метод получение данных из Redis.
- Написать метод получение данных из MySQL.
- Сравнить скорость получения одних и тех же данных из MySQL и Redis.
Настройка Docker
Докер – это открытая платформа для разработки, доставки и эксплуатации приложений. Мы его будем использовать для того, чтоб не устанавливать и настраивать Redis на локальной машине, а использовать уже готовый image. Подробнее о докере можно почитать здесь или посмотреть здесь. Если не знаком с докером – рекомендую посмотреть как раз вторую ссылку.
Для того, чтоб убедиться, что докер у тебя установлен и настроен, выполни команду: docker -v
Если все ОК, ты увидишь версию докера

Запустить MySQL сервер как докер-контейнер
Для того, чтоб можно было сравнить время отдачи данных из MySQL и Redis, MySQL тоже будем использовать в докере. В PowerShell (или другом консольном терминале, если у тебя не Windows) выполни команду:
docker run --name mysql -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=root --restart unless-stopped -v mysql:/var/lib/mysql mysql:8
Рассмотрим, что мы делаем этой командой:
docker run
– запуск (и скачивание, если он еще не скачан на локальную машину) имиджа. В результате запуска получим запущенный контейнер.--name mysql
– задаем имя контейнера mysql.-d
– флаг, который говорит, что контейнер должен продолжать работать, даже если закрыть окно терминала, откуда этот контейнер запускался.-p 3306:3306
– указывает порты. До двоеточия – порт на локальной машине, после двоеточия – порт в контейнере.-e MYSQL_ROOT_PASSWORD=root
– передача переменной окружения MYSQL_ROOT_PASSWORD со значением root в контейнер. Флаг специфичный именно для образа mysql/--restart unless-stopped
– установка политики поведения (должен ли контейнер перезапускаться при закрытии). Значение unless-stopped значит перезапускать всегда, кроме случая, когда контейнер был остановлен /-v mysql:/var/lib/mysql
– добавить volume (образ для хранения информации).mysql:8
– название имиджа и его версия.
После выполнения команды в терминале докер скачает все слои имиджа и запустит контейнер:

Важное примечание: если у тебя на локальном компьютере установлен MySQL как сервис и он запущен – нужно в команде запуска указать другой порт, либо остановить этот запущенный сервис.

Развернуть дамп
Для разворачивания дампа нужно из Workbench создать новый конекшн к БД, где указать параметры. Я использовал стандартный порт (3306), не изменял имя пользователя (root по умолчанию) и задал пароль для рутового пользователя (root).

В Workbench-е делаем Data Import/Restore
и выбираем Import from Self Contained File
. В качестве файла укажи куда ты скачал дамп. Схему заранее создавать не нужно – ее создание включено в дамп-файл. После успешного импорта, у тебя будет схема world с тремя таблицами:
- city – это таблица городов.
- country – таблица стран.
- country_language – таблица, в которой указывается какой процент населения в стране говорит на определенном языке.

Так как при запуске контейнера мы использовали вольюм, после остановки и даже удаления контейнера mysql и повторного выполнения команды запуска (docker run --name mysql -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=root --restart unless-stopped -v mysql:/var/lib/mysql mysql:8
), повторно дамп разворачивать не нужно будет – он уже развернут в вольюме.
Создать проект в Идее, добавить зависимости maven
Как в Идее создать проект ты уже давно знаешь – это самый легкий пункт в сегодняшнем проекте.

Добавляем в pom-файл зависимости:
<dependencies>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.30</version>
</dependency>
<dependency>
<groupId>org.hibernate</groupId>
<artifactId>hibernate-core-jakarta</artifactId>
<version>5.6.14.Final</version>
</dependency>
<dependency>
<groupId>p6spy</groupId>
<artifactId>p6spy</artifactId>
<version>3.9.1</version>
</dependency>
<dependency>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
<version>6.2.2.RELEASE</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.14.0</version>
</dependency>
</dependencies>
Первых три зависимости тебе уже давно знакомы.
lettuce-core
– это один из доступных Java клиентов для работы с Redis.
jackson-databind
– зависимость для использования ObjectMapper (для преобразования данных для их хранения в Redis (ключ-значение типа String)).
Так же в папку ресурсов (src/main/resources) добавь spy.properties для просмотра запросов с параметрами, которые выполняет Hibernate. Содержимое файла:
driverlist=com.mysql.cj.jdbc.Driver
dateformat=yyyy-MM-dd hh:mm:ss a
appender=com.p6spy.engine.spy.appender.StdoutLogger
logMessageFormat=com.p6spy.engine.spy.appender.MultiLineFormat
Сделать слой domain
Создай пакет com.javarush.domain
Мне удобно при мапинге таблиц на ентити пользоваться структурой таблицы в Идее, поэтому добавим подключение БД в Идее.


Энтити я предлагаю создавать в таком порядке:
- Country
- City
- CountryLanguage
Желательно чтоб маппинг ты выполнил самостоятельно.
Код класса Country:
package com.javarush.domain;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.util.Set;
@Entity
@Table(schema = "world", name = "country")
public class Country {
@Id
@Column(name = "id")
private Integer id;
private String code;
@Column(name = "code_2")
private String alternativeCode;
private String name;
@Column(name = "continent")
@Enumerated(EnumType.ORDINAL)
private Continent continent;
private String region;
@Column(name = "surface_area")
private BigDecimal surfaceArea;
@Column(name = "indep_year")
private Short independenceYear;
private Integer population;
@Column(name = "life_expectancy")
private BigDecimal lifeExpectancy;
@Column(name = "gnp")
private BigDecimal GNP;
@Column(name = "gnpo_id")
private BigDecimal GNPOId;
@Column(name = "local_name")
private String localName;
@Column(name = "government_form")
private String governmentForm;
@Column(name = "head_of_state")
private String headOfState;
@OneToOne
@JoinColumn(name = "capital")
private City city;
@OneToMany(fetch = FetchType.EAGER)
@JoinColumn(name = "country_id")
private Set<CountryLanguage> languages;
//Getters and Setters omitted
}
В коде есть 3 интересных момента.
Первое – это энам Continent, который в БД хранится в качестве ordinal значений. В структуре таблицы country в комментарии к полю continent можно посмотреть какое числовое значение соответствует какому континенту.
package com.javarush.domain;
public enum Continent {
ASIA,
EUROPE,
NORTH_AMERICA,
AFRICA,
OCEANIA,
ANTARCTICA,
SOUTH_AMERICA
}
Второй момент – это сет сущностей CountryLanguage
. Здесь связь @OneToMany
, которой не было во втором проекте этого модуля. По умолчанию Hibernate не будет вытягивать значение этого сета при запросе энтити страны. Но так как нам нужно вычитать все значения из реляционной БД для кеширования, здесь добавлен параметр FetchType.EAGER
.
Третье – поле city. Связь @OneToOne
– вроде все привычно и понятно. Но, если посмотреть на структуру foreign key в БД, то видим что у страны (country) есть связь на столицу (city), а у города (city) – на страну (country). Налицо циклическая зависимость.
Пока с этим ничего делать не будем, но, когда дойдем до пункта «Написать метод получения всех данных из MySQL», посмотрим какие запросы выполняет Hibernate, посмотрим на их количество, и вспомним про этот пункт.Hibernate
Код класса City:
package com.javarush.domain;
import jakarta.persistence.*;
@Entity
@Table(schema = "world", name = "city")
public class City {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
private String name;
@ManyToOne
@JoinColumn(name = "country_id")
private Country country;
private String district;
private Integer population;
//Getters and Setters omitted
}
Код класса CountryLanguage:
package com.javarush.domain;
import jakarta.persistence.*;
import org.hibernate.annotations.Type;
import java.math.BigDecimal;
@Entity
@Table(schema = "world", name = "country_language")
public class CountryLanguage {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Integer id;
@ManyToOne
@JoinColumn(name = "country_id")
private Country country;
private String language;
@Column(name = "is_official", columnDefinition = "BIT")
@Type(type = "org.hibernate.type.NumericBooleanType")
private Boolean isOfficial;
private BigDecimal percentage;
//Getters and Setters omitted
}
Написать метод получения всех данных из MySQL
В классе Main объявим поля:
private final SessionFactory sessionFactory;
private final RedisClient redisClient;
private final ObjectMapper mapper;
private final CityDAO cityDAO;
private final CountryDAO countryDAO;
и инициализируем их в конструкторе класса Main:
public Main() {
sessionFactory = prepareRelationalDb();
cityDAO = new CityDAO(sessionFactory);
countryDAO = new CountryDAO(sessionFactory);
redisClient = prepareRedisClient();
mapper = new ObjectMapper();
}
Как видишь, не хватает методов и классов – давай их напишем.
Объяви пакет com.javarush.dao и добавь в него 2 класса:
package com.javarush.dao;
import com.javarush.domain.Country;
import org.hibernate.SessionFactory;
import org.hibernate.query.Query;
import java.util.List;
public class CountryDAO {
private final SessionFactory sessionFactory;
public CountryDAO(SessionFactory sessionFactory) {
this.sessionFactory = sessionFactory;
}
public List<Country> getAll() {
Query<Country> query = sessionFactory.getCurrentSession().createQuery("select c from Country c", Country.class);
return query.list();
}
}
package com.javarush.dao;
import com.javarush.domain.City;
import org.hibernate.SessionFactory;
import org.hibernate.query.Query;
import java.util.List;
public class CityDAO {
private final SessionFactory sessionFactory;
public CityDAO(SessionFactory sessionFactory) {
this.sessionFactory = sessionFactory;
}
public List<City> getItems(int offset, int limit) {
Query<City> query = sessionFactory.getCurrentSession().createQuery("select c from City c", City.class);
query.setFirstResult(offset);
query.setMaxResults(limit);
return query.list();
}
public int getTotalCount() {
Query<Long> query = sessionFactory.getCurrentSession().createQuery("select count(c) from City c", Long.class);
return Math.toIntExact(query.uniqueResult());
}
}
Теперь можно в Main заимпортить эти 2 класса. Еще не хватает двух методов:
private SessionFactory prepareRelationalDb() {
final SessionFactory sessionFactory;
Properties properties = new Properties();
properties.put(Environment.DIALECT, "org.hibernate.dialect.MySQL8Dialect");
properties.put(Environment.DRIVER, "com.p6spy.engine.spy.P6SpyDriver");
properties.put(Environment.URL, "jdbc:p6spy:mysql://localhost:3306/world");
properties.put(Environment.USER, "root");
properties.put(Environment.PASS, "root");
properties.put(Environment.CURRENT_SESSION_CONTEXT_CLASS, "thread");
properties.put(Environment.HBM2DDL_AUTO, "validate");
properties.put(Environment.STATEMENT_BATCH_SIZE, "100");
sessionFactory = new Configuration()
.addAnnotatedClass(City.class)
.addAnnotatedClass(Country.class)
.addAnnotatedClass(CountryLanguage.class)
.addProperties(properties)
.buildSessionFactory();
return sessionFactory;
}
До редиса мы еще не дошли, поэтому имплементация инициализации клиента редиса пока останется заглушкой:
private void shutdown() {
if (nonNull(sessionFactory)) {
sessionFactory.close();
}
if (nonNull(redisClient)) {
redisClient.shutdown();
}
}
Наконец можно написать метод, в котором мы вытянем все города:
private List<City> fetchData(Main main) {
try (Session session = main.sessionFactory.getCurrentSession()) {
List<City> allCities = new ArrayList<>();
session.beginTransaction();
int totalCount = main.cityDAO.getTotalCount();
int step = 500;
for (int i = 0; i < totalCount; i += step) {
allCities.addAll(main.cityDAO.getItems(i, step));
}
session.getTransaction().commit();
return allCities;
}
}
Особенность реализации такая, что города мы получаем по 500 штук. Это нужно для того, что существуют ограничения на объем передаваемых данных. Да, в нашем случае мы на них не попадем, т.к. у нас в БД всего 4079 городов. Но в продакшн приложениях, когда нужно получить много данных таким приемом пользуются часто.
И реализация метода main:
public static void main(String[] args) {
Main main = new Main();
List<City> allCities = main.fetchData(main);
main.shutdown();
}
Теперь можно первый раз запустить наше приложение в дебаге и посмотреть, как оно работает (или не работает – да, так часто бывает).

Города достаются. К каждому городу достается страна, если она еще не была вычитана из БД ранее для другого города. Давай примерно посчитаем сколько запросов Hibernate отправит в БД:
- 1 запрос узнать общее количество городов (нужно для цикла итерирования по 500 городов, чтоб знать когда остановиться).
- 4079 / 500 = 9 запросов (список городов).
- К каждому из городов достается страна, если она не была вычитана ранее. Так как в БД 239 стран, это нам даст 239 запросов.
Итого 249 запросов
. И это еще мы сказали, что вместе с страной сразу получать сет языков, иначе был бы вообще мрак. Но это все равно много, поэтому давай немного подтюним поведение. Начнем с размышлений: что делать, куда бежать? А если серьезно – почему так много запросов. Если смотреть на лог запросов, видим, что каждая страна запрашивается отдельно, поэтому первое простое решение: давай запросим все страны вместе, т. к. мы наперед знаем, что в этой транзакции они все нам будут нужны.
В методе fetchData() сразу после начала транзакции добавь строку:
List<Country> countries = main.countryDAO.getAll();
Считаем запросы:
- 1 – получить все страны
- 239 – запрос по каждой стране ее столицы
- 1 – запрос количества городов
- 9 – запрос списков городов
Итого 250
. Идея хорошая, но не сработало. Проблема в том, что в стране связь со столицей (city) @OneToOne
. А такая связь по умолчанию загружается сразу (FetchType.EAGER
). Давай поставим FetchType.LAZY
, т.к. мы все равно потом в этой же транзакции загрузим все города.
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "capital")
private City city;
Столицы отдельно перестали запрашиваться, но количество запросов не изменилось. Теперь по каждой стране запрашивается отдельным запросом список CountryLanguage. То есть, прогресс есть, и мы движемся в правильном направлении. Если помнишь, в лекциях предлагалось решение «join fetch» для того, чтоб путем добавления дополнительного джоина в запрос запрашивать сущность с зависимыми данными одним запросом. В CountryDAO перепиши HQL запрос в методе getAll()
на:
"select c from Country c join fetch c.languages"
Запуск. Смотрим лог, считаем запросы:
- 1 – все страны с языками
- 1 – количество городов
- 9 – списки городов.
Итого 11
- у нас получилось)) Если ты не просто прочитал весь этот текст а и пробовал запускать после каждого шага тюнинга приложения, ты должен был даже визуально отметить ускорение отработки всего приложения в несколько раз.
Написать метод трансформации данных
Создадим пакет com.javarush.redis
, в который добавим 2 класса: CityCountry (данные по городу и по стране, в которой этот город расположен) и Language (данные по языку). Сюда вынесены все поля, которые «по заданию» запрашиваются часто в «тормозящем запросе».
package com.javarush.redis;
import com.javarush.domain.Continent;
import java.math.BigDecimal;
import java.util.Set;
public class CityCountry {
private Integer id;
private String name;
private String district;
private Integer population;
private String countryCode;
private String alternativeCountryCode;
private String countryName;
private Continent continent;
private String countryRegion;
private BigDecimal countrySurfaceArea;
private Integer countryPopulation;
private Set<Language> languages;
//Getters and Setters omitted
}
package com.javarush.redis;
import java.math.BigDecimal;
public class Language {
private String language;
private Boolean isOfficial;
private BigDecimal percentage;
//Getters and Setters omitted
}
В методе main после получения всех городов добавь строку
List<CityCountry>> preparedData = main.transformData(allCities);
И реализуй этот метод:
private List<CityCountry> transformData(List<City> cities) {
return cities.stream().map(city -> {
CityCountry res = new CityCountry();
res.setId(city.getId());
res.setName(city.getName());
res.setPopulation(city.getPopulation());
res.setDistrict(city.getDistrict());
Country country = city.getCountry();
res.setAlternativeCountryCode(country.getAlternativeCode());
res.setContinent(country.getContinent());
res.setCountryCode(country.getCode());
res.setCountryName(country.getName());
res.setCountryPopulation(country.getPopulation());
res.setCountryRegion(country.getRegion());
res.setCountrySurfaceArea(country.getSurfaceArea());
Set<CountryLanguage> countryLanguages = country.getLanguages();
Set<Language> languages = countryLanguages.stream().map(cl -> {
Language language = new Language();
language.setLanguage(cl.getLanguage());
language.setOfficial(cl.getOfficial());
language.setPercentage(cl.getPercentage());
return language;
}).collect(Collectors.toSet());
res.setLanguages(languages);
return res;
}).collect(Collectors.toList());
}
Думаю, этот метод пояснений не требует: просто создаем сущность CityCountry и заполняем данными из City, Country, CountryLanguage.
Запустить Redis сервер как докер-контейнер
Здесь есть 2 варианта. Если ты будешь делать опциональный шаг «установить redis-insight, посмотреть на данные, которые хранятся в Redis», тогда команда для тебя:
docker run -d --name redis-stack -p 6379:6379 -p 8001:8001 redis/redis-stack:latest
Если же решил пропустить этот шаг, тогда достаточно:
docker run -d --name redis -p 6379:6379 redis:latest
Разница в том, что в первом варианте прокидывается порт 8001 на локальную машину, к которому можно подключиться внешним клиентом чтоб посмотреть что хранится внутри. И имена я привык давать значимые, поэтому redis-stack
или redis
.
После запуска можно посмотреть список запущенных контейнеров. Для этого выполни команду:
docker container ls
И увидишь что-то примерно такое:

Если нужно найти какую-то команду, можно либо в терминале посмотреть хелп (docker help) либо загуглить «how to …» (например, docker how to remove running container).
И еще мы в конструкторе Main вызвали инициализацию редис-клиента, но сам метод не реализовали. Добавь реализацию:
private RedisClient prepareRedisClient() {
RedisClient redisClient = RedisClient.create(RedisURI.create("localhost", 6379));
try (StatefulRedisConnection<String, String> connection = redisClient.connect()) {
System.out.println("\nConnected to Redis\n");
}
return redisClient;
}
sout добавлен в учебных целях чтоб в логе запуска видеть что все ОК и конекшн через редис-клиент прошел без ошибок.
Записать данные в Redis
В метод main добавь вызов
main.pushToRedis(preparedData);
С такой реализацией метода:
private void pushToRedis(List<CityCountry> data) {
try (StatefulRedisConnection<String, String> connection = redisClient.connect()) {
RedisStringCommands<String, String> sync = connection.sync();
for (CityCountry cityCountry : data) {
try {
sync.set(String.valueOf(cityCountry.getId()), mapper.writeValueAsString(cityCountry));
} catch (JsonProcessingException e) {
e.printStackTrace();
}
}
}
}
Здесь открывается синхронное соединение с редис-клиентом, и последовательно каждый объект типа CityCountry пишется в редис. Так как редис — это хранилище типа ключ-значение типа String, ключ (id города) преобразовывается к строке. А значение – тоже к строке, но используя ObjectMapper в JSON формат.
Осталось запустить и проверить что нет ошибок в логе. Все отработало.
Установить redis-insight, посмотреть на данные, которые хранятся в Redis (опционально)
Скачиваем по ссылке redis-insight и устанавливаем его. После запуска у меня сразу показывает наш инстанс редиса в докер-контейнере:

Если зайти – увидим список всех ключей:

И можно зайти на любой ключ, чтоб посмотреть какие данные по нему хранятся:

Написать метод получение данных из Redis
Для тестирования используем такой тест: получим 10 записей CityCountry. Каждый отдельным запросом, но в одном конекшне.
Данные из редиса можно получить через наш редис-клиент. Для этого напишем метод, который принимает список id, которые нужно получить.
private void testRedisData(List<Integer> ids) {
try (StatefulRedisConnection<String, String> connection = redisClient.connect()) {
RedisStringCommands<String, String> sync = connection.sync();
for (Integer id : ids) {
String value = sync.get(String.valueOf(id));
try {
mapper.readValue(value, CityCountry.class);
} catch (JsonProcessingException e) {
e.printStackTrace();
}
}
}
}
Реализация, думаю, интуитивно-понятна: открываем синхронное соединение, и для каждого id получаем JSON String, который преобразовываем в нужный нам объект типа CityCountry.
Написать метод получение данных из MySQL
В классе CityDAO добавь метод getById(Integer id)
, в котором получим город вместе с страной:
public City getById(Integer id) {
Query<City> query = sessionFactory.getCurrentSession().createQuery("select c from City c join fetch c.country where c.id = :ID", City.class);
query.setParameter("ID", id);
return query.getSingleResult();
}
По аналогии с предыдущим пунктом, добавим в класс Main аналогичный метод для MySQL:
private void testMysqlData(List<Integer> ids) {
try (Session session = sessionFactory.getCurrentSession()) {
session.beginTransaction();
for (Integer id : ids) {
City city = cityDAO.getById(id);
Set<CountryLanguage> languages = city.getCountry().getLanguages();
}
session.getTransaction().commit();
}
}
Из особенностей, чтоб наверняка получить полный объект (без прокси-заглушек), явно запросим у страны список языков.
Сравнить скорость получения одних и тех же данных из MySQL и Redis
Здесь приведу сразу код метода main, и результат, который получается на моем локальном компьютере.
public static void main(String[] args) {
Main main = new Main();
List<City> allCities = main.fetchData(main);
List<CityCountry> preparedData = main.transformData(allCities);
main.pushToRedis(preparedData);
//закроем текущую сессию, чтоб точно делать запрос к БД, а не вытянуть данные из кэша
main.sessionFactory.getCurrentSession().close();
//выбираем случайных 10 id городов
//так как мы не делали обработку невалидных ситуаций, используй существующие в БД id
List<Integer> ids = List.of(3, 2545, 123, 4, 189, 89, 3458, 1189, 10, 102);
long startRedis = System.currentTimeMillis();
main.testRedisData(ids);
long stopRedis = System.currentTimeMillis();
long startMysql = System.currentTimeMillis();
main.testMysqlData(ids);
long stopMysql = System.currentTimeMillis();
System.out.printf("%s:\t%d ms\n", "Redis", (stopRedis - startRedis));
System.out.printf("%s:\t%d ms\n", "MySQL", (stopMysql - startMysql));
main.shutdown();
}
При тестировании есть особенность – данные из MySQL только читаются, так что его можно не перезапускать между запусками нашего приложения. А в Redis пишутся.
Хотя при попытке добавить дубликат по такому же ключу, данные просто обновятся, я б рекомендовал между запусками приложения в терминале выполнять команды остановки контейнера docker stop redis-stack
и удаления контейнера docker rm redis-stack
. После этого заново поднимать контейнер с редисом docker run -d --name redis-stack -p 6379:6379 -p 8001:8001 redis/redis-stack:latest
и только после этого выполнять наше приложение.
Вот мои результаты тестирования:

Итого, мы добились повышения производительности ответа на «тормозящий частый» запрос в полтора раза. И это еще с учетом того, что в тестировании мы использовали не самую быструю десериализацию через ObjectMapper. Если ее поменять на GSON, скорее всего, можно «выиграть» еще немного времени.
В этот момент мне вспоминается прикол про программиста и время: почитайте и задумайтесь как нужно писать и оптимизировать свой код.
Дополнительные материалы
Виртуализация и Docker
Docker Compose
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ